import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ExchangeService } from '@edr/api-common'; import { DataSource } from 'typeorm'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; interface ItemAttributes { arrivedAt: Date | null; gateClearedAt: Date | null; releaseDate: Date | null; freightType: string | null; tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; inventoryQuantity: number; bookingContainerCount: number; facilityId: string | null; warehouseId: string | null; yardId: string | null; zoneId: string | null; } export interface FeePreview { ruleType: FeeRuleType; ruleId: string | null; ruleName: string | null; freeDays: number; ratePerDay: number; currency: string; ruleCurrency: string | null; billingCurrency: string; startDate: string | null; endDate: string; endIsOpen: boolean; // true when still accruing (no release/gate-clear yet) elapsedDays: number; chargeableDays: number; containerCount: number; billableUnits: number; amount: number; tiers: Array<{ fromDay: number; toDay: number | null; appliedFromDay: number; appliedToDay: number; days: number; ratePerDay: number; amount: number; }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @Injectable() export class WarehouseFeeService { constructor( private readonly dataSource: DataSource, private readonly feeRuleRepository: WarehouseFeeRuleRepository, private readonly exchangeService: ExchangeService, ) {} // ── Rule CRUD ────────────────────────────────────────────────────────────── listRules(): Promise { return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); } createRule(dto: CreateFeeRuleDto): Promise { return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...this.normalizeRuleInput(dto), }); } async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto)); if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); return updated; } deleteRule(id: string): Promise { return this.feeRuleRepository.softDelete(id); } private normalizeRuleInput(dto: T): T { if (dto.tiers === undefined) return dto; const tiers = (dto.tiers ?? []) .map((tier) => ({ fromDay: Number(tier.fromDay), toDay: tier.toDay == null ? null : Number(tier.toDay), ratePerDay: Number(tier.ratePerDay), })) .filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0); for (const tier of tiers) { if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) { throw new BadRequestException('Fee tier from day must be a positive whole number.'); } if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) { throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.'); } if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) { throw new BadRequestException('Fee tier rate per day must be zero or greater.'); } } const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity)); for (let i = 1; i < sorted.length; i += 1) { const prev = sorted[i - 1]; const current = sorted[i]; if (prev.toDay == null || current.fromDay <= prev.toDay) { throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.'); } } return { ...dto, tiers: sorted } as T; } private async loadItem(inventoryId: string): Promise { const [row] = await this.dataSource.query( `SELECT inv.arrived_at AS "arrivedAt", inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", w.facility_id AS "facilityId", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" 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 LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id LEFT JOIN LATERAL ( SELECT bc.container_type_id FROM freight.booking_container bc WHERE bc.booking_id = inv.booking_id AND bc.deleted_at IS NULL AND bc.container_type_id IS NOT NULL ORDER BY bc.created_at ASC LIMIT 1 ) booking_container_type ON true LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id LEFT JOIN LATERAL ( SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count FROM freight.booking_container bc WHERE bc.booking_id = inv.booking_id AND bc.deleted_at IS NULL ) container_lines ON true WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`); return row; } private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null { // Returns specificity score (#matched non-null scope fields), or null if any constraint fails. let score = 0; const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null; const check = ( ruleVal: string | null | undefined, itemVal: string | null, opts: { allowBoth?: boolean } = {}, ) => { const ruleCode = normalized(ruleVal); if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true; if (opts.allowBoth && ruleCode === 'BOTH') { score += 1; return true; } if (ruleCode === normalized(itemVal)) { score += 1; return true; } return false; }; if (!check(rule.freightType, item.freightType)) return null; if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; if (!check(rule.facilityId, item.facilityId)) return null; if (!check(rule.warehouseId, item.warehouseId)) return null; if (!check(rule.yardId, item.yardId)) return null; if (!check(rule.zoneId, item.zoneId)) return null; return score; } private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null { let best: WarehouseFeeRule | null = null; let bestScore = -1; for (const rule of rules) { const score = this.matchScore(rule, item); if (score == null) continue; if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) { best = rule; bestScore = score; } } return best; } private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { return currency === 'ETB' ? 'ETB' : 'USD'; } private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise { const from = this.normalizeCurrency(fromCurrency); const to = this.normalizeCurrency(toCurrency); if (from === to) return Math.round(amount * 100) / 100; const rate = await this.exchangeService.getRate(from, to); return Math.round(amount * rate * 100) / 100; } private calculateTieredAmount( tiers: WarehouseFeeTier[] | null | undefined, elapsedDays: number, containerCount: number, ): { sourceAmount: number; billableUnits: number; chargeableDays: number; weightedRatePerDay: number; tiers: FeePreview['tiers']; } { const sourceTiers = (tiers ?? []) .map((tier) => ({ fromDay: Number(tier.fromDay), toDay: tier.toDay == null ? null : Number(tier.toDay), ratePerDay: Number(tier.ratePerDay), })) .filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay)) .sort((a, b) => a.fromDay - b.fromDay); let sourceAmount = 0; let tierDays = 0; const appliedTiers: FeePreview['tiers'] = []; for (const tier of sourceTiers) { if (elapsedDays < tier.fromDay) continue; const appliedFromDay = tier.fromDay; const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays); const days = Math.max(0, appliedToDay - appliedFromDay + 1); if (days <= 0) continue; const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100; sourceAmount += amount; tierDays += days; appliedTiers.push({ fromDay: tier.fromDay, toDay: tier.toDay, appliedFromDay, appliedToDay, days, ratePerDay: tier.ratePerDay, amount, }); } return { sourceAmount: Math.round(sourceAmount * 100) / 100, billableUnits: tierDays * containerCount, chargeableDays: tierDays, weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0, tiers: appliedTiers, }; } private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date, billingCurrency: string, ): Promise { const start = item.arrivedAt ? new Date(item.arrivedAt) : null; const endDate = item.gateClearedAt ?? item.releaseDate ?? now; const endIsOpen = !item.gateClearedAt && !item.releaseDate; const freeDays = rule?.freeDays ?? 0; const ratePerDay = Number(rule?.ratePerDay ?? 0); const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; const targetCurrency = this.normalizeCurrency(billingCurrency); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) : 1; const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) : 0; const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount); const hasTiers = Boolean(rule?.tiers?.length); const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays); const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount; const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay; const convertedRatePerDay = ruleCurrency ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency) : 0; const convertedTiers = ruleCurrency ? await Promise.all( tiered.tiers.map(async (tier) => ({ ...tier, ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency), amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency), })), ) : []; return { ruleType, ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays, ratePerDay: convertedRatePerDay, currency: targetCurrency, ruleCurrency, billingCurrency: targetCurrency, startDate: start ? start.toISOString() : null, endDate: new Date(endDate).toISOString(), endIsOpen, elapsedDays, chargeableDays, containerCount, billableUnits, amount, tiers: hasTiers ? convertedTiers : [], }; } /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; return Promise.all( byType.map((type) => this.compute( type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now, billingCurrency, ), ), ); } }