import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { WarehouseFeeInvoice, WarehouseInvoiceStatus, WarehouseInvoiceType, } from './entities/warehouse-fee-invoice.entity'; import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; interface GenerateOptions { confirmZero?: boolean; performedBy?: string; } export interface PayInvoiceDto { amount: number; method?: string; reference?: string; } /** Invoices that still owe money and therefore block terminal release. */ const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; @Injectable() export class WarehouseInvoiceService { constructor( private readonly dataSource: DataSource, private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, ) {} // ── Generation ─────────────────────────────────────────────────────────── async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", w.facility_id AS "facilityId", b.company_id AS "customerId", b.freight_type AS "freightType" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); // Dedup: only one active (non-cancelled) invoice per inventory item. const active = await this.invoiceRepository.findAll({ where: { inventoryId } }); if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) { throw new ConflictException( 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', ); } const previews = await this.feeService.previewForInventory(inventoryId); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const items = previews .filter((p) => p.amount > 0) .map((p) => { const feeType: WarehouseFeeType = p.ruleType === 'STORAGE_FEE' ? 'STORAGE_FEE' : isContainer ? 'CONTAINER_DEMURRAGE' : 'BULK_DEMURRAGE'; return { feeRuleId: p.ruleId, feeType, description: p.ruleType === 'STORAGE_FEE' ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, currency: p.currency, chargeableDays: p.chargeableDays, freeDays: p.freeDays, }; }); const subtotal = items.reduce((s, i) => s + i.amount, 0); const total = subtotal; // tax model can be layered on later if (total <= 0 && !opts.confirmZero) { throw new BadRequestException('No payable warehouse fee found for this item.'); } const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); const invoiceType: WarehouseInvoiceType = hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; const currency = items[0]?.currency ?? 'USD'; const now = new Date(); const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; const invoice = await this.invoiceRepository.create({ invoiceNumber: await this.nextInvoiceNumber(), bookingId: item.bookingId ?? null, customerId: item.customerId ?? null, inventoryId, facilityId: item.facilityId ?? null, warehouseId: item.warehouseId ?? null, yardId: item.yardId ?? null, zoneId: item.zoneId ?? null, invoiceType, status: 'ISSUED', subtotalAmount: subtotal, taxAmount: 0, totalAmount: total, paidAmount: 0, balanceAmount: total, currency, periodStart: item.arrivedAt ?? null, periodEnd, issuedAt: now, payments: [], notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null, }); for (const it of items) { await this.itemRepository.create({ invoiceId: invoice.id, ...it }); } return this.findById(invoice.id); } /** WHF-YYYYMMDD-00001 — sequential per day. */ private async nextInvoiceNumber(): Promise { const now = new Date(); const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; const prefix = `WHF-${ymd}-`; const [row] = await this.dataSource.query( `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`, [`${prefix}%`], ); const next = Number(row?.seq ?? 0) + 1; return `${prefix}${String(next).padStart(5, '0')}`; } // ── Reads ──────────────────────────────────────────────────────────────── async findById(id: string): Promise { const invoice = await this.invoiceRepository.findById(id); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] }; } listForInventory(inventoryId: string): Promise { return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); } listForBooking(bookingId: string): Promise { return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); } findAll(filter: Partial>): Promise { const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); } // ── State changes ──────────────────────────────────────────────────────── async cancel(id: string): Promise { const invoice = await this.invoiceRepository.findById(id); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.'); const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() }); return updated as WarehouseFeeInvoice; } /** Record a payment against the invoice and sync status (links to existing payment flow). */ async pay(id: string, dto: PayInvoiceDto): Promise { const invoice = await this.invoiceRepository.findById(id); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.'); if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); const paidAmount = Number(invoice.paidAmount) + dto.amount; const total = Number(invoice.totalAmount); const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100); const fullyPaid = paidAmount >= total; const payments = [ ...(invoice.payments ?? []), { amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() }, ]; const updated = await this.invoiceRepository.update(id, { paidAmount: Math.round(paidAmount * 100) / 100, balanceAmount: balance, status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, payments, }); return updated as WarehouseFeeInvoice; } // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ async findBlockingInvoice(inventoryId: string): Promise { const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; } }