Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts

1041 lines
39 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
import { DataSource } from "typeorm";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.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 { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
import {
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,
WarehouseInvoiceType,
} from "./warehouse-invoice.types";
interface GenerateOptions {
confirmZero?: boolean;
performedBy?: string;
billingCurrency?: "ETB" | "USD";
}
export interface PayInvoiceDto {
amount: number;
method?: string;
reference?: string;
driverName?: string;
driverPhone?: string;
}
/** Warehouse fee invoices live in the global billing system under this source. */
const SOURCE = Freight.InvoiceSource.Warehouse;
/** 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;
customerName: string | null;
inventoryReference: string | null;
inventoryInfo: string | null;
inventoryStatus: string | null;
containerNumber: string | null;
cargoDescription: string | null;
clearanceStatus: string;
warehouseName: string | null;
yardName: string | null;
zoneName: string | null;
}
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 billing: BillingService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly feeService: WarehouseFeeService,
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) { }
// ── Generation ───────────────────────────────────────────────────────────
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 "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
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
[inventoryId],
);
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.
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.",
);
}
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 days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
let feeType: WarehouseFeeType;
let description: string;
switch (p.ruleType) {
case "STORAGE_FEE":
feeType = "STORAGE_FEE";
description = `Storage fee - ${days}${tierSuffix}`;
break;
case "DOUBLE_HANDLING_FEE": {
feeType = "DOUBLE_HANDLING";
const unit =
p.basis === "PER_TON"
? "ton(s)"
: p.basis === "PER_ITEM"
? "item(s)"
: "container(s)";
description = `Double handling - ${p.billableUnits} ${unit}`;
break;
}
case "TRUCK_DETENTION_FEE":
feeType = "TRUCK_DETENTION";
description = `Truck detention - ${days}${tierSuffix}`;
break;
default:
feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
}
return {
feeRuleId: p.ruleId,
feeType,
description,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
currency: p.currency,
chargeableDays: p.chargeableDays,
freeDays: p.freeDays,
};
});
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.",
);
}
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 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.billing.generateInvoice({
source: SOURCE,
sourceId: inventoryId,
type: invoiceType,
companyId: item.companyId,
companyProfileId: item.companyProfileId,
currency: billingCurrency,
lines,
status: Freight.InvoiceStatus.Issued,
});
const detail = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(detail);
return detail;
}
/**
* Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees
* (per inventory item), detention is a per-truck charge on the last-mile leg, so
* it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept
* separate from the delivery-fee invoice. Returns the global Invoice.
*/
async generateTruckDetentionInvoice(
lastMileId: string,
opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {},
): Promise<Invoice> {
const [lm] = await this.dataSource.query(
`SELECT lm.id,
b.company_id AS "companyId",
b.company_profile_id AS "companyProfileId",
b.payment_currency AS "paymentCurrency"
FROM freight.last_mile lm
LEFT JOIN freight.bookings b ON b.id = lm.booking_id
WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
[lastMileId],
);
if (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
if (!lm.companyId) {
throw new BadRequestException(
"Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).",
);
}
const existing = await this.billing.findPayable(
"last_mile" as Freight.InvoiceSource,
lastMileId,
"TRUCK_DETENTION_FEE",
);
if (existing) {
throw new ConflictException(
"An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.",
);
}
const billingCurrency: "ETB" | "USD" =
opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD");
const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency);
if (preview.amount <= 0 && !opts.confirmZero) {
throw new BadRequestException(
"No truck detention is currently payable for this last-mile leg.",
);
}
// One line per truck-type group (each billed by its own matching rule). Groups
// with no matching rule bill 0 and are dropped. Falls back to a single line.
const groups = preview.groups && preview.groups.length ? preview.groups : null;
const lines: InvoiceLineInput[] = groups
? groups
.filter((g) => g.amount > 0)
.map((g) => ({
chargeType: "TRUCK_DETENTION",
description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`,
quantity: g.truckCount * g.chargeableDays,
unitRate: g.ratePerDay,
amount: g.amount,
currency: preview.currency,
metadata: {
feeRuleId: g.ruleId ?? null,
chargeableDays: g.chargeableDays,
vehicleType: g.vehicleType ?? null,
},
}))
: [
{
chargeType: "TRUCK_DETENTION",
description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`,
quantity: preview.billableUnits,
unitRate: preview.ratePerDay,
amount: preview.amount,
currency: preview.currency,
metadata: {
feeRuleId: preview.ruleId ?? null,
chargeableDays: preview.chargeableDays ?? null,
},
},
];
if (lines.length === 0) {
throw new BadRequestException(
"No truck detention is currently payable for this last-mile leg.",
);
}
return this.billing.generateInvoice({
source: "last_mile" as Freight.InvoiceSource,
sourceId: lastMileId,
type: "TRUCK_DETENTION_FEE",
companyId: lm.companyId,
companyProfileId: lm.companyProfileId || "",
currency: billingCurrency,
lines,
status: Freight.InvoiceStatus.Issued,
});
}
// ── Reads ────────────────────────────────────────────────────────────────
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);
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 }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
}
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"),
);
}
// ── 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;
}
/** Initiate a wallet/gateway payment for the invoice. */
async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
return this.billing.payInvoice(invoice.id, {
method: dto.method ?? (invoice.currency === "USD" ? "WAAFI" : "TELEBIRR"),
platform: dto.platform ?? "web",
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
/**
* 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 ──────────────────────────────────────────────────────
/** 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::text = 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(
`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(
' / ',
NULLIF(inv.status, ''),
NULLIF(COALESCE(container.container_number, booking_container.container_number), ''),
NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '')
) AS "inventoryInfo",
wh.name AS "warehouseName",
yard.name AS "yardName",
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
AND booking_container.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.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.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,
inventoryReference: row?.inventoryReference ?? null,
inventoryInfo: row?.inventoryInfo ?? null,
inventoryStatus: row?.inventoryStatus ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
warehouseName: row?.warehouseName ?? null,
yardName: row?.yardName ?? null,
zoneName: row?.zoneName ?? null,
clearanceStatus,
};
}
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;
driverName: string | null;
driverPhone: string | null;
containerNumber: string | null;
cargoDescription: string | null;
}> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
last_vehicle.assigned_driver_name,
NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''),
first_vehicle.assigned_driver_name
) AS "driverName",
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_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
AND booking_container.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 LATERAL (
SELECT lm.vehicle_id
FROM freight.last_mile lm
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL
ORDER BY lm.created_at DESC
LIMIT 1
) latest_last_mile ON true
LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id
LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id
LEFT JOIN LATERAL (
SELECT fm.vehicle_id
FROM freight.first_mile fm
WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL
ORDER BY fm.created_at DESC
LIMIT 1
) 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 inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
);
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
customerPhone: row?.customerPhone ?? null,
driverName: row?.driverName ?? null,
driverPhone: row?.driverPhone ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
};
}
private async sendSms(
recipient: string | null | undefined,
message: string,
context: string,
): Promise<void> {
const phone = recipient?.trim();
if (!phone) return;
try {
await this.notifications.directSend("sms", phone, message);
} catch (error) {
this.logger.error(
`Failed to send ${context} SMS to ${phone}: ${String(error)}`,
);
}
}
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;
const cargoText = cargo ? ` Cargo: ${cargo}.` : "";
const message =
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` +
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
await this.sendSms(
contacts.customerPhone,
message,
`warehouse fee invoice ${invoice.invoiceNumber}`,
);
// In-app deep-link to pay the fee from the booking.
if (invoice.customerId && invoice.bookingId) {
try {
await this.inbox.notify({
recipients: { companyId: invoice.customerId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: "Warehouse fee due",
body:
`Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
link: `/bookings/${invoice.bookingId}`,
data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
});
} catch (err) {
this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
}
}
}
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 =
invoice.status === "PAID"
? "fully paid and ready for pickup release"
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
const customerMessage =
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
await this.sendSms(
contacts.customerPhone,
customerMessage,
`warehouse fee payment ${invoice.invoiceNumber}`,
);
if (invoice.status !== "PAID") return;
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
const driverName =
dto.driverName?.trim() || contacts.driverName || "Driver";
const cargo = contacts.containerNumber || contacts.cargoDescription;
const driverMessage =
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
(contacts.bookingReference
? ` Booking: ${contacts.bookingReference}.`
: "") +
(cargo ? ` Cargo: ${cargo}.` : "") +
" Proceed with pickup after gate verification.";
await this.sendSms(
driverPhone,
driverMessage,
`warehouse pickup driver ${invoice.invoiceNumber}`,
);
}
}