From 33b74a5da9cd3e6f7a4fffc750fe544611c0bfa2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 18 Jun 2026 00:04:07 +0000 Subject: [PATCH] demurrage invoices --- .../1790000000001-AddWarehouseFeeInvoices.ts | 88 +++++++ .../warehouse-fee-invoice-item.entity.ts | 50 ++++ .../entities/warehouse-fee-invoice.entity.ts | 107 +++++++++ .../warehouse-fee-invoice-item.repository.ts | 13 ++ .../warehouse-fee-invoice.repository.ts | 13 ++ .../warehouses/warehouse-inventory.service.ts | 1 + .../warehouses/warehouse-invoice.service.ts | 214 ++++++++++++++++++ 7 files changed, 486 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts diff --git a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts b/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts new file mode 100644 index 000000000..c34eb240a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +/** Batch 6 — warehouse fee invoices + invoice items. */ +export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_invoices', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'invoice_number', type: 'varchar', length: '40', isUnique: true }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'inventory_id', type: 'uuid' }, + { name: 'facility_id', type: 'uuid', isNullable: true }, + { name: 'warehouse_id', type: 'uuid', isNullable: true }, + { name: 'yard_id', type: 'uuid', isNullable: true }, + { name: 'zone_id', type: 'uuid', isNullable: true }, + { name: 'invoice_type', type: 'varchar', length: '32', default: "'MIXED_WAREHOUSE_FEES'" }, + { name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" }, + { name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'period_start', type: 'timestamptz', isNullable: true }, + { name: 'period_end', type: 'timestamptz', isNullable: true }, + { name: 'issued_at', type: 'timestamptz', isNullable: true }, + { name: 'due_date', type: 'timestamptz', isNullable: true }, + { name: 'paid_at', type: 'timestamptz', isNullable: true }, + { name: 'cancelled_at', type: 'timestamptz', isNullable: true }, + { name: 'payments', type: 'jsonb', default: "'[]'" }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wfi_booking', columnNames: ['booking_id'] }, + { name: 'idx_wfi_inventory', columnNames: ['inventory_id'] }, + { name: 'idx_wfi_status', columnNames: ['status'] }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_invoice_items', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'invoice_id', type: 'uuid' }, + { name: 'fee_rule_id', type: 'uuid', isNullable: true }, + { name: 'fee_type', type: 'varchar', length: '32' }, + { name: 'description', type: 'varchar', length: '255' }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }, + { name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'chargeable_days', type: 'int', isNullable: true }, + { name: 'free_days', type: 'int', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['invoice_id'], + referencedSchema: 'freight', + referencedTableName: 'warehouse_fee_invoices', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + indices: [{ name: 'idx_wfii_invoice', columnNames: ['invoice_id'] }], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.warehouse_fee_invoice_items', true); + await queryRunner.dropTable('freight.warehouse_fee_invoices', true); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts new file mode 100644 index 000000000..8b14dcea3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) +@Index(['invoiceId']) +export class WarehouseFeeInvoiceItem extends BaseEntity { + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId!: string; + + @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice?: WarehouseFeeInvoice; + + @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) + feeRuleId?: string | null; + + @Column({ name: 'fee_type', type: 'varchar', length: 32 }) + feeType!: WarehouseFeeType; + + @Column({ name: 'description', type: 'varchar', length: 255 }) + description!: string; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) + quantity!: number; + + @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) + unitRate!: number; + + @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + amount!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + @Column({ name: 'chargeable_days', type: 'int', nullable: true }) + chargeableDays?: number | null; + + @Column({ name: 'free_days', type: 'int', nullable: true }) + freeDays?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts new file mode 100644 index 000000000..e57d626d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts @@ -0,0 +1,107 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** + * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. + * Owns warehouse fees; links to booking/customer/inventory/location so it can + * connect to the existing payment module without duplicating it. + */ +@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) +@Index(['invoiceNumber'], { unique: true }) +@Index(['bookingId']) +@Index(['inventoryId']) +@Index(['status']) +export class WarehouseFeeInvoice extends BaseEntity { + @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) + invoiceNumber!: string; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) + facilityId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId?: string | null; + + @Column({ name: 'zone_id', type: 'uuid', nullable: true }) + zoneId?: string | null; + + @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) + invoiceType!: WarehouseInvoiceType; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: WarehouseInvoiceStatus; + + @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + + @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + totalAmount!: number; + + @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ + @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) + periodStart?: Date | null; + + @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) + periodEnd?: Date | null; + + @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) + issuedAt?: Date | null; + + @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) + dueDate?: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) + payments!: WarehouseInvoicePayment[]; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts new file mode 100644 index 000000000..5b5df396e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; + +@Injectable() +export class WarehouseFeeInvoiceItemRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts new file mode 100644 index 000000000..97328f46d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; + +@Injectable() +export class WarehouseFeeInvoiceRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index a4e9c9040..c150747d9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -9,6 +9,7 @@ import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; import { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts new file mode 100644 index 000000000..d58a74b7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -0,0 +1,214 @@ +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) after ${p.freeDays} free` + : `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`, + quantity: p.chargeableDays, + 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; + } +}