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); return this.initiateWalletPayment(booking);
} }
// Double-charge guard for payment-method switches. Before opening a fresh charge over // Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
// this booking, reconcile any still-open intent against the authoritative provider // single passenger projection row (upserted by bookingId below) tracks the latest session.
// status — the booking-status check above only blocks once the booking is CONFIRMED, // Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
// 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.
}
const { returnUrl, failureUrl } = this.resolveReturnUrls( const { returnUrl, failureUrl } = this.resolveReturnUrls(
method, method,
requestOrigin, requestOrigin,
@@ -902,6 +837,8 @@ export class PaymentsService {
intentId: string; intentId: string;
providerTxnId?: string; providerTxnId?: string;
paidAt?: Date; paidAt?: Date;
/** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */
force?: boolean;
}): Promise<{ alreadyFinalized: boolean }> { }): Promise<{ alreadyFinalized: boolean }> {
const intent = await this.prisma.paymentIntent.findUnique({ const intent = await this.prisma.paymentIntent.findUnique({
where: { id: input.intentId }, where: { id: input.intentId },
@@ -911,32 +848,35 @@ export class PaymentsService {
// Idempotency guard — but still repair missing tickets. They can be absent // Idempotency guard — but still repair missing tickets. They can be absent
// when the first finalization threw from generate() after the transaction // when the first finalization threw from generate() after the transaction
// committed: the caller got a 500, retried, and now hits this early-return. // committed: the caller got a 500, retried, and now hits this early-return.
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } }); // Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a
if (ticketCount === 0) { // recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER
try { // generate a ticket.
await this.ticketsService.generate(intent.bookingId); const idempotencyBooking = await this.prisma.booking.findUnique({
} catch (err) { where: { id: intent.bookingId },
const msg = err instanceof Error ? err.message : String(err); select: { status: true },
this.logger.warn( });
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`, if (idempotencyBooking?.status === "CONFIRMED") {
); const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
if (ticketCount === 0) {
try { try {
await this.ticketsService.smartAssignAndGenerate(intent.bookingId); await this.ticketsService.generate(intent.bookingId);
} catch (retryErr) { } catch (err) {
this.logger.error( const msg = err instanceof Error ? err.message : String(err);
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, this.logger.warn(
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
); );
try {
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
} catch (retryErr) {
this.logger.error(
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
);
}
} }
} }
} }
return { alreadyFinalized: true }; return { alreadyFinalized: true };
} }
if (intent.status === PaymentIntentStatus.CANCELLED) {
throw new BadRequestException(
"PaymentIntent is cancelled; cannot finalize",
);
}
const booking = await this.prisma.booking.findUnique({ const booking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId }, where: { id: intent.bookingId },
include: { seats: true }, include: { seats: true },
@@ -944,7 +884,24 @@ export class PaymentsService {
if (!booking) throw new NotFoundException("Booking not found"); if (!booking) throw new NotFoundException("Booking not found");
const paidAt = this.sanitizePaidAt(input.paidAt); 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({ await tx.paymentIntent.update({
where: { id: intent.id }, where: { id: intent.id },
data: { data: {
@@ -952,14 +909,23 @@ export class PaymentsService {
providerTxnId: providerTxnId:
input.providerTxnId ?? intent.providerTxnId ?? undefined, input.providerTxnId ?? intent.providerTxnId ?? undefined,
paidAt, paidAt,
failureCode: null,
failureMessage: null,
}, },
}); });
await tx.booking.update({ return res.count;
where: { id: booking.id },
data: { status: "CONFIRMED" },
});
}); });
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 { try {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId)); await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
} catch (err) { } catch (err) {
@@ -1100,25 +1066,78 @@ export class PaymentsService {
return { processed: false, reason: "amount-mismatch" }; return { processed: false, reason: "amount-mismatch" };
} }
// Local intent row is a projection during the strangler migration: reuse it when the // Payment on an already-CANCELLED booking: record the success on the passenger projection too
// legacy initiate path created one, otherwise materialize it from the event. // (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER
let intent = await this.prisma.paymentIntent.findUnique({ // generate a ticket — the seats may already be held by another passenger. Refund is manual.
where: { bookingId: event.referenceId }, if (booking.status === "CANCELLED") {
}); await this.prisma.paymentIntent.upsert({
if (!intent) { where: { bookingId: event.referenceId },
intent = await this.prisma.paymentIntent.create({ update: {
data: { status: PaymentIntentStatus.SUCCEEDED,
bookingId: event.referenceId, method: event.provider as unknown as PaymentMethodType,
amountMinor: event.amountMinor, amountMinor: event.amountMinor,
currency: event.currency, currency: event.currency,
method: event.provider as unknown as PaymentMethodType,
status: PaymentIntentStatus.PROCESSING,
merchantOrderId: event.merchantOrderId, merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId, 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,
),
}, },
}); });
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,
method: event.provider as unknown as PaymentMethodType,
status: PaymentIntentStatus.PROCESSING,
merchantOrderId: event.merchantOrderId,
providerTxnId: event.providerTxnId,
},
});
const { alreadyFinalized } = await this.finalizePaymentSuccess({ const { alreadyFinalized } = await this.finalizePaymentSuccess({
intentId: intent.id, intentId: intent.id,
providerTxnId: event.providerTxnId, providerTxnId: event.providerTxnId,
@@ -1174,6 +1193,7 @@ export class PaymentsService {
return this.finalizePaymentSuccess({ return this.finalizePaymentSuccess({
intentId: intent.id, intentId: intent.id,
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined, providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
force: true,
}).then(async (result) => { }).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 } }); await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
return result; 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. * adding a provider/status never needs an ALTER TYPE migration.
*/ */
@Entity({ name: "payment_intent" }) @Entity({ name: "payment_intent" })
// One ACTIVE intent per domain order; FAILED/CANCELLED attempts may accumulate as audit rows. // A domain order may have MANY intents (free method changes; a second real payment is stored as
@Index( // its own SUCCEEDED row). Plain lookup index — no uniqueness. Confirm-once is enforced by the
"uq_payment_intent_active_reference", // owning app confirming the booking only while it is still payable, not by the DB.
["service", "referenceType", "referenceId"], @Index("idx_payment_intent_reference", ["service", "referenceType", "referenceId"])
{
unique: true,
where: `status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`,
},
)
@Index("idx_payment_intent_sweep", ["status", "updatedAt"]) @Index("idx_payment_intent_sweep", ["status", "updatedAt"])
@Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"]) @Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"])
export class PaymentIntent extends BaseEntity { export class PaymentIntent extends BaseEntity {

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; 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 { BaseRepository } from "@edr/api-common";
import { import {
PaymentReferenceType, PaymentReferenceType,
@@ -18,20 +18,41 @@ export class IntentsRepository extends BaseRepository<PaymentIntent> {
super(repository); 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, service: PaymentService,
referenceType: PaymentReferenceType, referenceType: PaymentReferenceType,
referenceId: string, referenceId: string,
): Promise<PaymentIntent | null> { ): 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({ return this.repository.findOne({
where: { where: {
service, service,
referenceType, referenceType,
referenceId, referenceId,
status: Not( status: In([
In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]), ProviderPaymentStatus.REQUIRES_ACTION,
), ProviderPaymentStatus.PROCESSING,
]),
}, },
order: { createdAt: "DESC" }, order: { createdAt: "DESC" },
}); });

View File

@@ -1,12 +1,11 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
Inject, Inject,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { DataSource, QueryFailedError } from "typeorm"; import { DataSource } from "typeorm";
import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers"; import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers";
import { import {
ConfirmPaymentRequest, ConfirmPaymentRequest,
@@ -30,7 +29,6 @@ import {
} from "./entities/payment-intent.entity"; } from "./entities/payment-intent.entity";
import { IntentsRepository } from "./intents.repository"; import { IntentsRepository } from "./intents.repository";
const PG_UNIQUE_VIOLATION = "23505";
/** Don't hit the provider again if the intent was refreshed this recently. */ /** Don't hit the provider again if the intent was refreshed this recently. */
const REFRESH_MIN_AGE_MS = 5_000; const REFRESH_MIN_AGE_MS = 5_000;
@@ -74,58 +72,11 @@ export class IntentsService {
if (byKey) return this.toSnapshot(byKey); if (byKey) return this.toSnapshot(byKey);
} }
const existing = await this.intentsRepository.findActiveByReference( // Free method changes: no reuse/supersede. Every initiate opens a fresh intent, so a booking
request.service, // may accumulate many intents (each method attempt is its own row). The `idempotencyKey` check
request.referenceType, // above still collapses exact duplicate submissions (e.g. a double-click). Confirm-once is
request.referenceId, // 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.
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);
}
}
const provider = this.providers.get(request.provider); const provider = this.providers.get(request.provider);
if (!provider) { if (!provider) {
throw new BadRequestException( throw new BadRequestException(
@@ -155,40 +106,25 @@ export class IntentsService {
failureUrl: request.failureUrl, failureUrl: request.failureUrl,
}); });
try { const intent = await this.intentsRepository.create({
const intent = await this.intentsRepository.create({ service: request.service,
service: request.service, referenceType: request.referenceType,
referenceType: request.referenceType, referenceId: request.referenceId,
referenceId: request.referenceId, merchantOrderId,
merchantOrderId, provider: request.provider,
provider: request.provider, providerOrderId: result.providerOrderId,
providerOrderId: result.providerOrderId, amountMinor: request.amountMinor,
amountMinor: request.amountMinor, currency: request.currency,
currency: request.currency, status: ProviderPaymentStatus.REQUIRES_ACTION,
status: ProviderPaymentStatus.REQUIRES_ACTION, clientAction: result.clientAction,
clientAction: result.clientAction, idempotencyKey: request.idempotencyKey ?? null,
idempotencyKey: request.idempotencyKey ?? null, expiresAt: result.expiresAt,
expiresAt: result.expiresAt, rawInitiation: result.rawInitiation,
rawInitiation: result.rawInitiation, });
}); this.logger.log(
this.logger.log( `intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`, );
); return this.toSnapshot(intent);
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) */ /* ------------------------------------------------------------------ confirm (OTP providers) */
@@ -284,84 +220,6 @@ export class IntentsService {
return this.toSnapshot(updated); 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 */ /* ------------------------------------------------------------------ lookups */
async getIntent(id: string): Promise<PaymentIntentSnapshot> { async getIntent(id: string): Promise<PaymentIntentSnapshot> {
@@ -375,7 +233,7 @@ export class IntentsService {
referenceType: PaymentReferenceType, referenceType: PaymentReferenceType,
referenceId: string, referenceId: string,
): Promise<PaymentIntentSnapshot> { ): Promise<PaymentIntentSnapshot> {
const intent = await this.intentsRepository.findActiveByReference( const intent = await this.intentsRepository.findLatestByReference(
service, service,
referenceType, referenceType,
referenceId, referenceId,
@@ -390,7 +248,8 @@ export class IntentsService {
* {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without * {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without
* knowing the merchant order id. Pure read (no state-machine mutation). * 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 * - `provider`: the raw provider status response (queried using the intent's own provider), or
* `null` when there is no intent or the query fails. * `null` when there is no intent or the query fails.
*/ */
@@ -399,7 +258,7 @@ export class IntentsService {
referenceType: PaymentReferenceType, referenceType: PaymentReferenceType,
referenceId: string, referenceId: string,
): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> { ): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
const intent = await this.intentsRepository.findActiveByReference( const intent = await this.intentsRepository.findLatestByReference(
service, service,
referenceType, referenceType,
referenceId, referenceId,
@@ -552,7 +411,16 @@ export class IntentsService {
intent.status, 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 // Keep the audit payload current with the latest provider status body (surfaced as