This commit is contained in:
Marshal
2026-07-16 20:03:35 +00:00
parent 7fb5a58ab0
commit 35c53e3f36
5 changed files with 99 additions and 30 deletions

View File

@@ -96,9 +96,20 @@ export class IntentsService {
`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). Provider sessions are single-use, so re-serving the
// old clientAction hands the payer a dead checkout. Verify at the provider,
// then supersede: paid/processing intents are adopted, unpaid ones retired
// so a fresh session opens below.
const settled = await this.verifyThenSupersede(existing);
if (settled) return this.toSnapshot(settled);
} else {
const reusable = await this.reuseOrRetire(existing);
if (reusable) return this.toSnapshot(reusable);
// PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen —
// return the existing intent so the caller adopts its outcome.
return this.toSnapshot(existing);
}
}
@@ -261,24 +272,50 @@ export class IntentsService {
}
/**
* Decide whether an existing active intent can be returned as-is. An expired
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
* so a fresh provider session can be opened.
* Re-initiate guard 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. Paid/processing answers are applied through the
* state machine and the intent is returned for reuse. Anything still unpaid is
* 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 provider session. 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 reuseOrRetire(
private async verifyThenSupersede(
intent: PaymentIntent,
): Promise<PaymentIntent | null> {
const expired =
intent.status === ProviderPaymentStatus.REQUIRES_ACTION &&
intent.expiresAt != null &&
intent.expiresAt.getTime() < Date.now();
if (!expired) return intent;
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-supersede: 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;
}
const expired =
intent.expiresAt != null && intent.expiresAt.getTime() < Date.now();
await this.intentsRepository.update(intent.id, {
status: ProviderPaymentStatus.CANCELLED,
failureCode: "EXPIRED",
failureMessage: "Provider session expired before the payer acted",
failureCode: expired ? "EXPIRED" : "SUPERSEDED",
failureMessage: expired
? "Provider session expired before the payer acted"
: "Payer re-initiated; previous provider session superseded",
});
this.logger.log(
`intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
);
return null;
}