Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts
Marshal 8089e5cfd9 feat(billing): enhance invoice payment processing and revert handling
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
2026-08-05 20:46:44 +00:00

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,
};
}
}