mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( payment ) free method changes, register every payment, confirm booking once
This commit is contained in:
@@ -244,74 +244,9 @@ export class PaymentsService {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
|
||||
// Free method changes: no reuse/blocking. Every initiate opens a fresh provider session; the
|
||||
// single passenger projection row (upserted by bookingId below) tracks the latest session.
|
||||
// Confirm-once is enforced when a payment succeeds (finalizePaymentSuccess), not here.
|
||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(
|
||||
method,
|
||||
requestOrigin,
|
||||
@@ -902,6 +837,8 @@ export class PaymentsService {
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
/** Staff force-confirm: confirm the booking even if it is not PENDING_PAYMENT. */
|
||||
force?: boolean;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { id: input.intentId },
|
||||
@@ -911,6 +848,14 @@ export class PaymentsService {
|
||||
// Idempotency guard — but still repair missing tickets. They can be absent
|
||||
// when the first finalization threw from generate() after the transaction
|
||||
// committed: the caller got a 500, retried, and now hits this early-return.
|
||||
// Only repair for a CONFIRMED booking: a SUCCEEDED intent on a CANCELLED booking is a
|
||||
// recorded orphan payment (booking cancelled, seats possibly reassigned) and must NEVER
|
||||
// generate a ticket.
|
||||
const idempotencyBooking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (idempotencyBooking?.status === "CONFIRMED") {
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
@@ -929,14 +874,9 @@ export class PaymentsService {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
throw new BadRequestException(
|
||||
"PaymentIntent is cancelled; cannot finalize",
|
||||
);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { seats: true },
|
||||
@@ -944,7 +884,24 @@ export class PaymentsService {
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
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({
|
||||
where: { id: intent.id },
|
||||
data: {
|
||||
@@ -952,14 +909,23 @@ export class PaymentsService {
|
||||
providerTxnId:
|
||||
input.providerTxnId ?? intent.providerTxnId ?? undefined,
|
||||
paidAt,
|
||||
failureCode: null,
|
||||
failureMessage: null,
|
||||
},
|
||||
});
|
||||
await tx.booking.update({
|
||||
where: { id: booking.id },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
return res.count;
|
||||
});
|
||||
|
||||
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 {
|
||||
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
|
||||
} catch (err) {
|
||||
@@ -1100,14 +1066,68 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "amount-mismatch" };
|
||||
}
|
||||
|
||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||
// legacy initiate path created one, otherwise materialize it from the event.
|
||||
let intent = await this.prisma.paymentIntent.findUnique({
|
||||
// Payment on an already-CANCELLED booking: record the success on the passenger projection too
|
||||
// (it is already registered on the payment-api ledger), but NEVER confirm the booking and NEVER
|
||||
// generate a ticket — the seats may already be held by another passenger. Refund is manual.
|
||||
if (booking.status === "CANCELLED") {
|
||||
await this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId: event.referenceId },
|
||||
update: {
|
||||
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,
|
||||
),
|
||||
},
|
||||
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,
|
||||
),
|
||||
},
|
||||
});
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
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,
|
||||
@@ -1117,7 +1137,6 @@ export class PaymentsService {
|
||||
providerTxnId: event.providerTxnId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
@@ -1174,6 +1193,7 @@ export class PaymentsService {
|
||||
return this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
|
||||
force: true,
|
||||
}).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 } });
|
||||
return result;
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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,7 +106,6 @@ export class IntentsService {
|
||||
failureUrl: request.failureUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
const intent = await this.intentsRepository.create({
|
||||
service: request.service,
|
||||
referenceType: request.referenceType,
|
||||
@@ -175,20 +125,6 @@ export class IntentsService {
|
||||
`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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 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
|
||||
|
||||
Reference in New Issue
Block a user