mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: setup billing services
This commit is contained in:
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user