From 85de05cb40e3d1efe74ad3a4109eb32d0635ebe7 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 18 Jul 2026 09:55:09 +0300 Subject: [PATCH] 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