From 18c47ca4a31b145c296896e578a2a5e72f89367c Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 10 Aug 2026 14:25:14 +0300 Subject: [PATCH] recover: ( payment ) recover cancelation fix deleted by malware --- .../modules/payments/payments.service.spec.ts | 63 +++++++++++++++++++ .../src/modules/payments/payments.service.ts | 52 ++++++++++++--- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 186383335..9b94053b6 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -40,6 +40,7 @@ describe("PaymentsService", () => { findUniqueOrThrow: jest.fn(), upsert: jest.fn(), update: jest.fn(), + updateMany: jest.fn(), create: jest.fn(), }, paymentMethod: { @@ -81,6 +82,7 @@ describe("PaymentsService", () => { const mockPaymentClient = { initiate: jest.fn(), getIntentByReference: jest.fn(), + reconcileByReference: jest.fn(), }; // Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB). @@ -242,6 +244,14 @@ describe("PaymentsService", () => { merchantOrderId: "PSG-MERCH-123", clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); + // syncIntentProjection applies the status in a separate guarded write (never demoting a + // SUCCEEDED row), then reads the projection back — so this is what it returns. + mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ + id: "intent-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); const result = await service.initiatePayment({ bookingId: "booking-1", @@ -479,6 +489,14 @@ describe("PaymentsService", () => { merchantOrderId: "PSG-MERCH-123", clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, }); + // See above: the projection is read back after the guarded status write. + mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + merchantOrderId: "PSG-MERCH-123", + clientAction: { type: "REDIRECT", url: "https://provider.example/pay" }, + }); const result = await service.getIntentByBookingId("booking-1"); @@ -499,4 +517,49 @@ describe("PaymentsService", () => { ); }); }); + + // Regression: a booking confirmed between a sweep's candidate query and its turn in the loop + // used to have its SUCCEEDED projection demoted to PROCESSING by the re-sync, with nothing + // able to restore it (finalizePaymentSuccess only writes SUCCEEDED while the booking is still + // PENDING_PAYMENT). A later stale payment.failed from an abandoned sibling attempt could then + // push that same row to FAILED, because markPaymentFailed only shields SUCCEEDED/CANCELLED. + describe("confirmed-booking projection integrity", () => { + it("does not re-sync or cancel a booking confirmed since the caller's snapshot", async () => { + mockPrisma.booking.findUnique.mockResolvedValue({ status: "CONFIRMED" }); + + const result = await service.reconcileAndConfirmIfPaid("booking-1"); + + expect(result).toEqual({ paid: true, verified: true }); + // Neither the payment service nor the projection is touched. + expect(mockPaymentClient.reconcileByReference).not.toHaveBeenCalled(); + expect(mockPrisma.paymentIntent.upsert).not.toHaveBeenCalled(); + }); + + it("writes the mirrored status only where the row is not already SUCCEEDED", async () => { + mockPrisma.paymentIntent.findUnique.mockResolvedValue(null); + mockPaymentClient.getIntentByReference.mockResolvedValue( + requiresActionSnapshot(ProviderMethod.TELEBIRR), + ); + mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({ + id: "intent-1", + bookingId: "booking-1", + status: PaymentIntentStatus.REQUIRES_ACTION, + }); + + await service.getIntentByBookingId("booking-1"); + + // The upsert must never carry a status on its update path... + const upsertArg = mockPrisma.paymentIntent.upsert.mock.calls[0][0]; + expect(upsertArg.update).not.toHaveProperty("status"); + // ...the status arrives through a write guarded on the row not being SUCCEEDED, which is + // what makes demoting the confirming payment structurally impossible. + expect(mockPrisma.paymentIntent.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { not: PaymentIntentStatus.SUCCEEDED }, + }), + }), + ); + }); + }); }); 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 2b1b14cf1..5e004289a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -616,12 +616,13 @@ export class PaymentsService { bookingId: string, snapshot: PaymentIntentSnapshot, ) { + // Writing SUCCEEDED is finalizePaymentSuccess's job alone — it is the only place that can + // enforce confirm-once atomically — so a SUCCEEDED snapshot syncs as PROCESSING here. const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED ? PaymentIntentStatus.PROCESSING : (snapshot.status as unknown as PaymentIntentStatus); const data = { - status, method: snapshot.provider as unknown as PaymentMethodType, merchantOrderId: snapshot.merchantOrderId, clientAction: snapshot.clientAction @@ -635,7 +636,7 @@ export class PaymentsService { ? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue) : Prisma.DbNull, }; - return this.prisma.paymentIntent.upsert({ + await this.prisma.paymentIntent.upsert({ where: { bookingId }, // amountMinor/currency are refreshed on update too: a cross-currency method switch // (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection @@ -649,9 +650,17 @@ export class PaymentsService { bookingId, amountMinor: snapshot.amountMinor, currency: snapshot.currency, + status, ...data, }, }); + + await this.prisma.paymentIntent.updateMany({ + where: { bookingId, status: { not: PaymentIntentStatus.SUCCEEDED } }, + data: { status }, + }); + + return this.prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId } }); } private async initiateWalletPayment( @@ -1029,6 +1038,14 @@ export class PaymentsService { async reconcileAndConfirmIfPaid( bookingId: string, ): Promise<{ paid: boolean; verified: boolean }> { + const current = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { status: true }, + }); + if (current?.status === "CONFIRMED") { + return { paid: true, verified: true }; + } + const settlement = await this.paymentClient.reconcileByReference( PaymentReferenceType.BOOKING, bookingId, @@ -1161,12 +1178,31 @@ export class PaymentsService { }); if (confirmed === 0) { - // Booking already confirmed by another payment (or not payable and not forced). This capture - // is registered on the payment-api ledger; do not confirm, ticket, or touch this row. - this.logger.error( - `Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` + - `txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`, - ); + const recordsConfirmingCapture = + booking.status === "CONFIRMED" && intent.paidAt != null; + if (recordsConfirmingCapture) { + const { count } = await this.prisma.paymentIntent.updateMany({ + where: { id: intent.id, status: { not: PaymentIntentStatus.SUCCEEDED } }, + data: { status: PaymentIntentStatus.SUCCEEDED }, + }); + if (count > 0) { + this.logger.warn( + `Restored demoted payment projection for booking ${booking.id} ` + + `(intent ${intent.id}): ${intent.status} → SUCCEEDED`, + ); + } + } + + const duplicateCapture = + input.providerTxnId != null && + intent.providerTxnId != null && + input.providerTxnId !== intent.providerTxnId; + if (duplicateCapture || !recordsConfirmingCapture) { + this.logger.error( + `Capture on non-payable booking ${booking.id} (status=${booking.status}), intent ${intent.id} ` + + `txn=${input.providerTxnId ?? intent.providerTxnId ?? "n/a"} — registered in payment-api; not confirming`, + ); + } return { alreadyFinalized: true }; }