mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
381 lines
17 KiB
TypeScript
381 lines
17 KiB
TypeScript
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';
|
|
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
|
|
|
interface GenerateOptions {
|
|
confirmZero?: boolean;
|
|
performedBy?: string;
|
|
billingCurrency?: 'ETB' | 'USD';
|
|
}
|
|
|
|
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,
|
|
private readonly documents: WarehouseReleaseDocumentService,
|
|
) {}
|
|
|
|
// ── 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 billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD';
|
|
const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency);
|
|
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) x ${p.containerCount} container(s) after ${p.freeDays} free`
|
|
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
|
|
quantity: p.billableUnits,
|
|
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 = billingCurrency;
|
|
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[] };
|
|
}
|
|
|
|
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
|
const invoice = await this.findById(id);
|
|
const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE');
|
|
return {
|
|
filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`,
|
|
buffer: await this.documents.htmlToPdfBuffer(html),
|
|
};
|
|
}
|
|
|
|
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.');
|
|
}
|
|
const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT');
|
|
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 ────────────────────────────────────────────────────────
|
|
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;
|
|
}
|
|
|
|
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 buildInvoiceDocumentHtml(
|
|
invoice: WarehouseFeeInvoice & { items: unknown[] },
|
|
kind: 'INVOICE' | 'RECEIPT',
|
|
): 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 ID</span>${esc(invoice.bookingId)}</div>
|
|
<div><span>Inventory ID</span>${esc(invoice.inventoryId)}</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, '-');
|
|
}
|
|
}
|