fix: ( payment ) prevent duplicate booking charges and fix D-Money queryOrder

This commit is contained in:
Abubeker Yasin
2026-07-24 13:51:53 +03:00
parent e2bdc21c95
commit 3d1b06b7ee
3 changed files with 109 additions and 17 deletions

View File

@@ -242,6 +242,61 @@ export class PaymentsService {
return this.initiateWalletPayment(booking);
}
// Double-charge guard for payment-method switches. Before opening a fresh charge over
// this booking, reconcile any still-open intent against the authoritative provider
// status — the booking-status check above only blocks once the booking is CONFIRMED,
// which leaves a window where the first attempt actually paid but the mark-paid
// webhook/poll hasn't landed yet.
const existingIntent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: booking.id },
});
if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
let snapshot: PaymentIntentSnapshot | null = null;
try {
snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
);
}
// The previous attempt actually paid (provider SUCCEEDED, event just late):
// converge the booking now and return it — never charge a second time.
if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
let intent = await this.syncIntentProjection(booking.id, snapshot);
await this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
intent = await this.prisma.paymentIntent.findUniqueOrThrow({
where: { id: intent.id },
});
return this.formatIntentResponse(intent);
}
// Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
// service was unreachable and the local status is non-terminal. Block the switch:
// return the existing intent so the payer completes or waits out the open attempt
// rather than opening a second concurrent charge.
if (
!snapshot ||
snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
snapshot.status === ProviderPaymentStatus.PROCESSING
) {
const intent = snapshot
? await this.syncIntentProjection(booking.id, snapshot)
: existingIntent;
return this.formatIntentResponse(intent);
}
// Otherwise the provider reports FAILED/CANCELLED — fall through and initiate
// the newly selected method below.
}
const { returnUrl, failureUrl } = this.resolveReturnUrls(
method,
requestOrigin,