demurrage invoices

This commit is contained in:
Hagernesh
2026-06-18 00:04:07 +00:00
parent 01aec12ee9
commit 33b74a5da9
7 changed files with 486 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
/** Batch 6 — warehouse fee invoices + invoice items. */
export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_invoices',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'invoice_number', type: 'varchar', length: '40', isUnique: true },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'customer_id', type: 'uuid', isNullable: true },
{ name: 'inventory_id', type: 'uuid' },
{ name: 'facility_id', type: 'uuid', isNullable: true },
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
{ name: 'yard_id', type: 'uuid', isNullable: true },
{ name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'invoice_type', type: 'varchar', length: '32', default: "'MIXED_WAREHOUSE_FEES'" },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'period_start', type: 'timestamptz', isNullable: true },
{ name: 'period_end', type: 'timestamptz', isNullable: true },
{ name: 'issued_at', type: 'timestamptz', isNullable: true },
{ name: 'due_date', type: 'timestamptz', isNullable: true },
{ name: 'paid_at', type: 'timestamptz', isNullable: true },
{ name: 'cancelled_at', type: 'timestamptz', isNullable: true },
{ name: 'payments', type: 'jsonb', default: "'[]'" },
{ name: 'notes', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_wfi_booking', columnNames: ['booking_id'] },
{ name: 'idx_wfi_inventory', columnNames: ['inventory_id'] },
{ name: 'idx_wfi_status', columnNames: ['status'] },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'warehouse_fee_invoice_items',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'invoice_id', type: 'uuid' },
{ name: 'fee_rule_id', type: 'uuid', isNullable: true },
{ name: 'fee_type', type: 'varchar', length: '32' },
{ name: 'description', type: 'varchar', length: '255' },
{ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 },
{ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'chargeable_days', type: 'int', isNullable: true },
{ name: 'free_days', type: 'int', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['invoice_id'],
referencedSchema: 'freight',
referencedTableName: 'warehouse_fee_invoices',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
indices: [{ name: 'idx_wfii_invoice', columnNames: ['invoice_id'] }],
}),
true,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.warehouse_fee_invoice_items', true);
await queryRunner.dropTable('freight.warehouse_fee_invoices', true);
}
}

View File

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,107 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,13 @@
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

@@ -9,6 +9,7 @@ import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import {

View File

@@ -0,0 +1,214 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
WarehouseFeeInvoice,
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';
interface GenerateOptions {
confirmZero?: boolean;
performedBy?: string;
}
export interface PayInvoiceDto {
amount: number;
method?: string;
reference?: 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'];
@Injectable()
export class WarehouseInvoiceService {
constructor(
private readonly dataSource: DataSource,
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly feeService: WarehouseFeeService,
) {}
// ── Generation ───────────────────────────────────────────────────────────
async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise<WarehouseFeeInvoice> {
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"
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
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
[inventoryId],
);
if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// 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))) {
throw new ConflictException(
'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.',
);
}
const previews = await this.feeService.previewForInventory(inventoryId);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const items = previews
.filter((p) => p.amount > 0)
.map((p) => {
const feeType: WarehouseFeeType =
p.ruleType === 'STORAGE_FEE'
? 'STORAGE_FEE'
: isContainer
? 'CONTAINER_DEMURRAGE'
: 'BULK_DEMURRAGE';
return {
feeRuleId: p.ruleId,
feeType,
description:
p.ruleType === 'STORAGE_FEE'
? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
quantity: p.chargeableDays,
unitRate: p.ratePerDay,
amount: p.amount,
currency: p.currency,
chargeableDays: p.chargeableDays,
freeDays: p.freeDays,
};
});
const subtotal = items.reduce((s, i) => s + i.amount, 0);
const total = subtotal; // tax model can be layered on later
if (total <= 0 && !opts.confirmZero) {
throw new BadRequestException('No payable warehouse fee found for this item.');
}
const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE');
const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE');
const invoiceType: WarehouseInvoiceType =
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
const currency = items[0]?.currency ?? 'USD';
const now = new Date();
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
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,
});
for (const it of items) {
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
}
return this.findById(invoice.id);
}
/** 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 ────────────────────────────────────────────────────────────────
async findById(id: string): Promise<WarehouseFeeInvoice & { 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 } });
return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] };
}
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 = Number(invoice.paidAmount) + dto.amount;
const total = Number(invoice.totalAmount);
const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100);
const fullyPaid = paidAmount >= total;
const payments = [
...(invoice.payments ?? []),
{ amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() },
];
const updated = await this.invoiceRepository.update(id, {
paidAmount: Math.round(paidAmount * 100) / 100,
balanceAmount: balance,
status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID',
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments,
});
return updated as WarehouseFeeInvoice;
}
// ── 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;
}
}