mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
240 lines
9.7 KiB
TypeScript
240 lines
9.7 KiB
TypeScript
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<boolean> {
|
|
// Customs disabled → the prepay gate genuinely does not apply.
|
|
if (!contract.customsClearingEnabled) return false;
|
|
// No company to bill (government / unlinked) → the gate cannot raise an
|
|
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
|
if (!contract.companyId) return false;
|
|
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
|
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
|
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
|
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
|
if ((await this.feeAmountOrNull(contract)) === null) {
|
|
throw new UnprocessableEntityException(
|
|
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
|
async issueForContract(contract: Contract): Promise<Invoice> {
|
|
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<Invoice> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Retire (idempotently) the unpaid contract-level fee invoice when the
|
|
* contract reaches a terminal state — a dead contract must not leave a
|
|
* payable clearance invoice open for the customer to settle. No-op when the
|
|
* fee was already paid or never invoiced (mirrors the booking cancel path,
|
|
* {@link BillingService.expirePayable}).
|
|
*/
|
|
async expireForContract(contractId: string): Promise<Invoice | null> {
|
|
return this.billing.expirePayable(
|
|
Freight.InvoiceSource.Clearance,
|
|
contractId,
|
|
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|
|
}
|