From 3d1b06b7ee8ac3e4ea1c10d6fff1fbfaf661b9b9 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 24 Jul 2026 13:51:53 +0300 Subject: [PATCH] fix: ( payment ) prevent duplicate booking charges and fix D-Money queryOrder --- .../src/modules/payments/payments.service.ts | 55 ++++++++++++++++++ .../src/modules/intents/intents.service.ts | 56 +++++++++++++------ .../src/providers/dmoney/dmoney.provider.ts | 15 ++++- 3 files changed, 109 insertions(+), 17 deletions(-) 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 e5846b48f..bee599ba4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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, 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 704a46c2f..d54499108 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -100,11 +100,13 @@ export class IntentsService { existing.status === ProviderPaymentStatus.REQUIRES_ACTION ) { // Same provider, payer re-initiated while a session is open (back button, - // abandoned checkout). Provider sessions are single-use, so re-serving the - // old clientAction hands the payer a dead checkout. Verify at the provider, - // then supersede: paid/processing intents are adopted, unpaid ones retired - // so a fresh session opens below. - const settled = await this.verifyThenSupersede(existing); + // abandoned checkout, second device). Verify at the provider first, then: + // paid/processing sessions are adopted; an unpaid session that is still live + // (unexpired, same amount) is REUSED — its hosted page stays payable until + // expiresAt, so minting a fresh session would leave the old one concurrently + // payable and invite a double charge. Only genuinely expired/changed sessions + // are retired so a fresh one opens below. + const settled = await this.verifyThenReuseOrRetire(existing, request); if (settled) return this.toSnapshot(settled); } else { // PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen — @@ -272,18 +274,27 @@ export class IntentsService { } /** - * Re-initiate guard for an open REQUIRES_ACTION intent on the same provider. + * Re-initiate handler for an open REQUIRES_ACTION intent on the same provider. * Queries the provider first — the payer may have paid on the old session with - * the webhook still in flight. Paid/processing answers are applied through the - * state machine and the intent is returned for reuse. Anything still unpaid is - * retired (CANCELLED, no notification — nothing was paid; a payment.failed here - * would wrongly fail the domain order mid-retry) and null is returned so the - * caller opens a fresh provider session. When the status query itself errors, - * the existing intent is reused unchanged: superseding blind could leave two - * live sessions and a double charge. + * the webhook still in flight. Then: + * + * - Paid/processing: applied through the state machine and the intent is returned + * for the caller to adopt. + * - Unpaid but still live (not expired, same amount/currency): the existing intent + * is REUSED and returned — the provider's hosted page remains payable until + * expiresAt, so opening a fresh session would leave two concurrently-payable + * sessions and invite a double charge (observed in prod: a superseded Telebirr + * session was paid after cancellation, orphaning the capture). + * - Expired, or the requested amount/currency changed: retired (CANCELLED, no + * notification — nothing was paid; a payment.failed here would wrongly fail the + * domain order mid-retry) and null is returned so the caller opens a fresh session. + * + * When the status query itself errors, the existing intent is reused unchanged: + * superseding blind could leave two live sessions and a double charge. */ - private async verifyThenSupersede( + private async verifyThenReuseOrRetire( intent: PaymentIntent, + request: InitiatePaymentRequest, ): Promise { let status: ProviderStatus; try { @@ -291,7 +302,7 @@ export class IntentsService { } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.warn( - `verify-before-supersede: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`, + `verify-before-reuse: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`, ); return intent; } @@ -304,14 +315,27 @@ export class IntentsService { return (await this.intentsRepository.findById(intent.id)) ?? intent; } + // Unpaid at the provider. Reuse the still-live session rather than superseding it. const expired = intent.expiresAt != null && intent.expiresAt.getTime() < Date.now(); + const chargeChanged = + intent.amountMinor !== request.amountMinor || + intent.currency !== request.currency; + + if (!expired && !chargeChanged) { + this.logger.log( + `intent ${intent.id} reused (live ${intent.provider} session, unpaid, not expired) for ` + + `${request.service}/${request.referenceType}/${request.referenceId}`, + ); + return intent; + } + await this.intentsRepository.update(intent.id, { status: ProviderPaymentStatus.CANCELLED, failureCode: expired ? "EXPIRED" : "SUPERSEDED", failureMessage: expired ? "Provider session expired before the payer acted" - : "Payer re-initiated; previous provider session superseded", + : "Payer re-initiated with a changed amount; previous session superseded", }); this.logger.log( `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`, diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 91e33d2da..51e90381b 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -102,8 +102,12 @@ export class DMoneyProvider implements PaymentProvider { async queryStatus(merchantOrderId: string): Promise { const fabricToken = await this.applyFabricToken(); const requestBody = this.buildQueryOrderRequest(merchantOrderId); + const { sign: _sign, ...sanitizedBody } = requestBody; + this.logger.log( + `D-Money queryOrder send request merchOrderId=${merchantOrderId} body=${JSON.stringify(sanitizedBody)}`, + ); const response = await this.postJson( - `${this.baseUrl}/apiaccess/payment/v1/merchant/queryOrder`, + `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/queryOrder`, requestBody, { "Content-Type": "application/json", @@ -112,9 +116,18 @@ export class DMoneyProvider implements PaymentProvider { }, ); + this.logger.log( + `D-Money queryOrder response merchOrderId=${merchantOrderId} body=${JSON.stringify(response)}`, + ); + const orderStatus = response.biz_content?.order_status; const providerTxnId = response.biz_content?.payment_order_id; const mapped = this.mapOrderStatus(orderStatus); + this.logger.log( + `D-Money queryOrder result merchOrderId=${merchantOrderId} ` + + `orderStatus=${orderStatus ?? "n/a"} mapped=${mapped} ` + + `providerTxnId=${providerTxnId ?? "n/a"}`, + ); return { status: mapped,