mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1016,7 +1016,7 @@ export class BillingService {
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber.replace("-", "_"),
|
||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
@@ -1032,10 +1032,8 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
if (!result.immediateSuccess) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
@@ -120,17 +121,27 @@ export class BookingInvoiceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire the booking's currently-open prepaid invoice when the booking is
|
||||
* Expire the booking's currently-open invoices (freight PREPAID and the
|
||||
* per-shipment clearance fee) when the booking is
|
||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||
* open invoice (never invoiced, already paid/cancelled/expired). Pass a
|
||||
* caller `manager` to enlist in its transaction.
|
||||
*/
|
||||
expireOpenInvoices(
|
||||
async expireOpenInvoices(
|
||||
bookingId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
|
||||
// id under its own source/type — retire it alongside the freight invoice, or
|
||||
// a cancelled shipment keeps a payable clearance invoice open.
|
||||
await this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
bookingId,
|
||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
manager,
|
||||
);
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
|
||||
@@ -161,6 +161,21 @@ export class ClearanceFeeService {
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire (idempotently) the unpaid contract-level fee invoice when the
|
||||
* contract reaches a terminal state — a dead contract must not leave a
|
||||
* payable clearance invoice open for the customer to settle. No-op when the
|
||||
* fee was already paid or never invoiced (mirrors the booking cancel path,
|
||||
* {@link BillingService.expirePayable}).
|
||||
*/
|
||||
async expireForContract(contractId: string): Promise<Invoice | null> {
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
contractId,
|
||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement branch point for `clearance`-source invoices: unlock the
|
||||
* document-upload step the fee was gating. Idempotent — a replayed event on
|
||||
|
||||
@@ -419,6 +419,10 @@ export class ContractTransitionService {
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
||||
await this.clearanceFeeService.expireForContract(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
@@ -457,6 +461,10 @@ export class ContractTransitionService {
|
||||
'STAFF',
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
||||
await this.clearanceFeeService.expireForContract(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { PaymentClientService } from './payment-client.service';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
|
||||
// PaymentsService is request-scoped (AuditService.@Inject(REQUEST) bubbles up).
|
||||
// This service keeps only singleton deps so its @Cron method registers correctly,
|
||||
// then resolves PaymentsService per-tick via ModuleRef (same pattern as
|
||||
// PaymentEventsConsumer).
|
||||
@Injectable()
|
||||
export class PaymentSyncService {
|
||||
private readonly logger = new Logger(PaymentSyncService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
// it for an already-confirmed booking is safe.
|
||||
//
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
},
|
||||
include: { paymentIntent: true },
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
// resolve() (not get()) because PaymentsService is scoped — same pattern
|
||||
// as PaymentEventsConsumer.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
errored++;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import { PaymentSyncService } from "./payment-sync.service";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { SeatsModule } from "../seats/seats.module";
|
||||
import { TicketsModule } from "../tickets/tickets.module";
|
||||
@@ -70,6 +71,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
SupplementaryChargesService,
|
||||
PaymentClientService,
|
||||
PaymentEventsConsumer,
|
||||
PaymentSyncService,
|
||||
ServiceAuthGuard,
|
||||
],
|
||||
exports: [PaymentClientService, PaymentsService],
|
||||
|
||||
@@ -2,11 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { SeatStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { PaymentClientService } from '../payments/payment-client.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -31,8 +29,6 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -259,84 +255,435 @@ export class TasksService {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
// it for an already-confirmed booking is safe.
|
||||
// Every 1 min: detect and resolve duplicate seat assignments.
|
||||
//
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// Root cause: a stale RabbitMQ message, delivered after system recovery,
|
||||
// re-confirmed a cancelled booking whose seat had already been assigned to
|
||||
// a new booking — leaving two CONFIRMED bookings holding the same seat on
|
||||
// the same schedule.
|
||||
//
|
||||
// Resolution (FCFS):
|
||||
// • Earliest confirmed booking keeps the original seat.
|
||||
// • All later duplicates are reassigned to the next free seat within the
|
||||
// SAME coach type (same coach preferred; any coach of same type as
|
||||
// fallback).
|
||||
// • If no seat is available in that coach type the booking is flagged for
|
||||
// manual intervention and logged as unresolved.
|
||||
//
|
||||
// Idempotent: after reassignment the BookingSeat/JourneySegment rows no
|
||||
// longer share the same (seatId, scheduleId) key, so the next tick finds
|
||||
// nothing to do for the same pair.
|
||||
//
|
||||
// Scope: only schedules departing in the last 24 h or in the future, to
|
||||
// keep the per-tick DB scan bounded.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
async resolveDuplicateSeatAssignments() {
|
||||
const BATCH_SIZE = 20;
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
// Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules.
|
||||
const confirmedSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
booking: {
|
||||
status: 'CONFIRMED',
|
||||
schedule: { departureAt: { gte: since } },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
bookingRef: true,
|
||||
scheduleId: true,
|
||||
createdAt: true,
|
||||
contactPhone: true,
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
seat: {
|
||||
include: {
|
||||
coach: {
|
||||
include: { coachType: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { paymentIntent: true },
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
// Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for
|
||||
// round-trips; fall back to Booking.scheduleId for single-leg bookings.
|
||||
const groups = new Map<string, typeof confirmedSeats>();
|
||||
for (const bs of confirmedSeats) {
|
||||
if (!bs.seatId) continue;
|
||||
const scheduleId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (!scheduleId) continue;
|
||||
const key = `${bs.seatId}:${scheduleId}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
const duplicateGroups = [...groups.values()]
|
||||
.filter(g => g.length > 1)
|
||||
.slice(0, BATCH_SIZE);
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
if (duplicateGroups.length === 0) return;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`);
|
||||
|
||||
if (!snapshot) continue;
|
||||
// Track seats newly assigned within this run to prevent double-assignment.
|
||||
const newlyAssigned = new Map<string, Set<string>>(); // scheduleId → Set<seatId>
|
||||
let resolved = 0;
|
||||
let unresolved = 0;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await this.paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
// The payment deadline enforcer will cancel the booking when its
|
||||
// window expires; log now so operations can see failed intents early.
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
for (const group of duplicateGroups) {
|
||||
// FCFS: earliest confirmed booking keeps the seat.
|
||||
const sorted = [...group].sort(
|
||||
(a, b) =>
|
||||
new Date(a.booking.createdAt as Date).getTime() -
|
||||
new Date(b.booking.createdAt as Date).getTime(),
|
||||
);
|
||||
const [keeper, ...duplicates] = sorted;
|
||||
|
||||
for (const dup of duplicates) {
|
||||
const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!;
|
||||
const coachTypeId = dup.seat?.coach?.coachTypeId;
|
||||
const oldCoachId = dup.seat?.coachId;
|
||||
|
||||
if (!coachTypeId) {
|
||||
this.logger.error(
|
||||
`Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`,
|
||||
);
|
||||
failed++;
|
||||
unresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set());
|
||||
const takenThisRun = newlyAssigned.get(scheduleId)!;
|
||||
|
||||
// All seats already taken: confirmed bookings + those assigned this tick.
|
||||
const occupiedIds = new Set([
|
||||
...confirmedSeats
|
||||
.filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId)
|
||||
.map(bs => bs.seatId as string),
|
||||
...takenThisRun,
|
||||
]);
|
||||
|
||||
try {
|
||||
const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds);
|
||||
|
||||
if (!newSeat) {
|
||||
this.logger.warn(
|
||||
`Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` +
|
||||
`(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`,
|
||||
);
|
||||
unresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 1. Update BookingSeat to the new seat.
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: dup.id },
|
||||
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
|
||||
});
|
||||
|
||||
// 2. Update JourneySegment — look up journeyId first to avoid a
|
||||
// nested-relation filter in updateMany (not supported in all Prisma versions).
|
||||
const journey = await tx.journey.findUnique({
|
||||
where: { bookingId: dup.booking.id } as any,
|
||||
select: { id: true },
|
||||
});
|
||||
if (journey) {
|
||||
await tx.journeySegment.updateMany({
|
||||
where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId },
|
||||
data: { seatId: newSeat.id, coachId: newSeat.coachId },
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update Ticket seat reference (QR payload regeneration is out of scope
|
||||
// here; the backoffice can trigger that separately if required).
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: dup.booking.id, seatId: dup.seatId! },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
});
|
||||
|
||||
takenThisRun.add(newSeat.id);
|
||||
|
||||
const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?';
|
||||
const newCoach = (newSeat as any).coach;
|
||||
const coachTypeName = newCoach?.coachType?.name ?? '';
|
||||
const coachNumber = newCoach?.number ?? '';
|
||||
const origin = dup.booking.schedule?.originStation?.name ?? '';
|
||||
const dest = dup.booking.schedule?.destinationStation?.name ?? '';
|
||||
|
||||
if (dup.booking.contactPhone) {
|
||||
const message =
|
||||
`EDR: Your booking ${dup.booking.bookingRef} (${origin} → ${dest}): ` +
|
||||
`your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` +
|
||||
`in coach ${coachNumber} (${coachTypeName}). ` +
|
||||
`We apologize for the inconvenience.`;
|
||||
await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Seat dedup resolved: booking ${dup.booking.bookingRef} ` +
|
||||
`seat ${oldLabel} → ${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` +
|
||||
`keeper: ${keeper.booking.bookingRef}`,
|
||||
);
|
||||
resolved++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Seat dedup error for booking ${dup.booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
unresolved++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
errored++;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
);
|
||||
this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`);
|
||||
}
|
||||
|
||||
private async findReplacementSeat(
|
||||
scheduleId: string,
|
||||
coachTypeId: string,
|
||||
preferredCoachId: string | undefined,
|
||||
occupiedIds: Set<string>,
|
||||
) {
|
||||
const includeCoach = {
|
||||
coach: { include: { coachType: { select: { id: true, name: true } } } },
|
||||
};
|
||||
const baseWhere = (coachId?: string) => ({
|
||||
...(coachId ? { coachId } : {}),
|
||||
seatNumber: { not: '' },
|
||||
id: { notIn: [...occupiedIds] },
|
||||
coach: { coachTypeId, assignments: { some: { scheduleId } } },
|
||||
NOT: [
|
||||
{ seatNumber: { startsWith: '-' } },
|
||||
{ status: SeatStatus.BLOCKED },
|
||||
],
|
||||
});
|
||||
|
||||
// 1. Prefer the exact same coach.
|
||||
if (preferredCoachId) {
|
||||
const seat = await this.prisma.seat.findFirst({
|
||||
where: baseWhere(preferredCoachId),
|
||||
include: includeCoach,
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
if (seat) return seat;
|
||||
}
|
||||
|
||||
// 2. Any coach of the same coach type assigned to this schedule.
|
||||
return this.prisma.seat.findFirst({
|
||||
where: baseWhere(),
|
||||
include: includeCoach,
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: detect and resolve duplicate seat assignments caused by
|
||||
// RabbitMQ-recovered events re-confirming already-cancelled bookings.
|
||||
//
|
||||
// Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg).
|
||||
// Any group with >1 row means multiple bookings share the same physical seat.
|
||||
//
|
||||
// Resolution (FCFS): the booking created first keeps the seat; all later
|
||||
// bookings are reassigned to an available seat in:
|
||||
// 1. Same coach + same coach type (preferred)
|
||||
// 2. Same coach type, any coach (fallback)
|
||||
// 3. No seat available → logged, needs manual intervention
|
||||
//
|
||||
// Idempotency: once a duplicate's BookingSeat is updated to a new seatId it
|
||||
// no longer appears in the duplicate group on the next tick — naturally safe
|
||||
// to re-run without any extra flag.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async deduplicateSeatAssignments() {
|
||||
this.logger.log('Seat dedup cron started');
|
||||
// Scan at most 500 confirmed seat rows per run to stay lightweight.
|
||||
const confirmedSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
select: {
|
||||
id: true,
|
||||
seatId: true,
|
||||
scheduleId: true,
|
||||
leg: true,
|
||||
passengerName: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
bookingRef: true,
|
||||
scheduleId: true,
|
||||
createdAt: true,
|
||||
contactPhone: true,
|
||||
},
|
||||
},
|
||||
seat: {
|
||||
select: {
|
||||
id: true,
|
||||
seatNumber: true,
|
||||
coachId: true,
|
||||
coach: {
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
coachTypeId: true,
|
||||
coachType: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 500,
|
||||
});
|
||||
|
||||
// Group by (effectiveScheduleId :: seatId :: leg)
|
||||
type BsRow = (typeof confirmedSeats)[number];
|
||||
const groups = new Map<string, BsRow[]>();
|
||||
for (const bs of confirmedSeats) {
|
||||
const schedId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (!schedId) continue;
|
||||
const key = `${schedId}::${bs.seatId}::${bs.leg}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
const duplicateGroups = [...groups.values()].filter(g => g.length > 1);
|
||||
if (duplicateGroups.length === 0) return;
|
||||
|
||||
this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`);
|
||||
|
||||
// Build taken-seat sets keyed by (scheduleId::leg) — used when finding
|
||||
// a replacement seat so we don't assign an already-occupied seat.
|
||||
const takenByScheduleLeg = new Map<string, Set<string>>();
|
||||
for (const bs of confirmedSeats) {
|
||||
const schedId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (!schedId) continue;
|
||||
const key = `${schedId}::${bs.leg}`;
|
||||
if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set());
|
||||
takenByScheduleLeg.get(key)!.add(bs.seatId);
|
||||
}
|
||||
|
||||
let resolved = 0;
|
||||
let unresolved = 0;
|
||||
|
||||
for (const group of duplicateGroups) {
|
||||
// FCFS: earliest booking keeps the seat
|
||||
group.sort((a, b) =>
|
||||
new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(),
|
||||
);
|
||||
|
||||
const [winner, ...duplicates] = group;
|
||||
const schedId = winner.scheduleId ?? winner.booking.scheduleId;
|
||||
const coachTypeId = winner.seat.coach.coachTypeId;
|
||||
const origCoachId = winner.seat.coachId;
|
||||
const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set<string>();
|
||||
|
||||
for (const dup of duplicates) {
|
||||
try {
|
||||
// 1st choice: same coach + same coach type
|
||||
const newSeat =
|
||||
(await this.prisma.seat.findFirst({
|
||||
where: {
|
||||
id: { notIn: [...taken] },
|
||||
status: { not: SeatStatus.BLOCKED },
|
||||
coachId: origCoachId,
|
||||
coach: {
|
||||
coachTypeId,
|
||||
assignments: { some: { scheduleId: schedId } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true, seatNumber: true, coachId: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
})) ??
|
||||
// 2nd choice: any coach within same coach type
|
||||
(await this.prisma.seat.findFirst({
|
||||
where: {
|
||||
id: { notIn: [...taken] },
|
||||
status: { not: SeatStatus.BLOCKED },
|
||||
coach: {
|
||||
coachTypeId,
|
||||
assignments: { some: { scheduleId: schedId } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true, seatNumber: true, coachId: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
}));
|
||||
|
||||
if (!newSeat) {
|
||||
this.logger.warn(
|
||||
`Seat dedup: no available seat in coach type for ` +
|
||||
`booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`,
|
||||
);
|
||||
unresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Atomically update BookingSeat + Ticket + JourneySegment
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: dup.id },
|
||||
data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber },
|
||||
});
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg },
|
||||
data: { seatId: newSeat!.id },
|
||||
});
|
||||
await tx.journeySegment.updateMany({
|
||||
where: {
|
||||
journey: { bookingId: dup.booking.id },
|
||||
seatId: dup.seatId,
|
||||
scheduleId: schedId,
|
||||
},
|
||||
data: { seatId: newSeat!.id, coachId: newSeat!.coachId },
|
||||
});
|
||||
});
|
||||
|
||||
// Claim the new seat so subsequent duplicates in this run don't use it
|
||||
taken.add(newSeat.id);
|
||||
|
||||
const message =
|
||||
`EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` +
|
||||
`due to a system correction. ` +
|
||||
`New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` +
|
||||
`(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`;
|
||||
|
||||
if (dup.booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` +
|
||||
`seat ${dup.seat.seatNumber} → ${newSeat.seatNumber} (coach ${newSeat.coach.number})`,
|
||||
);
|
||||
resolved++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Seat dedup error for ${dup.booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
unresolved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` +
|
||||
`across ${duplicateGroups.length} conflict(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -96,9 +96,20 @@ export class IntentsService {
|
||||
`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). Provider sessions are single-use, so re-serving the
|
||||
// old clientAction hands the payer a dead checkout. Verify at the provider,
|
||||
// then supersede: paid/processing intents are adopted, unpaid ones retired
|
||||
// so a fresh session opens below.
|
||||
const settled = await this.verifyThenSupersede(existing);
|
||||
if (settled) return this.toSnapshot(settled);
|
||||
} else {
|
||||
const reusable = await this.reuseOrRetire(existing);
|
||||
if (reusable) return this.toSnapshot(reusable);
|
||||
// PROCESSING (money in flight) or SUCCEEDED (already paid): never reopen —
|
||||
// return the existing intent so the caller adopts its outcome.
|
||||
return this.toSnapshot(existing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,24 +272,50 @@ export class IntentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an existing active intent can be returned as-is. An expired
|
||||
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
|
||||
* so a fresh provider session can be opened.
|
||||
* Re-initiate guard 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. Paid/processing answers are applied through the
|
||||
* state machine and the intent is returned for reuse. Anything still unpaid is
|
||||
* 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 provider session. 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 reuseOrRetire(
|
||||
private async verifyThenSupersede(
|
||||
intent: PaymentIntent,
|
||||
): Promise<PaymentIntent | null> {
|
||||
const expired =
|
||||
intent.status === ProviderPaymentStatus.REQUIRES_ACTION &&
|
||||
intent.expiresAt != null &&
|
||||
intent.expiresAt.getTime() < Date.now();
|
||||
if (!expired) return intent;
|
||||
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-supersede: 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;
|
||||
}
|
||||
|
||||
const expired =
|
||||
intent.expiresAt != null && intent.expiresAt.getTime() < Date.now();
|
||||
await this.intentsRepository.update(intent.id, {
|
||||
status: ProviderPaymentStatus.CANCELLED,
|
||||
failureCode: "EXPIRED",
|
||||
failureMessage: "Provider session expired before the payer acted",
|
||||
failureCode: expired ? "EXPIRED" : "SUPERSEDED",
|
||||
failureMessage: expired
|
||||
? "Provider session expired before the payer acted"
|
||||
: "Payer re-initiated; previous provider session superseded",
|
||||
});
|
||||
this.logger.log(
|
||||
`intent ${intent.id} retired (${expired ? "EXPIRED" : "SUPERSEDED"}) — fresh session will be opened`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user