refactor: migrate the warehouse invoice to use the central one

This commit is contained in:
Nathnael
2026-06-30 12:54:04 +00:00
parent 5ad4efd7eb
commit fa3138f2ac
11 changed files with 744 additions and 391 deletions

View File

@@ -6,7 +6,7 @@
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
"dev": "nest start --watch",
"dev": "nest start --watch --clearScreen false",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",

View File

@@ -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';`);
}
}

View File

@@ -1,3 +1,4 @@
import { Freight, PaymentReferenceType } from "@edr/types";
import {
BadRequestException,
forwardRef,
@@ -7,22 +8,21 @@ import {
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
} from "./documents/invoice-document.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
@@ -96,6 +96,11 @@ export interface GenerateInvoiceInput {
* (default PENDING) stamps `issuedAt`.
*/
status?: Freight.InvoiceStatus;
/**
* Document number prefix for this source (e.g. `WHF` for warehouse fees);
* defaults to `FRT`. The daily sequence is allocated per prefix.
*/
numberCode?: string;
}
/** Payload broadcast on `${source}.invoice.<event>`. */
@@ -269,9 +274,9 @@ export class BillingService {
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code: "FRT" });
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" });
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,23 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import { BillingService, InvoiceLineInput } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import {
InvoiceDocumentModel,
InvoiceDocumentService,
} from '../billing/documents/invoice-document.service';
import { nextDailyInvoiceNumber } from '../billing/invoice-numbering.util';
import { applySettlement } from '../billing/invoice-settlement.util';
import { NotificationsService } from '../notifications/notifications.service';
import { WarehouseFeeService } from './warehouse-fee.service';
import {
WarehouseFeeInvoice,
WarehouseFeeInvoiceView,
WarehouseFeeType,
WarehouseInvoiceItemView,
WarehouseInvoiceStatus,
WarehouseInvoiceType,
} from './entities/warehouse-fee-invoice.entity';
import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity';
import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository';
import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository';
import { WarehouseFeeService } from './warehouse-fee.service';
} from './warehouse-invoice.types';
interface GenerateOptions {
confirmZero?: boolean;
@@ -32,9 +33,20 @@ export interface PayInvoiceDto {
driverPhone?: string;
}
/** Invoices that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID'];
const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID'];
/** Warehouse fee invoices live in the global billing system under this source. */
const SOURCE = Freight.InvoiceSource.Warehouse;
/** Document number prefix kept for warehouse fee invoices (e.g. `WHF-20260630-00001`). */
const NUMBER_CODE = 'WHF';
/** Global statuses that still owe money and therefore block terminal release. */
const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
/** Global statuses considered an "active" invoice for per-inventory dedup. */
const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid];
export interface InvoiceDocumentDetails {
bookingReference: string | null;
@@ -50,28 +62,75 @@ export interface InvoiceDocumentDetails {
zoneName: string | null;
}
export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<InvoiceDocumentDetails>;
export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView &
Partial<InvoiceDocumentDetails> & { items: WarehouseInvoiceItemView[] };
/** The warehouse-specific columns derived from the linked inventory item. */
interface InventoryContext {
bookingId: string | null;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
zoneId: string | null;
periodStart: Date | null;
}
/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */
interface ViewSource {
id: string;
invoiceNumber: string;
companyId: string;
sourceId: string;
type: string;
status: Freight.InvoiceStatus | string;
subtotalAmount: number | string;
taxAmount: number | string;
totalAmount: number | string;
paidAmount: number | string;
balanceAmount: number | string;
currency: string;
issuedAt?: Date | null;
dueAt?: Date | null;
paidAt?: Date | null;
createdAt: Date;
updatedAt: Date;
payments?: Array<{
amount: number | string;
method?: string | null;
reference?: string | null;
paidAt: string;
}> | null;
}
/**
* Thin warehouse layer over the central {@link BillingService}. Warehouse fee
* invoices are global `Invoice` rows (`source = warehouse`, `sourceId =
* inventoryId`); this service owns only the warehouse-specific concerns —
* computing fees, per-inventory dedup, release-blocking, SMS notifications, the
* sealed PDF, and reshaping the global invoice back into the historical
* `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering,
* status, and payment math live in billing.
*/
@Injectable()
export class WarehouseInvoiceService {
private readonly logger = new Logger(WarehouseInvoiceService.name);
constructor(
private readonly dataSource: DataSource,
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly feeService: WarehouseFeeService,
private readonly billing: BillingService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly notifications: NotificationsService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoiceDetail> {
const [item] = await this.dataSource.query(
`SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt",
w.facility_id AS "facilityId",
b.company_id AS "customerId", b.freight_type AS "freightType"
b.company_id AS "companyId", b.company_profile_id AS "companyProfileId",
b.freight_type AS "freightType"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -80,9 +139,16 @@ export class WarehouseInvoiceService {
);
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// Routing through the global invoice requires a billable company + profile,
// both of which come from the inventory's booking.
if (!item.companyId || !item.companyProfileId) {
throw new BadRequestException(
'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).',
);
}
// Dedup: only one active (non-cancelled) invoice per inventory item.
const active = await this.invoiceRepository.findAll({ where: { inventoryId } });
if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) {
if (await this.hasActiveInvoice(inventoryId)) {
throw new ConflictException(
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
);
@@ -117,9 +183,7 @@ export class WarehouseInvoiceService {
};
});
const subtotal = items.reduce((s, i) => s + i.amount, 0);
const total = subtotal; // tax model can be layered on later
const total = items.reduce((s, i) => s + i.amount, 0);
if (total <= 0 && !opts.confirmZero) {
throw new BadRequestException('No payable warehouse fee found for this item.');
}
@@ -129,58 +193,77 @@ export class WarehouseInvoiceService {
const invoiceType: WarehouseInvoiceType =
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
const currency = billingCurrency;
const now = new Date();
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
const lines: InvoiceLineInput[] = items.map((it) => ({
chargeType: it.feeType,
description: it.description,
quantity: it.quantity,
unitRate: it.unitRate,
amount: it.amount,
currency: it.currency,
metadata: {
feeRuleId: it.feeRuleId ?? null,
chargeableDays: it.chargeableDays ?? null,
freeDays: it.freeDays ?? null,
},
}));
const invoice = await this.invoiceRepository.create({
invoiceNumber: await this.nextInvoiceNumber(),
bookingId: item.bookingId ?? null,
customerId: item.customerId ?? null,
inventoryId,
facilityId: item.facilityId ?? null,
warehouseId: item.warehouseId ?? null,
yardId: item.yardId ?? null,
zoneId: item.zoneId ?? null,
invoiceType,
status: 'ISSUED',
subtotalAmount: subtotal,
taxAmount: 0,
totalAmount: total,
paidAmount: 0,
balanceAmount: total,
currency,
periodStart: item.arrivedAt ?? null,
periodEnd,
issuedAt: now,
payments: [],
notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null,
const invoice = await this.billing.generateInvoice({
source: SOURCE,
sourceId: inventoryId,
type: invoiceType,
companyId: item.companyId,
companyProfileId: item.companyProfileId,
currency: billingCurrency,
lines,
status: Freight.InvoiceStatus.Issued,
numberCode: NUMBER_CODE,
});
for (const it of items) {
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
}
const saved = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(saved);
return saved;
}
/** WHF-YYYYMMDD-00001 — sequential per day (shared billing numbering). */
private nextInvoiceNumber(): Promise<string> {
return nextDailyInvoiceNumber(this.dataSource, {
table: 'freight.warehouse_fee_invoices',
code: 'WHF',
});
const detail = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(detail);
return detail;
}
// ── Reads ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoiceWithDisplay & { items: unknown[] }> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const items = await this.itemRepository.findAll({ where: { invoiceId: id } });
async findById(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
const ctx = await this.getInventoryContext(invoice.sourceId);
const details = await this.getInvoiceDocumentDetails(invoice);
return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] };
const items = invoice.lines.map((l) => this.lineToItem(l));
return { ...this.buildView(invoice, ctx), ...details, items };
}
listForInventory(inventoryId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND i.source_id = $1', [inventoryId]);
}
listForBooking(bookingId: string): Promise<WarehouseFeeInvoiceView[]> {
return this.queryViews('AND inv.booking_id = $1', [bookingId]);
}
async findAll(
filter: Partial<
Pick<
WarehouseFeeInvoiceView,
'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId'
>
>,
): Promise<WarehouseFeeInvoiceView[]> {
const conditions: string[] = [];
const params: unknown[] = [];
const add = (sql: (p: string) => string, value: unknown) => {
params.push(value);
conditions.push(sql(`$${params.length}`));
};
if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus));
if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType);
if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId);
if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId);
if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId);
if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId);
return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params);
}
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
@@ -196,20 +279,219 @@ export class WarehouseInvoiceService {
return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT'));
}
/** Map a warehouse fee invoice (with display details + items) onto the shared document model. */
// ── State changes ────────────────────────────────────────────────────────
async cancel(id: string): Promise<WarehouseFeeInvoiceDetail> {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('A paid invoice cannot be cancelled.');
}
await this.billing.cancelInvoice(id);
return this.findById(id);
}
/** Record a payment against the invoice (delegates settlement to billing). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoiceDetail> {
// Guard that this is a warehouse invoice before recording (404 otherwise).
await this.loadWarehouseInvoice(id);
await this.billing.recordPayment(id, {
amount: dto.amount,
method: dto.method ?? null,
reference: dto.reference ?? null,
metadata:
dto.driverName || dto.driverPhone
? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null }
: null,
});
const detail = await this.findById(id);
await this.notifyWarehouseFeePayment(detail, dto);
return detail;
}
// ── Release blocking ──────────────────────────────────────────────────────
/** Returns the first unpaid invoice that blocks terminal release, or null. */
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoiceView | null> {
const blocking = await this.queryViews(
`AND i.source_id = $1 AND i.status::text = ANY($2::text[])`,
[inventoryId, BLOCKING_STATUSES],
);
return blocking[0] ?? null;
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]);
const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
);
}
if (invoices.some((inv) => inv.status === 'PAID')) return;
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
if (payableAmount > 0) {
throw new BadRequestException(
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
);
}
}
// ── 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: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
invoice: WarehouseFeeInvoiceDetail,
kind: 'INVOICE' | 'RECEIPT',
): InvoiceDocumentModel {
const items = invoice.items as Array<{
description?: string;
feeType?: string;
quantity?: number;
unitRate?: number;
amount?: number;
currency?: string;
chargeableDays?: number | null;
}>;
const lastPayment = [...(invoice.payments ?? [])].pop();
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null;
@@ -241,7 +523,7 @@ export class WarehouseInvoiceService {
},
],
categoryHeader: 'Fee type',
lines: items.map((item) => ({
lines: invoice.items.map((item) => ({
description: item.description ?? null,
category: item.feeType ?? null,
quantity: item.quantity ?? item.chargeableDays ?? 0,
@@ -259,92 +541,14 @@ export class WarehouseInvoiceService {
};
}
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 ────────────────────────────────────────────────────────
async cancel(id: string): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.');
const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() });
return updated as WarehouseFeeInvoice;
}
/** Record a payment against the invoice and sync status (links to existing payment flow). */
async pay(id: string, dto: PayInvoiceDto): Promise<WarehouseFeeInvoice> {
const invoice = await this.invoiceRepository.findById(id);
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.');
if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.');
if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.');
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount,
invoice.paidAmount,
dto.amount,
);
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,
balanceAmount,
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments,
});
const paidInvoice = updated as WarehouseFeeInvoice;
await this.notifyWarehouseFeePayment(paidInvoice, dto);
return paidInvoice;
}
// ── Release blocking ──────────────────────────────────────────────────────
/** Returns the first unpaid invoice that blocks terminal release, or null. */
async findBlockingInvoice(inventoryId: string): Promise<WarehouseFeeInvoice | null> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null;
}
async assertClearanceAllowed(inventoryId: string): Promise<void> {
const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } });
const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status));
if (blocking) {
throw new BadRequestException(
`Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`,
);
}
if (invoices.some((inv) => inv.status === 'PAID')) return;
const previews = await this.feeService.previewForInventory(inventoryId, 'USD');
const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0);
if (payableAmount > 0) {
throw new BadRequestException(
'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.',
);
}
}
private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise<InvoiceDocumentDetails> {
/** Warehouse-specific display details, derived from the linked inventory item. */
private async getInvoiceDocumentDetails(invoice: ViewSource): Promise<InvoiceDocumentDetails> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference",
inv.status AS "inventoryStatus",
inv.release_date AS "releaseDate",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
CONCAT_WS(
@@ -355,16 +559,10 @@ export class WarehouseInvoiceService {
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
zone.name AS "zoneName",
CASE
WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED'
WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE'
ELSE 'PENDING PAYMENT'
END AS "clearanceStatus"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
zone.name AS "zoneName"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -372,14 +570,21 @@ export class WarehouseInvoiceService {
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id
WHERE fee.id = $1
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id, invoice.status],
[invoice.sourceId],
);
const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID';
const clearanceStatus = row?.releaseDate
? 'RELEASE ISSUED'
: fullyPaid
? 'FEE PAID - READY FOR RELEASE'
: 'PENDING PAYMENT';
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
@@ -391,11 +596,33 @@ export class WarehouseInvoiceService {
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'),
clearanceStatus,
};
}
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
private async getInventoryContext(inventoryId: string): Promise<InventoryContext> {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart",
w.facility_id AS "facilityId"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
);
return {
bookingId: row?.bookingId ?? null,
facilityId: row?.facilityId ?? null,
warehouseId: row?.warehouseId ?? null,
yardId: row?.yardId ?? null,
zoneId: row?.zoneId ?? null,
periodStart: row?.periodStart ?? null,
};
}
// ── Notifications ──────────────────────────────────────────────────────────
private async getInvoiceNotificationContacts(inventoryId: string): Promise<{
bookingReference: string | null;
customerName: string | null;
customerPhone: string | null;
@@ -417,10 +644,9 @@ export class WarehouseInvoiceService {
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
@@ -446,9 +672,9 @@ export class WarehouseInvoiceService {
) latest_first_mile ON true
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
WHERE fee.id = $1
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[invoice.id],
[inventoryId],
);
return {
@@ -472,8 +698,8 @@ export class WarehouseInvoiceService {
}
}
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const cargo = contacts.containerNumber || contacts.cargoDescription;
@@ -486,8 +712,8 @@ export class WarehouseInvoiceService {
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
}
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const statusText =

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
"noEmit": false,
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo",
"preserveWatchOutput": true,
"module": "node16",
"moduleResolution": "node16"
},