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 & { 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 { 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 { 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 { 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 { return this.queryViews("AND i.source_id = $1", [inventoryId]); } listForBooking(bookingId: string): Promise { return this.queryViews("AND inv.booking_id = $1", [bookingId]); } async findAll( filter: Partial< Pick< WarehouseFeeInvoiceView, | "status" | "invoiceType" | "warehouseId" | "facilityId" | "customerId" | "bookingId" > >, ): Promise { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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).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 { 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 { 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 { 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 { 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 { 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}`, ); } }