From 85de05cb40e3d1efe74ad3a4109eb32d0635ebe7 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 18 Jul 2026 09:55:09 +0300 Subject: [PATCH 1/3] feat: ( payments ) add merchant-order and booking/PNR payment status lookups --- .../payments/payment-client.service.ts | 29 +++++++ .../modules/payments/payments.controller.ts | 27 ++++++ .../src/modules/payments/payments.service.ts | 50 ++++++++++- .../src/modules/intents/intents.controller.ts | 44 +++++++++- .../src/modules/intents/intents.service.ts | 84 +++++++++++++++++++ 5 files changed, 231 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index c9264eeda..84ca92c02 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -12,8 +12,15 @@ import { PaymentIntentSnapshot, PaymentReferenceType, PaymentService, + ProviderStatus, } from "@edr/types"; +/** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */ +export interface PaymentDiagnostic { + db: Record | null; + provider: ProviderStatus | null; +} + /** * Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's * side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here; @@ -55,6 +62,28 @@ export class PaymentClientService { } } + /** + * GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference, + * side by side. Returns { db: null, provider: null } when the payment service has no intent. + */ + async getDiagnosticByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const query = new URLSearchParams({ + service: PaymentService.PASSENGER, + referenceType, + referenceId, + }); + try { + return await this.call("GET", `/payments/diagnostic?${query.toString()}`); + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + return { db: null, provider: null }; + throw err; + } + } + /** * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank). * A wrong/expired OTP comes back as 400 from the payment service; surface that as a diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 9a0288656..88510f1a5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -133,6 +133,33 @@ export class PaymentsController { return this.service.getIntentByBookingId(bookingId); } + @Get("status/:bookingRefOrId") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: "Get payment status by booking id or booking reference (PNR)", + description: + "Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " + + "resolves it to the booking, and returns the authoritative payment status pulled from " + + "the payment microservice.", + }) + getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) { + return this.service.getIntentByBookingRefOrId(bookingRefOrId); + } + + @Get("diagnostic/:bookingRefOrId") + @SetMetadata("isPublic", true) + @ApiOperation({ + summary: + "Get { db, provider } by booking id or booking reference (PNR) — diagnostic", + description: + "Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " + + "the booking, and returns { db, provider }: the payment service's stored intent row and a " + + "live provider status query, side by side. Pure read — does not reconcile the booking.", + }) + getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) { + return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId); + } + @Post(":bookingId/confirm") @SetMetadata("isPublic", true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index a9e4557f1..e57d94643 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -24,7 +24,10 @@ import { ForceConfirmDto, } from "./payments.dto"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; -import { PaymentClientService } from "./payment-client.service"; +import { + PaymentClientService, + PaymentDiagnostic, +} from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { AuditService } from "../../common/audit.service"; import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util"; @@ -534,6 +537,51 @@ export class PaymentsService { }; } + /** + * Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001). + * Resolves the PNR to its booking id, then pulls the authoritative status from the payment + * microservice (via {@link getIntentByBookingId}). + */ + async getIntentByBookingRefOrId( + bookingRefOrId: string, + ): Promise { + const bookingId = await this.resolveBookingId(bookingRefOrId); + return this.getIntentByBookingId(bookingId); + } + + /** + * Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's + * stored intent row and a live provider status query, side by side ({ db, provider }). + * Pure read — does not reconcile or confirm the booking. + */ + async getPaymentDiagnosticByBookingRefOrId( + bookingRefOrId: string, + ): Promise { + const bookingId = await this.resolveBookingId(bookingRefOrId); + return this.paymentClient.getDiagnosticByReference( + PaymentReferenceType.BOOKING, + bookingId, + ); + } + + /** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */ + private async resolveBookingId(bookingRefOrId: string): Promise { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + bookingRefOrId, + ); + if (isUuid) return bookingRefOrId; + + const booking = await this.prisma.booking.findUnique({ + where: { bookingRef: bookingRefOrId }, + select: { id: true }, + }); + if (!booking) { + throw new NotFoundException(`Booking not found: ${bookingRefOrId}`); + } + return booking.id; + } + async getIntentByBookingId(bookingId: string): Promise { const local = await this.prisma.paymentIntent.findUnique({ where: { bookingId }, diff --git a/apps/edr-payment-api/src/modules/intents/intents.controller.ts b/apps/edr-payment-api/src/modules/intents/intents.controller.ts index 35f3461d2..dc47adf6c 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.controller.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.controller.ts @@ -8,8 +8,12 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { PaymentIntentSnapshot } from "@edr/types"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { + PaymentIntentSnapshot, + ProviderMethod, + ProviderStatus, +} from "@edr/types"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { InitiatePaymentRequestDto, @@ -17,6 +21,7 @@ import { } from "./dto/initiate-payment.dto"; import { ConfirmPaymentDto } from "./dto/confirm-payment.dto"; import { IntentsService } from "./intents.service"; +import { PaymentIntent } from "./entities/payment-intent.entity"; /** * Internal surface — called only by the domain apps (service-authenticated), never by @@ -68,6 +73,41 @@ export class IntentsController { ); } + @Get("diagnostic") + @ApiOperation({ + summary: "DB row + live provider status by domain reference (diagnostic)", + description: + "Returns { db, provider } for a domain reference (service + referenceType + referenceId): " + + "the active stored intent row and a live provider status query, side by side. Pure read — " + + "does not mutate the intent. `db` is null when no active intent exists for the reference.", + }) + async getDiagnosticByReference( + @Query() query: IntentReferenceQueryDto, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + return this.intentsService.getDiagnosticByReference( + query.service, + query.referenceType, + query.referenceId, + ); + } + + @Get("by-merchant-order/:merchantOrderId") + @ApiOperation({ + summary: "DB row + live provider status by merchant order id (diagnostic)", + description: + "Returns { db, provider } for a provider-facing merchant order id (PSG-/FRT-…): the " + + "stored intent row and a live provider status query, side by side. Pure read — does not " + + "mutate the intent. `db` is null when no intent has this merchant order id; supply " + + "`?provider=` in that case so the provider can still be queried by merchant order id.", + }) + @ApiQuery({ name: "provider", enum: ProviderMethod, required: false }) + async getByMerchantOrderId( + @Param("merchantOrderId") merchantOrderId: string, + @Query("provider") provider?: ProviderMethod, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + return this.intentsService.getByMerchantOrderId(merchantOrderId, provider); + } + @Post("intents/:id/confirm") @ApiOperation({ summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)", diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index a14469dd1..5a751c3b5 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -304,6 +304,90 @@ export class IntentsService { return this.toSnapshot(await this.refreshIfStale(intent)); } + /** + * Diagnostic lookup by domain reference (service + referenceType + referenceId). Returns the + * stored intent AND a LIVE provider status query side by side — the reference-keyed twin of + * {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without + * knowing the merchant order id. Pure read (no state-machine mutation). + * + * - `db`: the active stored intent for the reference, or `null` when none exists. + * - `provider`: the raw provider status response (queried using the intent's own provider), or + * `null` when there is no intent or the query fails. + */ + async getDiagnosticByReference( + service: PaymentService, + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + const intent = await this.intentsRepository.findActiveByReference( + service, + referenceType, + referenceId, + ); + const providerStatus = intent + ? await this.queryProviderForMerchantOrder( + intent.merchantOrderId, + intent, + undefined, + ) + : null; + return { db: intent ?? null, provider: providerStatus }; + } + + /** + * Diagnostic lookup by provider-facing merchant order id (PSG-/FRT-…). Returns the stored + * intent AND a LIVE provider status query side by side, so the caller can compare what the + * platform believes against what the provider currently reports. This is a pure read — it + * does NOT mutate the intent (no state-machine transition, no outbox event). + * + * - `db`: the full stored intent row, or `null` when no intent has this merchant order id. + * - `provider`: the raw provider status response. When there is a DB row its provider is + * used; when there is no DB row a `providerHint` must be supplied to know which provider + * to ask (the merchant-order prefix only identifies the service). `null` if the provider + * is unknown or the query fails. + */ + async getByMerchantOrderId( + merchantOrderId: string, + providerHint?: ProviderMethod, + ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { + const intent = + await this.intentsRepository.findByMerchantOrderId(merchantOrderId); + + const providerStatus = await this.queryProviderForMerchantOrder( + merchantOrderId, + intent, + providerHint, + ); + + return { db: intent ?? null, provider: providerStatus }; + } + + /** Best-effort live provider status for a merchant order id; never throws (returns null). */ + private async queryProviderForMerchantOrder( + merchantOrderId: string, + intent: PaymentIntent | null, + providerHint?: ProviderMethod, + ): Promise { + try { + if (intent) { + const provider = this.providers.get(intent.provider); + if (!provider) return null; + return await this.queryProviderStatus(intent); + } + // No DB row — fall back to the caller-supplied provider hint keyed on merchantOrderId. + if (!providerHint) return null; + const provider = this.providers.get(providerHint); + if (!provider) return null; + return await provider.queryStatus(merchantOrderId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `provider status query failed for merchantOrderId ${merchantOrderId}: ${message}`, + ); + return null; + } + } + /** * Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the * provider for the truth and run the answer through the state machine. The browser From 56057a1e1621b52042ba9be84162790ede96194c Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:31:19 +0300 Subject: [PATCH 2/3] Migration conflict issues resolution --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/edr-passenger-api/prisma/migrations/{20260717000003_booking_seat_schedule_unique => 20260717000004_booking_seat_schedule_unique}/migration.sql (100%) diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql similarity index 100% rename from apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql rename to apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql From e55dd01c500f6e56516d778df13866c1335648d8 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 18 Jul 2026 12:47:05 +0300 Subject: [PATCH 3/3] Migration issues resolution --- .../migration.sql | 21 ------ .../migration.sql | 15 ----- .../migration.sql | 4 -- .../migration.sql | 1 + .../migration.sql | 66 ------------------- 5 files changed, 1 insertion(+), 106 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql delete mode 100644 apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql index 83a49ef67..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260716202849_add_journey_segment_unique_seat_per_schedule/migration.sql @@ -1,22 +1 @@ -<<<<<<< Updated upstream --- Remove duplicate JourneySegment rows, keeping the one with the lowest id --- (earliest created) per (scheduleId, seatId, departureStationId) group. --- This cleans up any existing double-bookings before the unique index is applied. -DELETE FROM passenger."JourneySegment" -WHERE id NOT IN ( - SELECT MIN(id) - FROM passenger."JourneySegment" - WHERE "seatId" IS NOT NULL - GROUP BY "scheduleId", "seatId", "departureStationId" -) -AND "seatId" IS NOT NULL; - --- Prevents two confirmed bookings from occupying the same seat on the same --- schedule hop — the hard DB backstop against application-level race conditions. --- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat. -CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key" -ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId") -WHERE "seatId" IS NOT NULL; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql index e04c02621..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000001_add_route_checkin_minutes_rename_stop_status/migration.sql @@ -1,16 +1 @@ -<<<<<<< Updated upstream --- Rename StopStatus enum values to reflect segment-level booking lifecycle. --- UPCOMING → OPEN (segment is bookable) --- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings) --- CURRENT → BOARDED (train has departed this stop) --- COMPLETED stays as-is -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED'; -ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED'; - --- Add per-route check-in window. Each route can define how many minutes before --- a stop's planned departure check-in is closed. Defaults to 30 minutes. -ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql index 5fd9ae0f5..bcea78e50 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260717000002_add_route_stop_checkin_minutes/migration.sql @@ -1,5 +1 @@ -<<<<<<< Updated upstream -ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER; -======= -- Migration already applied directly to the database. ->>>>>>> Stashed changes diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql new file mode 100644 index 000000000..bcea78e50 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717000003_booking_seat_schedule_unique/migration.sql @@ -0,0 +1 @@ +-- Migration already applied directly to the database. diff --git a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql deleted file mode 100644 index 8553fc81b..000000000 --- a/apps/edr-passenger-api/prisma/migrations/20260717000004_booking_seat_schedule_unique/migration.sql +++ /dev/null @@ -1,66 +0,0 @@ --- Make scheduleId non-nullable: backfill from the parent booking, then add NOT NULL. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL -======= -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL ->>>>>>> Stashed changes - AND bs.leg = 1; - --- For leg=2 rows (return/transit), pull from the booking's returnScheduleId / leg2ScheduleId. -UPDATE passenger."BookingSeat" bs -<<<<<<< Updated upstream -SET schedule_id = COALESCE( - (b.return_schedule_id), - (b.leg2_schedule_id), - b.schedule_id -) -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs (leg 3/4 from ROUND_TRIP_TRANSIT) using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET schedule_id = b.schedule_id -FROM passenger."Booking" b -WHERE bs.booking_id = b.id - AND bs.schedule_id IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN schedule_id SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"(schedule_id, seat_id); -======= -SET "scheduleId" = COALESCE( - b."returnScheduleId", - b."leg2ScheduleId", - b."scheduleId" -) -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL - AND bs.leg = 2; - --- Catch any remaining NULLs using the booking's schedule. -UPDATE passenger."BookingSeat" bs -SET "scheduleId" = b."scheduleId" -FROM passenger."Booking" b -WHERE bs."bookingId" = b.id - AND bs."scheduleId" IS NULL; - --- Now enforce NOT NULL. -ALTER TABLE passenger."BookingSeat" ALTER COLUMN "scheduleId" SET NOT NULL; - --- Add the unique constraint that is the actual double-booking guard. -CREATE UNIQUE INDEX "BookingSeat_scheduleId_seatId_key" - ON passenger."BookingSeat"("scheduleId", "seatId"); ->>>>>>> Stashed changes