mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #737 from Tria-plc/alpha
Check payment before generating ticket
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export class BookingAmountResponseDto {
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function ConfirmationPage() {
|
||||
import("@/lib/generate-voucher");
|
||||
}, []);
|
||||
|
||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||
const { data: _booking, refetch: refetchBooking } = useQuery<BookingWithTicket>({
|
||||
queryKey: ["booking", bookingId],
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
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<any>({
|
||||
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
|
||||
|
||||
@@ -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<any>({
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
@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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export type PaymentEventType = "payment.succeeded" | "payment.failed";
|
||||
|
||||
Reference in New Issue
Block a user