import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { ContractPricingBreakdown } from './contract-pricing.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; /** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */ export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT'; /** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */ export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING'; /** * The prepaid customs clearance service fee (Path B) — the GL service charge, * separate from both freight (booking invoice) and duty/tax (paid offline). * Issued as its own `clearance`-source invoice and paid BEFORE the clearance * document step opens and before GL touches the file: * - ONE_TIME: once per contract, at staff counter-sign * (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS); * - GENERAL: once per shipment request, on the initiated booking instance * (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS). * The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so * customers pay what their contract shows, not the live rate of the day. */ @Injectable() export class ClearanceFeeService { private readonly logger = new Logger(ClearanceFeeService.name); constructor( private readonly billing: BillingService, private readonly contractsRepository: ContractsRepository, private readonly bookingsRepository: BookingsRepository, private readonly notifier: ContractNotifierService, ) {} /** The frozen flat fee for a contract; falls back to the pricing breakdown. */ private async feeAmountOrNull( contract: Contract, ): Promise<{ amount: number; currency: string } | null> { const snapshots = await this.contractsRepository.findRateSnapshots(contract.id); const snapshot = snapshots.find( (s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE', ); if (snapshot && Number(snapshot.unitPrice) > 0) { return { amount: Number(snapshot.unitPrice), currency: snapshot.currency }; } const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null; const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE'); if (line && Number(line.unitPrice) > 0) { return { amount: Number(line.unitPrice), currency: breakdown!.currency }; } return null; } private async feeAmount( contract: Contract, ): Promise<{ amount: number; currency: string }> { const fee = await this.feeAmountOrNull(contract); if (!fee) { throw new UnprocessableEntityException( `Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`, ); } return fee; } /** * Whether the payment gate applies. Skipped for government/unlinked * contracts (no company to bill — invoices require one, same rule the * booking invoice applies) and for legacy customs contracts frozen before * the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep * the pre-fee flow instead of dead-ending. */ async gateApplies(contract: Contract): Promise { if (!contract.customsClearingEnabled || !contract.companyId) return false; if ((await this.feeAmountOrNull(contract)) !== null) return true; this.logger.warn( `Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`, ); return false; } /** Issue (idempotently) the ONE_TIME contract-level fee invoice. */ async issueForContract(contract: Contract): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Clearance, contract.id, CLEARANCE_CONTRACT_INVOICE_TYPE, ); if (existing) return existing; const { amount, currency } = await this.feeAmount(contract); const invoice = await this.billing.generateInvoice({ source: Freight.InvoiceSource.Clearance, sourceId: contract.id, type: CLEARANCE_CONTRACT_INVOICE_TYPE, companyId: contract.companyId!, companyProfileId: contract.companyProfileId!, currency, lines: [ { chargeType: 'CUSTOMS_CLEARANCE', description: `Customs clearance service fee — contract ${contract.reference}`, quantity: 1, unitRate: amount, amount, currency, }, ], status: Freight.InvoiceStatus.Pending, }); this.notifier.clearanceFeeDue(contract, amount, currency); return invoice; } /** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */ async issueForBooking(booking: Booking, contract: Contract): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Clearance, booking.id, CLEARANCE_BOOKING_INVOICE_TYPE, ); if (existing) return existing; const { amount, currency } = await this.feeAmount(contract); const invoice = await this.billing.generateInvoice({ source: Freight.InvoiceSource.Clearance, sourceId: booking.id, type: CLEARANCE_BOOKING_INVOICE_TYPE, companyId: booking.companyId ?? contract.companyId!, companyProfileId: booking.companyProfileId ?? contract.companyProfileId!, currency, lines: [ { chargeType: 'CUSTOMS_CLEARANCE', description: `Customs clearance service fee — shipment ${booking.reference}`, quantity: 1, unitRate: amount, amount, currency, }, ], status: Freight.InvoiceStatus.Pending, }); this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference); return invoice; } /** * Settlement branch point for `clearance`-source invoices: unlock the * document-upload step the fee was gating. Idempotent — a replayed event on * an already-advanced contract/booking is a no-op. */ @OnEvent('clearance.invoice.paid') async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise { this.logger.log( `clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`, ); switch (payload.type) { case CLEARANCE_CONTRACT_INVOICE_TYPE: await this.advanceContract(payload.sourceId); break; case CLEARANCE_BOOKING_INVOICE_TYPE: await this.advanceBooking(payload.sourceId); break; default: this.logger.warn( `Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`, ); } } private async advanceContract(contractId: string): Promise { const contract = await this.contractsRepository.findById(contractId); if (!contract) { this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`); return; } if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return; await this.contractsRepository.update(contractId, { status: 'AWAITING_CLEARANCE_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS', clearanceFeePaidAt: new Date(), } as never); const updated = await this.contractsRepository.findByIdWithRelations(contractId); if (updated) this.notifier.clearanceFeePaid(updated); } private async advanceBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`); return; } if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return; await this.bookingsRepository.update(bookingId, { status: 'AWAITING_DOCUMENTS', clearanceFeePaidAt: new Date(), } as never); if (booking.contractId) { const contract = await this.contractsRepository.findByIdWithRelations( booking.contractId, ); if (contract) this.notifier.clearanceFeePaid(contract, booking.reference); } } }