feat: ( payment ) free method changes, register every payment, confirm booking once

This commit is contained in:
Abubeker Yasin
2026-07-29 09:55:45 +03:00
parent ed09bfd8b6
commit e76ff5e159
5 changed files with 232 additions and 289 deletions

View File

@@ -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,6 +848,14 @@ 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.
// 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 {
@@ -929,14 +874,9 @@ export class PaymentsService {
}
}
}
}
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,14 +1066,68 @@ 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({
// 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,
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,
),
},
});
if (!intent) {
intent = await this.prisma.paymentIntent.create({
data: {
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,
@@ -1117,7 +1137,6 @@ export class PaymentsService {
providerTxnId: event.providerTxnId,
},
});
}
const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id,
@@ -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;

View File

@@ -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<void> {
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<void> {
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`,
);
}
}

View File

@@ -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 {

View File

@@ -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<PaymentIntent> {
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<PaymentIntent | null> {
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" },
});

View File

@@ -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,7 +106,6 @@ export class IntentsService {
failureUrl: request.failureUrl,
});
try {
const intent = await this.intentsRepository.create({
service: request.service,
referenceType: request.referenceType,
@@ -175,20 +125,6 @@ export class IntentsService {
`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;
}
}
/* ------------------------------------------------------------------ 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<PaymentIntent | null> {
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<PaymentIntentSnapshot> {
@@ -375,7 +233,7 @@ export class IntentsService {
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntentSnapshot> {
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