This commit is contained in:
marshal
2026-07-02 13:02:33 +03:00
256 changed files with 15730 additions and 5422 deletions

View File

@@ -1,5 +1,6 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
@@ -7,6 +8,7 @@ import { BillingService } from "./billing.service";
@ApiTags("billing")
@Controller("billing")
@FreightAdmin()
@ApiBearerAuth()
export class BillingController {
constructor(private readonly billingService: BillingService) { }
@@ -21,4 +23,26 @@ export class BillingController {
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
@Get("invoices/:id/document")
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
sendPdf(res, filename, buffer);
}
@Get("invoices/:id/receipt")
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.receipt(id);
sendPdf(res, filename, buffer);
}
}
/** Stream a generated PDF as a file download. */
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
res.send(buffer);
}

View File

@@ -3,7 +3,9 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { PaymentController } from "./payment.controller";
import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
@@ -16,8 +18,9 @@ import { CompaniesModule } from "../companies/companies.module";
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
DocumentsModule,
],
controllers: [BillingController, PortalBillingController],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})

View File

@@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
});
@@ -88,7 +89,7 @@ describe("BillingService.generateInvoice", () => {
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
@@ -115,12 +116,14 @@ describe("BillingService.generateInvoice", () => {
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
@@ -134,6 +137,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -141,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => {
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
{
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
paidAt: expect.any(Date),
paidAmount: 1500,
balanceAmount: 0,
payments: [
{
amount: 1500,
method: "GATEWAY",
reference: "pay-1",
paidAt: expect.any(String),
metadata: null,
},
],
},
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
@@ -171,6 +190,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -180,71 +200,99 @@ describe("BillingService.markInvoiceAsPaid", () => {
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
};
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(open),
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const dataSource = {
manager: mg,
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
};
const service = new BillingService(
{ manager: mg } as never,
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
return { service, mg, events };
}
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
id: "inv-1",
status: Freight.InvoiceStatus.Issued,
source: "warehouse",
sourceId: "inv-item-1",
totalAmount: 1000,
paidAmount: 0,
balanceAmount: 1000,
payments: [],
paidAt: null,
...overrides,
});
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
const { service, mg, events } = serviceFor(openInvoice());
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
expect(updated.paidAmount).toBe(400);
expect(updated.balanceAmount).toBe(600);
expect(updated.payments).toHaveLength(1);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.PartiallyPaid,
paidAmount: 400,
balanceAmount: 600,
}),
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.anything(),
);
});
it("is a no-op (returns null) when the source has no open invoice", async () => {
const mg = {
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
const updated = await service.recordPayment("inv-1", { amount: 600 });
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
expect(updated.balanceAmount).toBe(0);
expect(updated.paidAt).toBeInstanceOf(Date);
expect(mg.update).toHaveBeenCalled();
expect(events.emit).toHaveBeenCalledWith(
"warehouse.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
);
});
it("rejects a non-positive amount", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects a payment that exceeds the outstanding balance", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(
service.recordPayment("inv-1", { amount: 1500 }),
).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
);
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
});

View File

@@ -1,15 +1,28 @@
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
@@ -20,13 +33,25 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
amount: number;
method?: string | null;
reference?: string | null;
/** When the settlement occurred; defaults to now. */
paidAt?: Date;
metadata?: Record<string, unknown> | null;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
@@ -56,7 +81,11 @@ export interface GenerateInvoiceInput {
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
subtotalAmount?: number;
/** Tax applied on top of the subtotal; defaults to 0. */
taxAmount?: number;
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
@@ -95,6 +124,7 @@ export class BillingService {
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -115,6 +145,89 @@ export class BillingService {
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException(
"A receipt is available only after payment is recorded.",
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
);
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
const totals: InvoiceDocumentModel["totals"] = [
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
];
if (Number(invoice.taxAmount) > 0) {
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
}
totals.push({
label: "Total",
amount: Number(invoice.totalAmount),
grand: true,
});
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
return {
kind,
title,
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
category: l.chargeType,
quantity: l.quantity,
unitRate: l.unitRate,
amount: l.amount,
currency: l.currency,
})),
totals,
};
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
@@ -127,19 +240,33 @@ export class BillingService {
}
}
/** Every invoice billed to a company, newest first, with billing relations. */
findByCompany(companyId: string): Promise<Invoice[]> {
/**
* Every invoice billed to a company, newest first, with billing relations.
* Optionally narrow to a single source record (e.g. a booking's invoices) via
* `{ source, sourceId }`.
*/
findByCompany(
companyId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
return this.invoices.findAll({
where: { companyId },
where: {
companyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
relations: { company: true, companyProfile: true },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(userId: string): Promise<Invoice[]> {
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId) : [];
return companyId ? this.findByCompany(companyId, filter) : [];
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
@@ -157,36 +284,43 @@ export class BillingService {
/**
* Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges whichever open invoice the source currently has
* (see {@link payInvoice}).
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
*/
async payInvoiceForUser(
id: string,
userId: string,
opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId);
return this.payInvoice(
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
await this.findByIdForUser(id, userId);
return this.payInvoice(id, opts);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.document(id);
}
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
async receiptForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.receipt(id);
}
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
});
}
/**
@@ -204,6 +338,7 @@ export class BillingService {
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
@@ -230,8 +365,11 @@ export class BillingService {
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const subtotalAmount =
input.subtotalAmount ??
lines.reduce((sum, l) => sum + Number(l.amount), 0);
const taxAmount = input.taxAmount ?? 0;
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
const dueAt =
input.dueAt ??
@@ -250,7 +388,12 @@ export class BillingService {
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
paidAmount: 0,
balanceAmount: round2(totalAmount),
payments: [],
currency,
status,
issuedAt: issued ? new Date() : null,
@@ -274,28 +417,182 @@ export class BillingService {
// ── State transitions ────────────────────────────────────────────────────────
/**
* Mark an invoice paid and link the gateway payment, then emit
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
* No-op when the invoice is already paid. Pass `manager` to enlist in a
* caller's transaction.
* Run `fn` inside a transaction and only emit its returned domain event
* after commit. When the caller passes their own `manager`, they own commit
* timing — `fn`'s event fires inline as soon as it resolves (the outer
* transaction may still roll back afterwards; this is the caller's
* documented tradeoff). When no `manager` is given, this opens its own
* transaction and defers the emit until after that transaction commits, so
* listeners (e.g. booking advancement) can never observe an invoice change
* that then rolls back.
*/
private async runTransition<T>(
manager: EntityManager | undefined,
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
): Promise<T> {
if (manager) {
const { result, emit } = await fn(manager);
emit?.();
return result;
}
let pending: (() => void) | undefined;
const result = await this.dataSource.transaction(async (mg) => {
const out = await fn(mg);
pending = out.emit;
return out.result;
});
pending?.();
return result;
}
/**
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
* append the settlement to the `payments` ledger, link the gateway payment,
* then emit `${source}.invoice.paid`. Full-payment only — no partial
* settlement. No-op when the invoice is already paid. Pass `manager` to
* enlist in a caller's transaction; otherwise locks the row for update and
* emits only after commit (see {@link runTransition}).
*/
async markInvoiceAsPaid(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
settlement: { providerTxnId?: string; paidAt?: Date } = {},
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
return { result: invoice };
}
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
const settledAmount = round2(
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
);
const entry: InvoicePayment = {
amount: settledAmount,
method: "GATEWAY",
reference: settlement.providerTxnId ?? paymentId ?? null,
paidAt: paidAt.toISOString(),
metadata: null,
};
const payments = [...(invoice.payments ?? []), entry];
const patch = {
status: Freight.InvoiceStatus.Paid,
paymentId,
paidAt,
paidAmount: invoice.totalAmount,
balanceAmount: 0,
payments,
};
await mg.update(Invoice, { id: invoiceId }, patch as never);
const updated = { ...invoice, ...patch } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent("paid", updated),
};
});
}
/**
* Record a (possibly partial) settlement against an invoice and sync its
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
* balance reaches zero — PAID, stamping `paidAt` and emitting
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
* at the warehouse counter); gateway settlement goes through
* {@link markInvoiceAsPaid}.
*
* Throws when the invoice is missing, cancelled, refunded, already fully
* paid, `amount` is not positive, or `amount` exceeds the outstanding
* balance. Pass `manager` to enlist in a caller's transaction; otherwise
* locks the row for update and emits only after commit (see
* {@link runTransition}).
*/
async recordPayment(
invoiceId: string,
input: RecordPaymentInput,
manager?: EntityManager,
): Promise<Invoice> {
if (!(input.amount > 0)) {
throw new BadRequestException(
"Payment amount must be greater than zero.",
);
}
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Cannot pay a cancelled invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Refunded) {
throw new BadRequestException("Cannot pay a refunded invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
);
}
const at = input.paidAt ?? new Date();
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount,
invoice.paidAmount,
input.amount,
);
const status = fullyPaid
? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = {
amount: round2(input.amount),
method: input.method ?? null,
reference: input.reference ?? null,
paidAt: at.toISOString(),
metadata: input.metadata ?? null,
};
const payments = [...(invoice.payments ?? []), entry];
const patch = {
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
};
await mg.update(Invoice, { id: invoice.id }, patch as never);
const updated = { ...invoice, ...patch } as Invoice;
return {
result: updated,
emit: fullyPaid
? () => this.emitInvoiceEvent("paid", updated)
: undefined,
};
});
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
* No-op when already refunded. Throws when the invoice has no recorded
* payment (nothing to refund).
*/
async markInvoiceAsRefunded(
invoiceId: string,
@@ -307,12 +604,20 @@ export class BillingService {
"refunded",
{},
manager,
(invoice) => {
if (!(Number(invoice.paidAmount) > 0)) {
throw new BadRequestException(
"Cannot refund an invoice with no recorded payment.",
);
}
},
);
}
/**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled.
* No-op when already cancelled. Throws when the invoice has payments
* recorded against it (refund it instead).
*/
async cancelInvoice(
invoiceId: string,
@@ -324,16 +629,23 @@ export class BillingService {
"cancelled",
{},
manager,
(invoice) => {
if (Number(invoice.paidAmount) > 0) {
throw new BadRequestException(
"Cannot cancel an invoice that has payments recorded against it.",
);
}
},
);
}
/**
* Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
* in the target status. Throws when the invoice does not exist.
*
* Note: the event fires in-process synchronously. When a `manager` from an
* outer transaction is passed, listeners run before that transaction commits.
* `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
* when it is already in the target status. Throws when the invoice does not
* exist or `guard` rejects the current state. Pass `manager` to enlist in a
* caller's transaction; otherwise locks the row for update and emits only
* after commit (see {@link runTransition}).
*/
private async transition(
invoiceId: string,
@@ -341,17 +653,27 @@ export class BillingService {
event: string,
extra: { paymentId?: string },
manager?: EntityManager,
guard?: (invoice: Invoice) => void,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === status) return invoice;
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === status) return { result: invoice };
guard?.(invoice);
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated);
return updated;
const updated = { ...invoice, ...extra, status } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent(event, updated),
};
});
}
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
@@ -375,16 +697,17 @@ export class BillingService {
// ── Payment reconciliation (by source) ───────────────────────────────────────
/**
* The invoice a gateway payment should settle for a source record, or null if
* none. This is the billing document of record for "what is owed" — callers
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
* recomputing from the source's own total, so discounts/penalties/adjustments
* carried on the invoice are honored.
* The invoice a source record already has open, or null if it needs a new
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
* first-mile, last-mile) runs before generating — it must see DRAFT
* invoices too, not just issued ones, otherwise a source that already has
* an unissued draft gets a second, duplicate invoice minted alongside it
* instead of that draft being reused and then issued.
*
* Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid,
* non-cancelled) invoice.
* invoice is currently open. Returns the most recent matching draft-or-open
* (unpaid, non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
@@ -395,7 +718,7 @@ export class BillingService {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
@@ -403,74 +726,130 @@ export class BillingService {
}
/**
* Settle a source's currently-open invoice as paid and link the gateway
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
* settlement. No-op (returns null) when the source has no open invoice.
*
* Type-blind by design: settles whichever invoice is due; any per-type reaction
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
* Pass the caller's transaction `manager` to enlist in its DB transaction.
*
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
* for callers that settle by source rather than by gateway intent id.
* Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid,
* non-cancelled) invoice.
*/
async settlePayable(
findInvoice(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
type?: string,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
return this.dataSource.getRepository(Invoice).findOne({
where: {
source,
sourceId,
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
}
/**
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
* No-op (returns null) when the source has no paid invoice.
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
* (already paid/cancelled/expired).
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
* the batch engine) to enlist in its DB transaction.
*/
async refundPayable(
async expirePayable(
source: Freight.InvoiceSource,
sourceId: string,
type?: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
return this.transition(
invoice.id,
Freight.InvoiceStatus.Expired,
"expired",
{},
mg,
);
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
*/
async syncPayableDueDate(
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
}
/**
* Force an invoice to `status`, including issuing a still-DRAFT invoice
* (stamping `issuedAt`) — unlike the other transitions here, this is a
* blunt admin/workflow override, not a settlement. No-op when the invoice
* is missing or already terminal (paid/cancelled/refunded/expired).
*/
async updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
});
if (!invoice) return;
await mg.update(
Invoice,
{ id: invoice.id },
{ status, issuedAt: invoice.issuedAt ?? new Date() },
);
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**
* Charge a source's open invoice through the payment gateway. Billing is the
* single place that turns "what is owed" (the invoice) into a payment intent —
* the domain never talks to the payment service directly. Resolves the open
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
* the invoice (the settlement correlation key), and returns the client action.
* Charge an invoice through the payment gateway. Billing is the single place
* that turns "what is owed" (the invoice) into a payment intent — the domain
* never talks to the payment service directly. Resolves the invoice by ID,
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
* records the intent id on the invoice (the settlement correlation key), and
* returns the client action.
*
* When the provider settles synchronously, the invoice is settled inline here —
* after the intent id is stored — so the `payment.succeeded` correlation can
* never fire before the link exists. Throws when the source has no open invoice.
* never fire before the link exists. Throws when the invoice is not found or
* not in an open/payable status.
*/
async payInvoice(
source: Freight.InvoiceSource,
sourceId: string,
invoiceId: string,
opts: {
method?: string;
platform?: "web" | "mobile";
@@ -479,22 +858,26 @@ export class BillingService {
failureUrl?: string;
} = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
});
if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
throw new NotFoundException(
`Invoice ${invoiceId} not found or not in a payable status`,
);
}
const result = await this.payment.initiate({
referenceId: sourceId,
referenceId: invoice.sourceId,
source: invoice.source,
// Gateway reference type derives from the invoice source by convention
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
// the domain never supplies it. New sources add their uppercased value to
// the PaymentReferenceType enum.
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
// orderRef: invoice.invoiceNumber,
orderRef:Date.now().toString(),
amountMinor: Math.round(Number(invoice.totalAmount)),
// Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice
// link is the intent id (`paymentId`); per-source post-payment reactions live
// 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,
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -529,8 +912,8 @@ export class BillingService {
*/
async settleByPaymentId(
paymentId: string,
_providerTxnId?: string,
_paidAt?: Date,
providerTxnId?: string,
paidAt?: Date,
): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) },
@@ -538,6 +921,9 @@ export class BillingService {
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId);
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
providerTxnId,
paidAt,
});
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { InvoiceDocumentService } from "./invoice-document.service";
import { PdfRenderService } from "./pdf-render.service";
/**
* Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the
* billing payment graph.
*/
@Module({
providers: [PdfRenderService, InvoiceDocumentService],
exports: [PdfRenderService, InvoiceDocumentService],
})
export class DocumentsModule {}

View File

@@ -0,0 +1,179 @@
import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
category?: string | null;
quantity?: number | null;
unitRate?: number | null;
amount?: number | null;
currency?: string | null;
}
/** A labelled total row in the totals box; mark `grand` for the headline total. */
export interface InvoiceDocumentTotal {
label: string;
amount: number;
grand?: boolean;
}
/**
* Source-agnostic description of a printable invoice/receipt. Each billing
* source maps its own entity onto this shape; the renderer owns the layout so
* every EDR invoice document looks identical regardless of source.
*/
export interface InvoiceDocumentModel {
kind: InvoiceDocumentKind;
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
title: string;
documentNumber: string;
issuedAt?: Date | string | null;
status: string;
currency: string;
/** Free-form summary grid (label/value pairs). */
summary: Array<{ label: string; value: string | null }>;
/** Header for the line-item category column; column hidden when omitted. */
categoryHeader?: string;
lines: InvoiceDocumentLine[];
totals: InvoiceDocumentTotal[];
/** Override the round seal text; defaults from kind/status. */
sealText?: string;
}
/**
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
* `WarehouseInvoiceService`; it now serves all invoices.
*/
@Injectable()
export class InvoiceDocumentService {
constructor(private readonly pdf: PdfRenderService) {}
async render(
model: InvoiceDocumentModel,
): Promise<{ filename: string; buffer: Buffer }> {
const html = this.buildHtml(model);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
};
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const money = (amount: unknown, currency = model.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
.join("");
const itemRows = model.lines
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
<td class="num">${esc(item.quantity ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
</tr>`,
)
.join("");
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
)
.join("");
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(model.documentNumber)}</strong>
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>
<tr>
<th>Description</th>
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${itemRows}
</tbody>
</table>
<div class="totals">${totalRows}</div>
<div class="footer">
<div class="line">Prepared by EDR finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
}
}

View File

@@ -0,0 +1,160 @@
import { existsSync } from "fs";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="edr-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */
label?: string;
/**
* Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
* header). When omitted, a generic single-page fallback is produced.
*/
fallback?: (preparedHtml: string) => Buffer;
}
/**
* Generic HTML → PDF renderer shared by every document producer (invoices,
* receipts, warehouse release orders). Renders via headless Chromium when
* available and degrades to a caller-supplied (or generic) hand-built PDF
* otherwise. This is pure infrastructure — it knows nothing about invoices.
*/
@Injectable()
export class PdfRenderService {
private readonly logger = new Logger(PdfRenderService.name);
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
const label = opts.label ?? "document";
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import("puppeteer");
const launchOptions: import("puppeteer").LaunchOptions = {
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
await page.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
}
this.logger.log(
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) {
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
];
return candidates.find((path) => existsSync(path));
}
isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
}
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
private genericFallbackPdf(html: string): Buffer {
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 900);
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
const stream =
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
"ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
}

View File

@@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity";
import { Company } from "../../companies/entities/company.entity";
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
/** A single recorded settlement against an invoice (payment ledger entry). */
export interface InvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
/** ISO timestamp of when the settlement was recorded. */
paidAt: string;
metadata?: Record<string, unknown> | null;
}
@Entity({ schema: "freight", name: "invoices" })
@Index(["companyId"])
@Index(["companyProfileId"])
@@ -28,9 +38,24 @@ export class Invoice extends BaseEntity {
@JoinColumn({ name: "company_profile_id" })
companyProfile?: CompanyProfile;
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
totalAmount!: number;
/** Cumulative amount settled so far (supports partial payment). */
@Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
paidAmount!: number;
/** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */
@Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
currency!: string;
@@ -62,6 +87,14 @@ export class Invoice extends BaseEntity {
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt?: Date | null;
/** Set when the invoice is fully settled. */
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
/** Ledger of individual settlements (manual or gateway), newest last. */
@Column({ name: "payments", type: "jsonb", default: () => "'[]'" })
payments!: InvoicePayment[];
/** The ID of the payment that generated this invoice. */
@Column({ name: "payment_id", type: "uuid", nullable: true })
paymentId?: string | null;

View File

@@ -0,0 +1,51 @@
/**
* Shared per-day sequential invoice numbering, used by every billing source
* (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the
* `MAX(seq)+1` allocation live in one place instead of being copy-pasted per
* service.
*
* Produces `<CODE>-YYYYMMDD-00001`: the sequence is the max existing suffix for
* the day + 1. Run inside the caller's transaction (pass that transaction's
* manager) so concurrent generation within a transaction stays consistent.
*/
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
export interface SqlRunner {
query(sql: string, params?: unknown[]): Promise<unknown>;
}
export interface InvoiceNumberOptions {
/** Schema-qualified table to scan, e.g. `freight.invoices`. */
table: string;
/** Document code prefix, e.g. `FRT` or `WHF`. */
code: string;
/** Column holding the number; defaults to `invoice_number`. */
column?: string;
/** Clock injection point (tests); defaults to now. */
now?: Date;
}
export async function nextDailyInvoiceNumber(
runner: SqlRunner,
opts: InvoiceNumberOptions,
): Promise<string> {
const now = opts.now ?? new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `${opts.code}-${ymd}-`;
const column = opts.column ?? "invoice_number";
// Serialize concurrent allocation for this exact day+code prefix so two
// simultaneous transactions can't both read the same MAX(seq) and mint a
// duplicate number. Session-scoped to the caller's transaction — released
// automatically on commit/rollback. Different prefixes hash to different
// keys and never contend with each other.
await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]);
const rows = (await runner.query(
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
FROM ${opts.table} WHERE ${column} LIKE $1`,
[`${prefix}%`],
)) as Array<{ seq: number | string }>;
const next = Number(rows[0]?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
}

View File

@@ -0,0 +1,36 @@
/**
* Shared payment/settlement math for invoices. Both the global
* `BillingService.recordPayment` and the warehouse fee invoice flow apply a
* payment the same way — accumulate `paidAmount`, derive the outstanding
* `balanceAmount`, and decide whether the invoice is now fully settled. Keeping
* it here means the two flows can never drift on rounding or the
* partial-vs-full threshold.
*/
/** Round to 2 decimals, avoiding binary float drift. */
export const round2 = (n: number): number => Math.round(n * 100) / 100;
export interface SettlementResult {
/** New cumulative amount paid. */
paidAmount: number;
/** Remaining balance (0 once fully paid). */
balanceAmount: number;
/** True once the balance reaches zero. */
fullyPaid: boolean;
}
/**
* Apply a single payment of `amount` to an invoice with `totalAmount` already
* carrying `currentPaid`. Caller is responsible for validating `amount > 0` and
* the invoice being in a payable state.
*/
export function applySettlement(
totalAmount: number,
currentPaid: number,
amount: number,
): SettlementResult {
const total = Number(totalAmount);
const paidAmount = round2(Number(currentPaid) + Number(amount));
const balanceAmount = Math.max(0, round2(total - paidAmount));
return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total };
}

View File

@@ -16,9 +16,8 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import { BillingService } from "./billing.service";
import {
InitiatePaymentDto,
InitiateResponseDto,
@@ -27,25 +26,24 @@ import {
} from "../payment/payments.dto";
/**
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
* booking it maps the request to {@link Freight.InvoiceSource.Booking} and hands
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
* Central payment entrypoints. Domain-agnostic the caller supplies an
* invoice ID and the billing service resolves the amount and drives the
* gateway. The domain never talks to the payment service directly.
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/
@ApiTags("Payment")
@Controller("payments")
export class BookingPaymentController {
export class PaymentController {
constructor(private readonly billing: BillingService) { }
@Post("initiate")
@ApiOperation({
summary: "Initiate payment for a freight booking",
description: "Charges the booking's open invoice through the payment gateway.",
summary: "Initiate payment for an invoice",
description: "Charges the invoice through the payment gateway.",
})
@ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
return this.billing.payInvoice(dto.invoiceId, {
method: dto.method,
platform: dto.platform,
payerAccount: dto.payerAccount,
@@ -59,23 +57,23 @@ export class BookingPaymentController {
@ApiOperation({
summary: "Browser checkout redirect",
description:
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
"Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
})
@ApiQuery({ name: "bookingId", required: true })
@ApiQuery({ name: "invoiceId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html")
async checkout(
@Query("bookingId") bookingId: string,
@Query("invoiceId") invoiceId: string,
@Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response,
) {
if (!bookingId) {
if (!invoiceId) {
return res
.status(HttpStatus.BAD_REQUEST)
.type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
.send(this.buildErrorHtml("Missing required query parameter: invoiceId"));
}
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res
@@ -86,8 +84,7 @@ export class BookingPaymentController {
try {
const result = await this.billing.payInvoice(
Freight.InvoiceSource.Booking,
bookingId,
invoiceId,
{ method, platform },
);
const url =

View File

@@ -5,14 +5,18 @@ import {
Param,
ParseUUIDPipe,
Post,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import {
type AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
@@ -29,8 +33,15 @@ export class PortalBillingController {
@Get("my-invoices")
@ApiOperation({ summary: "List the signed-in customer's invoices" })
findMine(@CurrentUser() user: AuthUserPayload) {
return this.billingService.findForUser(resolveAuthUserId(user));
findMine(
@CurrentUser() user: AuthUserPayload,
@Query("source") source?: string,
@Query("sourceId") sourceId?: string,
) {
return this.billingService.findForUser(resolveAuthUserId(user), {
source,
sourceId,
});
}
@Get("my-invoices/:id")
@@ -42,6 +53,34 @@ export class PortalBillingController {
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
}
@Get("my-invoices/:id/document")
@ApiOperation({ summary: "Download one of the customer's invoice PDFs" })
async document(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.documentForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Get("my-invoices/:id/receipt")
@ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" })
async receipt(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Res() res: Response,
) {
const { filename, buffer } = await this.billingService.receiptForUser(
id,
resolveAuthUserId(user),
);
sendPdf(res, filename, buffer);
}
@Post("my-invoices/:id/pay")
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
pay(

View File

@@ -0,0 +1,20 @@
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { AllocateContainersDto } from './dto/allocate-containers.dto';
@ApiTags('bookings')
@Controller('bookings')
@ApiBearerAuth()
export class BookingAllocationController {
constructor(private readonly bookingsService: BookingsService) {}
@Post(':bookingId/allocate-containers')
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AllocateContainersDto,
) {
return this.bookingsService.allocateContainers(bookingId, dto.allocations);
}
}

View File

@@ -1,20 +1,26 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
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';
} 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 {
@@ -23,6 +29,12 @@ interface StoredPricingBreakdown {
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;
@@ -52,32 +64,28 @@ export class BookingInvoiceService {
* 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.
* Returns `null` (and logs) 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.
* 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): Promise<Invoice | null> {
async ensureInvoiceForBooking(
booking: Booking,
invoiceOptions: InvoiceOptions = {},
): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
booking.id,
Freight.InvoiceType.Prepaid,
"PREPAID",
);
if (existing) return existing;
if (!booking.companyId) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
throw new BadRequestException(
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
);
return null;
}
const input = this.buildInput(booking);
if (!input) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
return null;
}
const input = this.buildInput(booking, invoiceOptions);
return this.billing.generateInvoice(input);
}
@@ -87,10 +95,10 @@ export class BookingInvoiceService {
* 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')
@OnEvent("booking.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
switch (payload.type) {
case Freight.InvoiceType.Prepaid:
case "PREPAID":
await this.advanceBookingOnPayment(payload.sourceId);
break;
default:
@@ -100,6 +108,14 @@ export class BookingInvoiceService {
}
}
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
@@ -114,16 +130,18 @@ export class BookingInvoiceService {
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.`);
this.logger.warn(
`Cannot advance unknown booking ${bookingId} on payment.`,
);
return;
}
if (booking.paymentStatus === 'PAID') return;
if (booking.paymentStatus === "PAID") return;
await this.dataSource.transaction(async (mg) => {
await mg.update(
Booking,
{ id: bookingId },
{ paymentStatus: 'PAID', status: 'PAID' },
{ paymentStatus: "PAID", status: "PAID" },
);
await this.firstMile.acceptBooking(bookingId);
});
@@ -138,9 +156,13 @@ export class BookingInvoiceService {
}
/** Map a booking's pricing snapshot into a generic invoice request. */
private buildInput(booking: Booking): GenerateInvoiceInput | null {
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
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,
@@ -155,10 +177,14 @@ export class BookingInvoiceService {
// 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) return null;
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',
chargeType: "FREIGHT",
description: "Rail freight",
quantity: 1,
unitRate: amount,
amount,
@@ -166,7 +192,9 @@ export class BookingInvoiceService {
});
}
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
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
@@ -176,8 +204,8 @@ export class BookingInvoiceService {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: 'ADJUSTMENT',
description: 'Staff price adjustment',
chargeType: "ADJUSTMENT",
description: "Staff price adjustment",
quantity: 1,
unitRate: delta,
amount: delta,
@@ -190,12 +218,14 @@ export class BookingInvoiceService {
return {
source: Freight.InvoiceSource.Booking,
sourceId: booking.id,
type: Freight.InvoiceType.Prepaid,
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency,
lines,
totalAmount,
dueAt: invoiceOptions.dueDate,
type: invoiceOptions.invoiceType ?? "PREPAID",
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
};
}
}

View File

@@ -1,43 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { BillingService } from '../billing/billing.service';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService,
) { }
/**
* Start payment for a booking. The booking never touches the payment gateway
* directly — it charges its invoice through billing, which resolves the amount
* and drives the provider. Returns the provider redirect URL (empty when none).
*/
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
method: PaymentMethodTypeEnum.TELEBIRR,
platform: 'web',
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
};
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
}

View File

@@ -4,8 +4,8 @@ import {
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
@@ -15,7 +15,6 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
@@ -29,16 +28,18 @@ import { BookingClearanceService } from '../contracts/booking-clearance.service'
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
import { Freight } from "@edr/types";
import { BookingInvoiceService } from "./booking-invoice.service";
@Injectable()
export class BookingTransitionService {
private readonly logger = new Logger(BookingTransitionService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly invoiceService: BookingInvoiceService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
@@ -49,6 +50,8 @@ export class BookingTransitionService {
private readonly bookingClearanceService: BookingClearanceService,
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
@@ -57,11 +60,11 @@ export class BookingTransitionService {
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException(
'Generate a price before submitting (POST /bookings/:id/generate-price)',
"Generate a price before submitting (POST /bookings/:id/generate-price)",
);
}
@@ -80,7 +83,8 @@ export class BookingTransitionService {
totalAmount?: number;
} | null;
const unchanged = this.pricingService.pricesMatch(stored, computed);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
if (unchanged) {
await this.pricingService.createPricingSnapshots(
@@ -90,7 +94,7 @@ export class BookingTransitionService {
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
status: "SUBMITTED",
priorityScore,
} as never);
@@ -120,7 +124,7 @@ export class BookingTransitionService {
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
status: 'PRICE_CHANGED_PENDING_CONFIRM',
status: "PRICE_CHANGED_PENDING_CONFIRM",
} as never);
const updatedBooking = await this.bookingsService.findById(bookingId);
@@ -132,16 +136,17 @@ export class BookingTransitionService {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
message: 'Price has changed since preview. Confirm to submit with the updated price.',
message:
"Price has changed since preview. Confirm to submit with the updated price.",
};
}
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException('No price to confirm');
throw new BadRequestException("No price to confirm");
}
const computed = await this.pricingService.computePriceForBooking(booking);
@@ -160,9 +165,10 @@ export class BookingTransitionService {
computed.appliedModifiers,
);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const priorityScore =
await this.pricingService.computeSubmitPriorityScore(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
status: "SUBMITTED",
priorityScore,
totalAmount: computed.totalAmount,
pricingBreakdown: {
@@ -184,7 +190,7 @@ export class BookingTransitionService {
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
message: 'Booking submitted with confirmed price.',
message: "Booking submitted with confirmed price.",
};
}
@@ -194,17 +200,17 @@ export class BookingTransitionService {
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
assertBookingStatus(booking, ["SUBMITTED"]);
await this.bookingsRepository.createReviewNote(
bookingId,
note,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CHANGES_REQUESTED',
status: "CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -214,7 +220,7 @@ export class BookingTransitionService {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
}
@@ -228,14 +234,14 @@ export class BookingTransitionService {
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
assertBookingStatus(booking, ["SUBMITTED"]);
// The backoffice must define how long the accepted contract stays valid.
// Without a window the contract has no end date and cannot be relied on, so
// accept is blocked until a positive number of days is supplied.
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this booking.',
"A contract validity (in days) is required to accept this booking.",
);
}
@@ -245,12 +251,12 @@ export class BookingTransitionService {
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
status: "PENDING_APPROVAL",
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
@@ -266,17 +272,17 @@ export class BookingTransitionService {
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -294,8 +300,8 @@ export class BookingTransitionService {
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
@@ -307,14 +313,17 @@ export class BookingTransitionService {
bookingId,
stepId,
);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
if (!step || step.status !== "PENDING") {
throw new BadRequestException(
"Approval step not found or already actioned",
);
}
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
const next =
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Approval steps must be completed in order',
"Approval steps must be completed in order",
);
}
@@ -326,29 +335,36 @@ export class BookingTransitionService {
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
throw new BadRequestException(
`Role ${requiredRole} is blocked for this step`,
);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"APPROVED",
);
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
if (requiredRole === "LINE_STAFF") {
updates.status = "APPROVED_PENDING_SIGNATURE";
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
} else if (requiredRole === "DIRECTOR") {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
} else if (requiredRole === "CEO") {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
const allDone =
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = 'APPROVED';
updates.status = "APPROVED";
}
if (Object.keys(updates).length > 0) {
@@ -370,90 +386,64 @@ export class BookingTransitionService {
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException('Approval step not found');
if (!step) throw new BadRequestException("Approval step not found");
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
'REJECTED',
"REJECTED",
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CONTRACT_READY']);
assertBookingStatus(booking, ["CONTRACT_READY"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SIGNED_CUSTOMER',
status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
const executed = await this.bookingsService.findById(updated!.id);
// Billable state reached — generate the invoice payment will settle.
// Non-blocking: a billing hiccup must not undo the execution.
await this.invoiceService
.ensureInvoiceForBooking(executed)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for booking ${executed.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return executed;
}
async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PAID']);
assertBookingStatus(booking, ["PAID"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'IN_TRANSIT',
status: "IN_TRANSIT",
} as never);
return this.bookingsService.findById(updated!.id);
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['IN_TRANSIT']);
assertBookingStatus(booking, ["IN_TRANSIT"]);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'COMPLETED',
status: "COMPLETED",
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
@@ -462,22 +452,23 @@ export class BookingTransitionService {
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
"REJECTION",
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CANCELLED',
status: "CANCELLED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -490,20 +481,20 @@ export class BookingTransitionService {
async reject(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'PENDING_CONSOLIDATION',
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"PENDING_CONSOLIDATION",
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason?.trim() || 'Customer rejected the price estimate.',
'REJECTION',
reason?.trim() || "Customer rejected the price estimate.",
"REJECTION",
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
status: "REJECTED",
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -524,10 +515,10 @@ export class BookingTransitionService {
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
uploadedBy: "customer" | "gl";
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
note: string | null;
}>;
allApproved: boolean;
@@ -547,20 +538,21 @@ export class BookingTransitionService {
const { inputCode, outputCode, includesCustoms } =
clearanceCodesForBooking(booking);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const files = await this.filesService.findByResource(bookingId, "bookings");
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviews =
await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
);
const documents: Awaited<
ReturnType<BookingTransitionService['getClearanceView']>
>['documents'] = [];
ReturnType<BookingTransitionService["getClearanceView"]>
>["documents"] = [];
const pushSetting = async (
code: string | null,
uploadedBy: 'customer' | 'gl',
uploadedBy: "customer" | "gl",
) => {
if (!code) return;
let setting;
@@ -578,28 +570,26 @@ export class BookingTransitionService {
required: field.isRequired,
uploadedBy,
settingCode: code,
file: file
? { id: file.id, name: file.name, url: file.url }
: null,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
await pushSetting(inputCode, "customer");
await pushSetting(outputCode, "gl");
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
for (const f of files) {
if (!f.code?.startsWith('custom_')) continue;
if (!f.code?.startsWith("custom_")) continue;
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
uploadedBy: "customer",
settingCode: "custom",
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
@@ -633,13 +623,15 @@ export class BookingTransitionService {
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true;
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
const reviews = await this.bookingsRepository.findDocumentReviews(
booking.id,
);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
r.status === "APPROVED",
),
);
}
@@ -654,33 +646,38 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
]);
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) {
throw new BadRequestException('This booking has no document-clearance step');
throw new BadRequestException(
"This booking has no document-clearance step",
);
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
throw new BadRequestException("No documents uploaded");
}
// First submission (nothing in review yet): every required input field must
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
// is only fixing queried/pending docs, so the already-uploaded required docs
// stay in place and we don't re-gate on the full required set.
if (booking.status === 'AWAITING_DOCUMENTS') {
if (booking.status === "AWAITING_DOCUMENTS") {
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
resource: "bookings",
code: file.fieldname,
file,
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith('custom_')
? 'custom'
const settingCode = file.fieldname.startsWith("custom_")
? "custom"
: inputCode;
await this.bookingsRepository.upsertDocumentReviewPending({
bookingId,
@@ -691,7 +688,7 @@ export class BookingTransitionService {
}
await this.bookingsRepository.update(bookingId, {
status: 'DOCUMENTS_UNDER_REVIEW',
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedGeneralCustoms(booking)) {
@@ -728,7 +725,10 @@ export class BookingTransitionService {
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(bookingId, 'bookings');
const existing = await this.filesService.findByResource(
bookingId,
"bookings",
);
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
@@ -736,7 +736,7 @@ export class BookingTransitionService {
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
const labels = missing.map((f) => f.fileLabel).join(", ");
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
@@ -747,22 +747,27 @@ export class BookingTransitionService {
async reviewDocument(
bookingId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
status: "APPROVED" | "QUERIED",
staffId: string,
note?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
const existing =
await this.bookingsRepository.findDocumentReviews(bookingId);
const match = existing.find((r) => r.fileKey === fileKey);
const settingCode =
match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
(fileKey.startsWith("custom_")
? "custom"
: (inputCode ?? outputCode ?? "custom"));
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
if (status === "QUERIED" && !note?.trim()) {
throw new BadRequestException(
"A note is required when querying a document",
);
}
if (
status === 'QUERIED' &&
@@ -782,11 +787,11 @@ export class BookingTransitionService {
staffId,
note,
);
if (status === 'QUERIED') {
if (status === "QUERIED") {
await this.bookingsRepository.createReviewNote(
bookingId,
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedGeneralCustoms(booking)) {
@@ -821,18 +826,20 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
const { outputCode } = clearanceCodesForBooking(booking);
if (!outputCode) {
throw new BadRequestException('This booking has no customs output documents');
throw new BadRequestException(
"This booking has no customs output documents",
);
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
throw new BadRequestException("No documents uploaded");
}
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
resource: "bookings",
code: file.fieldname,
file,
});
@@ -856,14 +863,18 @@ export class BookingTransitionService {
const approved = await this.isClearanceFullyApproved(booking);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
"All required documents must be approved before clearance can be finalized",
);
}
const { outputCode } = clearanceCodesForBooking(booking);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const setting =
await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(
bookingId,
"bookings",
);
const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey),
@@ -872,13 +883,13 @@ export class BookingTransitionService {
throw new BadRequestException(
`Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel)
.join(', ')}`,
.join(", ")}`,
);
}
}
await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY',
status: "CLEARANCE_READY",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -897,11 +908,14 @@ export class BookingTransitionService {
scheduledDate: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
assertBookingStatus(booking, [
"CLEARANCE_READY",
"OPERATION_CHANGES_REQUESTED",
]);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException('A valid schedule date is required');
throw new BadRequestException("A valid schedule date is required");
}
// The binding shipment day must have at least one OPEN departure on the
@@ -914,12 +928,12 @@ export class BookingTransitionService {
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
"No departures available on the selected day for this route",
);
}
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING',
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
} as never);
return this.bookingsService.findById(bookingId);
@@ -935,27 +949,27 @@ export class BookingTransitionService {
*/
async reviewOperationRequest(
bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES',
decision: "ACCEPT" | "REQUEST_CHANGES",
actorId: string,
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
if (decision === 'REQUEST_CHANGES') {
if (decision === "REQUEST_CHANGES") {
if (!options.note?.trim()) {
throw new BadRequestException(
'A note is required when requesting changes',
"A note is required when requesting changes",
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
options.note,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
actorId,
);
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
status: "OPERATION_CHANGES_REQUESTED",
} as never);
return this.bookingsService.findById(bookingId);
}
@@ -977,9 +991,17 @@ export class BookingTransitionService {
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
const now = new Date();
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
);
await this.invoiceService.updateStatus(
invoice.id,
Freight.InvoiceStatus.Pending,
);
if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, {
status: 'ROAD_DISPATCH_PENDING',
status: "ROAD_DISPATCH_PENDING",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
@@ -987,7 +1009,7 @@ export class BookingTransitionService {
}
await this.bookingsRepository.update(booking.id, {
status: 'FULLY_EXECUTED',
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
@@ -1002,21 +1024,23 @@ export class BookingTransitionService {
return this.bookingsService.findById(booking.id);
}
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
async enrichBookingResponse(booking: Booking): Promise<
Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}
> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
'CHANGES_REQUESTED',
"CHANGES_REQUESTED",
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
@@ -1027,4 +1051,4 @@ export class BookingTransitionService {
nextStep,
};
}
}
}

View File

@@ -28,8 +28,8 @@ import {
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
} from "@nestjs/swagger";
import type { Response } from "express";
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
@@ -58,18 +58,21 @@ import {
RequestOperationDto,
OperationReviewDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
import { SignContractDto } from "./dto/sign-contract.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
} from "../../common/resolve-auth-user-id";
import {
assertFreightPermission,
hasFreightPermission,
} from "../../common/freight-permission.util";
@ApiTags('bookings')
@Controller('bookings')
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
export class BookingsController {
constructor(
@@ -83,8 +86,8 @@ export class BookingsController {
@Post()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@ApiBody({ type: CreateBookingDto })
async create(
@Body() dto: CreateBookingDto,
@@ -94,15 +97,24 @@ export class BookingsController {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
const result = await this.bookingsService.create(
dto,
files ?? [],
user?.id,
);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.bookings.staffAccept,
);
if (isStaff && !dto.isGovernment) {
try {
await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(result.booking.id);
const submitted = await this.bookingsService.findById(
result.booking.id,
);
return { booking: submitted, warnings: result.warnings };
} catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
@@ -112,16 +124,16 @@ export class BookingsController {
return result;
}
@Patch(':id')
@Patch(":id")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: 'Update booking',
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
summary: "Update booking",
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
@@ -129,7 +141,7 @@ export class BookingsController {
}
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@CurrentUser() user: TCurrentUser,
@@ -146,7 +158,7 @@ export class BookingsController {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
if (!userId) throw new UnauthorizedException("Authentication required");
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
@@ -172,27 +184,29 @@ export class BookingsController {
return this.bookingsService.findAll(filter, companyId);
}
@Get('by-company/:companyId/customer-view')
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
@Get("by-company/:companyId/customer-view")
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
findByCompanyCustomerView(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
return this.bookingsService.findCustomerBookings(companyId);
}
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@Get("list-summary")
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get('my')
@Get("my")
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
'Bookings owned by the authenticated user\'s company that are payable ' +
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
"Bookings owned by the authenticated user's company that are payable " +
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
})
findMyPayable(
@CurrentUser() user: AuthUserPayload,
@@ -201,32 +215,32 @@ export class BookingsController {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
}
@Get('queues/:queue')
@Get("queues/:queue")
@ApiOperation({
summary: 'List bookings for a dashboard queue',
description: 'Queues: intake, approval, signatures, marketing, finance',
summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance",
})
findQueue(
@Param('queue') queue: string,
@Param("queue") queue: string,
@Query() filter: FilterBookingDto,
@Query('excludeBulk') excludeBulk?: string,
@Query("excludeBulk") excludeBulk?: string,
) {
return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === 'true',
excludeBulk: excludeBulk === "true",
});
}
@Get('reference-data')
@ApiOperation({ summary: 'Booking form catalog' })
@Get("reference-data")
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
@Get("by-reference/:reference")
@ApiOperation({ summary: "Get booking by reference" })
async findByReference(
@Param('reference') reference: string,
@Param("reference") reference: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findByReference(reference);
@@ -240,10 +254,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
@Get(":id")
@ApiOperation({ summary: "Get booking by ID" })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
@@ -261,15 +275,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/tracking')
@Get(":id/tracking")
@ApiOperation({
summary: 'Shipment tracking timeline for a booking',
summary: "Shipment tracking timeline for a booking",
description:
"Returns the booking's consignment (once dispatched) and its ordered " +
'tracking events. Scoped to the customer\'s own company.',
"tracking events. Scoped to the customer's own company.",
})
async findTracking(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
@@ -283,66 +297,66 @@ export class BookingsController {
return this.bookingsService.getBookingTracking(id);
}
@Delete(':id')
@Delete(":id")
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
@Post(':id/documents')
@Post(":id/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/generate-price')
@Post(":id/generate-price")
@ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@Post(":id/submit")
@ApiOperation({
summary: 'Customer submit booking',
summary: "Customer submit booking",
description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) {
submit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@Post(":id/confirm-submit")
@ApiOperation({
summary: 'Confirm submit after price change',
summary: "Confirm submit after price change",
description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/reject')
@Post(":id/reject")
@ApiOperation({
summary: 'Customer reject price estimate',
summary: "Customer reject price estimate",
description:
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
})
async reject(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.reject(id, dto.reason);
@@ -367,20 +381,21 @@ export class BookingsController {
@Get(':id/clearance')
@ApiOperation({
summary: 'Document-clearance grid (required docs + upload + GL review status)',
summary:
"Document-clearance grid (required docs + upload + GL review status)",
})
getClearance(@Param('id', ParseUUIDPipe) id: string) {
getClearance(@Param("id", ParseUUIDPipe) id: string) {
return this.transitionService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@Post(":id/clearance/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: 'Customer uploads clearance documents (fieldname = document key)',
summary: "Customer uploads clearance documents (fieldname = document key)",
})
async submitClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.submitClearanceDocuments(
@@ -390,14 +405,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/proceed')
@Post(":id/clearance/proceed")
@ApiOperation({
summary:
'Customer requests operation with a schedule day ' +
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
"Customer requests operation with a schedule day " +
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
})
async proceedToOperation(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
) {
const booking = await this.transitionService.requestOperation(
@@ -407,15 +422,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/review')
@Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
})
async reviewOperationRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -428,11 +443,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/review')
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
@ApiOperation({
summary: "GL reviews a clearance document (Approve | Query)",
})
async reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -446,13 +463,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/output-documents')
@Post(":id/clearance/output-documents")
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
async uploadClearanceOutput(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
@@ -462,12 +479,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize')
@Post(":id/clearance/finalize")
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -628,9 +646,9 @@ export class BookingsController {
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
@ApiOperation({ summary: "Staff return booking for customer updates" })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -642,14 +660,14 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/accept')
@Post(":id/staff/accept")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary:
'Staff accept intake → set contract validity window + start approval chain',
"Staff accept intake → set contract validity window + start approval chain",
})
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -661,11 +679,11 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/reject')
@Post(":id/staff/reject")
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
@ApiOperation({ summary: "Staff final reject" })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -677,11 +695,13 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
@ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
})
async governmentExpedite(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingsService.governmentExpedite(
@@ -691,16 +711,16 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@Post(":id/approval-steps/:stepId/approve")
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@ApiOperation({ summary: "Approve one approval step in sequence" })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
@@ -714,12 +734,12 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@Post(":id/approval-steps/:stepId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
@ApiOperation({ summary: "Reject at approval step" })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
@@ -732,53 +752,53 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
@Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Generate contract PDF from template" })
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/view')
@Get(":id/contract/view")
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download contract PDF' })
@Get(":id/contract/document")
@ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
res.setHeader(
'Content-Disposition',
"Content-Disposition",
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
@Get(":id/contract")
@ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
@Post(":id/contract/sign")
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
@@ -790,28 +810,28 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/signatures')
@ApiOperation({ summary: 'List contract signatures' })
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/contract/signatures")
@ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(':id/summary')
@ApiOperation({ summary: 'Contract summary string for dashboard' })
getSummary(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/summary")
@ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(':id/customer/sign')
@Post(":id/customer/sign")
@ApiOperation({
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
summary: "Customer digital signature (deprecated — use POST contract/sign)",
})
async customerSign(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip,
@@ -819,20 +839,21 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/marketing/approve')
@Post(":id/marketing/approve")
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
summary:
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
})
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string },
) {
const payload: SignContractDto = {
...dto,
role: 'STAFF',
role: "STAFF",
};
const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user),
@@ -841,48 +862,48 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@Post(":id/operations/start-transit")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Mark in transit" })
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/complete')
@Post(":id/operations/complete")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Mark completed" })
async complete(@Param("id", ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/cancel')
@Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
@ApiOperation({ summary: "Cancel booking" })
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/consolidation')
@ApiOperation({ summary: 'Request freight consolidation' })
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
@Post(":id/consolidation")
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(':id/consolidation')
@ApiOperation({ summary: 'Remove consolidation pairing' })
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
@Delete(":id/consolidation")
@ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(':id/consolidation')
@ApiOperation({ summary: 'Get consolidation details' })
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id/consolidation")
@ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}

View File

@@ -1,7 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { Module, forwardRef } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
@@ -14,13 +14,13 @@ import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentController } from './booking-payment.controller';
import { BookingPaymentService } from './booking-payment.service';
// import { BookingPaymentController } from './booking-payment.controller';
// import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
@@ -39,6 +39,8 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
@Module({
imports: [
@@ -51,6 +53,7 @@ import { ContractsModule } from '../contracts/contracts.module';
BookingRateSnapshot,
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
]),
BillingModule,
forwardRef(() => FirstMileModule),
@@ -67,10 +70,10 @@ import { ContractsModule } from '../contracts/contracts.module';
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
}),
],
controllers: [BookingsController, PayController, BookingPaymentController],
controllers: [BookingsController],
providers: [
BookingsService,
BookingsRepository,
@@ -80,13 +83,17 @@ import { ContractsModule } from '../contracts/contracts.module';
BookingTransitionService,
BookingContractService,
BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
exports: [
BookingsService,
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
],
})
export class BookingsModule {}
export class BookingsModule { }

View File

@@ -43,6 +43,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
@@ -1380,4 +1381,35 @@ export class BookingsService {
createdAt: b.createdAt,
}));
}
async allocateContainers(
bookingId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const booking = await this.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
});
await manager.insert(BookingContainerAllocation, {
bookingId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,8 @@
export class ContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateContainersDto {
allocations!: ContainerAllocationDto[];
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ schema: 'freight', name: 'booking_container_allocations' })
@Index(['bookingId'])
@Index(['vehicleId'])
export class BookingContainerAllocation extends BaseEntity {
@ManyToOne(() => Booking, (b) => b.containerAllocations)
@JoinColumn({ name: 'booking_id' })
booking!: Booking;
@Column('uuid', { name: 'booking_id' })
bookingId!: string;
@Column('uuid', { name: 'container_id' })
containerId!: string;
@ManyToOne(() => Vehicle)
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string;
@Column('text')
containerType!: string; // CONTAINER, BULK_DRY, etc
@Column('integer', { default: 1 })
quantity!: number;
}

View File

@@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity';
import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingContainerAllocation } from './booking-container-allocation.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
import { BookingReviewNote } from './booking-review-note.entity';
@@ -476,6 +477,9 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];
@OneToMany(() => BookingContainerAllocation, (ca) => ca.booking)
containerAllocations?: BookingContainerAllocation[];
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
cargoModifiers?: BookingCargoModifier[];

View File

@@ -1,27 +0,0 @@
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
// import { BookingTransitionService } from './booking-transition.service';
// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
// import { Booking } from './entities/booking.entity';
// import { BookingNextStep } from './booking-next-step.util';
@ApiTags('payments')
@ApiBearerAuth()
@Controller('bookings')
export class PayController {
constructor(
private readonly paymentService: BookingPaymentService,
// private readonly transitionService: BookingTransitionService,
) { }
@Post(':id/payment/pay')
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
async pay(@Param('id', ParseUUIDPipe) id: string) {
return await this.paymentService.pay(id);
// const abstract = await this.transitionService.enrichBookingResponse(booking);
// return { ...abstract, paymentReceipt: receipt };
}
}

View File

@@ -0,0 +1,8 @@
export class FirstMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateFirstMileContainersDto {
allocations!: FirstMileContainerAllocationDto[];
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity';
@@ -58,4 +58,9 @@ export class CreateFirstMileDto {
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID()
vehicleId?: string | null;
@ApiPropertyOptional({ description: 'Invoice payment status', default: false })
@IsOptional()
@IsBoolean()
paid?: boolean;
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { FirstMile } from './first-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'first_mile_container_allocations', schema: 'freight' })
@Index(['firstMileId'])
@Index(['vehicleId'])
export class FirstMileContainerAllocation extends BaseEntity {
@Column({ name: 'first_mile_id', type: 'uuid' })
firstMileId!: string;
@ManyToOne(() => FirstMile, (firstMile) => firstMile.containerAllocations, {
nullable: false,
eager: false,
})
@JoinColumn({ name: 'first_mile_id' })
firstMile?: FirstMile;
@Column({ name: 'container_id', type: 'uuid' })
containerId!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column({ name: 'container_type', type: 'text' })
containerType!: string;
@Column({ name: 'quantity', type: 'int', default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -34,6 +35,10 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ name: 'paid', type: 'boolean', default: false })
paid!: boolean;
// TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@@ -49,4 +54,11 @@ export class FirstMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(
() => FirstMileContainerAllocation,
(containerAllocation) => containerAllocation.firstMile,
{ eager: false },
)
containerAllocations!: FirstMileContainerAllocation[];
}

View File

@@ -0,0 +1,106 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import {
BillingService,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMile } from './entities/first-mile.entity';
/**
* Owns the first-mile ⇄ invoice mapping — the one place that knows how a
* first-mile record turns into invoices, which type to use, and how it
* advances when paid. First-mile records are billable entities, so they
* generate their own invoices directly via {@link BillingService}.
*/
@Injectable()
export class FirstMileInvoiceService {
private readonly logger = new Logger(FirstMileInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly firstMileRepo: FirstMileRepository,
) {}
/**
* Ensure the first-mile record has its invoice, generating one from the
* remaining payment if absent. Called when a first-mile record reaches a
* billable state. Idempotent — returns the existing open invoice instead
* of a duplicate. Returns `null` (and logs) when the record is not billable:
* no company to bill.
*/
async ensureInvoiceFor(record: FirstMile): Promise<Invoice | null> {
const existing = await this.billing.findPayable(
'first_mile' as Freight.InvoiceSource,
record.id,
'DELIVERY_FEE',
);
if (existing) return existing;
if (!record.bookingId) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no booking to reference.`,
);
return null;
}
// Fetch the booking to get the companyId and companyProfileId
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } }));
if (!fm) return null;
if (!fm.booking?.companyId) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no company to bill.`,
);
return null;
}
const totalAmount = record.remainingPayment || 0;
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
);
return null;
}
return this.billing.generateInvoice({
source: 'first_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'First-mile delivery',
quantity: 1,
unitRate: totalAmount,
amount: totalAmount,
},
],
totalAmount,
});
}
/**
* React to a first-mile invoice being paid — the settlement branch point.
* Mark the first-mile record as having completed post-payment processing.
*/
@OnEvent('first_mile.invoice.paid')
async onPaid(payload: InvoiceEventPayload): Promise<void> {
if (payload.type === 'DELIVERY_FEE') {
const record = await this.firstMileRepo.findById(payload.sourceId);
if (!record) {
this.logger.warn(
`Cannot mark unknown first-mile record ${payload.sourceId} as paid.`,
);
return;
}
this.logger.log(`First-mile invoice paid for record ${payload.sourceId}.`);
}
}
}

View File

@@ -17,15 +17,25 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { BillingService } from '../billing/billing.service';
import { BookingsService } from '../bookings/bookings.service';
import { Freight } from '@edr/types';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
export class FirstMileController {
constructor(private readonly firstMileService: FirstMileService) {}
constructor(
private readonly firstMileService: FirstMileService,
private readonly firstMileInvoiceService: FirstMileInvoiceService,
private readonly billingService: BillingService,
private readonly bookingsService: BookingsService
) { }
@Get()
@ApiOperation({ summary: 'List first-mile legs' })
@@ -72,8 +82,40 @@ export class FirstMileController {
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a first-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
return this.firstMileService.update(id, dto);
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
const record = await this.firstMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
const booking = await this.bookingsService.findById(record.bookingId);
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
await this.billingService.generateInvoice({
source: Freight.InvoiceSource.FirstMile,
sourceId: record.id,
type: "FIRST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
lines: [
{
chargeType: "FIRST_MILE",
description: "First Mile Transportation Service",
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
},
],
subtotalAmount: record.remainingPayment,
taxAmount: 0, // Replace if VAT/tax applies
totalAmount: record.remainingPayment,
dueInDays: 7,
status: Freight.InvoiceStatus.Pending,
});
await this.firstMileInvoiceService.ensureInvoiceFor(record);
}
return record;
}
@Delete(':id')
@@ -83,4 +125,14 @@ export class FirstMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.remove(id);
}
@Post(':firstMileId/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
allocateContainers(
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
@Body() dto: AllocateFirstMileContainersDto,
) {
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
}
}

View File

@@ -1,25 +1,29 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([FirstMile]),
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],
providers: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
exports: [FirstMileRepository, FirstMileService, FirstMileInvoiceService],
})
export class FirstMileModule {}

View File

@@ -1,5 +1,7 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
@@ -8,7 +10,10 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileRepository } from './first-mile.repository';
import { OnEvent } from '@nestjs/event-emitter';
import { InvoiceEventPayload } from '../billing/billing.service';
type FirstMileListFilter = {
status?: FirstMileStatus;
@@ -32,6 +37,7 @@ export class FirstMileService {
private readonly logger = new Logger(FirstMileService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
@@ -142,6 +148,18 @@ export class FirstMileService {
};
}
@OnEvent("firstmile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
await this.firstMileRepository.update(payload.sourceId, { paid: true } as any);
this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
} catch (err) {
this.logger.error(
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
);
}
}
async findById(id: string): Promise<FirstMile> {
const record = await this.firstMileRepository.findById(id, {
relations: {
@@ -171,6 +189,7 @@ export class FirstMileService {
estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
}
@@ -203,6 +222,7 @@ export class FirstMileService {
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id);
const dtoAny = dto as any;
const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
@@ -211,7 +231,8 @@ export class FirstMileService {
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
} as any);
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
@@ -273,4 +294,35 @@ export class FirstMileService {
await this.findById(id);
await this.firstMileRepository.softDelete(id);
}
async allocateContainers(
firstMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const firstMile = await this.findById(firstMileId);
if (!firstMile) {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
});
await manager.insert(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,40 @@
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
import { PaymentMethod } from '../entities/fuel-purchase.entity';
export class CreateFuelPurchaseDto {
@IsUUID()
vehicleId!: string;
@IsDateString()
purchaseDate!: string;
@IsNumber()
liters!: number;
@IsNumber()
costPerLiter!: number;
@IsOptional()
@IsString()
fuelStation?: string;
@IsEnum(PaymentMethod)
@IsOptional()
paymentMethod?: PaymentMethod;
@IsOptional()
@IsNumber()
odometerReading?: number;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsString()
receiptNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,35 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'fuel_consumption', schema: 'freight' })
@Index(['vehicleId', 'month'])
export class FuelConsumption extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'month', type: 'date' })
month!: Date;
@Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 })
totalLiters!: number;
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
totalCost!: number;
@Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 })
totalDistanceKm: number = 0;
@Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true })
fuelEfficiencyKmPerL?: number;
@Column({ name: 'number_of_purchases', type: 'integer', default: 0 })
numberOfPurchases!: number;
@Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true })
averageCostPerLiter?: number;
}

View File

@@ -0,0 +1,51 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum PaymentMethod {
CASH = 'CASH',
CARD = 'CARD',
FUEL_CARD = 'FUEL_CARD',
TRANSFER = 'TRANSFER',
CHEQUE = 'CHEQUE',
}
@Entity({ name: 'fuel_purchases', schema: 'freight' })
export class FuelPurchase extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'purchase_date', type: 'timestamptz' })
purchaseDate!: Date;
@Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 })
liters!: number;
@Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 })
costPerLiter!: number;
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
totalCost!: number;
@Column({ name: 'fuel_station', nullable: true })
fuelStation?: string;
@Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH })
paymentMethod!: PaymentMethod;
@Column({ name: 'odometer_reading', type: 'numeric', nullable: true })
odometerReading?: number;
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string;
@Column({ name: 'receipt_number', nullable: true })
receiptNumber?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,60 @@
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { FuelService } from './fuel.service';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
@ApiTags('Fuel Management')
@Controller('fuel')
export class FuelController {
constructor(private readonly fuelService: FuelService) {}
@Post('purchases')
@ApiOperation({ summary: 'Record fuel purchase' })
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
return this.fuelService.recordFuelPurchase(dto);
}
@Get('purchases')
@ApiOperation({ summary: 'Get all fuel purchases' })
async getAllFuelPurchases() {
return this.fuelService.getAllFuelPurchases();
}
@Get('purchases/:vehicleId')
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
async getFuelPurchases(
@Param('vehicleId') vehicleId: string,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.fuelService.getFuelPurchases(
vehicleId,
new Date(startDate),
new Date(endDate),
);
}
@Get('consumption/:vehicleId/:month')
@ApiOperation({ summary: 'Get monthly fuel consumption' })
async getMonthlyConsumption(
@Param('vehicleId') vehicleId: string,
@Param('month') month: string,
) {
return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month));
}
@Get('stats')
@ApiOperation({ summary: 'Get fleet-wide fuel statistics' })
async getFleetFuelStats(@Query('months') months: number = 12) {
return this.fuelService.getFleetFuelStats(months);
}
@Get('stats/:vehicleId')
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
async getVehicleFuelStats(
@Param('vehicleId') vehicleId: string,
@Query('months') months: number = 12,
) {
return this.fuelService.getVehicleFuelStats(vehicleId, months);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FuelController } from './fuel.controller';
import { FuelService } from './fuel.service';
import { FuelRepository } from './fuel.repository';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
@Module({
imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])],
controllers: [FuelController],
providers: [FuelService, FuelRepository],
exports: [FuelService],
})
export class FuelModule {}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, Between } from 'typeorm';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
@Injectable()
export class FuelRepository extends BaseRepository<FuelPurchase> {
constructor(
@InjectRepository(FuelPurchase)
private readonly purchaseRepository: Repository<FuelPurchase>,
@InjectRepository(FuelConsumption)
private readonly consumptionRepository: Repository<FuelConsumption>,
) {
super(purchaseRepository);
}
async findByVehicleAndDateRange(
vehicleId: string,
startDate: Date,
endDate: Date,
): Promise<FuelPurchase[]> {
return this.purchaseRepository.find({
where: {
vehicleId,
purchaseDate: Between(startDate, endDate),
},
order: { purchaseDate: 'DESC' },
});
}
async getMonthlyConsumption(
vehicleId: string,
month: Date,
): Promise<FuelConsumption | null> {
return this.consumptionRepository.findOne({
where: {
vehicleId,
month,
},
});
}
async updateMonthlyConsumption(
vehicleId: string,
month: Date,
data: Partial<FuelConsumption>,
): Promise<FuelConsumption> {
let consumption = await this.consumptionRepository.findOne({
where: {
vehicleId,
month,
},
});
if (!consumption) {
consumption = this.consumptionRepository.create({
vehicleId,
month,
...data,
});
} else {
Object.assign(consumption, data);
}
return this.consumptionRepository.save(consumption);
}
async findPurchasesByVehicle(vehicleId: string): Promise<FuelPurchase[]> {
return this.purchaseRepository.find({
where: { vehicleId },
order: { purchaseDate: 'DESC' },
});
}
}

View File

@@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FuelRepository } from './fuel.repository';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
@Injectable()
export class FuelService {
constructor(
private readonly fuelRepository: FuelRepository,
@InjectRepository(FuelPurchase)
private readonly purchaseRepository: Repository<FuelPurchase>,
) {}
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
const totalCost = dto.liters * dto.costPerLiter;
const purchase = this.purchaseRepository.create({
...dto,
totalCost,
});
const saved = await this.purchaseRepository.save(purchase);
// Update monthly consumption
await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate));
return saved;
}
async getAllFuelPurchases(): Promise<FuelPurchase[]> {
return this.purchaseRepository
.createQueryBuilder('purchase')
.leftJoinAndSelect('purchase.vehicle', 'vehicle')
.orderBy('purchase.purchaseDate', 'DESC')
.getMany();
}
async getFuelPurchases(
vehicleId: string,
startDate: Date,
endDate: Date,
): Promise<FuelPurchase[]> {
return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate);
}
async getMonthlyConsumption(
vehicleId: string,
month: Date,
): Promise<FuelConsumption | null> {
return this.fuelRepository.getMonthlyConsumption(vehicleId, month);
}
async getFleetFuelStats(monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const purchases = await this.purchaseRepository
.createQueryBuilder('purchase')
.where('purchase.purchaseDate BETWEEN :startDate AND :endDate', { startDate, endDate })
.getMany();
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0;
return {
totalPurchases: purchases.length,
totalLiters,
totalCost,
averagePricePerLiter: averagePrice,
averageEfficiency: 0, // Placeholder - would need distance data
dateRange: { startDate, endDate },
};
}
async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate);
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0;
return {
vehicleId,
totalPurchases: purchases.length,
totalLiters,
totalCost,
averagePricePerLiter: averagePrice,
dateRange: { startDate, endDate },
};
}
private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise<void> {
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1);
const purchases = await this.fuelRepository.findByVehicleAndDateRange(
vehicleId,
monthStart,
monthEnd,
);
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
const numberOfPurchases = purchases.length;
const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0;
await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, {
totalLiters,
totalCost,
numberOfPurchases,
averageCostPerLiter,
});
}
}

View File

@@ -0,0 +1,8 @@
export class LastMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateLastMileContainersDto {
allocations!: LastMileContainerAllocationDto[];
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity';
@@ -58,4 +58,9 @@ export class CreateLastMileDto {
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID()
vehicleId?: string | null;
@ApiPropertyOptional({ description: 'Invoice payment status', default: false })
@IsOptional()
@IsBoolean()
paid?: boolean;
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { LastMile } from './last-mile.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ schema: 'freight', name: 'last_mile_container_allocations' })
@Index(['lastMileId'])
@Index(['vehicleId'])
export class LastMileContainerAllocation extends BaseEntity {
@ManyToOne(() => LastMile, (lm) => lm.containerAllocations)
@JoinColumn({ name: 'last_mile_id' })
lastMile!: LastMile;
@Column('uuid', { name: 'last_mile_id' })
lastMileId!: string;
@Column('uuid', { name: 'container_id' })
containerId!: string;
@ManyToOne(() => Vehicle)
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string | null;
@Column('text')
containerType!: string;
@Column('integer', { default: 1 })
quantity!: number;
}

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -34,6 +35,10 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ name: 'paid', type: 'boolean', default: false })
paid!: boolean;
// TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean;
@@ -49,4 +54,7 @@ export class LastMile extends BaseEntity {
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
containerAllocations?: LastMileContainerAllocation[];
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import {
BillingService,
GenerateInvoiceInput,
InvoiceEventPayload,
} from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { LastMileRepository } from './last-mile.repository';
import { LastMile } from './entities/last-mile.entity';
/**
* Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile
* record turns into invoices, which type to use, and how it advances when paid.
* Last-mile records are billable business entities for delivery fees, so they
* generate their own invoices directly via {@link BillingService}. All last-mile-specific
* type branching lives here, at the two points it belongs: invoice creation and
* settlement (the paid handler).
*/
@Injectable()
export class LastMileInvoiceService {
private readonly logger = new Logger(LastMileInvoiceService.name);
constructor(
private readonly billing: BillingService,
private readonly lastMileRepo: LastMileRepository,
) {}
/**
* Ensure the last-mile record has its invoice, generating one from the
* remainingPayment if absent. Called when a last-mile record reaches a
* billable state. Idempotent — returns the existing open invoice instead
* of a duplicate. Returns `null` (and logs) when the record is not billable:
* no company to bill (invoices FK requires a companyId).
*/
async ensureInvoiceFor(record: LastMile): Promise<Invoice | null> {
// Check if invoice already exists
const existing = await this.billing.findPayable(
'last_mile' as Freight.InvoiceSource,
record.id,
'DELIVERY_FEE',
);
if (existing) return existing;
// Can't bill without company
const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } }));
if (!lm) return null;
if (!lm.booking?.companyId) {
this.logger.warn(
`Skipping invoice for last-mile record ${record.id}: no company to bill.`,
);
return null;
}
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'Last-mile delivery',
quantity: 1,
unitRate: record.remainingPayment || 0,
amount: record.remainingPayment || 0,
},
],
totalAmount: record.remainingPayment || 0,
};
return this.billing.generateInvoice(input);
}
/**
* React to a last-mile invoice being paid — the settlement branch point.
* Advances the last-mile record to mark post-payment as completed.
*/
@OnEvent('last_mile.invoice.paid')
async onPaid(payload: InvoiceEventPayload): Promise<void> {
if (payload.type === 'DELIVERY_FEE') {
const record = await this.lastMileRepo.findById(payload.sourceId);
if (record) {
this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`);
} else {
this.logger.warn(
`Cannot mark last-mile record ${payload.sourceId} as paid: not found.`,
);
}
}
}
}

View File

@@ -17,15 +17,25 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { Freight } from '@edr/types';
import { BillingService } from '../billing/billing.service';
import { BookingsService } from '../bookings/bookings.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
export class LastMileController {
constructor(private readonly lastMileService: LastMileService) {}
constructor(
private readonly lastMileService: LastMileService,
private readonly lastMileInvoiceService: LastMileInvoiceService,
private readonly billingService: BillingService,
private readonly bookingsService: BookingsService
) {}
@Get()
@ApiOperation({ summary: 'List last-mile legs' })
@@ -72,8 +82,40 @@ export class LastMileController {
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a last-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
return this.lastMileService.update(id, dto);
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
const record = await this.lastMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
const booking = await this.bookingsService.findById(record.bookingId);
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
await this.billingService.generateInvoice({
source: Freight.InvoiceSource.LastMile,
sourceId: record.id,
type: "LAST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
lines: [
{
chargeType: "LAST_MILE",
description: "Last Mile Transportation Service",
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
},
],
subtotalAmount: record.remainingPayment,
taxAmount: 0, // Replace if VAT/tax applies
totalAmount: record.remainingPayment,
dueInDays: 7,
status: Freight.InvoiceStatus.Pending,
});
await this.lastMileInvoiceService.ensureInvoiceFor(record);
}
return record;
}
@Delete(':id')
@@ -83,4 +125,14 @@ export class LastMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.remove(id);
}
@Post(':id/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AllocateLastMileContainersDto,
) {
return this.lastMileService.allocateContainers(id, dto.allocations);
}
}

View File

@@ -1,25 +1,29 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile]),
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
exports: [LastMileRepository, LastMileService, LastMileInvoiceService],
})
export class LastMileModule {}

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
@@ -8,7 +8,10 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
type LastMileListFilter = {
status?: LastMileStatus;
@@ -37,6 +40,7 @@ export class LastMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -135,12 +139,26 @@ export class LastMileService {
estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
}
@OnEvent("lastmile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
await this.lastMileRepository.update(payload.sourceId, { paid: true } as any);
this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
} catch (err) {
this.logger.error(
`Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`,
);
}
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
const existing = await this.findById(id);
const dtoAny = dto as any;
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
@@ -149,7 +167,8 @@ export class LastMileService {
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
} as any);
if (!updated) {
throw new NotFoundException(`Last-mile record ${id} not found`);
@@ -206,4 +225,35 @@ export class LastMileService {
await this.findById(id);
await this.lastMileRepository.softDelete(id);
}
async allocateContainers(
lastMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const lastMile = await this.findById(lastMileId);
if (!lastMile) {
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
});
await manager.insert(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}

View File

@@ -0,0 +1,87 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity';
export class CreateMaintenanceScheduleDto {
@IsUUID()
vehicleId!: string;
@IsEnum(MaintenanceType)
maintenanceType!: MaintenanceType;
@IsString()
description!: string;
@IsDateString()
scheduledDate!: string;
@IsOptional()
@IsNumber()
estimatedCost?: number;
@IsOptional()
@IsString()
serviceProvider?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsNumber()
nextDueKm?: number;
@IsOptional()
@IsDateString()
nextDueDate?: string;
}
export class CreateMaintenanceCostDto {
@IsUUID()
vehicleId!: string;
@IsOptional()
@IsUUID()
maintenanceScheduleId?: string;
@IsDateString()
incurredDate!: string;
@IsNumber()
costAmount!: number;
@IsString()
costType!: string;
@IsString()
description!: string;
@IsOptional()
@IsString()
serviceProvider?: string;
@IsOptional()
@IsString()
invoiceNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateMaintenanceScheduleDto {
@IsOptional()
@IsEnum(MaintenanceStatus)
status?: MaintenanceStatus;
@IsOptional()
@IsDateString()
completedDate?: string;
@IsOptional()
@IsNumber()
actualCost?: number;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { MaintenanceSchedule } from './maintenance-schedule.entity';
@Entity({ name: 'maintenance_costs', schema: 'freight' })
@Index(['vehicleId', 'incurredDate'])
export class MaintenanceCost extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true })
maintenanceScheduleId?: string;
@ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' })
@JoinColumn({ name: 'maintenance_schedule_id' })
maintenanceSchedule?: MaintenanceSchedule;
@Column({ name: 'incurred_date', type: 'timestamptz' })
incurredDate!: Date;
@Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 })
costAmount!: number;
@Column({ name: 'cost_type' })
costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER'
@Column({ name: 'description' })
description!: string;
@Column({ name: 'service_provider', nullable: true })
serviceProvider?: string;
@Column({ name: 'invoice_number', nullable: true })
invoiceNumber?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,65 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum MaintenanceType {
PREVENTIVE = 'PREVENTIVE',
CORRECTIVE = 'CORRECTIVE',
INSPECTION = 'INSPECTION',
REPAIR = 'REPAIR',
}
export enum MaintenanceStatus {
SCHEDULED = 'SCHEDULED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
OVERDUE = 'OVERDUE',
}
@Entity({ name: 'maintenance_schedules', schema: 'freight' })
@Index(['vehicleId', 'scheduledDate'])
export class MaintenanceSchedule extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'maintenance_type', type: 'varchar' })
maintenanceType!: MaintenanceType;
@Column({ name: 'description' })
description!: string;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({ name: 'completed_date', type: 'timestamptz', nullable: true })
completedDate?: Date;
@Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
estimatedCost?: number;
@Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
actualCost?: number;
@Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED })
status!: MaintenanceStatus;
@Column({ name: 'odometer_reading', type: 'numeric', nullable: true })
odometerReading?: number;
@Column({ name: 'service_provider', nullable: true })
serviceProvider?: string;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
@Column({ name: 'next_due_km', type: 'numeric', nullable: true })
nextDueKm?: number;
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
nextDueDate?: Date;
}

View File

@@ -0,0 +1,52 @@
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { MaintenanceService } from './maintenance.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
@ApiTags('Maintenance Management')
@Controller('maintenance')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
@Post('schedules')
@ApiOperation({ summary: 'Schedule maintenance' })
async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) {
return this.maintenanceService.scheduleMaintenanceAsync(dto);
}
@Post('costs')
@ApiOperation({ summary: 'Record maintenance cost' })
async recordCost(@Body() dto: CreateMaintenanceCostDto) {
return this.maintenanceService.recordMaintenanceCost(dto);
}
@Patch('schedules/:id')
@ApiOperation({ summary: 'Update maintenance schedule' })
async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('upcoming/:vehicleId')
@ApiOperation({ summary: 'Get upcoming maintenance' })
async getUpcoming(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getUpcomingMaintenance(vehicleId);
}
@Get('history/:vehicleId')
@ApiOperation({ summary: 'Get maintenance history' })
async getHistory(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getMaintenanceHistory(vehicleId);
}
@Get('stats')
@ApiOperation({ summary: 'Get fleet-wide maintenance statistics' })
async getFleetStats() {
return this.maintenanceService.getFleetMaintenanceStats();
}
@Get('stats/:vehicleId')
@ApiOperation({ summary: 'Get maintenance statistics' })
async getStats(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
providers: [MaintenanceService, MaintenanceRepository],
controllers: [MaintenanceController],
exports: [MaintenanceService],
})
export class MaintenanceModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, Between } from 'typeorm';
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
@Injectable()
export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
constructor(
@InjectRepository(MaintenanceSchedule)
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
private readonly costRepository: Repository<MaintenanceCost>,
) {
super(scheduleRepository);
}
async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) {
const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000);
return this.scheduleRepository.find({
where: {
vehicleId,
scheduledDate: Between(new Date(), futureDate),
status: MaintenanceStatus.SCHEDULED,
},
order: { scheduledDate: 'ASC' },
});
}
async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) {
return this.costRepository.find({
where: {
vehicleId,
incurredDate: Between(startDate, endDate),
},
order: { incurredDate: 'DESC' },
});
}
async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) {
const result = await this.costRepository
.createQueryBuilder()
.select('SUM(cost_amount)', 'total')
.where('vehicle_id = :vehicleId', { vehicleId })
.andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
return result?.total || 0;
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
@Injectable()
export class MaintenanceService {
constructor(
private readonly maintenanceRepository: MaintenanceRepository,
@InjectRepository(MaintenanceSchedule)
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
private readonly costRepository: Repository<MaintenanceCost>,
) {}
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
const schedule = this.scheduleRepository.create({
...dto,
scheduledDate: new Date(dto.scheduledDate),
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
});
return this.scheduleRepository.save(schedule);
}
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
const cost = this.costRepository.create({
...dto,
incurredDate: new Date(dto.incurredDate),
});
return this.costRepository.save(cost);
}
async updateMaintenanceSchedule(
id: string,
dto: UpdateMaintenanceScheduleDto,
): Promise<MaintenanceSchedule> {
await this.scheduleRepository.update(id, {
...dto,
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
});
const updated = await this.scheduleRepository.findOneBy({ id });
return updated!;
}
async getUpcomingMaintenance(vehicleId: string) {
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
}
async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
}
async getFleetMaintenanceStats(monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const costs = await this.costRepository
.createQueryBuilder('cost')
.where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate })
.getMany();
const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0);
return {
totalCost,
numberOfMaintenanceItems: costs.length,
averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0,
costByType: this.groupCostsByType(costs),
};
}
async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0);
return {
vehicleId,
totalCost,
numberOfMaintenanceItems: costs.length,
averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0,
costByType: this.groupCostsByType(costs),
};
}
private groupCostsByType(costs: MaintenanceCost[]) {
const grouped: Record<string, number> = {};
costs.forEach((c) => {
if (!grouped[c.costType]) grouped[c.costType] = 0;
grouped[c.costType] += Number(c.costAmount);
});
return grouped;
}
}

View File

@@ -6,8 +6,6 @@ import {
ParseUUIDPipe,
Query,
Res,
Body,
Post,
} from "@nestjs/common";
import {
ApiTags,
@@ -18,9 +16,9 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView, FreightAdmin } from "../../common/booking-guards";
import { BookingView } from "../../common/booking-guards";
import { PaymentService } from "./payment.service";
import { IntentStatusDto, RefundDto } from "./payments.dto";
import { IntentStatusDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
@@ -73,13 +71,6 @@ export class PaymentController {
return this.paymentService.getIntentByBookingId(bookingId);
}
@Post("refund")
@FreightAdmin()
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
refund(@Body() dto: RefundDto) {
return this.paymentService.refund(dto);
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum {
}
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@ApiProperty({ example: "invoice-uuid" })
@IsString()
bookingId!: string;
invoiceId!: string;
@ApiProperty({
enum: PaymentMethodTypeEnum,

View File

@@ -110,6 +110,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
);
});

View File

@@ -22,6 +22,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -29,7 +33,7 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS,
} from './booking-batch.constants';
} from "./booking-batch.constants";
import {
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
@@ -56,12 +60,12 @@ interface RouteDayGroup {
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
| 'ALLOCATED'
| 'SELECTED_FOR_BATCH'
| 'READY'
| 'WAITING'
| 'PENDING_CONTRACT'
| 'EXPIRED';
| "ALLOCATED"
| "SELECTED_FOR_BATCH"
| "READY"
| "WAITING"
| "PENDING_CONTRACT"
| "EXPIRED";
export interface BatchBoardBooking {
id: string;
@@ -76,10 +80,10 @@ export interface BatchBoardBooking {
}
export type BookingAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
| "NOT_ATTEMPTED"
| "ASSIGNED"
| "DEFERRED"
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null;
@@ -117,9 +121,9 @@ export interface BatchBoardScheduleDetail {
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
locomotive: BatchBoardSchedule['locomotive'];
capacity: BatchBoardSchedule['capacity'];
counts: BatchBoardSchedule['counts'];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
allocationViolations: string[];
@@ -182,7 +186,10 @@ export class BookingBatchService implements OnModuleInit {
private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
@@ -199,10 +206,10 @@ export class BookingBatchService implements OnModuleInit {
}
const reserved = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.createQueryBuilder("b")
.select("DISTINCT b.train_schedule_id", "scheduleId")
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.train_schedule_id IS NOT NULL')
.andWhere("b.train_schedule_id IS NOT NULL")
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
@@ -280,7 +287,7 @@ export class BookingBatchService implements OnModuleInit {
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
where: { bookingWindowStatus: "OPEN" },
});
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
@@ -314,25 +321,29 @@ export class BookingBatchService implements OnModuleInit {
if (!booking?.trainScheduleId) return;
const isBatchPaid =
booking.status === 'SELECTED_FOR_BATCH' ||
booking.status === 'AWAITING_PAYMENT' ||
booking.status === 'PAID' ||
booking.paymentStatus === 'PAID';
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT" ||
booking.status === "PAID" ||
booking.paymentStatus === "PAID";
if (!isBatchPaid) return;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
} else if (booking.paymentStatus !== 'PAID') {
.update(bookingId, { paymentStatus: "PAID", status: "PAID" });
} else if (booking.paymentStatus !== "PAID") {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
.update(bookingId, { paymentStatus: "PAID" });
}
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
await this.allocate(booking.trainScheduleId, booking, 'paid');
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
@@ -342,7 +353,7 @@ export class BookingBatchService implements OnModuleInit {
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
await this.setWindow(booking.trainScheduleId, "FULL");
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
@@ -353,7 +364,11 @@ export class BookingBatchService implements OnModuleInit {
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
);
}
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
if (
result.issues.some(
(i) => i.bookingId === bookingId && i.status !== "ASSIGNED",
)
) {
const issue = result.issues.find((i) => i.bookingId === bookingId);
this.logger.warn(
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
@@ -368,9 +383,10 @@ export class BookingBatchService implements OnModuleInit {
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
);
@@ -379,7 +395,7 @@ export class BookingBatchService implements OnModuleInit {
// ---- cron entry point -----------------------------------------------------
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
@@ -409,7 +425,7 @@ export class BookingBatchService implements OnModuleInit {
destinationStation: true,
route: true,
},
order: { scheduledDepartureDate: 'ASC' },
order: { scheduledDepartureDate: "ASC" },
});
const wagonLengths = await this.loadWagonLengths();
@@ -417,7 +433,7 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue;
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -429,13 +445,15 @@ export class BookingBatchService implements OnModuleInit {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
};
});
@@ -446,11 +464,15 @@ export class BookingBatchService implements OnModuleInit {
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
throw new BadRequestException('Schedule is no longer active');
async getBatchBoardDetail(
scheduleId: string,
): Promise<BatchBoardScheduleDetail> {
const s =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s)
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
const wagonLengths = await this.loadWagonLengths();
@@ -460,12 +482,18 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>;
try {
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
} catch {
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
allocationPreview = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
}
const allocationByBooking = new Map(
allocationPreview.issues.map((i) => [i.bookingId, i]),
@@ -478,17 +506,23 @@ export class BookingBatchService implements OnModuleInit {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
fullyExecutedAt: b.fullyExecutedAt
? b.fullyExecutedAt.toISOString()
: null,
selectedForBatchAt: b.selectedForBatchAt
? b.selectedForBatchAt.toISOString()
: null,
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
allocationIssue: alloc?.issue ?? null,
};
});
@@ -518,11 +552,11 @@ export class BookingBatchService implements OnModuleInit {
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts();
for (const b of bookingsInWindow) {
if (b.state === 'ALLOCATED') counts.allocated += 1;
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
else if (b.state === 'READY') counts.ready += 1;
else if (b.state === 'WAITING') counts.waiting += 1;
else if (b.state === 'EXPIRED') counts.expired += 1;
if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1;
else if (b.state === "WAITING") counts.waiting += 1;
else if (b.state === "EXPIRED") counts.expired += 1;
else counts.pendingContract += 1;
}
return counts;
@@ -530,7 +564,7 @@ export class BookingBatchService implements OnModuleInit {
const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) {
if (key === 'pending-contract' || !bucket.window) continue;
if (key === "pending-contract" || !bucket.window) continue;
const w = bucket.window;
windows.push({
key: w.key,
@@ -543,44 +577,51 @@ export class BookingBatchService implements OnModuleInit {
bookings: bucket.items,
});
}
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
windows.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
windows,
pendingContract: {
key: 'pending-contract',
label: 'Pending contract',
date: '',
dateLabel: '',
start: '',
end: '',
key: "pending-contract",
label: "Pending contract",
date: "",
dateLabel: "",
start: "",
end: "",
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
@@ -601,17 +642,21 @@ export class BookingBatchService implements OnModuleInit {
lengthMeters: number;
}>,
loco: Locomotive | null,
): BatchBoardSchedule['capacity'] {
const allocated = items.filter((i) => i.state === 'ALLOCATED');
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters:
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
Math.round(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
usedWeightTons:
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
};
}
@@ -627,51 +672,66 @@ export class BookingBatchService implements OnModuleInit {
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
bookings: items.slice(0, 3),
};
}
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
if (linked) return 'ALLOCATED';
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return 'SELECTED_FOR_BATCH';
private boardState(
booking: Booking,
linked: boolean,
): BatchBoardBookingState {
if (linked) return "ALLOCATED";
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
return "SELECTED_FOR_BATCH";
}
if (booking.status === 'EXPIRED') return 'EXPIRED';
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
if (booking.status === 'PAID') return 'WAITING';
return 'PENDING_CONTRACT';
if (booking.status === "EXPIRED") return "EXPIRED";
if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt)
return "READY";
if (booking.status === "PAID") return "WAITING";
return "PENDING_CONTRACT";
}
// ---- core fill ------------------------------------------------------------
/** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return;
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`);
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
);
return;
}
@@ -681,7 +741,7 @@ export class BookingBatchService implements OnModuleInit {
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (budget.wagons <= 0) {
await this.setWindow(scheduleId, 'FULL');
await this.setWindow(scheduleId, "FULL");
return;
}
@@ -693,7 +753,12 @@ export class BookingBatchService implements OnModuleInit {
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
budget = await this.preemptForGovernment(
scheduleId,
need,
budget,
wagonLengths,
);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip a booking that exceeds weight/length/wagons, try the next
@@ -701,7 +766,7 @@ export class BookingBatchService implements OnModuleInit {
}
if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov');
await this.allocate(scheduleId, booking, "gov");
} else {
await this.reserve(booking, scheduleId);
armed = true;
@@ -710,7 +775,7 @@ export class BookingBatchService implements OnModuleInit {
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
}
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
@@ -736,13 +801,14 @@ export class BookingBatchService implements OnModuleInit {
const scheduleIds = bookable
.filter(
(s) =>
s.bookingWindowStatus === 'OPEN' &&
s.bookingWindowStatus === "OPEN" &&
s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day,
)
.sort(
(a, b) =>
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
new Date(a.scheduleDate).getTime() -
new Date(b.scheduleDate).getTime(),
)
.map((s) => s.id);
@@ -754,15 +820,22 @@ export class BookingBatchService implements OnModuleInit {
// Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
const budget = await this.remainingCapacity(
schedule,
limits,
wagonLengths,
);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
@@ -783,7 +856,12 @@ export class BookingBatchService implements OnModuleInit {
// Government booking fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
t.budget = await this.preemptForGovernment(
t.id,
need,
t.budget,
wagonLengths,
);
if (this.fits(need, t.budget)) {
target = t;
break;
@@ -798,7 +876,7 @@ export class BookingBatchService implements OnModuleInit {
}
if (booking.isGovernment) {
await this.allocate(target.id, booking, 'gov');
await this.allocate(target.id, booking, "gov");
} else {
await this.reserve(booking, target.id);
target.armed = true;
@@ -807,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
}
for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
@@ -817,18 +895,20 @@ export class BookingBatchService implements OnModuleInit {
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
let anySettled = false;
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: false;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
anySettled = true;
} else if (expired) {
await this.expire(booking);
@@ -844,17 +924,19 @@ export class BookingBatchService implements OnModuleInit {
/** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: true;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
await this.allocate(scheduleId, booking, "paid");
} else if (expired) {
await this.expire(booking);
}
@@ -866,11 +948,13 @@ export class BookingBatchService implements OnModuleInit {
}
private triggerWagonAllocation(scheduleId: string): void {
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
void this.trainSchedulingService
.tryAutoWagonAllocation(scheduleId)
.catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
}
// ---- staff override actions ----------------------------------------------
@@ -882,18 +966,20 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.trainScheduleId) {
throw new BadRequestException('Booking has no target schedule to allocate to');
throw new BadRequestException(
"Booking has no target schedule to allocate to",
);
}
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
await this.allocate(booking.trainScheduleId, booking, 'paid');
.update(bookingId, { paymentStatus: "PAID" });
await this.allocate(booking.trainScheduleId, booking, "paid");
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
await this.setWindow(booking.trainScheduleId, "FULL");
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
}
@@ -902,7 +988,10 @@ export class BookingBatchService implements OnModuleInit {
* Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
* Used for EXPIRED or full-schedule bookings — no re-approval.
*/
async moveToSchedule(bookingId: string, newScheduleId: string): Promise<void> {
async moveToSchedule(
bookingId: string,
newScheduleId: string,
): Promise<void> {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
@@ -911,15 +1000,20 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: newScheduleId } });
if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Target schedule is not accepting bookings');
if (!schedule)
throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
if (schedule.bookingWindowStatus !== "OPEN") {
throw new BadRequestException(
"Target schedule is not accepting bookings",
);
}
if (
schedule.originStationId !== booking.originYardId ||
schedule.destinationStationId !== booking.destinationYardId
) {
throw new BadRequestException('Target schedule is not on the booking route');
throw new BadRequestException(
"Target schedule is not on the booking route",
);
}
await this.dataSource.transaction(async (manager) => {
@@ -931,15 +1025,15 @@ export class BookingBatchService implements OnModuleInit {
);
}
const restoredStatus =
booking.status === 'EXPIRED'
booking.status === "EXPIRED"
? booking.isGovernment
? 'APPROVED'
: 'FULLY_EXECUTED'
? "APPROVED"
: "FULLY_EXECUTED"
: booking.status;
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
schedulingStatus: 'ELIGIBLE',
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -953,7 +1047,8 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
await this.expire(booking);
if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId);
if (booking.trainScheduleId)
await this.fillSchedule(booking.trainScheduleId);
}
// ---- mutations ------------------------------------------------------------
@@ -971,11 +1066,19 @@ export class BookingBatchService implements OnModuleInit {
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH',
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
// The invoice was generated at booking creation/approval, before this pay
// window opened — refresh its printed due date to the real deadline.
await this.billing.syncPayableDueDate(
Freight.InvoiceSource.Booking,
booking.id,
deadline,
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
}
@@ -983,13 +1086,14 @@ export class BookingBatchService implements OnModuleInit {
private async allocate(
scheduleId: string,
booking: Booking,
reason: 'paid' | 'gov',
reason: "paid" | "gov",
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const exists = await this.trainScheduleBookingsRepository.existsForBooking(
booking.id,
manager,
);
const exists =
await this.trainScheduleBookingsRepository.existsForBooking(
booking.id,
manager,
);
if (!exists) {
await this.trainScheduleBookingsRepository.createMany(
[{ trainScheduleId: scheduleId, bookingId: booking.id }],
@@ -997,8 +1101,8 @@ export class BookingBatchService implements OnModuleInit {
);
}
await manager.getRepository(Booking).update(booking.id, {
status: reason === 'paid' ? 'PAID' : booking.status,
schedulingStatus: 'SCHEDULED',
status: reason === "paid" ? "PAID" : booking.status,
schedulingStatus: "SCHEDULED",
scheduledAt: new Date(),
paymentDeadline: null,
selectedForBatchAt: null,
@@ -1026,12 +1130,16 @@ export class BookingBatchService implements OnModuleInit {
private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
// Pay window closed before settlement → expire the booking's open invoice too
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
this.notifier.expired(booking);
}
@@ -1049,7 +1157,9 @@ export class BookingBatchService implements OnModuleInit {
await this.bookingsRepository.findReservedForSchedule(scheduleId)
).filter((b) => !b.isGovernment);
const allocatedCommercial =
await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId);
await this.bookingsRepository.findAllocatedCommercialForSchedule(
scheduleId,
);
// lowest priority first; reserved are cheaper to free than allocated
const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
@@ -1066,11 +1176,19 @@ export class BookingBatchService implements OnModuleInit {
manager,
);
await manager.getRepository(Booking).update(victim.id, {
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
// Displaced → EXPIRED: close its open invoice too, so a dead booking
// can't still be paid (mirrors `expire()`; enlisted in this txn).
await this.billing.expirePayable(
Freight.InvoiceSource.Booking,
victim.id,
"PREPAID",
manager,
);
});
this.notifier.displaced(victim);
freed = this.add(freed, this.needFor(victim, wagonLengths));
@@ -1088,7 +1206,10 @@ export class BookingBatchService implements OnModuleInit {
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING);
return Math.max(
DEFAULT_WAGONS_PER_BOOKING,
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
);
}
/** What one booking consumes along all three capacity axes. */
@@ -1175,7 +1296,7 @@ export class BookingBatchService implements OnModuleInit {
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
where: [{ code: "NW5" }, { code: "CW3" }],
});
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
@@ -1186,17 +1307,23 @@ export class BookingBatchService implements OnModuleInit {
private async loadWagonLengths(): Promise<WagonLengths> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
where: [{ code: "NW5" }, { code: "CW3" }],
});
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
return {
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
container:
byCode.get("NW5")?.lengthMeters ??
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
};
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} });
return this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.findOne({ where: {} });
}
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
@@ -1208,7 +1335,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
@@ -1221,7 +1350,9 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const used =
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
@@ -1230,7 +1361,7 @@ export class BookingBatchService implements OnModuleInit {
private async setWindow(
scheduleId: string,
status: 'OPEN' | 'FULL' | 'CLOSED',
status: "OPEN" | "FULL" | "CLOSED",
): Promise<void> {
await this.dataSource
.getRepository(TrainSchedule)
@@ -1247,7 +1378,9 @@ export class BookingBatchService implements OnModuleInit {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`),
this.logger.error(
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
),
);
}, PAYMENT_WINDOW_MS);
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
@@ -1256,7 +1389,7 @@ export class BookingBatchService implements OnModuleInit {
private removeTimeout(scheduleId: string): void {
const name = this.timeoutName(scheduleId);
try {
if (this.scheduler.doesExist('timeout', name)) {
if (this.scheduler.doesExist("timeout", name)) {
this.scheduler.deleteTimeout(name);
}
} catch {

View File

@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
@@ -43,6 +44,7 @@ import { ContractsModule } from '../contracts/contracts.module';
ImportDjiboutiOperation,
]),
forwardRef(() => BookingsModule),
BillingModule,
NotificationsModule,
LocomotivesModule,
WagonTypesModule,

View File

@@ -1,50 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity';
export const WAREHOUSE_FEE_TYPES = [
'CONTAINER_DEMURRAGE',
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' })
@Index(['invoiceId'])
export class WarehouseFeeInvoiceItem extends BaseEntity {
@Column({ name: 'invoice_id', type: 'uuid' })
invoiceId!: string;
@ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice?: WarehouseFeeInvoice;
@Column({ name: 'fee_rule_id', type: 'uuid', nullable: true })
feeRuleId?: string | null;
@Column({ name: 'fee_type', type: 'varchar', length: 32 })
feeType!: WarehouseFeeType;
@Column({ name: 'description', type: 'varchar', length: 255 })
description!: string;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 })
quantity!: number;
@Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 })
unitRate!: number;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
amount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
@Column({ name: 'chargeable_days', type: 'int', nullable: true })
chargeableDays?: number | null;
@Column({ name: 'free_days', type: 'int', nullable: true })
freeDays?: number | null;
}

View File

@@ -1,107 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
/** A single recorded payment against a warehouse fee invoice (history). */
export interface WarehouseInvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
/**
* Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation.
* Owns warehouse fees; links to booking/customer/inventory/location so it can
* connect to the existing payment module without duplicating it.
*/
@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' })
@Index(['invoiceNumber'], { unique: true })
@Index(['bookingId'])
@Index(['inventoryId'])
@Index(['status'])
export class WarehouseFeeInvoice extends BaseEntity {
@Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true })
invoiceNumber!: string;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
@Column({ name: 'inventory_id', type: 'uuid' })
inventoryId!: string;
@Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null;
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId?: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId?: string | null;
@Column({ name: 'zone_id', type: 'uuid', nullable: true })
zoneId?: string | null;
@Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' })
invoiceType!: WarehouseInvoiceType;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: WarehouseInvoiceStatus;
@Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
subtotalAmount!: number;
@Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
taxAmount!: number;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
@Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
paidAmount!: number;
@Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
balanceAmount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;
/** Charge window covered by this invoice — used to allow a later invoice for a new period. */
@Column({ name: 'period_start', type: 'timestamptz', nullable: true })
periodStart?: Date | null;
@Column({ name: 'period_end', type: 'timestamptz', nullable: true })
periodEnd?: Date | null;
@Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
issuedAt?: Date | null;
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
dueDate?: Date | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" })
payments!: WarehouseInvoicePayment[];
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
volume?: number | null;
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
grnNumber?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus;

View File

@@ -1,13 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
@Injectable()
export class WarehouseFeeInvoiceItemRepository extends BaseRepository<WarehouseFeeInvoiceItem> {
constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository<WarehouseFeeInvoiceItem>) {
super(repository);
}
}

View File

@@ -1,13 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@Injectable()
export class WarehouseFeeInvoiceRepository extends BaseRepository<WarehouseFeeInvoice> {
constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository<WarehouseFeeInvoice>) {
super(repository);
}
}

View File

@@ -273,6 +273,16 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {

View File

@@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
export interface InventoryInquiryResult {
id: string;
@@ -249,6 +250,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
@@ -295,6 +297,7 @@ export interface ImportUnloadedRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
@@ -302,6 +305,8 @@ export interface ImportUnloadedRow {
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
}
@@ -415,7 +420,10 @@ export class WarehouseInventoryService {
const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search
? { ...base, notes: ILike(`%${search}%`) }
? [
{ ...base, notes: ILike(`%${search}%`) },
{ ...base, grnNumber: ILike(`%${search}%`) },
]
: base;
const items = await this.inventoryRepository.findAll({
@@ -766,6 +774,7 @@ export class WarehouseInventoryService {
const [booking] = await manager.query(
`SELECT b.reference AS "reference",
b.payment_status AS "paymentStatus",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
@@ -847,6 +856,12 @@ export class WarehouseInventoryService {
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
continue;
}
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const truckEntrance = dto.truckEntrance
@@ -867,8 +882,9 @@ export class WarehouseInventoryService {
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: Number(booking.containerQuantity) || 1,
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight: Number(booking.weight) || 0,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
@@ -962,6 +978,7 @@ export class WarehouseInventoryService {
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
@@ -1021,6 +1038,7 @@ export class WarehouseInventoryService {
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
@@ -1029,6 +1047,8 @@ export class WarehouseInventoryService {
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
@@ -1669,6 +1689,7 @@ export class WarehouseInventoryService {
quantity,
weight,
volume: dto.volume ?? null,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
@@ -1933,24 +1954,31 @@ export class WarehouseInventoryService {
);
}
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
const reference = dto.reference?.trim() || null;
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionNote = this.buildExitInspectionNote(dto);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: id,
warehouseId: item.warehouseId,
description: reference
? `Release order ${reference} sent to customer`
: 'Release order sent to customer',
description: isTruckLeaving
? reference
? `Exit paper ${reference} generated`
: 'Exit paper generated'
: reference
? `Truck arrival ${reference} registered`
: 'Truck arrival registered',
performedBy: dto.performedBy,
},
manager,
@@ -2039,6 +2067,106 @@ export class WarehouseInventoryService {
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
inv.quantity,
inv.weight,
inv.volume,
inv.status,
inv.notes,
b.id AS "bookingId",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
company.name AS "customerName",
company.tin AS "customerTin",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MIN(bc.container_number) AS container_number,
STRING_AGG(
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (!row.grnNumber) {
throw new BadRequestException('GRN number is missing for this inventory item');
}
const html = this.buildGrnDocumentHtml({
grnNumber: row.grnNumber,
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
customerTin: row.customerTin ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
volume: row.volume == null ? null : Number(row.volume),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
receiveSummary: this.extractReceiveSummary(row.notes),
});
return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
@@ -2121,8 +2249,17 @@ export class WarehouseInventoryService {
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.scheduled_date AS "scheduledDate",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
company.name AS "customerName",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
@@ -2134,14 +2271,25 @@ export class WarehouseInventoryService {
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN LATERAL (
SELECT MIN(bc.container_number) AS container_number,
STRING_AGG(
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -2158,18 +2306,37 @@ export class WarehouseInventoryService {
}
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const reference =
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
if (!generatedAtValue) {
await this.inventoryRepository.update(id, {
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
});
}
const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
handedOverAt: new Date(row.handoverDate ?? Date.now()),
reference,
handedOverAt,
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
@@ -2178,11 +2345,12 @@ export class WarehouseInventoryService {
releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null,
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
});
return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
@@ -2666,6 +2834,128 @@ export class WarehouseInventoryService {
return this.findById(id);
}
private buildGrnDocumentHtml(data: {
grnNumber: string;
receivedAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
customerTin: string | null;
serviceType: string | null;
freightType: string | null;
tradeDirection: string | null;
route: string | null;
containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
volume: number | null;
bookingDeclaredWeight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
receiveSummary: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const rows: Array<[string, unknown]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Customer TIN', data.customerTin],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Route', data.route],
['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Received Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
];
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Goods Received Note</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; font-size: 31px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; white-space: pre-line; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 34px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Goods Received Note</h1>
<div class="subtitle">Warehouse receiving confirmation</div>
</div>
<div class="ref">
GRN Number
<strong>${esc(data.grnNumber)}</strong>
Received: ${esc(receivedAt)}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
</div>
<div class="section-title">Receiving Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Receipt Clause</div>
<div class="clause">
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
</div>
<div class="signatures">
<div class="line">Warehouse receiver name / signature / date</div>
<div class="line">Driver or customer representative name / signature / date</div>
</div>
</body>
</html>`;
}
private buildReleaseDocumentHtml(data: {
reference: string;
issuedAt: Date;
@@ -2721,7 +3011,7 @@ export class WarehouseInventoryService {
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Gate Clearance / Release Order</title>
<title>Warehouse Release / Exit Paper</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
@@ -2752,8 +3042,8 @@ export class WarehouseInventoryService {
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Gate Clearance / Release Order</h1>
<div class="subtitle">Official warehouse release and exit authorization</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="subtitle">Official gate clearance and warehouse exit authorization</div>
</div>
<div class="ref">
Document / Release No.
@@ -2763,7 +3053,7 @@ export class WarehouseInventoryService {
</div>
<div class="rule"></div>
<div class="notice">
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
</div>
<div class="section-title">Release Particulars</div>
<table>
@@ -2792,12 +3082,17 @@ export class WarehouseInventoryService {
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
serviceType: string | null;
freightType: string | null;
tradeDirection: string | null;
route: string | null;
scheduledDate: Date | null;
containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
bookingDeclaredWeight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
@@ -2806,6 +3101,7 @@ export class WarehouseInventoryService {
releaseOrderReference: string | null;
releaseDate: Date | null;
trainSchedule: string | null;
lastMileDeliveryAddress: string | null;
customerApproval: {
approvedAt: string;
signerDisplayName: string;
@@ -2835,13 +3131,18 @@ export class WarehouseInventoryService {
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Route', data.route],
['Scheduled Date', fmt(data.scheduledDate)],
['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
@@ -2849,6 +3150,7 @@ export class WarehouseInventoryService {
['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)],
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
];
const approval = data.customerApproval;
@@ -2898,7 +3200,8 @@ export class WarehouseInventoryService {
</div>
<div class="rule"></div>
<div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
inspection, release, and customer approval details for the goods being handed to the customer.
</div>
<div class="section-title">Handover Particulars</div>
<table>
@@ -2911,7 +3214,9 @@ export class WarehouseInventoryService {
<tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
</tbody>
</table>
<div class="section-title">Handover Clause</div>
@@ -3156,6 +3461,21 @@ export class WarehouseInventoryService {
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
let bookingReference = item.booking?.reference;
if (!bookingReference && item.bookingId) {
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
[item.bookingId],
);
bookingReference = booking?.reference ?? undefined;
}
if (bookingReference) {
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
}
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
}
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
const hasExitInspection =
Boolean(dto.truckPlateNumber?.trim()) ||
@@ -3179,17 +3499,27 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
if (dto.tareWeight === undefined) {
throw new BadRequestException('Tare weight is required for truck arrival');
}
const tareWeight = Number(dto.tareWeight);
const grossWeight = Number(dto.grossWeight);
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight =
grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
if (grossWeight != null && !dto.gateOutTime) {
throw new BadRequestException('Gate out time is required for truck exit');
}
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
throw new BadRequestException('Gross weight is required for truck exit');
}
const rows = [
@@ -3205,14 +3535,27 @@ export class WarehouseInventoryService {
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} kg`,
`Gross Weight: ${grossWeight} kg`,
`Net Weight: ${computedNetWeight} kg`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
return rows.filter(Boolean).join('\n');
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
if (!trimmed) return exitInspectionNote;
const marker = '[Exit Inspection]';
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return `${trimmed}\n\n${exitInspectionNote}`;
}
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
}
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
@@ -3221,6 +3564,40 @@ export class WarehouseInventoryService {
return notes.slice(index + marker.length).trim() || null;
}
private extractReceiveSummary(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
}
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
return [
HANDOVER_DOCUMENT_MARKER,
`Handover Reference: ${reference}`,
`Generated At: ${generatedAt.toISOString()}`,
].join('\n');
}
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
const trimmed = notes?.trim();
if (!trimmed) return handoverDocumentNote;
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) {
return `${trimmed}\n\n${handoverDocumentNote}`;
}
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
}
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
if (!notes) return null;
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) return null;
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() || null;
}
private buildReceiveNote(input: {
grnNumber: string;
direction?: string | null;

View File

@@ -1,17 +1,24 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import { NotificationsService } from '../notifications/notifications.service';
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import {
WarehouseFeeInvoice,
InvoiceDocumentModel,
InvoiceDocumentService,
} from '../billing/documents/invoice-document.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WarehouseFeeService } from './warehouse-fee.service';
import {
WarehouseFeeInvoiceView,
WarehouseFeeType,
WarehouseInvoiceItemView,
WarehouseInvoiceStatus,
WarehouseInvoiceType,
} from './entities/warehouse-fee-invoice.entity';
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
} from './warehouse-invoice.types';
interface GenerateOptions {
confirmZero?: boolean;
@@ -27,9 +34,18 @@ export interface PayInvoiceDto {
driverPhone?: string;
}
/** Invoices that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
/** Warehouse fee invoices live in the global billing system under this source. */
const SOURCE = Freight.InvoiceSource.Warehouse;
/** Global statuses that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/** Global statuses considered an "active" invoice for per-inventory dedup. */
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
@@ -45,28 +61,75 @@ export interface InvoiceDocumentDetails {
zoneName: string | null;
}
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView &
Partial<InvoiceDocumentDetails> & { items: WarehouseInvoiceItemView[] };
/** The warehouse-specific columns derived from the linked inventory item. */
interface InventoryContext {
bookingId: string | null;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
zoneId: string | null;
periodStart: Date | null;
}
/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */
interface ViewSource {
id: string;
invoiceNumber: string;
companyId: string;
sourceId: string;
type: string;
status: Freight.InvoiceStatus | string;
subtotalAmount: number | string;
taxAmount: number | string;
totalAmount: number | string;
paidAmount: number | string;
balanceAmount: number | string;
currency: string;
issuedAt?: Date | null;
dueAt?: Date | null;
paidAt?: Date | null;
createdAt: Date;
updatedAt: Date;
payments?: Array<{
amount: number | string;
method?: string | null;
reference?: string | null;
paidAt: string;
}> | null;
}
/**
* Thin warehouse layer over the central {@link BillingService}. Warehouse fee
* invoices are global `Invoice` rows (`source = warehouse`, `sourceId =
* inventoryId`); this service owns only the warehouse-specific concerns —
* computing fees, per-inventory dedup, release-blocking, SMS notifications, the
* sealed PDF, and reshaping the global invoice back into the historical
* `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering,
* status, and payment math live in billing.
*/
@Injectable()
export class WarehouseInvoiceService {
private readonly logger = new Logger(WarehouseInvoiceService.name);
constructor(
private readonly dataSource: DataSource,
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly billing: BillingService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly documents: WarehouseReleaseDocumentService,
private readonly notifications: NotificationsService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
const [item] = await this.dataSource.query(
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
w.facility_id AS "facilityId",
b.company_id AS "customerId", b.freight_type AS "freightType"
b.company_id AS "companyId", b.company_profile_id AS "companyProfileId",
b.freight_type AS "freightType"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -75,9 +138,16 @@ export class WarehouseInvoiceService {
);
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// Routing through the global invoice requires a billable company + profile,
// both of which come from the inventory's booking.
if (!item.companyId || !item.companyProfileId) {
throw new BadRequestException(
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
);
}
// Dedup: only one active (non-cancelled) invoice per inventory item.
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
if (await this.hasActiveInvoice(inventoryId)) {
throw new ConflictException(
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
);
@@ -112,9 +182,7 @@ export class WarehouseInvoiceService {
};
});
const subtotal = items.reduce((s, i) => s + i.amount, 0);
const total = subtotal; // tax model can be layered on later
const total = items.reduce((s, i) => s + i.amount, 0);
if (total <= 0 && !opts.confirmZero) {
throw new BadRequestException('No payable warehouse fee found for this item.');
}
@@ -124,74 +192,81 @@ export class WarehouseInvoiceService {
const invoiceType: WarehouseInvoiceType =
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
const currency = billingCurrency;
const now = new Date();
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
const lines: InvoiceLineInput[] = items.map((it) => ({
chargeType: it.feeType,
description: it.description,
quantity: it.quantity,
unitRate: it.unitRate,
amount: it.amount,
currency: it.currency,
metadata: {
feeRuleId: it.feeRuleId ?? null,
chargeableDays: it.chargeableDays ?? null,
freeDays: it.freeDays ?? null,
},
}));
const invoice = await this.invoiceRepository.create({
invoiceNumber: await this.nextInvoiceNumber(),
bookingId: item.bookingId ?? null,
customerId: item.customerId ?? null,
inventoryId,
facilityId: item.facilityId ?? null,
warehouseId: item.warehouseId ?? null,
yardId: item.yardId ?? null,
zoneId: item.zoneId ?? null,
invoiceType,
status: 'ISSUED',
subtotalAmount: subtotal,
taxAmount: 0,
totalAmount: total,
paidAmount: 0,
balanceAmount: total,
currency,
periodStart: item.arrivedAt ?? null,
periodEnd,
issuedAt: now,
payments: [],
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
const invoice = await this.billing.generateInvoice({
source: SOURCE,
sourceId: inventoryId,
type: invoiceType,
companyId: item.companyId,
companyProfileId: item.companyProfileId,
currency: billingCurrency,
lines,
status: Freight.InvoiceStatus.Issued,
});
for (const it of items) {
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
}
const saved = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(saved);
return saved;
}
/** WHF-YYYYMMDD-00001 — sequential per day. */
private async nextInvoiceNumber(): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const prefix = `WHF-${ymd}-`;
const [row] = await this.dataSource.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, '0')}`;
const detail = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(detail);
return detail;
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
const ctx = await this.getInventoryContext(invoice.sourceId);
const details = await this.getInvoiceDocumentDetails(invoice);
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
const items = invoice.lines.map((l) => this.lineToItem(l));
return { ...this.buildView(invoice, ctx), ...details, items };
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND i.source_id = $1', [inventoryId]);
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
}
async findAll(
filter: Partial<
Pick<
WarehouseFeeInvoiceView,
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
>
>,
): Promise<WarehouseFeeInvoiceView[]> {
const conditions: string[] = [];
const params: unknown[] = [];
const add = (sql: (p: string) => string, value: unknown) => {
params.push(value);
conditions.push(sql(`$${params.length}`));
};
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
return {
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
}
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
@@ -199,76 +274,65 @@ export class WarehouseInvoiceService {
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException('A receipt is available only after payment is recorded.');
}
const details = await this.getInvoiceDocumentDetails(invoice);
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details);
return {
filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
buffer: await this.documents.htmlToPdfBuffer(html),
};
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoice[]> {
return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } });
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoice[]> {
return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } });
}
findAll(filter: Partial<Pick<WarehouseFeeInvoice, 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'>>): Promise<WarehouseFeeInvoice[]> {
const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null));
return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } });
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
}
// ── State changes ────────────────────────────────────────────────────────
async cancel(id: string): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
return updated as WarehouseFeeInvoice;
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('A paid invoice cannot be cancelled.');
}
await this.billing.cancelInvoice(id);
return this.findById(id);
}
/** Record a payment against the invoice and sync status (links to existing payment flow). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
const paidAmount = Number(invoice.paidAmount) + dto.amount;
const total = Number(invoice.totalAmount);
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
const fullyPaid = paidAmount >= total;
const payments = [
...(invoice.payments ?? []),
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
];
const updated = await this.invoiceRepository.update(id, {
paidAmount: Math.round(paidAmount * 100) / 100,
balanceAmount: balance,
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments,
/** Record a payment against the invoice (delegates settlement to billing). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
// Guard that this is a warehouse invoice before recording (404 otherwise).
await this.loadWarehouseInvoice(id);
await this.billing.recordPayment(id, {
amount: dto.amount,
method: dto.method ?? null,
reference: dto.reference ?? null,
metadata:
dto.driverName || dto.driverPhone
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
: null,
});
const paidInvoice = updated as WarehouseFeeInvoice;
await this.notifyWarehouseFeePayment(paidInvoice, dto);
return paidInvoice;
const detail = await this.findById(id);
await this.notifyWarehouseFeePayment(detail, dto);
return detail;
}
/**
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
* fee being paid through billing's payment flow. The counter {@link pay} path
* notifies inline (and carries driver details from the request), so this only
* handles gateway payments: those stamp the invoice `paymentId`, whereas a
* counter settlement leaves it null. Skipping null-`paymentId` events avoids
* double-notifying a counter payment that already sent its SMS.
*/
@OnEvent('warehouse.invoice.paid')
async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
if (!payload.paymentId) return;
const detail = await this.findById(payload.invoiceId);
await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) });
}
// ── Release blocking ──────────────────────────────────────────────────────
/** Returns the first unpaid invoice that blocks terminal release, or null. */
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
const blocking = await this.queryViews(
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
[inventoryId, BLOCKING_STATUSES],
);
return blocking[0] ?? null;
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
@@ -286,12 +350,218 @@ export class WarehouseInvoiceService {
}
}
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
// ── Internal: loading & projection ─────────────────────────────────────────
/** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */
private async loadWarehouseInvoice(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.billing.findById(id);
if (invoice.source !== SOURCE) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;
}
private async hasActiveInvoice(inventoryId: string): Promise<boolean> {
const [row] = await this.dataSource.query(
`SELECT 1
FROM freight.invoices
WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL
LIMIT 1`,
[SOURCE, inventoryId, ACTIVE_STATUSES],
);
return Boolean(row);
}
/**
* Project warehouse-source global invoices into the historical view, joined to
* their inventory item for the typed FKs. Powers every list/filter read.
*/
private async queryViews(extraWhere: string, params: unknown[]): Promise<WarehouseFeeInvoiceView[]> {
const rows = await this.dataSource.query(
`SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId",
i.source_id AS "sourceId", i.type, i.status,
i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount",
i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount",
i.balance_amount AS "balanceAmount", i.currency, i.payments,
i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt",
i.created_at AS "createdAt", i.updated_at AS "updatedAt",
inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.invoices i
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere}
ORDER BY i.created_at DESC`,
[...params, SOURCE],
);
return (rows as Array<ViewSource & InventoryContext>).map((row) =>
this.buildView(row, {
bookingId: row.bookingId ?? null,
facilityId: row.facilityId ?? null,
warehouseId: row.warehouseId ?? null,
yardId: row.yardId ?? null,
zoneId: row.zoneId ?? null,
periodStart: row.periodStart ?? null,
}),
);
}
/** Reshape a global invoice (+ derived inventory context) into the warehouse view. */
private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView {
const status = this.toWarehouseStatus(inv.status);
return {
id: inv.id,
invoiceNumber: inv.invoiceNumber,
bookingId: ctx.bookingId,
customerId: inv.companyId ?? null,
inventoryId: inv.sourceId,
facilityId: ctx.facilityId,
warehouseId: ctx.warehouseId,
yardId: ctx.yardId,
zoneId: ctx.zoneId,
invoiceType: inv.type as WarehouseInvoiceType,
status,
subtotalAmount: Number(inv.subtotalAmount),
taxAmount: Number(inv.taxAmount),
totalAmount: Number(inv.totalAmount),
paidAmount: Number(inv.paidAmount),
balanceAmount: Number(inv.balanceAmount),
currency: inv.currency,
periodStart: ctx.periodStart,
// No standalone period column once centralized: the charge window ends at
// issuance, so `issuedAt` is the period end.
periodEnd: inv.issuedAt ?? null,
issuedAt: inv.issuedAt ?? null,
dueDate: inv.dueAt ?? null,
paidAt: inv.paidAt ?? null,
cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null,
payments: (inv.payments ?? []).map((p) => ({
amount: Number(p.amount),
method: p.method ?? null,
reference: p.reference ?? null,
paidAt: p.paidAt,
})),
notes: null,
createdAt: inv.createdAt,
updatedAt: inv.updatedAt,
};
}
private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView {
const meta = (line.metadata ?? {}) as {
feeRuleId?: string | null;
chargeableDays?: number | null;
freeDays?: number | null;
};
return {
feeRuleId: meta.feeRuleId ?? null,
feeType: line.chargeType as WarehouseFeeType,
description: line.description ?? '',
quantity: Number(line.quantity),
unitRate: Number(line.unitRate),
amount: Number(line.amount),
currency: line.currency,
chargeableDays: meta.chargeableDays ?? null,
freeDays: meta.freeDays ?? null,
};
}
private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus {
switch (status) {
case Freight.InvoiceStatus.Draft:
return 'DRAFT';
case Freight.InvoiceStatus.PartiallyPaid:
return 'PARTIALLY_PAID';
case Freight.InvoiceStatus.Paid:
return 'PAID';
case Freight.InvoiceStatus.Cancelled:
case Freight.InvoiceStatus.Refunded:
return 'CANCELLED';
default:
// Issued / Pending / Overdue → an issued, still-owed invoice.
return 'ISSUED';
}
}
private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus {
switch (status) {
case 'DRAFT':
return Freight.InvoiceStatus.Draft;
case 'PARTIALLY_PAID':
return Freight.InvoiceStatus.PartiallyPaid;
case 'PAID':
return Freight.InvoiceStatus.Paid;
case 'CANCELLED':
return Freight.InvoiceStatus.Cancelled;
default:
return Freight.InvoiceStatus.Issued;
}
}
/** Map a warehouse fee invoice view onto the shared document model. */
private toDocumentModel(
invoice: WarehouseFeeInvoiceDetail,
kind: 'INVOICE' | 'RECEIPT',
): InvoiceDocumentModel {
const lastPayment = [...(invoice.payments ?? [])].pop();
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
return {
kind,
title: 'Warehouse Fee',
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: 'Status', value: invoice.status.replace(/_/g, ' ') },
{ label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') },
{ label: 'Booking reference', value: invoice.bookingReference ?? null },
{ label: 'Customer', value: invoice.customerName ?? null },
{ label: 'Inventory reference', value: invoice.inventoryReference ?? null },
{ label: 'Inventory info', value: invoice.inventoryInfo ?? null },
{ label: 'Clearance', value: invoice.clearanceStatus ?? null },
{ label: 'Warehouse', value: invoice.warehouseName ?? null },
{
label: 'Yard / Zone',
value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null,
},
{ label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` },
{
label: 'Payment',
value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null,
},
],
categoryHeader: 'Fee type',
lines: invoice.items.map((item) => ({
description: item.description ?? null,
category: item.feeType ?? null,
quantity: item.quantity ?? item.chargeableDays ?? 0,
unitRate: item.unitRate,
amount: item.amount,
currency: item.currency ?? invoice.currency,
})),
totals: [
{ label: 'Subtotal', amount: Number(invoice.subtotalAmount) },
{ label: 'Tax', amount: Number(invoice.taxAmount) },
{ label: 'Total', amount: Number(invoice.totalAmount), grand: true },
{ label: 'Paid', amount: Number(invoice.paidAmount) },
{ label: 'Balance', amount: Number(invoice.balanceAmount) },
],
};
}
/** Warehouse-specific display details, derived from the linked inventory item. */
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
inv.release_date AS "releaseDate",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
CONCAT_WS(
@@ -302,16 +572,10 @@ export class WarehouseInvoiceService {
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
zone.name AS "zoneName",
CASE
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
ELSE 'PENDING PAYMENT'
END AS "clearanceStatus"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
zone.name AS "zoneName"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -319,14 +583,21 @@ export class WarehouseInvoiceService {
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
WHERE fee.id = $1
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id, invoice.status],
[invoice.sourceId],
);
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
const clearanceStatus = row?.releaseDate
? 'RELEASE ISSUED'
: fullyPaid
? 'FEE PAID - READY FOR RELEASE'
: 'PENDING PAYMENT';
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
@@ -338,11 +609,33 @@ export class WarehouseInvoiceService {
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
clearanceStatus,
};
}
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
);
return {
bookingId: row?.bookingId ?? null,
facilityId: row?.facilityId ?? null,
warehouseId: row?.warehouseId ?? null,
yardId: row?.yardId ?? null,
zoneId: row?.zoneId ?? null,
periodStart: row?.periodStart ?? null,
};
}
// ── Notifications ──────────────────────────────────────────────────────────
private async getInvoiceNotificationContacts(inventoryId: string): Promise<{
bookingReference: string | null;
customerName: string | null;
customerPhone: string | null;
@@ -364,10 +657,9 @@ export class WarehouseInvoiceService {
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -393,9 +685,9 @@ export class WarehouseInvoiceService {
) latest_first_mile ON true
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
WHERE fee.id = $1
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id],
[inventoryId],
);
return {
@@ -419,8 +711,8 @@ export class WarehouseInvoiceService {
}
}
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const cargo = contacts.containerNumber || contacts.cargoDescription;
@@ -433,8 +725,8 @@ export class WarehouseInvoiceService {
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
}
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const statusText =
@@ -460,131 +752,4 @@ export class WarehouseInvoiceService {
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
}
private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT',
details: InvoiceDocumentDetails,
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const money = (amount: unknown, currency = invoice.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const items = invoice.items as Array<{
id?: string;
description?: string;
feeType?: string;
quantity?: number;
unitRate?: number;
amount?: number;
currency?: string;
chargeableDays?: number | null;
}>;
const lastPayment = [...(invoice.payments ?? [])].pop();
const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(invoice.invoiceNumber)}</strong>
Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">
<div><span>Status</span>${esc(invoice.status.replace(/_/g, ' '))}</div>
<div><span>Invoice type</span>${esc(invoice.invoiceType.replace(/_/g, ' '))}</div>
<div><span>Booking reference</span>${esc(details.bookingReference)}</div>
<div><span>Customer</span>${esc(details.customerName)}</div>
<div><span>Inventory reference</span>${esc(details.inventoryReference)}</div>
<div><span>Inventory info</span>${esc(details.inventoryInfo)}</div>
<div><span>Clearance</span>${esc(details.clearanceStatus)}</div>
<div><span>Warehouse</span>${esc(details.warehouseName)}</div>
<div><span>Yard / Zone</span>${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}</div>
<div><span>Period</span>${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}</div>
<div><span>Payment</span>${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Fee type</th>
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${items
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
<td>${esc((item.feeType ?? '').replace(/_/g, ' '))}</td>
<td class="num">${esc(item.quantity ?? item.chargeableDays ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? invoice.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? invoice.currency))}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
<div class="totals">
<div class="total-row"><span>Subtotal</span><strong>${esc(money(invoice.subtotalAmount))}</strong></div>
<div class="total-row"><span>Tax</span><strong>${esc(money(invoice.taxAmount))}</strong></div>
<div class="total-row grand"><span>Total</span><strong>${esc(money(invoice.totalAmount))}</strong></div>
<div class="total-row"><span>Paid</span><strong>${esc(money(invoice.paidAmount))}</strong></div>
<div class="total-row"><span>Balance</span><strong>${esc(money(invoice.balanceAmount))}</strong></div>
</div>
<div class="footer">
<div class="line">Prepared by EDR warehouse finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
private safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
}

View File

@@ -0,0 +1,88 @@
/**
* Public shapes for warehouse fee invoices.
*
* Warehouse fee invoices are no longer a standalone table — they are global
* `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the
* central {@link BillingService}. These types preserve the warehouse-facing API
* contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines +
* inventory context) back into the historical `WarehouseFeeInvoice` JSON so the
* portal/backoffice stay untouched.
*/
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
export const WAREHOUSE_FEE_TYPES = [
'CONTAINER_DEMURRAGE',
'BULK_DEMURRAGE',
'STORAGE_FEE',
'HANDLING_FEE',
] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
/** A single recorded payment against a warehouse fee invoice (history). */
export interface WarehouseInvoicePayment {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
/** A billed warehouse fee line, projected from a global `InvoiceLine`. */
export interface WarehouseInvoiceItemView {
feeRuleId: string | null;
feeType: WarehouseFeeType;
description: string;
quantity: number;
unitRate: number;
amount: number;
currency: string;
chargeableDays: number | null;
freeDays: number | null;
}
/**
* The warehouse-facing invoice header — same field set the old
* `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The
* typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the
* charge `period` are derived from the linked inventory item; `customerId` is the
* billed company; `invoiceType` is the invoice `type`.
*/
export interface WarehouseFeeInvoiceView {
id: string;
invoiceNumber: string;
bookingId: string | null;
customerId: string | null;
inventoryId: string;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
zoneId: string | null;
invoiceType: WarehouseInvoiceType;
status: WarehouseInvoiceStatus;
subtotalAmount: number;
taxAmount: number;
totalAmount: number;
paidAmount: number;
balanceAmount: number;
currency: string;
periodStart: Date | null;
periodEnd: Date | null;
issuedAt: Date | null;
dueDate: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
payments: WarehouseInvoicePayment[];
notes: string | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -1,101 +1,23 @@
import { existsSync } from 'fs';
import { Injectable } from '@nestjs/common';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
const MIN_VALID_PDF_BYTES = 2_000;
const RELEASE_DOCUMENT_PRINT_STYLES = `
<style id="warehouse-release-document-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
@Injectable()
export class WarehouseReleaseDocumentService {
private readonly logger = new Logger(WarehouseReleaseDocumentService.name);
constructor(private readonly pdf: PdfRenderService) {}
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 });
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`);
}
this.logger.log(
`Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(
`Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`,
);
const fallback = this.htmlToBasicPdfBuffer(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes('warehouse-release-document-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${RELEASE_DOCUMENT_PRINT_STYLES}</head>`);
}
return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((path) => existsSync(path));
}
private isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-';
/**
* Render the gate-clearance release document to PDF via the shared renderer,
* falling back to the release-specific hand-built layout when Chromium is
* unavailable.
*/
htmlToPdfBuffer(html: string): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label: 'Warehouse release',
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
});
}
private htmlToBasicPdfBuffer(html: string): Buffer {

View File

@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { DocumentsModule } from '../billing/documents/documents.module';
import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
@@ -10,8 +12,6 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
@@ -38,8 +38,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseInvoiceController } from './warehouse-invoice.controller';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
import { WarehouseRulesController } from './warehouse-rules.controller';
@@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport,
WarehouseAllocationRule,
WarehouseFeeRule,
WarehouseFeeInvoice,
WarehouseFeeInvoiceItem,
]),
BillingModule,
DocumentsModule,
FilesModule,
InterchangeDocumentsModule,
forwardRef(() => LastMileModule),
@@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionRepository,
WarehouseAllocationRuleRepository,
WarehouseFeeRuleRepository,
WarehouseFeeInvoiceRepository,
WarehouseFeeInvoiceItemRepository,
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,