mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(bookings): add event handlers for booking invoice payment processing fix(bookings): include PAYMENT_VERIFICATION_IN_PROGRESS status in queries fix(train-scheduling): update status checks to include PAYMENT_VERIFICATION_IN_PROGRESS feat(notifier): notify customers when a train is cancelled
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
forwardRef,
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
} from "@nestjs/common";
|
|
import { OnEvent } from "@nestjs/event-emitter";
|
|
import { Freight } from "@edr/types";
|
|
import { DataSource, EntityManager } from "typeorm";
|
|
|
|
import {
|
|
BillingService,
|
|
GenerateInvoiceInput,
|
|
InvoiceEventPayload,
|
|
InvoiceLineInput,
|
|
} from "../billing/billing.service";
|
|
import { Invoice } from "../billing/entities/invoice.entity";
|
|
import { FirstMileService } from "../first-mile/first-mile.service";
|
|
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
|
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
|
import { BookingsRepository } from "./bookings.repository";
|
|
import { Booking } from "./entities/booking.entity";
|
|
|
|
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
|
interface StoredPricingBreakdown {
|
|
lineItems?: PriceLineItemDto[];
|
|
totalAmount?: number;
|
|
currency?: string;
|
|
}
|
|
|
|
export interface InvoiceOptions {
|
|
dueDate?: Date;
|
|
invoiceType?: string;
|
|
invoiceStatus?: Freight.InvoiceStatus;
|
|
}
|
|
|
|
/** Round to 2 decimals, avoiding binary float drift. */
|
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
|
|
|
/**
|
|
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
|
|
* turns into invoices, which type to use, and how it advances when paid. Bookings
|
|
* are the billable business entity, so they generate their own invoices directly
|
|
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
|
|
* type branching lives here, at the two points it belongs: invoice creation and
|
|
* settlement (the paid handler).
|
|
*/
|
|
@Injectable()
|
|
export class BookingInvoiceService {
|
|
private readonly logger = new Logger(BookingInvoiceService.name);
|
|
|
|
constructor(
|
|
private readonly billing: BillingService,
|
|
private readonly bookingsRepository: BookingsRepository,
|
|
private readonly dataSource: DataSource,
|
|
@Inject(forwardRef(() => FirstMileService))
|
|
private readonly firstMile: FirstMileService,
|
|
@Inject(forwardRef(() => BookingBatchService))
|
|
private readonly bookingBatch: BookingBatchService,
|
|
) { }
|
|
|
|
/**
|
|
* Ensure the booking has its invoice, generating one from the snapshotted
|
|
* pricing breakdown if absent. Called when a booking reaches a billable state.
|
|
* Idempotent — returns the existing open invoice instead of a duplicate.
|
|
* Throws `BadRequestException` when the booking is not billable: no company
|
|
* to bill (e.g. government bookings whose `companyId` is null, which the
|
|
* invoices FK requires), or no priced amount.
|
|
*/
|
|
async ensureInvoiceForBooking(
|
|
booking: Booking,
|
|
invoiceOptions: InvoiceOptions = {},
|
|
): Promise<Invoice> {
|
|
const existing = await this.billing.findPayable(
|
|
Freight.InvoiceSource.Booking,
|
|
booking.id,
|
|
"PREPAID",
|
|
);
|
|
if (existing) return existing;
|
|
|
|
if (!booking.companyId) {
|
|
throw new BadRequestException(
|
|
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
|
);
|
|
}
|
|
|
|
const input = this.buildInput(booking, invoiceOptions);
|
|
|
|
return this.billing.generateInvoice(input);
|
|
}
|
|
|
|
/**
|
|
* React to a booking invoice being paid — the settlement branch point. Per-type
|
|
* reactions live here (not in the payment process): each invoice type advances
|
|
* the booking its own way. Only PREPAID exists today.
|
|
*/
|
|
@OnEvent("booking.invoice.paid")
|
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
|
this.logger.log(
|
|
`onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
|
|
);
|
|
switch (payload.type) {
|
|
case "PREPAID":
|
|
// Before advancing: if this invoice belonged to a partial offer that
|
|
// lapsed before the settlement landed, revive it, or the booking boards
|
|
// whole having paid only the offered part.
|
|
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
|
|
await this.advanceBookingOnPayment(payload.sourceId);
|
|
break;
|
|
default:
|
|
this.logger.warn(
|
|
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Success-redirect ack: the customer finished provider checkout, webhook not
|
|
* in yet. Mirror the invoice's PAYMENT_PROCESSING on the booking so the
|
|
* portal stops offering "Pay now". Display state only — settlement
|
|
* (`booking.invoice.paid`) still drives PAID. Status-guarded, so it never
|
|
* touches a booking that already advanced or was terminated.
|
|
*/
|
|
@OnEvent("booking.invoice.payment-processing")
|
|
async onBookingInvoicePaymentProcessing(
|
|
payload: InvoiceEventPayload,
|
|
): Promise<void> {
|
|
await this.dataSource.getRepository(Booking).update(
|
|
{ id: payload.sourceId, status: "SELECTED_FOR_BATCH" },
|
|
{ status: "PAYMENT_VERIFICATION_IN_PROGRESS" },
|
|
);
|
|
}
|
|
|
|
/** Payment failed after a redirect ack — the booking reads payable again. */
|
|
@OnEvent("booking.invoice.payment-processing-reverted")
|
|
async onBookingInvoicePaymentProcessingReverted(
|
|
payload: InvoiceEventPayload,
|
|
): Promise<void> {
|
|
await this.dataSource.getRepository(Booking).update(
|
|
{ id: payload.sourceId, status: "PAYMENT_VERIFICATION_IN_PROGRESS" },
|
|
{ status: "SELECTED_FOR_BATCH" },
|
|
);
|
|
}
|
|
|
|
updateStatus(
|
|
invoiceId: string,
|
|
status: Freight.InvoiceStatus,
|
|
manager?: EntityManager,
|
|
): Promise<void> {
|
|
return this.billing.updateStatus(invoiceId, status, manager);
|
|
}
|
|
|
|
/**
|
|
* Expire the booking's currently-open freight (PREPAID) invoice 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.
|
|
*/
|
|
async expireOpenInvoices(
|
|
bookingId: string,
|
|
manager?: EntityManager,
|
|
): Promise<Invoice | null> {
|
|
return this.billing.expirePayable(
|
|
Freight.InvoiceSource.Booking,
|
|
bookingId,
|
|
"PREPAID",
|
|
manager,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
|
* of payment, relocated out of the payment service: the booking becomes PAID
|
|
* and is allocated into its batch. Idempotent — no-op when already PAID.
|
|
*
|
|
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
|
|
* lifecycle and ordering window live in the contracts module, advanced by the
|
|
* contract transition/clearance services — not by booking payment. Every
|
|
* booking that settles here is a ONE_TIME shipment, so there is no contract
|
|
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
|
|
*/
|
|
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
|
const booking = await this.bookingsRepository.findById(bookingId);
|
|
if (!booking) {
|
|
this.logger.warn(
|
|
`Cannot advance unknown booking ${bookingId} on payment.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Idempotency + state-machine guard (restored). The prepaid-invoice paid
|
|
// event can be delivered more than once (retries / re-emit), and a booking
|
|
// may have moved on or been terminated between invoicing and settlement.
|
|
// Only advance one that is still awaiting payment: no-op when already PAID,
|
|
// and refuse to advance a booking in a terminal/advanced status
|
|
// (CANCELLED/REJECTED or already past the payment gate) so we never rewrite
|
|
// its status or re-run allocation.
|
|
//
|
|
// EXPIRED is NOT in that list: settlement is async, so a payment can land
|
|
// after the pay window and its drain tail (relay backlog, payment-api
|
|
// restart, a CBE bill paid at a counter). The money was captured, so it gets
|
|
// exactly the same treatment as an in-window payment — the booking becomes
|
|
// PAID and ensurePaidBookingAllocated re-places it via
|
|
// replaceStrandedPaidBooking (a same-day train with room, or a manual-assign
|
|
// log). Leaving EXPIRED here debited the customer for nothing. CANCELLED and
|
|
// REJECTED stay: a person terminated those, so a payment against them is a
|
|
// refund case, not a boarding.
|
|
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
|
return;
|
|
}
|
|
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
|
|
"CANCELLED",
|
|
"REJECTED",
|
|
"IN_TRANSIT",
|
|
"ARRIVED",
|
|
"COMPLETED",
|
|
"CONTRACT_CLOSED",
|
|
];
|
|
if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
|
|
this.logger.warn(
|
|
`Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
await this.dataSource.transaction(async (mg) => {
|
|
await mg.update(
|
|
Booking,
|
|
{ id: bookingId },
|
|
{ paymentStatus: "PAID", status: "PAID" },
|
|
);
|
|
});
|
|
|
|
try {
|
|
await this.firstMile.acceptBooking(bookingId);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Map a booking's pricing snapshot into a generic invoice request. */
|
|
private buildInput(
|
|
booking: Booking,
|
|
invoiceOptions: InvoiceOptions = {},
|
|
): GenerateInvoiceInput {
|
|
const breakdown = (booking.pricingBreakdown ??
|
|
{}) as StoredPricingBreakdown;
|
|
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
|
|
|
|
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
|
chargeType: l.code,
|
|
description: l.description,
|
|
quantity: l.quantity,
|
|
unitRate: l.unitAmount,
|
|
amount: l.amount,
|
|
currency: l.currency ?? currency,
|
|
metadata: l.unit ? { unit: l.unit } : null,
|
|
}));
|
|
|
|
// Fall back to a single freight line when no breakdown was snapshotted.
|
|
if (lines.length === 0) {
|
|
const amount = Number(booking.totalAmount);
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
throw new BadRequestException(
|
|
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
|
);
|
|
}
|
|
lines.push({
|
|
chargeType: "FREIGHT",
|
|
description: "Rail freight",
|
|
quantity: 1,
|
|
unitRate: amount,
|
|
amount,
|
|
currency,
|
|
});
|
|
}
|
|
|
|
const subtotal = round2(
|
|
lines.reduce((sum, l) => sum + Number(l.amount), 0),
|
|
);
|
|
let totalAmount = subtotal;
|
|
|
|
// Honor a staff price override: bill the adjusted total, recording the delta
|
|
// as an ADJUSTMENT line so the lines still sum to the invoice total.
|
|
const adjusted = booking.adjustedTotalAmount;
|
|
if (adjusted != null && Number.isFinite(Number(adjusted))) {
|
|
const delta = round2(Number(adjusted) - subtotal);
|
|
if (delta !== 0) {
|
|
lines.push({
|
|
chargeType: "ADJUSTMENT",
|
|
description: "Staff price adjustment",
|
|
quantity: 1,
|
|
unitRate: delta,
|
|
amount: delta,
|
|
currency,
|
|
});
|
|
}
|
|
totalAmount = round2(Number(adjusted));
|
|
}
|
|
|
|
return {
|
|
source: Freight.InvoiceSource.Booking,
|
|
sourceId: booking.id,
|
|
companyId: booking.companyId,
|
|
companyProfileId: booking.companyProfileId,
|
|
currency,
|
|
lines,
|
|
totalAmount,
|
|
dueAt: invoiceOptions.dueDate,
|
|
type: invoiceOptions.invoiceType ?? "PREPAID",
|
|
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
|
|
};
|
|
}
|
|
}
|