mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: ( payment ) prevent duplicate booking charges and fix D-Money queryOrder
This commit is contained in:
@@ -100,11 +100,13 @@ export class IntentsService {
|
||||
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);
|
||||
// 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 {
|
||||
// PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen —
|
||||
@@ -272,18 +274,27 @@ export class IntentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-initiate guard for an open REQUIRES_ACTION intent on the same provider.
|
||||
* 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. 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.
|
||||
* 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 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.
|
||||
*
|
||||
* 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 verifyThenSupersede(
|
||||
private async verifyThenReuseOrRetire(
|
||||
intent: PaymentIntent,
|
||||
request: InitiatePaymentRequest,
|
||||
): Promise<PaymentIntent | null> {
|
||||
let status: ProviderStatus;
|
||||
try {
|
||||
@@ -291,7 +302,7 @@ export class IntentsService {
|
||||
} 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`,
|
||||
`verify-before-reuse: queryStatus failed for intent ${intent.id}: ${message}; reusing existing session`,
|
||||
);
|
||||
return intent;
|
||||
}
|
||||
@@ -304,14 +315,27 @@ export class IntentsService {
|
||||
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;
|
||||
|
||||
if (!expired && !chargeChanged) {
|
||||
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; previous provider session superseded",
|
||||
: "Payer re-initiated with a changed amount; previous session superseded",
|
||||
});
|
||||
this.logger.log(
|
||||
`intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
|
||||
|
||||
Reference in New Issue
Block a user