diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index a80c63468..777a2704b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { @@ -18,7 +19,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER]; export class PaymentEventsConsumer { private readonly logger = new Logger(PaymentEventsConsumer.name); - constructor(private readonly paymentsService: PaymentsService) {} + // IMPORTANT: do NOT constructor-inject PaymentsService here. It is a REQUEST/TRANSIENT-scoped + // provider (its scope bubbles up from a scoped dependency), so it has no singleton instance at + // bootstrap. Constructor-injecting it makes THIS consumer scoped too — and golevelup binds the + // @RabbitSubscribe handler to the singleton instance it discovers at bootstrap. With no such + // instance, the subscription still registers but delivered messages are never dispatched to + // handle(): they pile up unacked and the booking never confirms. Injecting only the lightweight + // (singleton) ModuleRef keeps this consumer a clean singleton; PaymentsService is resolved per + // message via resolve() (get() throws for scoped providers). + constructor(private readonly moduleRef: ModuleRef) {} @IsPublic() @RabbitSubscribe({ @@ -37,7 +46,13 @@ export class PaymentEventsConsumer { `RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`, ); try { - const result = await this.paymentsService.handlePaymentEvent( + // resolve() (not get()) because PaymentsService is scoped — get() throws for scoped providers. + const paymentsService = await this.moduleRef.resolve( + PaymentsService, + undefined, + { strict: false }, + ); + const result = await paymentsService.handlePaymentEvent( event as unknown as PaymentEventDto, ); this.logger.log( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts index 825fa114e..e0b594fd2 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.dto.ts @@ -154,6 +154,13 @@ export class IntentStatusDto { @ApiPropertyOptional() paidAt?: string; @ApiPropertyOptional() failureCode?: string; @ApiPropertyOptional() failureMessage?: string; + @ApiPropertyOptional({ + type: "object", + additionalProperties: true, + description: + "Raw provider payload (initiation response merged with the latest status query) for inspection/debugging. Provider-specific shape; never trusted for state.", + }) + providerResponse?: Record; } export class BookingAmountResponseDto { 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 78856ccd5..8bf07c6bf 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -432,6 +432,9 @@ export class PaymentsService { expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null, failureCode: snapshot.failureCode ?? null, failureMessage: snapshot.failureMessage ?? null, + rawInitiation: snapshot.providerResponse + ? (snapshot.providerResponse as unknown as Prisma.InputJsonValue) + : Prisma.DbNull, }; return this.prisma.paymentIntent.upsert({ where: { bookingId }, @@ -589,6 +592,10 @@ export class PaymentsService { paidAt: intent.paidAt?.toISOString(), failureCode: intent.failureCode ?? undefined, failureMessage: intent.failureMessage ?? undefined, + providerResponse: + intent.rawInitiation && typeof intent.rawInitiation === "object" + ? (intent.rawInitiation as Record) + : undefined, }; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index b10109c0f..c48e30177 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -68,7 +68,7 @@ export default function ConfirmationPage() { import("@/lib/generate-voucher"); }, []); - const { data: _booking } = useQuery({ + const { data: _booking, refetch: refetchBooking } = useQuery({ queryKey: ["booking", bookingId], queryFn: async (): Promise => { try { @@ -88,6 +88,23 @@ export default function ConfirmationPage() { enabled: !!bookingId, }); + // Poll the payment intent every 10 s while the booking is PENDING_PAYMENT. + // The backend auto-confirms (and generates tickets) when the payment-api reports + // SUCCEEDED, so detecting that here means the booking is now CONFIRMED — refetch + // to update the UI without requiring the user to refresh. + const { data: intentStatus } = useQuery({ + queryKey: ["payment-intent-status", bookingId], + queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), + enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, + refetchInterval: 10_000, + }); + + useEffect(() => { + if (intentStatus?.status === "SUCCEEDED") { + refetchBooking(); + } + }, [intentStatus?.status]); + // Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge — // a gateway redirect back here does not mean payment succeeded (see payment return pages). // Ticket generation itself is never triggered from this page — the payment webhook diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index a54ce795b..82d357a28 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -125,6 +125,22 @@ function BookingDetailContent() { booking?.status === "PENDING_PAYMENT" || booking?.status === "DRAFT", }); + // When the booking is PENDING_PAYMENT, poll the payment intent endpoint every 10 s. + // The backend auto-confirms the booking when it finds a SUCCEEDED intent, so detecting + // SUCCEEDED here means the booking is now CONFIRMED — refetch to update the UI. + const { data: intentStatus } = useQuery({ + queryKey: ["payment-intent-status", booking?.id], + queryFn: () => apiClient.get(`/payments/intents/${booking!.id}`), + enabled: booking?.status === "PENDING_PAYMENT" && !!booking?.id, + refetchInterval: 10_000, + }); + + useEffect(() => { + if (intentStatus?.status === "SUCCEEDED") { + refetch(); + } + }, [intentStatus?.status]); + const selectedPaymentMethod = (paymentMethods || []).find((m: any) => m.type === selectedMethod) || null; 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 0ebaaad05..a14469dd1 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -41,6 +41,8 @@ export interface ProviderResultInput { confirmedAmountMinor?: number; failureCode?: string; failureMessage?: string; + /** Raw provider status-query body, merged into the intent's audit payload when present. */ + rawResponse?: Record; } @Injectable() @@ -357,6 +359,7 @@ export class IntentsService { providerTxnId: status.providerTxnId, failureCode: status.failureCode, failureMessage: status.failureMessage, + rawResponse: status.rawResponse, }; } @@ -388,6 +391,15 @@ export class IntentsService { return { alreadyTerminal: true }; } + // Keep the audit payload current with the latest provider status body (surfaced as + // `providerResponse` in the snapshot). Merged so the initiation keys are preserved. + if (result.rawResponse) { + intent.rawInitiation = { + ...(intent.rawInitiation ?? {}), + statusResponse: result.rawResponse, + }; + } + if (result.status === ProviderPaymentStatus.SUCCEEDED) { const paidAt = result.paidAt ?? new Date(); intent.status = ProviderPaymentStatus.SUCCEEDED; @@ -482,6 +494,7 @@ export class IntentsService { failureCode: intent.failureCode ?? undefined, failureMessage: intent.failureMessage ?? undefined, expiresAt: intent.expiresAt?.toISOString(), + providerResponse: intent.rawInitiation ?? undefined, }; } } diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 9b5f49482..f5d8c7e4c 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -154,6 +154,13 @@ export type PaymentIntentSnapshot ={ failureCode?: string; failureMessage?: string; expiresAt?: string; + /** + * Raw provider payload for inspection/debugging — the audit copy of the provider + * initiation response merged with the latest status-query response (secrets redacted + * upstream). Not a contract with the provider; shape is provider-specific. Never trusted + * for state decisions — the state machine drives `status`. + */ + providerResponse?: Record; } export type PaymentEventType = "payment.succeeded" | "payment.failed";