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,32 +848,35 @@ 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.
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
// 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 {
|
||||
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)}`,
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
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 };
|
||||
}
|
||||
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,25 +1066,78 @@ 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({
|
||||
where: { bookingId: event.referenceId },
|
||||
});
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: event.referenceId,
|
||||
// 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,
|
||||
method: event.provider as unknown as PaymentMethodType,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
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,
|
||||
),
|
||||
},
|
||||
});
|
||||
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({
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user