From e76ff5e1594b9bf5026b8ebf6d45fe627b31502d Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 29 Jul 2026 09:55:45 +0300 Subject: [PATCH] feat: ( payment ) free method changes, register every payment, confirm booking once --- .../src/modules/payments/payments.service.ts | 226 ++++++++++-------- ...00000000-DropActiveReferenceUniqueIndex.ts | 39 +++ .../intents/entities/payment-intent.entity.ts | 13 +- .../src/modules/intents/intents.repository.ts | 33 ++- .../src/modules/intents/intents.service.ts | 210 +++------------- 5 files changed, 232 insertions(+), 289 deletions(-) create mode 100644 apps/edr-payment-api/src/migrations/1782200000000-DropActiveReferenceUniqueIndex.ts 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 8f3aabbab..d0520998b 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -244,74 +244,9 @@ 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); - } - - // A web↔mobile switch needs a different clientAction (Telebirr: REDIRECT for web - // vs LAUNCH_APP for the native app). Detect the open session's platform from its - // clientAction shape so a platform change is NOT blocked below: it must flow - // through to re-initiate, where the payment service retires the stale session and - // opens a fresh one with the correct launch method for the requested platform. - const requestedMobile = (dto.platform ?? "web") === "mobile"; - const storedAction = (snapshot?.clientAction ?? - existingIntent.clientAction) as unknown as ClientAction | null; - const platformChanged = - (storedAction?.type === "LAUNCH_APP") !== requestedMobile; - - // 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. A platform switch is exempt — it - // falls through so a session with the correct clientAction is opened for it. - if ( - !platformChanged && - (!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, or the payer switched platform — - // fall through and initiate the newly selected method below. - } - + // Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the + // single passenger projection row (upserted by bookingId below) tracks the latest session. + // Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here. const { returnUrl, failureUrl } = this.resolveReturnUrls( method, requestOrigin, @@ -902,6 +837,8 @@ export class PaymentsService { intentId: string; providerTxnId?: string; paidAt?: Date; + /** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */ + force?: boolean; }): Promise<{ alreadyFinalized: boolean }> { const intent = await this.prisma.paymentIntent.findUnique({ where: { id: input.intentId }, @@ -911,32 +848,35 @@ export class PaymentsService { // Idempotency guard — but still repair missing tickets. They can be absent // when the first finalization threw from generate() after the transaction // committed: the caller got a 500, retried, and now hits this early-return. - const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } }); - if (ticketCount === 0) { - try { - await this.ticketsService.generate(intent.bookingId); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.logger.warn( - `Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`, - ); + // Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a + // recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER + // generate a ticket. + const idempotencyBooking = await this.prisma.booking.findUnique({ + where: { id: intent.bookingId }, + select: { status: true }, + }); + if (idempotencyBooking?.status === "CONFIRMED") { + const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } }); + if (ticketCount === 0) { try { - await this.ticketsService.smartAssignAndGenerate(intent.bookingId); - } catch (retryErr) { - this.logger.error( - `Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, + await this.ticketsService.generate(intent.bookingId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`, ); + try { + await this.ticketsService.smartAssignAndGenerate(intent.bookingId); + } catch (retryErr) { + this.logger.error( + `Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, + ); + } } } } return { alreadyFinalized: true }; } - if (intent.status === PaymentIntentStatus.CANCELLED) { - throw new BadRequestException( - "PaymentIntent is cancelled; cannot finalize", - ); - } - const booking = await this.prisma.booking.findUnique({ where: { id: intent.bookingId }, include: { seats: true }, @@ -944,7 +884,24 @@ export class PaymentsService { if (!booking) throw new NotFoundException("Booking not found"); const paidAt = this.sanitizePaidAt(input.paidAt); - await this.prisma.$transaction(async (tx) => { + + // Atomic confirm-once. A booking may have many intents (free method changes); only the FIRST + // success on a still-PENDING_PAYMENT booking confirms it + generates the ticket. The conditional + // update is the race guard: two simultaneous payments both reach here, but exactly one flips + // PENDING_PAYMENT→CONFIRMED (count 1) — the other gets count 0 and is register-only (the payment + // is already stored on the payment-api ledger; we don't confirm, don't ticket, don't mark this + // row SUCCEEDED). `force` (staff) confirms regardless of the current booking status. + const confirmed = await this.prisma.$transaction(async (tx) => { + const res = input.force + ? await tx.booking.updateMany({ + where: { id: booking.id, status: { not: "CONFIRMED" } }, + data: { status: "CONFIRMED" }, + }) + : await tx.booking.updateMany({ + where: { id: booking.id, status: "PENDING_PAYMENT" }, + data: { status: "CONFIRMED" }, + }); + if (res.count === 0) return 0; await tx.paymentIntent.update({ where: { id: intent.id }, data: { @@ -952,14 +909,23 @@ export class PaymentsService { providerTxnId: input.providerTxnId ?? intent.providerTxnId ?? undefined, paidAt, + failureCode: null, + failureMessage: null, }, }); - await tx.booking.update({ - where: { id: booking.id }, - data: { status: "CONFIRMED" }, - }); + return res.count; }); + 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`, + ); + return { alreadyFinalized: true }; + } + try { await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); } catch (err) { @@ -1100,25 +1066,78 @@ export class PaymentsService { return { processed: false, reason: "amount-mismatch" }; } - // Local intent row is a projection during the strangler migration: reuse it when the - // legacy initiate path created one, otherwise materialize it from the event. - let intent = await this.prisma.paymentIntent.findUnique({ - where: { bookingId: event.referenceId }, - }); - if (!intent) { - intent = await this.prisma.paymentIntent.create({ - data: { - bookingId: event.referenceId, + // Payment on an already-CANCELLED booking: record the success on the passenger projection too + // (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER + // generate a ticket — the seats may already be held by another passenger. Refund is manual. + if (booking.status === "CANCELLED") { + await this.prisma.paymentIntent.upsert({ + where: { bookingId: event.referenceId }, + update: { + status: PaymentIntentStatus.SUCCEEDED, + method: event.provider as unknown as PaymentMethodType, amountMinor: event.amountMinor, currency: event.currency, - method: event.provider as unknown as PaymentMethodType, - status: PaymentIntentStatus.PROCESSING, merchantOrderId: event.merchantOrderId, providerTxnId: event.providerTxnId, + paidAt: this.sanitizePaidAt( + event.paidAt ? new Date(event.paidAt) : undefined, + ), + }, + create: { + bookingId: event.referenceId, + status: PaymentIntentStatus.SUCCEEDED, + method: event.provider as unknown as PaymentMethodType, + amountMinor: event.amountMinor, + currency: event.currency, + merchantOrderId: event.merchantOrderId, + providerTxnId: event.providerTxnId, + paidAt: this.sanitizePaidAt( + event.paidAt ? new Date(event.paidAt) : undefined, + ), }, }); + this.logger.error( + `Payment on CANCELLED booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` + + `txn=${event.providerTxnId ?? "n/a"}) — recorded on passenger + payment-api; NOT confirming ` + + `(seats may be reassigned). Refund required.`, + ); + return { processed: true, alreadyFinalized: true }; } + // Sequential duplicate on an already-CONFIRMED booking: this success is a second payment, + // already registered on the payment-api ledger. Do NOT touch the passenger row — it must keep + // the confirming payment. (The concurrent-race case is caught atomically in finalizePaymentSuccess.) + if (booking.status !== "PENDING_PAYMENT") { + this.logger.error( + `Duplicate capture on ${booking.status} booking ${booking.id} (merchantOrder=${event.merchantOrderId}, ` + + `txn=${event.providerTxnId ?? "n/a"}) — registered in payment-api; not confirming`, + ); + return { processed: true, alreadyFinalized: true }; + } + + // Booking is payable — point the single passenger projection row at THIS paying session (so the + // row reflects the payment that confirms the booking, even if the payer switched methods), then + // finalize (which does the atomic confirm-once). + const intent = await this.prisma.paymentIntent.upsert({ + where: { bookingId: event.referenceId }, + update: { + method: event.provider as unknown as PaymentMethodType, + amountMinor: event.amountMinor, + currency: event.currency, + merchantOrderId: event.merchantOrderId, + providerTxnId: event.providerTxnId, + }, + create: { + bookingId: event.referenceId, + amountMinor: event.amountMinor, + currency: event.currency, + method: event.provider as unknown as PaymentMethodType, + status: PaymentIntentStatus.PROCESSING, + merchantOrderId: event.merchantOrderId, + providerTxnId: event.providerTxnId, + }, + }); + const { alreadyFinalized } = await this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: event.providerTxnId, @@ -1174,6 +1193,7 @@ export class PaymentsService { return this.finalizePaymentSuccess({ intentId: intent.id, providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined, + force: true, }).then(async (result) => { await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } }); return result; diff --git a/apps/edr-payment-api/src/migrations/1782200000000-DropActiveReferenceUniqueIndex.ts b/apps/edr-payment-api/src/migrations/1782200000000-DropActiveReferenceUniqueIndex.ts new file mode 100644 index 000000000..6c2ed3da0 --- /dev/null +++ b/apps/edr-payment-api/src/migrations/1782200000000-DropActiveReferenceUniqueIndex.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Drops the per-reference partial UNIQUE index so a domain order may have MANY intents — including + * more than one SUCCEEDED (a second real payment is registered as its own row, not blocked by the + * DB). A plain, non-unique lookup index replaces it for the reference lookups. + * + * DATA SAFETY: dropping an index never touches row data — no rows or column values change, and + * relaxing a constraint cannot conflict with existing rows. + * + * ROLLBACK CAVEAT: `down()` re-creates the UNIQUE index on a best-effort basis. Once the new + * "free initiate" behaviour has produced two active/SUCCEEDED intents for one order, re-creating + * the unique index WILL FAIL (the duplicates violate it). Roll forward is always safe; rolling + * back to the strict constraint is only possible while no duplicates exist. + */ +export class DropActiveReferenceUniqueIndex1782200000000 + implements MigrationInterface +{ + name = "DropActiveReferenceUniqueIndex1782200000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "edr_payment"."uq_payment_intent_active_reference"`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_payment_intent_reference" ON "edr_payment"."payment_intent" ("service", "reference_type", "reference_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "edr_payment"."idx_payment_intent_reference"`, + ); + // Best-effort restore — fails if duplicate active/SUCCEEDED intents already exist (see note above). + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "uq_payment_intent_active_reference" ON "edr_payment"."payment_intent" ("service", "reference_type", "reference_id") WHERE status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`, + ); + } +} diff --git a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts index be32804ec..12fa3c402 100644 --- a/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts +++ b/apps/edr-payment-api/src/modules/intents/entities/payment-intent.entity.ts @@ -17,15 +17,10 @@ import { * adding a provider/status never needs an ALTER TYPE migration. */ @Entity({ name: "payment_intent" }) -// One ACTIVE intent per domain order; FAILED/CANCELLED attempts may accumulate as audit rows. -@Index( - "uq_payment_intent_active_reference", - ["service", "referenceType", "referenceId"], - { - unique: true, - where: `status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`, - }, -) +// A domain order may have MANY intents (free method changes; a second real payment is stored as +// its own SUCCEEDED row). Plain lookup index — no uniqueness. Confirm-once is enforced by the +// owning app confirming the booking only while it is still payable, not by the DB. +@Index("idx_payment_intent_reference", ["service", "referenceType", "referenceId"]) @Index("idx_payment_intent_sweep", ["status", "updatedAt"]) @Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"]) export class PaymentIntent extends BaseEntity { diff --git a/apps/edr-payment-api/src/modules/intents/intents.repository.ts b/apps/edr-payment-api/src/modules/intents/intents.repository.ts index a17407069..b37aa7827 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.repository.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { In, LessThan, Not, Repository } from "typeorm"; +import { In, LessThan, Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; import { PaymentReferenceType, @@ -18,20 +18,41 @@ export class IntentsRepository extends BaseRepository { super(repository); } - /** The single non-FAILED/CANCELLED intent for a domain order (matches the partial unique index). */ - async findActiveByReference( + /** + * The intent that represents an order's payment status, tolerant of MANY intents per reference + * (once the per-reference unique index is dropped). Resolution order: + * 1. a SUCCEEDED intent — the order is paid; this is the confirming payment; + * 2. else the most recent non-terminal intent — the session the payer is currently on; + * 3. else null — no open or paid intent (only FAILED/CANCELLED attempts exist). + * + * While the per-reference unique index existed this returned the single active row; with the + * index gone it prefers the paid intent, then the newest open session. + */ + async findLatestByReference( service: PaymentService, referenceType: PaymentReferenceType, referenceId: string, ): Promise { + const succeeded = await this.repository.findOne({ + where: { + service, + referenceType, + referenceId, + status: ProviderPaymentStatus.SUCCEEDED, + }, + order: { createdAt: "DESC" }, + }); + if (succeeded) return succeeded; + return this.repository.findOne({ where: { service, referenceType, referenceId, - status: Not( - In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]), - ), + status: In([ + ProviderPaymentStatus.REQUIRES_ACTION, + ProviderPaymentStatus.PROCESSING, + ]), }, order: { createdAt: "DESC" }, }); 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 ec334195b..0d8ccb120 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -1,12 +1,11 @@ import { BadRequestException, - ConflictException, Inject, Injectable, Logger, NotFoundException, } from "@nestjs/common"; -import { DataSource, QueryFailedError } from "typeorm"; +import { DataSource } from "typeorm"; import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers"; import { ConfirmPaymentRequest, @@ -30,7 +29,6 @@ import { } from "./entities/payment-intent.entity"; import { IntentsRepository } from "./intents.repository"; -const PG_UNIQUE_VIOLATION = "23505"; /** Don't hit the provider again if the intent was refreshed this recently. */ const REFRESH_MIN_AGE_MS = 5_000; @@ -74,58 +72,11 @@ export class IntentsService { if (byKey) return this.toSnapshot(byKey); } - const existing = await this.intentsRepository.findActiveByReference( - request.service, - request.referenceType, - request.referenceId, - ); - if (existing) { - // Payer switched method (e.g. Waafi → Telebirr) on an uncharged session: retire the - // open intent and fall through to open a fresh one for the new provider. Only safe - // while REQUIRES_ACTION — PROCESSING/SUCCEEDED intents may have money in flight, so - // they keep the reuse path (the switch is silently refused until they resolve). - const switchingProvider = - existing.provider !== request.provider && - existing.status === ProviderPaymentStatus.REQUIRES_ACTION; - if (switchingProvider) { - await this.intentsRepository.update(existing.id, { - status: ProviderPaymentStatus.CANCELLED, - failureCode: "METHOD_CHANGED", - failureMessage: `Payer switched from ${existing.provider} to ${request.provider}`, - }); - this.logger.log( - `intent ${existing.id} retired (METHOD_CHANGED ${existing.provider} → ${request.provider}) for ` + - `${request.service}/${request.referenceType}/${request.referenceId}`, - ); - } else if ( - existing.status === ProviderPaymentStatus.REQUIRES_ACTION - ) { - // Same provider, payer re-initiated while a session is open (back button, - // 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 if (existing.provider !== request.provider) { - // PROCESSING/SUCCEEDED on a DIFFERENT provider than requested: money may already be - // in flight there. Must not silently hand back that other provider's clientAction - // (e.g. its redirect URL) as if it belonged to the newly requested provider — the - // caller has no way to tell the two apart (see InitiateResponseDto), so it would - // blindly redirect the payer to the wrong gateway's checkout page. - throw new ConflictException( - `A ${existing.provider} payment is already ${existing.status.toLowerCase()} for this ` + - `booking. Complete or wait for it to resolve before switching payment methods.`, - ); - } else { - // Same provider, already PROCESSING/SUCCEEDED: never reopen — return the existing - // intent so the caller adopts its outcome. - return this.toSnapshot(existing); - } - } - + // Free method changes: no reuse/supersede. Every initiate opens a fresh intent, so a booking + // may accumulate many intents (each method attempt is its own row). The `idempotencyKey` check + // above still collapses exact duplicate submissions (e.g. a double-click). Confirm-once is + // enforced downstream — only the first success to reach a still-PENDING_PAYMENT booking + // confirms it; any other real payment is simply registered as its own SUCCEEDED intent. const provider = this.providers.get(request.provider); if (!provider) { throw new BadRequestException( @@ -155,40 +106,25 @@ export class IntentsService { failureUrl: request.failureUrl, }); - try { - const intent = await this.intentsRepository.create({ - service: request.service, - referenceType: request.referenceType, - referenceId: request.referenceId, - merchantOrderId, - provider: request.provider, - providerOrderId: result.providerOrderId, - amountMinor: request.amountMinor, - currency: request.currency, - status: ProviderPaymentStatus.REQUIRES_ACTION, - clientAction: result.clientAction, - idempotencyKey: request.idempotencyKey ?? null, - expiresAt: result.expiresAt, - rawInitiation: result.rawInitiation, - }); - this.logger.log( - `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`, - ); - return this.toSnapshot(intent); - } catch (err) { - if ( - err instanceof QueryFailedError && - (err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION - ) { - const winner = await this.intentsRepository.findActiveByReference( - request.service, - request.referenceType, - request.referenceId, - ); - if (winner) return this.toSnapshot(winner); - } - throw err; - } + const intent = await this.intentsRepository.create({ + service: request.service, + referenceType: request.referenceType, + referenceId: request.referenceId, + merchantOrderId, + provider: request.provider, + providerOrderId: result.providerOrderId, + amountMinor: request.amountMinor, + currency: request.currency, + status: ProviderPaymentStatus.REQUIRES_ACTION, + clientAction: result.clientAction, + idempotencyKey: request.idempotencyKey ?? null, + expiresAt: result.expiresAt, + rawInitiation: result.rawInitiation, + }); + this.logger.log( + `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`, + ); + return this.toSnapshot(intent); } /* ------------------------------------------------------------------ confirm (OTP providers) */ @@ -284,84 +220,6 @@ export class IntentsService { return this.toSnapshot(updated); } - /** - * 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. 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 or platform (web↔mobile) 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 with the correct amount/clientAction for the new platform. - * - * 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 verifyThenReuseOrRetire( - intent: PaymentIntent, - request: InitiatePaymentRequest, - ): Promise { - let status: ProviderStatus; - try { - status = await this.queryProviderStatus(intent); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.warn( - `verify-before-reuse: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`, - ); - return intent; - } - - if ( - status.status === ProviderPaymentStatus.SUCCEEDED || - status.status === ProviderPaymentStatus.PROCESSING - ) { - await this.applyProviderResult(intent.id, this.fromProviderStatus(status)); - 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; - // A web↔mobile switch needs a different clientAction shape (e.g. Telebirr: - // REDIRECT for web vs LAUNCH_APP for the native app), so reusing the stored - // session would hand the payer the wrong launch method and break the return. - // Detect the stored session's platform from its clientAction and retire on a switch. - const storedIsMobileLaunch = intent.clientAction?.type === "LAUNCH_APP"; - const requestedMobile = (request.platform ?? "web") === "mobile"; - const platformChanged = storedIsMobileLaunch !== requestedMobile; - - if (!expired && !chargeChanged && !platformChanged) { - 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 with a changed amount or platform; previous session superseded", - }); - this.logger.log( - `intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`, - ); - return null; - } - /* ------------------------------------------------------------------ lookups */ async getIntent(id: string): Promise { @@ -375,7 +233,7 @@ export class IntentsService { referenceType: PaymentReferenceType, referenceId: string, ): Promise { - const intent = await this.intentsRepository.findActiveByReference( + const intent = await this.intentsRepository.findLatestByReference( service, referenceType, referenceId, @@ -390,7 +248,8 @@ export class IntentsService { * {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without * knowing the merchant order id. Pure read (no state-machine mutation). * - * - `db`: the active stored intent for the reference, or `null` when none exists. + * - `db`: the stored intent that represents the order's payment status (a SUCCEEDED one if the + * order is paid, else the current open session), or `null` when none exists. * - `provider`: the raw provider status response (queried using the intent's own provider), or * `null` when there is no intent or the query fails. */ @@ -399,7 +258,7 @@ export class IntentsService { referenceType: PaymentReferenceType, referenceId: string, ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { - const intent = await this.intentsRepository.findActiveByReference( + const intent = await this.intentsRepository.findLatestByReference( service, referenceType, referenceId, @@ -552,7 +411,16 @@ export class IntentsService { intent.status, ) ) { - return { alreadyTerminal: true }; + // A SUCCEEDED signal on an intent we already retired (EXPIRED/CANCELLED/FAILED) means the + // provider session was paid late — its page stayed payable after we retired it. Register + // the capture: fall through so it becomes SUCCEEDED and emits payment.succeeded. The owning + // app confirms the booking only if it is still payable; otherwise it just records the + // payment (many SUCCEEDED per order are allowed now that the unique index is gone). An + // already-SUCCEEDED intent, or any non-success signal on a terminal intent, stays absorbing. + const lateCapture = + result.status === ProviderPaymentStatus.SUCCEEDED && + intent.status !== ProviderPaymentStatus.SUCCEEDED; + if (!lateCapture) return { alreadyTerminal: true }; } // Keep the audit payload current with the latest provider status body (surfaced as