mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
fix: ( payment ) prevent duplicate booking charges and fix D-Money queryOrder
This commit is contained in:
@@ -242,6 +242,61 @@ export class PaymentsService {
|
|||||||
return this.initiateWalletPayment(booking);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if (
|
||||||
|
!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 — fall through and initiate
|
||||||
|
// the newly selected method below.
|
||||||
|
}
|
||||||
|
|
||||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||||
method,
|
method,
|
||||||
requestOrigin,
|
requestOrigin,
|
||||||
|
|||||||
@@ -100,11 +100,13 @@ export class IntentsService {
|
|||||||
existing.status === ProviderPaymentStatus.REQUIRES_ACTION
|
existing.status === ProviderPaymentStatus.REQUIRES_ACTION
|
||||||
) {
|
) {
|
||||||
// Same provider, payer re-initiated while a session is open (back button,
|
// Same provider, payer re-initiated while a session is open (back button,
|
||||||
// abandoned checkout). Provider sessions are single-use, so re-serving the
|
// abandoned checkout, second device). Verify at the provider first, then:
|
||||||
// old clientAction hands the payer a dead checkout. Verify at the provider,
|
// paid/processing sessions are adopted; an unpaid session that is still live
|
||||||
// then supersede: paid/processing intents are adopted, unpaid ones retired
|
// (unexpired, same amount) is REUSED — its hosted page stays payable until
|
||||||
// so a fresh session opens below.
|
// expiresAt, so minting a fresh session would leave the old one concurrently
|
||||||
const settled = await this.verifyThenSupersede(existing);
|
// 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);
|
if (settled) return this.toSnapshot(settled);
|
||||||
} else {
|
} else {
|
||||||
// PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen —
|
// 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
|
* 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
|
* the webhook still in flight. Then:
|
||||||
* state machine and the intent is returned for reuse. Anything still unpaid is
|
*
|
||||||
* retired (CANCELLED, no notification — nothing was paid; a payment.failed here
|
* - Paid/processing: applied through the state machine and the intent is returned
|
||||||
* would wrongly fail the domain order mid-retry) and null is returned so the
|
* for the caller to adopt.
|
||||||
* caller opens a fresh provider session. When the status query itself errors,
|
* - Unpaid but still live (not expired, same amount/currency): the existing intent
|
||||||
* the existing intent is reused unchanged: superseding blind could leave two
|
* is REUSED and returned — the provider's hosted page remains payable until
|
||||||
* live sessions and a double charge.
|
* 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,
|
intent: PaymentIntent,
|
||||||
|
request: InitiatePaymentRequest,
|
||||||
): Promise<PaymentIntent | null> {
|
): Promise<PaymentIntent | null> {
|
||||||
let status: ProviderStatus;
|
let status: ProviderStatus;
|
||||||
try {
|
try {
|
||||||
@@ -291,7 +302,7 @@ export class IntentsService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.warn(
|
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;
|
return intent;
|
||||||
}
|
}
|
||||||
@@ -304,14 +315,27 @@ export class IntentsService {
|
|||||||
return (await this.intentsRepository.findById(intent.id)) ?? intent;
|
return (await this.intentsRepository.findById(intent.id)) ?? intent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unpaid at the provider. Reuse the still-live session rather than superseding it.
|
||||||
const expired =
|
const expired =
|
||||||
intent.expiresAt != null && intent.expiresAt.getTime() < Date.now();
|
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, {
|
await this.intentsRepository.update(intent.id, {
|
||||||
status: ProviderPaymentStatus.CANCELLED,
|
status: ProviderPaymentStatus.CANCELLED,
|
||||||
failureCode: expired ? "EXPIRED" : "SUPERSEDED",
|
failureCode: expired ? "EXPIRED" : "SUPERSEDED",
|
||||||
failureMessage: expired
|
failureMessage: expired
|
||||||
? "Provider session expired before the payer acted"
|
? "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(
|
this.logger.log(
|
||||||
`intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
|
`intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
|
||||||
|
|||||||
@@ -102,8 +102,12 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
|
||||||
const fabricToken = await this.applyFabricToken();
|
const fabricToken = await this.applyFabricToken();
|
||||||
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
|
||||||
|
const { sign: _sign, ...sanitizedBody } = requestBody;
|
||||||
|
this.logger.log(
|
||||||
|
`D-Money queryOrder send request merchOrderId=${merchantOrderId} body=${JSON.stringify(sanitizedBody)}`,
|
||||||
|
);
|
||||||
const response = await this.postJson<DMoneyQueryOrderResponse>(
|
const response = await this.postJson<DMoneyQueryOrderResponse>(
|
||||||
`${this.baseUrl}/apiaccess/payment/v1/merchant/queryOrder`,
|
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/queryOrder`,
|
||||||
requestBody,
|
requestBody,
|
||||||
{
|
{
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -112,9 +116,18 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`D-Money queryOrder response merchOrderId=${merchantOrderId} body=${JSON.stringify(response)}`,
|
||||||
|
);
|
||||||
|
|
||||||
const orderStatus = response.biz_content?.order_status;
|
const orderStatus = response.biz_content?.order_status;
|
||||||
const providerTxnId = response.biz_content?.payment_order_id;
|
const providerTxnId = response.biz_content?.payment_order_id;
|
||||||
const mapped = this.mapOrderStatus(orderStatus);
|
const mapped = this.mapOrderStatus(orderStatus);
|
||||||
|
this.logger.log(
|
||||||
|
`D-Money queryOrder result merchOrderId=${merchantOrderId} ` +
|
||||||
|
`orderStatus=${orderStatus ?? "n/a"} mapped=${mapped} ` +
|
||||||
|
`providerTxnId=${providerTxnId ?? "n/a"}`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: mapped,
|
status: mapped,
|
||||||
|
|||||||
Reference in New Issue
Block a user