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

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