mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
Merge pull request #383 from Tria-plc/freight/feat/invoice
Freight/feat/invoice
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||||
"predev": "pnpm run clean",
|
"predev": "pnpm run clean",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch --clearScreen false",
|
||||||
"prebuild": "pnpm run clean",
|
"prebuild": "pnpm run clean",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend `freight.invoices` into the billing record of record for every source
|
||||||
|
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
|
||||||
|
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
|
||||||
|
*
|
||||||
|
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
|
||||||
|
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
|
||||||
|
* statuses the warehouse flow uses.
|
||||||
|
*
|
||||||
|
* Matches billing/entities/invoice.entity.ts. All columns are additive with
|
||||||
|
* defaults, so existing booking/demurrage rows are unaffected.
|
||||||
|
*/
|
||||||
|
export class ExtendInvoicesForPartialPayment1828000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "ExtendInvoicesForPartialPayment1828000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
|
||||||
|
// as the value is not referenced in the same transaction (it is not here).
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
|
||||||
|
// the outstanding balance is the full total for unpaid invoices.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.invoices
|
||||||
|
SET subtotal_amount = total_amount,
|
||||||
|
balance_amount = total_amount;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.invoices
|
||||||
|
SET paid_amount = total_amount,
|
||||||
|
balance_amount = 0,
|
||||||
|
paid_at = updated_at
|
||||||
|
WHERE status = 'PAID';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.invoices
|
||||||
|
DROP COLUMN IF EXISTS payments,
|
||||||
|
DROP COLUMN IF EXISTS paid_at,
|
||||||
|
DROP COLUMN IF EXISTS balance_amount,
|
||||||
|
DROP COLUMN IF EXISTS paid_amount,
|
||||||
|
DROP COLUMN IF EXISTS tax_amount,
|
||||||
|
DROP COLUMN IF EXISTS subtotal_amount;
|
||||||
|
`);
|
||||||
|
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
|
||||||
|
// left on freight.invoices_status_enum (harmless, unused after down).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold warehouse fee invoices into the central billing system.
|
||||||
|
*
|
||||||
|
* Warehouse fee invoices are no longer a standalone aggregate: each becomes a
|
||||||
|
* global `freight.invoices` row (`source = 'warehouse'`, `source_id =
|
||||||
|
* inventory_id`) with its items as `freight.invoice_lines`. The warehouse
|
||||||
|
* service is now a thin layer over `BillingService`. This migration backfills the
|
||||||
|
* existing rows (preserving ids, numbers, status, amounts and payment history),
|
||||||
|
* then drops the two legacy tables.
|
||||||
|
*
|
||||||
|
* Rows that cannot be billed centrally — no company to bill (`company_id` /
|
||||||
|
* `company_profile_id` underivable from the customer or the booking) — are not
|
||||||
|
* migrated; they could never have been charged through the gateway and are
|
||||||
|
* dropped with the table.
|
||||||
|
*/
|
||||||
|
export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
|
||||||
|
name = 'CentralizeWarehouseInvoices1829000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 1. Invoice headers. Keep the same id so items still link, and so any
|
||||||
|
// external reference to the invoice id stays valid.
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.invoices (
|
||||||
|
id, invoice_number, company_id, company_profile_id,
|
||||||
|
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||||
|
currency, status, source, source_id, type,
|
||||||
|
issued_at, paid_at, payments, payment_id, due_at,
|
||||||
|
created_at, updated_at, deleted_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
fee.id,
|
||||||
|
fee.invoice_number,
|
||||||
|
COALESCE(fee.customer_id, b.company_id),
|
||||||
|
COALESCE(
|
||||||
|
b.company_profile_id,
|
||||||
|
(SELECT cp.id
|
||||||
|
FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||||
|
AND cp.deleted_at IS NULL
|
||||||
|
ORDER BY cp.created_at ASC
|
||||||
|
LIMIT 1)
|
||||||
|
),
|
||||||
|
fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
|
||||||
|
fee.currency,
|
||||||
|
fee.status::freight.invoices_status_enum,
|
||||||
|
'warehouse',
|
||||||
|
fee.inventory_id,
|
||||||
|
fee.invoice_type,
|
||||||
|
fee.issued_at,
|
||||||
|
fee.paid_at,
|
||||||
|
COALESCE(fee.payments, '[]'::jsonb),
|
||||||
|
NULL,
|
||||||
|
COALESCE(fee.due_date, fee.issued_at, fee.created_at),
|
||||||
|
fee.created_at, fee.updated_at, fee.deleted_at
|
||||||
|
FROM freight.warehouse_fee_invoices fee
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = fee.booking_id
|
||||||
|
WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
|
||||||
|
AND COALESCE(
|
||||||
|
b.company_profile_id,
|
||||||
|
(SELECT cp.id
|
||||||
|
FROM freight.company_profiles cp
|
||||||
|
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
|
||||||
|
AND cp.deleted_at IS NULL
|
||||||
|
ORDER BY cp.created_at ASC
|
||||||
|
LIMIT 1)
|
||||||
|
) IS NOT NULL
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. Invoice lines — only for items whose parent invoice migrated. Warehouse
|
||||||
|
// fee fields (fee_rule_id / chargeable_days / free_days) move into the
|
||||||
|
// line's jsonb metadata.
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.invoice_lines (
|
||||||
|
id, invoice_id, charge_type, description, quantity, unit_rate, amount,
|
||||||
|
currency, metadata, created_at, updated_at, deleted_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
item.id,
|
||||||
|
item.invoice_id,
|
||||||
|
item.fee_type,
|
||||||
|
item.description,
|
||||||
|
item.quantity,
|
||||||
|
item.unit_rate,
|
||||||
|
item.amount,
|
||||||
|
item.currency,
|
||||||
|
jsonb_build_object(
|
||||||
|
'feeRuleId', item.fee_rule_id,
|
||||||
|
'chargeableDays', item.chargeable_days,
|
||||||
|
'freeDays', item.free_days
|
||||||
|
),
|
||||||
|
item.created_at, item.updated_at, item.deleted_at
|
||||||
|
FROM freight.warehouse_fee_invoice_items item
|
||||||
|
JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. Drop the legacy tables (items first — FK to invoices).
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Recreate the legacy tables …
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
invoice_number varchar(40) NOT NULL,
|
||||||
|
booking_id uuid,
|
||||||
|
customer_id uuid,
|
||||||
|
inventory_id uuid NOT NULL,
|
||||||
|
facility_id uuid,
|
||||||
|
warehouse_id uuid,
|
||||||
|
yard_id uuid,
|
||||||
|
zone_id uuid,
|
||||||
|
invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||||
|
total_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||||
|
paid_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||||
|
balance_amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||||
|
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||||
|
period_start timestamptz,
|
||||||
|
period_end timestamptz,
|
||||||
|
issued_at timestamptz,
|
||||||
|
due_date timestamptz,
|
||||||
|
paid_at timestamptz,
|
||||||
|
cancelled_at timestamptz,
|
||||||
|
payments jsonb NOT NULL DEFAULT '[]',
|
||||||
|
notes text,
|
||||||
|
CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
|
||||||
|
CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
invoice_id uuid NOT NULL,
|
||||||
|
fee_rule_id uuid,
|
||||||
|
fee_type varchar(32) NOT NULL,
|
||||||
|
description varchar(255) NOT NULL,
|
||||||
|
quantity numeric(12,2) NOT NULL DEFAULT 1,
|
||||||
|
unit_rate numeric(14,2) NOT NULL DEFAULT 0,
|
||||||
|
amount numeric(14,2) NOT NULL DEFAULT 0,
|
||||||
|
currency varchar(8) NOT NULL DEFAULT 'USD',
|
||||||
|
chargeable_days int,
|
||||||
|
free_days int,
|
||||||
|
CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
|
||||||
|
CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// … then copy the warehouse-source invoices back, deriving the typed FKs and
|
||||||
|
// period from the linked inventory item.
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.warehouse_fee_invoices (
|
||||||
|
id, created_at, updated_at, deleted_at, invoice_number,
|
||||||
|
booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
|
||||||
|
invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||||
|
currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
|
||||||
|
inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
|
||||||
|
i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
|
||||||
|
i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
|
||||||
|
CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
|
||||||
|
i.payments, NULL
|
||||||
|
FROM freight.invoices i
|
||||||
|
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
|
||||||
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
|
WHERE i.source = 'warehouse'
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.warehouse_fee_invoice_items (
|
||||||
|
id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
|
||||||
|
description, quantity, unit_rate, amount, currency, chargeable_days, free_days
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
|
||||||
|
NULLIF(l.metadata->>'feeRuleId', '')::uuid,
|
||||||
|
l.charge_type,
|
||||||
|
COALESCE(l.description, ''),
|
||||||
|
l.quantity, l.unit_rate, l.amount, l.currency,
|
||||||
|
NULLIF(l.metadata->>'chargeableDays', '')::int,
|
||||||
|
NULLIF(l.metadata->>'freeDays', '')::int
|
||||||
|
FROM freight.invoice_lines l
|
||||||
|
JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Remove the migrated rows from the central tables.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM freight.invoice_lines
|
||||||
|
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
import { PortalBillingController } from "./portal-billing.controller";
|
import { PortalBillingController } from "./portal-billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
|
import { DocumentsModule } from "./documents/documents.module";
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
@@ -16,6 +17,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
|||||||
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
forwardRef(() => PaymentModule),
|
forwardRef(() => PaymentModule),
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
|
DocumentsModule,
|
||||||
],
|
],
|
||||||
controllers: [BillingController, PortalBillingController],
|
controllers: [BillingController, PortalBillingController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
expect(invoice.sourceId).toBe("booking-1");
|
expect(invoice.sourceId).toBe("booking-1");
|
||||||
expect(invoice.totalAmount).toBe(1500);
|
expect(invoice.totalAmount).toBe(1500);
|
||||||
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
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);
|
expect(savedLines).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -171,6 +173,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -180,6 +183,89 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("BillingService.recordPayment", () => {
|
||||||
|
function serviceFor(invoice: Record<string, unknown> | null) {
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(invoice),
|
||||||
|
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
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
|
);
|
||||||
|
return { service, mg, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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" },
|
||||||
|
expect.objectContaining({
|
||||||
|
status: Freight.InvoiceStatus.PartiallyPaid,
|
||||||
|
paidAmount: 400,
|
||||||
|
balanceAmount: 600,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("BillingService.settlePayable", () => {
|
describe("BillingService.settlePayable", () => {
|
||||||
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
||||||
const open = {
|
const open = {
|
||||||
@@ -200,6 +286,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
@@ -234,6 +321,7 @@ describe("BillingService.settlePayable", () => {
|
|||||||
events as never,
|
events as never,
|
||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
|
{} as never, // invoiceDocuments
|
||||||
);
|
);
|
||||||
|
|
||||||
const settled = await service.settlePayable(
|
const settled = await service.settlePayable(
|
||||||
|
|||||||
@@ -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 { 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 { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
|
||||||
import { InvoiceRepository } from "./invoice.repository";
|
|
||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
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. */
|
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||||
export interface PayInvoiceOptions {
|
export interface PayInvoiceOptions {
|
||||||
@@ -20,13 +33,26 @@ export interface PayInvoiceOptions {
|
|||||||
failureUrl?: string;
|
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`. */
|
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||||
const DEFAULT_DUE_DAYS = 14;
|
const DEFAULT_DUE_DAYS = 14;
|
||||||
|
|
||||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||||
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||||
Freight.InvoiceStatus.Draft,
|
Freight.InvoiceStatus.Draft,
|
||||||
|
Freight.InvoiceStatus.Issued,
|
||||||
Freight.InvoiceStatus.Pending,
|
Freight.InvoiceStatus.Pending,
|
||||||
|
Freight.InvoiceStatus.PartiallyPaid,
|
||||||
Freight.InvoiceStatus.Overdue,
|
Freight.InvoiceStatus.Overdue,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -56,7 +82,11 @@ export interface GenerateInvoiceInput {
|
|||||||
companyProfileId: string;
|
companyProfileId: string;
|
||||||
lines: InvoiceLineInput[];
|
lines: InvoiceLineInput[];
|
||||||
currency?: string;
|
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;
|
totalAmount?: number;
|
||||||
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||||
dueAt?: Date;
|
dueAt?: Date;
|
||||||
@@ -95,6 +125,7 @@ export class BillingService {
|
|||||||
@Inject(forwardRef(() => PaymentService))
|
@Inject(forwardRef(() => PaymentService))
|
||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -115,6 +146,69 @@ export class BillingService {
|
|||||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
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) ───────────────────────────────────────────
|
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||||
|
|
||||||
/** Resolve the customer's company id from their IAM user id (null if none). */
|
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||||
@@ -175,18 +269,9 @@ export class BillingService {
|
|||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||||
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||||
const now = new Date();
|
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" });
|
||||||
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")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,8 +315,12 @@ export class BillingService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const subtotalAmount =
|
||||||
|
input.subtotalAmount ??
|
||||||
|
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||||
|
const taxAmount = input.taxAmount ?? 0;
|
||||||
const totalAmount =
|
const totalAmount =
|
||||||
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
||||||
|
|
||||||
const dueAt =
|
const dueAt =
|
||||||
input.dueAt ??
|
input.dueAt ??
|
||||||
@@ -250,7 +339,12 @@ export class BillingService {
|
|||||||
type: input.type,
|
type: input.type,
|
||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
companyProfileId: input.companyProfileId,
|
companyProfileId: input.companyProfileId,
|
||||||
totalAmount,
|
subtotalAmount: round2(subtotalAmount),
|
||||||
|
taxAmount: round2(taxAmount),
|
||||||
|
totalAmount: round2(totalAmount),
|
||||||
|
paidAmount: 0,
|
||||||
|
balanceAmount: round2(totalAmount),
|
||||||
|
payments: [],
|
||||||
currency,
|
currency,
|
||||||
status,
|
status,
|
||||||
issuedAt: issued ? new Date() : null,
|
issuedAt: issued ? new Date() : null,
|
||||||
@@ -293,6 +387,85 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
* or when `amount` is not positive. Pass `manager` to enlist in a caller's
|
||||||
|
* transaction.
|
||||||
|
*/
|
||||||
|
async recordPayment(
|
||||||
|
invoiceId: string,
|
||||||
|
input: RecordPaymentInput,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice> {
|
||||||
|
if (!(input.amount > 0)) {
|
||||||
|
throw new BadRequestException("Payment amount must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
await mg.update(
|
||||||
|
Invoice,
|
||||||
|
{ id: invoice.id },
|
||||||
|
{
|
||||||
|
paidAmount,
|
||||||
|
balanceAmount,
|
||||||
|
status,
|
||||||
|
payments,
|
||||||
|
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...invoice,
|
||||||
|
paidAmount,
|
||||||
|
balanceAmount,
|
||||||
|
status,
|
||||||
|
payments,
|
||||||
|
paidAt: fullyPaid ? at : invoice.paidAt ?? null,
|
||||||
|
} as Invoice;
|
||||||
|
|
||||||
|
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||||
* No-op when already refunded.
|
* No-op when already refunded.
|
||||||
@@ -487,11 +660,12 @@ export class BillingService {
|
|||||||
const result = await this.payment.initiate({
|
const result = await this.payment.initiate({
|
||||||
referenceId: sourceId,
|
referenceId: sourceId,
|
||||||
source: invoice.source,
|
source: invoice.source,
|
||||||
// Gateway reference type derives from the invoice source by convention
|
// Freight payments settle under the generic SHIPMENT reference — how the
|
||||||
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
// payment service attributes them to the freight API. The payment ↔ invoice
|
||||||
// the domain never supplies it. New sources add their uppercased value to
|
// link is the intent id (`paymentId`); per-source post-payment reactions live
|
||||||
// the PaymentReferenceType enum.
|
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||||
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
// service branches on a domain-specific reference type.
|
||||||
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
orderRef: invoice.invoiceNumber,
|
orderRef: invoice.invoiceNumber,
|
||||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
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, "-");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity";
|
|||||||
import { Company } from "../../companies/entities/company.entity";
|
import { Company } from "../../companies/entities/company.entity";
|
||||||
import { CompanyProfile } from "../../companies/entities/company-profile.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" })
|
@Entity({ schema: "freight", name: "invoices" })
|
||||||
@Index(["companyId"])
|
@Index(["companyId"])
|
||||||
@Index(["companyProfileId"])
|
@Index(["companyProfileId"])
|
||||||
@@ -28,9 +38,24 @@ export class Invoice extends BaseEntity {
|
|||||||
@JoinColumn({ name: "company_profile_id" })
|
@JoinColumn({ name: "company_profile_id" })
|
||||||
companyProfile?: CompanyProfile;
|
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 })
|
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
||||||
totalAmount!: number;
|
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" })
|
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
|
|
||||||
@@ -62,6 +87,14 @@ export class Invoice extends BaseEntity {
|
|||||||
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
||||||
issuedAt?: Date | null;
|
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. */
|
/** The ID of the payment that generated this invoice. */
|
||||||
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
||||||
paymentId?: string | null;
|
paymentId?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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<Array<{ seq: number | string }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
const [row] = await runner.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
|
||||||
|
FROM ${opts.table} WHERE ${column} LIKE $1`,
|
||||||
|
[`${prefix}%`],
|
||||||
|
);
|
||||||
|
const next = Number(row?.seq ?? 0) + 1;
|
||||||
|
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
Body,
|
|
||||||
Post,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -18,9 +16,9 @@ import {
|
|||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { Public } from "@edr/api-common";
|
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 { PaymentService } from "./payment.service";
|
||||||
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
import { IntentStatusDto } from "./payments.dto";
|
||||||
|
|
||||||
@ApiTags("Payment")
|
@ApiTags("Payment")
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
@@ -73,13 +71,6 @@ export class PaymentController {
|
|||||||
return this.paymentService.getIntentByBookingId(bookingId);
|
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")
|
@Get("receipt/:orderId")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
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 { 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 {
|
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,
|
WarehouseInvoiceStatus,
|
||||||
WarehouseInvoiceType,
|
WarehouseInvoiceType,
|
||||||
} from './entities/warehouse-fee-invoice.entity';
|
} from './warehouse-invoice.types';
|
||||||
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';
|
|
||||||
|
|
||||||
interface GenerateOptions {
|
interface GenerateOptions {
|
||||||
confirmZero?: boolean;
|
confirmZero?: boolean;
|
||||||
@@ -27,9 +34,18 @@ export interface PayInvoiceDto {
|
|||||||
driverPhone?: string;
|
driverPhone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invoices that still owe money and therefore block terminal release. */
|
/** Warehouse fee invoices live in the global billing system under this source. */
|
||||||
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
|
const SOURCE = Freight.InvoiceSource.Warehouse;
|
||||||
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
|
|
||||||
|
/** 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 {
|
export interface InvoiceDocumentDetails {
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
@@ -45,28 +61,75 @@ export interface InvoiceDocumentDetails {
|
|||||||
zoneName: string | null;
|
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()
|
@Injectable()
|
||||||
export class WarehouseInvoiceService {
|
export class WarehouseInvoiceService {
|
||||||
private readonly logger = new Logger(WarehouseInvoiceService.name);
|
private readonly logger = new Logger(WarehouseInvoiceService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
private readonly billing: BillingService,
|
||||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
private readonly feeService: WarehouseFeeService,
|
private readonly feeService: WarehouseFeeService,
|
||||||
private readonly documents: WarehouseReleaseDocumentService,
|
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────
|
||||||
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
|
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
|
||||||
const [item] = await this.dataSource.query(
|
const [item] = await this.dataSource.query(
|
||||||
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
|
`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",
|
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
|
||||||
w.facility_id AS "facilityId",
|
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
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_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`);
|
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.
|
// Dedup: only one active (non-cancelled) invoice per inventory item.
|
||||||
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
if (await this.hasActiveInvoice(inventoryId)) {
|
||||||
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
|
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
|
'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 = items.reduce((s, i) => s + i.amount, 0);
|
||||||
const total = subtotal; // tax model can be layered on later
|
|
||||||
|
|
||||||
if (total <= 0 && !opts.confirmZero) {
|
if (total <= 0 && !opts.confirmZero) {
|
||||||
throw new BadRequestException('No payable warehouse fee found for this item.');
|
throw new BadRequestException('No payable warehouse fee found for this item.');
|
||||||
}
|
}
|
||||||
@@ -124,74 +192,81 @@ export class WarehouseInvoiceService {
|
|||||||
const invoiceType: WarehouseInvoiceType =
|
const invoiceType: WarehouseInvoiceType =
|
||||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||||
|
|
||||||
const currency = billingCurrency;
|
const lines: InvoiceLineInput[] = items.map((it) => ({
|
||||||
const now = new Date();
|
chargeType: it.feeType,
|
||||||
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
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({
|
const invoice = await this.billing.generateInvoice({
|
||||||
invoiceNumber: await this.nextInvoiceNumber(),
|
source: SOURCE,
|
||||||
bookingId: item.bookingId ?? null,
|
sourceId: inventoryId,
|
||||||
customerId: item.customerId ?? null,
|
type: invoiceType,
|
||||||
inventoryId,
|
companyId: item.companyId,
|
||||||
facilityId: item.facilityId ?? null,
|
companyProfileId: item.companyProfileId,
|
||||||
warehouseId: item.warehouseId ?? null,
|
currency: billingCurrency,
|
||||||
yardId: item.yardId ?? null,
|
lines,
|
||||||
zoneId: item.zoneId ?? null,
|
status: Freight.InvoiceStatus.Issued,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const it of items) {
|
const detail = await this.findById(invoice.id);
|
||||||
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
await this.notifyWarehouseFeeIssued(detail);
|
||||||
}
|
return detail;
|
||||||
|
|
||||||
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')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reads ────────────────────────────────────────────────────────────────
|
// ── Reads ────────────────────────────────────────────────────────────────
|
||||||
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
|
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||||
const invoice = await this.invoiceRepository.findById(id);
|
const invoice = await this.loadWarehouseInvoice(id);
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
const ctx = await this.getInventoryContext(invoice.sourceId);
|
||||||
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
|
|
||||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
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 }> {
|
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE'));
|
||||||
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details);
|
|
||||||
return {
|
|
||||||
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
|
||||||
buffer: await this.documents.htmlToPdfBuffer(html),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
@@ -199,76 +274,65 @@ export class WarehouseInvoiceService {
|
|||||||
if (Number(invoice.paidAmount) <= 0) {
|
if (Number(invoice.paidAmount) <= 0) {
|
||||||
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
throw new BadRequestException('A receipt is available only after payment is recorded.');
|
||||||
}
|
}
|
||||||
const details = await this.getInvoiceDocumentDetails(invoice);
|
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
|
||||||
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' } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── State changes ────────────────────────────────────────────────────────
|
// ── State changes ────────────────────────────────────────────────────────
|
||||||
async cancel(id: string): Promise<WarehouseFeeInvoice> {
|
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
|
||||||
const invoice = await this.invoiceRepository.findById(id);
|
const invoice = await this.loadWarehouseInvoice(id);
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||||
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
|
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;
|
await this.billing.cancelInvoice(id);
|
||||||
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a payment against the invoice and sync status (links to existing payment flow). */
|
/** Record a payment against the invoice (delegates settlement to billing). */
|
||||||
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
|
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
|
||||||
const invoice = await this.invoiceRepository.findById(id);
|
// Guard that this is a warehouse invoice before recording (404 otherwise).
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
await this.loadWarehouseInvoice(id);
|
||||||
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
|
await this.billing.recordPayment(id, {
|
||||||
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
|
amount: dto.amount,
|
||||||
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
|
method: dto.method ?? null,
|
||||||
|
reference: dto.reference ?? null,
|
||||||
const paidAmount = Number(invoice.paidAmount) + dto.amount;
|
metadata:
|
||||||
const total = Number(invoice.totalAmount);
|
dto.driverName || dto.driverPhone
|
||||||
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
|
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
|
||||||
const fullyPaid = paidAmount >= total;
|
: null,
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
const paidInvoice = updated as WarehouseFeeInvoice;
|
const detail = await this.findById(id);
|
||||||
await this.notifyWarehouseFeePayment(paidInvoice, dto);
|
await this.notifyWarehouseFeePayment(detail, dto);
|
||||||
return paidInvoice;
|
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 ──────────────────────────────────────────────────────
|
// ── Release blocking ──────────────────────────────────────────────────────
|
||||||
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
/** Returns the first unpaid invoice that blocks terminal release, or null. */
|
||||||
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
|
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
|
||||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
const blocking = await this.queryViews(
|
||||||
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
|
`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> {
|
async assertClearanceAllowed(inventoryId: string): Promise<void> {
|
||||||
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
|
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
|
||||||
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
|
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||||
if (blocking) {
|
if (blocking) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
|
`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(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT b.reference AS "bookingReference",
|
`SELECT b.reference AS "bookingReference",
|
||||||
company.name AS "customerName",
|
company.name AS "customerName",
|
||||||
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
|
||||||
inv.status AS "inventoryStatus",
|
inv.status AS "inventoryStatus",
|
||||||
|
inv.release_date AS "releaseDate",
|
||||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||||
CONCAT_WS(
|
CONCAT_WS(
|
||||||
@@ -302,16 +572,10 @@ export class WarehouseInvoiceService {
|
|||||||
) AS "inventoryInfo",
|
) AS "inventoryInfo",
|
||||||
wh.name AS "warehouseName",
|
wh.name AS "warehouseName",
|
||||||
yard.name AS "yardName",
|
yard.name AS "yardName",
|
||||||
zone.name AS "zoneName",
|
zone.name AS "zoneName"
|
||||||
CASE
|
FROM freight.warehouse_inventory inv
|
||||||
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||||
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
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)
|
|
||||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
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 (
|
LEFT JOIN freight.booking_container booking_container ON (
|
||||||
booking_container.booking_id = b.id
|
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.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.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.warehouses wh ON wh.id = inv.warehouse_id
|
||||||
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
|
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||||
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
|
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||||
WHERE fee.id = $1
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||||
LIMIT 1`,
|
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 {
|
return {
|
||||||
bookingReference: row?.bookingReference ?? null,
|
bookingReference: row?.bookingReference ?? null,
|
||||||
customerName: row?.customerName ?? null,
|
customerName: row?.customerName ?? null,
|
||||||
@@ -338,11 +609,33 @@ export class WarehouseInvoiceService {
|
|||||||
warehouseName: row?.warehouseName ?? null,
|
warehouseName: row?.warehouseName ?? null,
|
||||||
yardName: row?.yardName ?? null,
|
yardName: row?.yardName ?? null,
|
||||||
zoneName: row?.zoneName ?? 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;
|
bookingReference: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
customerPhone: string | null;
|
customerPhone: string | null;
|
||||||
@@ -364,10 +657,9 @@ export class WarehouseInvoiceService {
|
|||||||
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
|
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
|
||||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
|
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
|
||||||
FROM freight.warehouse_fee_invoices fee
|
FROM freight.warehouse_inventory inv
|
||||||
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 = inv.booking_id AND b.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 = b.company_id
|
||||||
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
|
|
||||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
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 (
|
LEFT JOIN freight.booking_container booking_container ON (
|
||||||
booking_container.booking_id = b.id
|
booking_container.booking_id = b.id
|
||||||
@@ -393,9 +685,9 @@ export class WarehouseInvoiceService {
|
|||||||
) latest_first_mile ON true
|
) latest_first_mile ON true
|
||||||
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
|
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
|
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`,
|
LIMIT 1`,
|
||||||
[invoice.id],
|
[inventoryId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -419,8 +711,8 @@ export class WarehouseInvoiceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
|
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
|
||||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||||
@@ -433,8 +725,8 @@ export class WarehouseInvoiceService {
|
|||||||
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
|
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
|
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
|
||||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
|
||||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||||
const statusText =
|
const statusText =
|
||||||
@@ -460,131 +752,4 @@ export class WarehouseInvoiceService {
|
|||||||
|
|
||||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
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, '-');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 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()
|
@Injectable()
|
||||||
export class WarehouseReleaseDocumentService {
|
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);
|
* Render the gate-clearance release document to PDF via the shared renderer,
|
||||||
const executablePath = this.resolveExecutablePath();
|
* falling back to the release-specific hand-built layout when Chromium is
|
||||||
|
* unavailable.
|
||||||
try {
|
*/
|
||||||
const puppeteer = await import('puppeteer');
|
htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||||
const launchOptions: import('puppeteer').LaunchOptions = {
|
return this.pdf.htmlToPdfBuffer(html, {
|
||||||
headless: true,
|
label: 'Warehouse release',
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
|
||||||
...(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-';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { FilesModule } from '../files/files.module';
|
||||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.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 { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.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 { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.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 { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
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 { WarehouseInvoiceController } from './warehouse-invoice.controller';
|
||||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||||
import { WarehouseRulesController } from './warehouse-rules.controller';
|
import { WarehouseRulesController } from './warehouse-rules.controller';
|
||||||
@@ -67,9 +65,9 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseInspectionReport,
|
WarehouseInspectionReport,
|
||||||
WarehouseAllocationRule,
|
WarehouseAllocationRule,
|
||||||
WarehouseFeeRule,
|
WarehouseFeeRule,
|
||||||
WarehouseFeeInvoice,
|
|
||||||
WarehouseFeeInvoiceItem,
|
|
||||||
]),
|
]),
|
||||||
|
BillingModule,
|
||||||
|
DocumentsModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
forwardRef(() => LastMileModule),
|
forwardRef(() => LastMileModule),
|
||||||
@@ -102,8 +100,6 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseInspectionRepository,
|
WarehouseInspectionRepository,
|
||||||
WarehouseAllocationRuleRepository,
|
WarehouseAllocationRuleRepository,
|
||||||
WarehouseFeeRuleRepository,
|
WarehouseFeeRuleRepository,
|
||||||
WarehouseFeeInvoiceRepository,
|
|
||||||
WarehouseFeeInvoiceItemRepository,
|
|
||||||
WarehousesService,
|
WarehousesService,
|
||||||
WarehouseYardsService,
|
WarehouseYardsService,
|
||||||
WarehouseZonesService,
|
WarehouseZonesService,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||||
|
"preserveWatchOutput": true,
|
||||||
"module": "node16",
|
"module": "node16",
|
||||||
"moduleResolution": "node16"
|
"moduleResolution": "node16"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5183",
|
"dev": "vite --port 5183 --clearScreen false",
|
||||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5183",
|
"preview": "vite preview --port 5183",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5173",
|
"dev": "vite --port 5173 --clearScreen false",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview --port 5173",
|
"preview": "vite preview --port 5173",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ export enum PaymentStatus {
|
|||||||
|
|
||||||
export enum InvoiceStatus {
|
export enum InvoiceStatus {
|
||||||
Draft = "DRAFT",
|
Draft = "DRAFT",
|
||||||
|
/** Issued and awaiting payment (alias of PENDING for fee invoices). */
|
||||||
|
Issued = "ISSUED",
|
||||||
Pending = "PENDING",
|
Pending = "PENDING",
|
||||||
|
/** Some, but not all, of the balance has been settled. */
|
||||||
|
PartiallyPaid = "PARTIALLY_PAID",
|
||||||
Paid = "PAID",
|
Paid = "PAID",
|
||||||
Overdue = "OVERDUE",
|
Overdue = "OVERDUE",
|
||||||
Cancelled = "CANCELLED",
|
Cancelled = "CANCELLED",
|
||||||
|
|||||||
Reference in New Issue
Block a user