[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries

This commit is contained in:
Marshal
2026-08-20 15:45:42 +00:00
parent 8d7551bb8e
commit 9e1d5ee9f2
58 changed files with 3324 additions and 810 deletions

View File

@@ -376,12 +376,21 @@ export class ContractPricingService {
// own container-type rate), bulk contracts freeze the route's bulk fee.
// A customs contract may not proceed without the fee(s) configured.
if (contract.customsClearingEnabled) {
// An Ethiopian-side-only customs service prices off its own rate; the
// snapshot codes carry the same prefix so booking pricing finds them.
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
: 'CUSTOMS_CLEARANCE';
const customsLabel =
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
? 'Ethiopian customs clearance service'
: 'Customs clearance service';
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.rateType === customsType &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
@@ -406,14 +415,14 @@ export class ContractPricingService {
);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`,
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
);
}
lineItems.push({
// Distinct code per size so the frozen snapshots don't collide —
// booking pricing looks each size up by CUSTOMS_CLEARANCE_<FT>FT.
code: `CUSTOMS_CLEARANCE_${sizeFt}FT`,
label: `Customs clearance service (${size})`,
// booking pricing looks each size up by <customsType>_<FT>FT.
code: `${customsType}_${sizeFt}FT`,
label: `${customsLabel} (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
@@ -432,12 +441,12 @@ export class ContractPricingService {
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
code: customsType,
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,

View File

@@ -124,11 +124,14 @@ export class ContractsService {
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** The service type a contract is sold under (null when the id is unknown). */
private resolveServiceType(serviceTypeId: string): Promise<ServiceType | null> {
return this.dataSource.getRepository(ServiceType).findOne({ where: { id: serviceTypeId } });
}
/** Whether a service type bundles customs clearance. */
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
const serviceType = await this.resolveServiceType(serviceTypeId);
return serviceType?.includesCustoms ?? false;
}
@@ -324,7 +327,8 @@ export class ContractsService {
}
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const serviceType = await this.resolveServiceType(dto.serviceTypeId);
const includesCustoms = serviceType?.includesCustoms ?? false;
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
@@ -344,6 +348,8 @@ export class ContractsService {
freightType: dto.freightType,
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
// Decides which customs fee the probe looks up (Ethiopian-only vs full).
serviceType,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
equipmentReturn: dto.equipmentReturn ?? null,