mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
242 lines
8.0 KiB
TypeScript
242 lines
8.0 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":
|
|
await this.advanceBookingOnPayment(payload.sourceId);
|
|
break;
|
|
default:
|
|
this.logger.warn(
|
|
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
updateStatus(
|
|
invoiceId: string,
|
|
status: Freight.InvoiceStatus,
|
|
manager?: EntityManager,
|
|
): Promise<void> {
|
|
return this.billing.updateStatus(invoiceId, status, 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;
|
|
}
|
|
// if (booking.paymentStatus === "PAID") 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,
|
|
};
|
|
}
|
|
}
|