feat: setup billing services

This commit is contained in:
Nathnael
2026-06-29 07:11:52 +00:00
parent db305d6fcd
commit 5bad245ce8
5 changed files with 419 additions and 338 deletions

View File

@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
@@ -76,7 +77,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
}),
ScheduleModule.forRoot(),
// EventEmitterModule.forRoot(),
EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>

View File

@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
@Controller("billing")
@FreightAdmin()
export class BillingController {
constructor(private readonly billingService: BillingService) {}
constructor(private readonly billingService: BillingService) { }
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
@@ -16,21 +16,9 @@ export class BillingController {
return this.billingService.findAll();
}
@Get("invoices/booking/:bookingId")
@ApiOperation({ summary: "List invoices for a booking" })
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
return this.billingService.findByBooking(bookingId);
}
@Get("invoices/:id")
@ApiOperation({ summary: "Get an invoice with its line items" })
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
@Post("invoices/generate/:bookingId")
@ApiOperation({ summary: "Generate (or return the existing) invoice for a booking" })
generate(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
return this.billingService.generateForBooking(bookingId);
}
}

View File

@@ -1,17 +1,16 @@
import { Freight } from "@edr/types";
import { BillingService } from "./billing.service";
import type { Invoice } from "./entities/invoice.entity";
/**
* Minimal in-memory EntityManager stand-in covering the methods
* `generateForBooking` / `markBookingInvoicePaid` call on the transaction manager.
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
*/
function makeManager(savedLines: unknown[]) {
return {
create: (_entity: unknown, data: Record<string, unknown>) => data,
save: (data: Record<string, unknown>) => {
const row = { id: data.id ?? `gen-${Math.round(0)}`, ...data };
const row = { id: data.id ?? "gen-1", ...data };
if (data.invoiceId) savedLines.push(row);
return Promise.resolve(row);
},
@@ -21,127 +20,176 @@ function makeManager(savedLines: unknown[]) {
};
}
function makeBooking(overrides: Record<string, unknown> = {}) {
function makeEvents() {
return { emit: jest.fn() };
}
function generateInput(overrides: Record<string, unknown> = {}) {
return {
id: "booking-1",
reference: "BK-001",
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "prepaid",
companyId: "company-1",
companyProfileId: "profile-1",
paymentCurrency: "ETB",
totalAmount: 1500,
pricingBreakdown: {
currency: "ETB",
totalAmount: 1500,
lineItems: [
{ code: "RAIL_FREIGHT", description: "Rail freight", amount: 1000, unitAmount: 500, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" },
{ code: "HAZARD_SURCHARGE", description: "Hazard surcharge", amount: 500, unitAmount: 250, unit: "PER_CONTAINER", quantity: 2, currency: "ETB" },
],
},
currency: "ETB",
lines: [
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000 },
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500 },
],
...overrides,
};
}
describe("BillingService.generateForBooking", () => {
let invoices: { findAll: jest.Mock; findById: jest.Mock };
let invoiceLines: { findAll: jest.Mock };
describe("BillingService.generateInvoice", () => {
let savedLines: unknown[];
let manager: ReturnType<typeof makeManager>;
let bookingRow: Record<string, unknown> | null;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
manager: unknown;
};
let events: ReturnType<typeof makeEvents>;
let dataSource: { transaction: jest.Mock; manager: unknown };
let service: BillingService;
beforeEach(() => {
savedLines = [];
manager = makeManager(savedLines);
invoices = { findAll: jest.fn().mockResolvedValue([]), findById: jest.fn() };
invoiceLines = { findAll: jest.fn().mockResolvedValue([]) };
bookingRow = makeBooking();
events = makeEvents();
dataSource = {
getRepository: jest.fn().mockReturnValue({
findOne: jest.fn().mockImplementation(() => Promise.resolve(bookingRow)),
}),
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
service = new BillingService(dataSource as never, invoices as never, invoiceLines as never);
service = new BillingService(dataSource as never, {} as never, {} as never, events as never);
});
it("creates a PENDING invoice with one line per pricing line item", async () => {
const invoice = (await service.generateForBooking("booking-1")) as Invoice;
it("creates a PENDING invoice with one line per input line", async () => {
const invoice = await service.generateInvoice(generateInput());
expect(invoice).toBeTruthy();
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
expect(invoice.companyId).toBe("company-1");
expect(invoice.companyProfileId).toBe("profile-1");
expect(invoice.source).toBe("booking");
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(savedLines).toHaveLength(2);
});
it("returns the existing active invoice instead of creating a duplicate", async () => {
const existing = { id: "inv-existing", status: Freight.InvoiceStatus.Pending } as Invoice;
invoices.findAll.mockResolvedValueOnce([existing]);
const invoice = await service.generateForBooking("booking-1");
expect(invoice).toBe(existing);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("skips generation (returns null) when the booking has no company to bill", async () => {
bookingRow = makeBooking({ companyId: null, companyProfileId: null });
const invoice = await service.generateForBooking("booking-1");
expect(invoice).toBeNull();
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("falls back to a single freight line when no pricing breakdown exists", async () => {
bookingRow = makeBooking({ pricingBreakdown: null });
const invoice = (await service.generateForBooking("booking-1")) as Invoice;
it("sums line amounts when no explicit totalAmount is given", async () => {
const invoice = await service.generateInvoice(
generateInput({ totalAmount: undefined }),
);
expect(invoice.totalAmount).toBe(1500);
expect(savedLines).toHaveLength(1);
expect((savedLines[0] as { chargeType: string }).chargeType).toBe("FREIGHT");
});
it("leaves issuedAt null for a DRAFT invoice", async () => {
const invoice = await service.generateInvoice(
generateInput({ status: Freight.InvoiceStatus.Draft }),
);
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
expect(invoice.issuedAt).toBeNull();
});
it("enlists in a caller's transaction when a manager is passed", async () => {
await service.generateInvoice(generateInput(), manager as never);
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(savedLines).toHaveLength(2);
});
});
describe("BillingService.markBookingInvoicePaid", () => {
it("marks the open booking invoice PAID and links the payment", async () => {
const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending };
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const dataSource = { manager: mg } as never;
const service = new BillingService(dataSource, {} as never, {} as never);
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
);
await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }),
);
});
it("is a no-op when the booking has no open invoice", async () => {
it("is a no-op (no event) when the invoice is already paid", async () => {
const paid = { id: "inv-1", status: Freight.InvoiceStatus.Paid, source: "booking" };
const mg = {
findOne: jest.fn().mockResolvedValue(paid),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});
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",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
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 service = new BillingService({ manager: mg } as never, {} as never, {} as never);
const events = makeEvents();
const service = new BillingService({ manager: mg } as never, {} as never, {} as never, events as never);
await service.markBookingInvoicePaid("booking-1", "pay-1", mg as never);
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();
});
});

View File

@@ -1,57 +1,74 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight } from "@edr/types";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
/** Source tag stamped on booking invoices (`source` column). */
const BOOKING_SOURCE = "booking";
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/**
* Statuses an invoice can hold while it still represents the live bill for a
* booking. A second `generateForBooking` call returns the existing one of these
* instead of creating a duplicate (idempotency / dedup guard).
*/
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Paid,
Freight.InvoiceStatus.Overdue,
];
/** Shape of a single line inside `booking.pricingBreakdown.lineItems`. */
interface StoredPriceLine {
code: string;
description: string;
amount: number;
unitAmount: number;
unit: string;
quantity: number;
currency: string;
}
interface StoredPricingBreakdown {
lineItems?: StoredPriceLine[];
totalAmount?: number;
currency?: string;
}
/** A fully-resolved invoice line ready to persist. */
interface BuiltLine {
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
description: string;
quantity: number;
unitRate: number;
amount: number;
description?: string;
/** Units this line bills for; defaults to 1. */
quantity?: number;
/** Price per unit; defaults to 0. */
unitRate?: number;
/** Line total; defaults to `quantity * unitRate`. */
amount?: number;
currency?: string;
metadata?: Record<string, unknown> | null;
}
/** Everything needed to generate an invoice for any source. */
export interface GenerateInvoiceInput {
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
source: Freight.InvoiceSource;
/** Identifier of the source record (e.g. booking id). */
sourceId: string;
/** What the invoice is for (e.g. "prepaid", "credit"). */
type: string;
companyId: string;
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
dueInDays?: number;
/**
* Initial status. DRAFT leaves `issuedAt` null; any issued status
* (default PENDING) stamps `issuedAt`.
*/
status?: Freight.InvoiceStatus;
}
/** Payload broadcast on `${source}.invoice.<event>`. */
export interface InvoiceEventPayload {
invoiceId: string;
invoiceNumber: string;
source: Freight.InvoiceSource;
sourceId: string;
type: string;
companyId: string;
companyProfileId: string;
totalAmount: number;
currency: string;
metadata?: Record<string, unknown>;
status: Freight.InvoiceStatus;
paymentId?: string | null;
}
@Injectable()
@@ -62,7 +79,8 @@ export class BillingService {
private readonly dataSource: DataSource,
private readonly invoices: InvoiceRepository,
private readonly invoiceLines: InvoiceLineRepository,
) {}
private readonly events: EventEmitter2,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -71,14 +89,6 @@ export class BillingService {
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/** List invoices for a given booking. */
findByBooking(bookingId: string): Promise<Invoice[]> {
return this.invoices.findAll({
where: { source: BOOKING_SOURCE, sourceId: bookingId },
order: { issuedAt: "DESC" },
});
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
@@ -92,126 +102,6 @@ export class BillingService {
// ── Generation ───────────────────────────────────────────────────────────────
/**
* Generate the booking's invoice from its snapshotted pricing breakdown.
*
* Called when a booking reaches a billable state (full contract execution /
* contract activation). Idempotent: a booking that already has an active
* invoice gets that invoice back instead of a duplicate.
*
* Returns `null` (and logs) when the booking is not billable yet — no pricing
* breakdown, or no customer company/profile to bill (e.g. government/legacy
* bookings whose `company_id` is null, which the `invoices` FK requires).
*/
async generateForBooking(bookingId: string): Promise<Invoice | null> {
const existing = await this.invoices.findAll({
where: { source: BOOKING_SOURCE, sourceId: bookingId },
});
const active = existing.find((inv) => ACTIVE_STATUSES.includes(inv.status));
if (active) return active;
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.companyId) {
this.logger.warn(
`Skipping invoice for booking ${booking.reference} (${bookingId}): no company to bill.`,
);
return null;
}
const companyId = booking.companyId;
const companyProfileId = booking.companyProfileId ?? null;
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
const lines = this.buildLines(breakdown, booking.totalAmount, currency);
const subtotal = round2(lines.reduce((sum, l) => sum + l.amount, 0));
// Honor a staff price override: bill the adjusted total, recording the delta
// as an ADJUSTMENT line so the signed lines still sum to the invoice total.
const adjusted = booking.adjustedTotalAmount;
let total = subtotal;
if (adjusted != null && Number.isFinite(Number(adjusted))) {
const delta = round2(Number(adjusted) - subtotal);
if (delta !== 0) {
lines.push({
chargeType: "ADJUSTMENT",
description: "Staff price adjustment",
quantity: 1,
unitRate: delta,
amount: delta,
currency,
});
}
total = round2(Number(adjusted));
}
const issuedAt = new Date();
const dueAt = new Date(issuedAt);
dueAt.setDate(dueAt.getDate() + DEFAULT_DUE_DAYS);
return this.dataSource.transaction(async (mg) => {
const invoice = await mg.save(
mg.create(Invoice, {
invoiceNumber: await this.nextInvoiceNumber(mg),
companyId,
companyProfileId,
totalAmount: total,
currency,
status: Freight.InvoiceStatus.Pending,
source: BOOKING_SOURCE,
sourceId: bookingId,
type: "PREPAID",
issuedAt,
dueAt,
}),
);
for (const line of lines) {
await mg.save(mg.create(InvoiceLine, { invoiceId: invoice.id, ...line }));
}
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} for booking ${booking.reference} (${total} ${currency}).`,
);
return invoice;
});
}
/** Map the stored pricing line items to invoice lines (one breakdown line → one invoice line). */
private buildLines(
breakdown: StoredPricingBreakdown,
fallbackTotal: number,
currency: string,
): BuiltLine[] {
const items = breakdown.lineItems ?? [];
if (items.length === 0) {
// No itemized breakdown — bill a single line for the booking total.
return [
{
chargeType: "FREIGHT",
description: "Freight charge",
quantity: 1,
unitRate: round2(fallbackTotal),
amount: round2(fallbackTotal),
currency,
},
];
}
return items.map((item) => ({
chargeType: item.code,
description: item.description,
quantity: item.quantity,
unitRate: round2(item.unitAmount),
amount: round2(item.amount),
currency: item.currency ?? currency,
metadata: { unit: item.unit },
}));
}
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
@@ -226,108 +116,254 @@ export class BillingService {
return `${prefix}${String(next).padStart(5, "0")}`;
}
// ── Payment reconciliation ───────────────────────────────────────────────────
/**
* The invoice a gateway payment should settle for a booking, or null if none.
* Generate an invoice for any source (booking, demurrage, manual, …).
*
* This is the billing document of record for "what is owed" — callers (e.g.
* `payment.service.initiatePayment`) should charge `invoice.totalAmount` against
* it rather than recomputing from `booking.totalAmount`, so discounts/penalties/
* adjustments carried on the invoice are honored. Returns the most recent open
* (unpaid, non-cancelled) invoice.
* Persists the header plus its lines in one transaction and assigns the next
* sequential `invoice_number`. The total defaults to the sum of line amounts
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
*
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
* invoice as part of a larger booking flow).
*/
findPayableForBooking(bookingId: string): Promise<Invoice | null> {
return this.dataSource.getRepository(Invoice).findOne({
where: {
source: BOOKING_SOURCE,
sourceId: bookingId,
status: In([
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Overdue,
]),
},
order: { issuedAt: "DESC" },
});
}
/**
* Mark a booking's paid invoice as refunded. Called from `payment.service.refund`
* inside its DB transaction so the invoice tracks the booking/payment reversal.
* No-op when the booking has no paid invoice.
*/
async markBookingInvoiceRefunded(
bookingId: string,
async generateInvoice(
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source: BOOKING_SOURCE,
sourceId: bookingId,
status: Freight.InvoiceStatus.Paid,
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(
Invoice,
{ id: invoice.id },
{ status: Freight.InvoiceStatus.Refunded },
);
): Promise<Invoice & { lines: InvoiceLine[] }> {
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* Mark a booking's open invoice as paid and link the gateway payment.
*
* Called from `payment.service.finalizePaymentSuccess()` inside its existing DB
* transaction (pass the transaction's `EntityManager`). Full-payment only — no
* partial settlement in this phase. No-op when the booking has no open invoice.
*/
async markBookingInvoicePaid(
bookingId: string,
paymentId: string | null,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source: BOOKING_SOURCE,
sourceId: bookingId,
status: In([Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Draft, Freight.InvoiceStatus.Overdue]),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
private async createInvoice(
input: GenerateInvoiceInput,
mg: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const currency = input.currency ?? "ETB";
const status = input.status ?? Freight.InvoiceStatus.Pending;
const issued = status !== Freight.InvoiceStatus.Draft;
await mg.update(
Invoice,
{ id: invoice.id },
{ status: Freight.InvoiceStatus.Paid, paymentId: paymentId ?? undefined },
const lines = input.lines.map((l) => {
const quantity = l.quantity ?? 1;
const unitRate = l.unitRate ?? 0;
return {
chargeType: l.chargeType,
description: l.description,
quantity,
unitRate,
amount: l.amount ?? quantity * unitRate,
currency: l.currency ?? currency,
metadata: l.metadata ?? null,
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const dueAt =
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
const invoice = await mg.save(
mg.create(Invoice, {
invoiceNumber,
source: input.source,
sourceId: input.sourceId,
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
currency,
status,
issuedAt: issued ? new Date() : null,
dueAt,
}),
);
const savedLines = await Promise.all(
lines.map((l) =>
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
),
);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
);
return { ...invoice, lines: savedLines };
}
// ── State transitions ────────────────────────────────────────────────────────
/**
* Single settlement chokepoint for any path that marks a booking PAID: ensure
* the booking has an invoice (idempotent generate), then mark it paid. Reused by
* the gateway flow and offline/manual "mark paid" so invoicing holds everywhere.
*
* No-op for bookings that have no invoice and cannot get one (e.g. government
* bookings with no company to bill — `generateForBooking` returns null).
* 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.
*/
async settleBookingInvoice(
bookingId: string,
async markInvoiceAsPaid(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
): Promise<void> {
await this.generateForBooking(bookingId);
await this.markBookingInvoicePaid(bookingId, paymentId, manager);
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
*/
async markInvoiceAsRefunded(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Refunded,
"refunded",
{},
manager,
);
}
/**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled.
*/
async cancelInvoice(
invoiceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Cancelled,
"cancelled",
{},
manager,
);
}
/**
* 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.
*/
private async transition(
invoiceId: string,
status: Freight.InvoiceStatus,
event: string,
extra: { paymentId?: string },
manager?: EntityManager,
): 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;
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated);
return updated;
}
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
private emitInvoiceEvent(event: string, invoice: Invoice): void {
const payload: InvoiceEventPayload = {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
source: invoice.source as Freight.InvoiceSource,
sourceId: invoice.sourceId,
type: invoice.type,
companyId: invoice.companyId,
companyProfileId: invoice.companyProfileId,
totalAmount: invoice.totalAmount,
currency: invoice.currency,
status: invoice.status,
paymentId: invoice.paymentId ?? null,
};
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
}
// ── 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. `payment.service.initiatePayment`) should charge `invoice.totalAmount`
* against it rather than recomputing from the source's own total, so
* discounts/penalties/adjustments carried on the invoice are honored. Returns
* the most recent open (unpaid, non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
sourceId: string,
): Promise<Invoice | null> {
return this.dataSource.getRepository(Invoice).findOne({
where: { source, sourceId, status: In(OPEN_STATUSES) },
order: { issuedAt: "DESC" },
});
}
/**
* Settle a source's open invoice as paid and link the gateway payment, then
* emit `${source}.invoice.paid`. Resolves the open invoice via {@link findPayable}
* then delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
* settlement. No-op (returns null) when the source has no open invoice.
*
* Pass the caller's transaction `manager` (e.g. from
* `payment.service.finalizePaymentSuccess`) to enlist in its DB transaction.
*/
async settlePayable(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
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.
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
*/
async refundPayable(
source: Freight.InvoiceSource,
sourceId: 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 },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
}
}
/** Round to 2 decimal places without float drift. */
function round2(n: number): number {
return Math.round(Number(n) * 100) / 100;
}

View File

@@ -137,6 +137,14 @@ export enum InvoiceStatus {
Refunded = "REFUNDED",
}
/** Originating subsystem an invoice bills for; namespaces invoice events. */
export enum InvoiceSource {
Booking = "booking",
Contract = "contract",
Warehouse = "warehouse",
Demurrage = "demurrage",
}
export enum SchedulingStatus {
NotScheduled = "NOT_SCHEDULED",
Holding = "HOLDING",