From 7762d6c2d300b097c6a6e73f92aa24c5bd3c195d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 2 Aug 2026 18:28:40 +0000 Subject: [PATCH] fix: payemetn race condition --- .../modules/payment/payment.service.spec.ts | 42 +++++++++++++++++++ .../src/modules/payment/payment.service.ts | 14 ++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts index ed0f8da58..aca224c07 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => { ); }); }); + +describe("PaymentService.markIntentSucceeded", () => { + const build = (rows: Record[]) => { + const repo = makeRepo(rows); + const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; + const service = new PaymentService( + repo as never, + {} as never, + billing as never, + ); + return { service, repo, billing }; + }; + + it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => { + const paidAt = new Date("2026-08-01T09:00:00.000Z"); + const { service, repo, billing } = build([ + localIntent({ status: "success", transactionId: "txn-1", paidAt }), + ]); + + const result = await service.markIntentSucceeded("intent-1", { + notify: true, + }); + + expect(result.alreadyFinalized).toBe(true); + // No re-write of the intent row… + expect(repo.update).not.toHaveBeenCalled(); + // …but billing still gets the (idempotent) settle call. + expect(billing.settleByPaymentId).toHaveBeenCalledWith( + "intent-1", + "txn-1", + paidAt, + ); + }); + + it("does not notify billing when notify is false, even when already success", async () => { + const { service, billing } = build([localIntent({ status: "success" })]); + + await service.markIntentSucceeded("intent-1", { notify: false }); + + expect(billing.settleByPaymentId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 985b4f53f..669c7c27b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -451,7 +451,19 @@ export class PaymentService { ): Promise<{ alreadyFinalized: boolean }> { const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; + if (intent.status === "success") { + // Still notify billing: a prior delivery may have flipped the intent to + // success and then died before the invoice settled (the two steps are not + // atomic). settleByPaymentId is idempotent — no open invoice, no-op. + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId ?? intent.transactionId ?? undefined, + opts.paidAt ?? intent.paidAt ?? undefined, + ); + } + return { alreadyFinalized: true }; + } const paidAt = opts.paidAt ?? new Date(); await this.paymentRepo.update(