import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { InvoiceDocumentModel, InvoiceDocumentService, } from '../billing/documents/invoice-document.service'; import { 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, ) {} // ── 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 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 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; } // ── 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; } /** * 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 = 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}`); } 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}`); } }