From c75ed8f1490aed2033aad8f1bf55f07ab0041e79 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Mon, 10 Aug 2026 12:35:43 +0300 Subject: [PATCH 1/2] fix --- .../src/pages/accounts/CompanyProfileForm.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 5fd19fe4f..21956c9a1 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -769,7 +769,14 @@ export default function CompanyProfileForm({ // themselves, so there is no delegation to evidence. Mirrors the API's own // waiver in `assertPoaDelegationSatisfied` — the two must agree, or this // demands a file the server would accept the submission without. - const delegationRequired = (poaProvided || requirePoa) && !poaSameAsOwner; + // + // Split from `poaDue` — "there is a representative, so their details are + // owed" — because a self-PoA keeps the second while dropping the first. The + // API draws the same line (`poaDue` / `delegationDue` in + // getOnboardingRequirements); anything that is about the *details* must key + // on `poaDue`, only the paper keys on this. + const poaDue = poaProvided || requirePoa; + const delegationRequired = poaDue && !poaSameAsOwner; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -853,9 +860,15 @@ export default function CompanyProfileForm({ // here. if (gmGaps.name) requiredKeys.push("generalManagerName"); if (gmGaps.phone) requiredKeys.push("generalManagerPhone"); - } else if (step === "poa" && delegationRequired) { + } else if (step === "poa" && poaDue) { // Only once a PoA is required or provided: an untouched optional PoA is // still a step the customer may walk straight past. + // + // `poaDue`, NOT `delegationRequired`: the paper is waived for a self-PoA + // but `REQUIRED_POA_FIELDS` is not, and the API reports every one of them + // missing (`missingPoaFields` keys on its own `poaDue`) — which fails the + // submit and clamps the resume back here. Keying this on the paper let the + // customer walk past an input this step had already put on screen. if (poaGaps.name) requiredKeys.push("poaName"); if (poaGaps.email) requiredKeys.push("poaEmail"); if (poaGaps.phone) requiredKeys.push("poaPhone"); From 18c47ca4a31b145c296896e578a2a5e72f89367c Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 10 Aug 2026 14:25:14 +0300 Subject: [PATCH 2/2] 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 }; }