mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +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);
|
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;
|
||||||
|
|||||||
@@ -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.
|
* 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 {
|
||||||
|
|||||||
@@ -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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user