[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

@@ -32,6 +32,14 @@ export class CreateServiceTypeDto {
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Customs cleared on the Ethiopian side only — requires includesCustoms. Prices off the Ethiopian customs rate.',
})
@IsOptional()
@IsBoolean()
includesEthiopianCustomsOnly?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -16,6 +16,7 @@ describe('deriveRateType — surcharge triggers', () => {
['DEMURRAGE', 'DEMURRAGE'],
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
['ETHIOPIAN_CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE'],
] as const)('maps trigger %s to %s', (trigger, expected) => {
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
});

View File

@@ -46,6 +46,8 @@ export function deriveRateType(input: {
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
return 'ETHIOPIAN_CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
}

View File

@@ -28,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
/** Customs clearance / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
@@ -67,6 +67,7 @@ function unitsForShape(input: {
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE':
case 'ETHIOPIAN_CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK'

View File

@@ -25,6 +25,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE',
] as const;
@@ -96,12 +97,22 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE',
// Same shape as CUSTOMS_CLEARANCE; priced instead of it when the booking's
// service type has includesEthiopianCustomsOnly (Ethiopian-side clearance).
'ETHIOPIAN_CUSTOMS_CLEARANCE',
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
// billed off the lane-scoped rate (direction + route + cargo type).
'FUEL',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
/**
* The two customs clearance service fees share one rate shape (per direction +
* route + cargo kind); only which one a booking prices off differs.
*/
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@@ -117,7 +128,7 @@ export class Rate extends BaseEntity {
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
appliesTo!: RateAppliesTo;
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' })
@Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
trigger!: RateTrigger;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })

View File

@@ -27,6 +27,14 @@ export class ServiceType extends BaseEntity {
@Column({ name: 'includes_customs', type: 'boolean', default: false })
includesCustoms!: boolean;
/**
* EDR clears customs on the Ethiopian side only. Requires includesCustoms —
* the clearance flow (GL review, duty) is identical; only the fee differs:
* pricing looks up ETHIOPIAN_CUSTOMS_CLEARANCE instead of CUSTOMS_CLEARANCE.
*/
@Column({ name: 'includes_ethiopian_customs_only', type: 'boolean', default: false })
includesEthiopianCustomsOnly!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import {
@@ -29,10 +29,15 @@ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINE
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'ETHIOPIAN_CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
@@ -144,7 +149,7 @@ export class RatesService {
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
isCustomsClearanceTrigger(trigger) ||
trigger === 'WITH_RETURN' ||
trigger === 'FUEL'
);
@@ -261,7 +266,7 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -43,6 +49,7 @@ export class ServiceTypesService {
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
this.assertCustomsFlags(dto.includesCustoms ?? false, dto.includesEthiopianCustomsOnly ?? false);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
@@ -56,6 +63,7 @@ export class ServiceTypesService {
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
includesEthiopianCustomsOnly: dto.includesEthiopianCustomsOnly ?? false,
isActive: dto.isActive ?? true,
displayOrder,
});
@@ -63,13 +71,26 @@ export class ServiceTypesService {
/** Update an existing service type. */
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
const existing = await this.findById(id);
this.assertCustomsFlags(
dto.includesCustoms ?? existing.includesCustoms,
dto.includesEthiopianCustomsOnly ?? existing.includesEthiopianCustomsOnly,
);
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Service type ${id} not found`);
return updated;
}
/** "Ethiopian customs only" narrows a customs service — it cannot stand alone. */
private assertCustomsFlags(includesCustoms: boolean, ethiopianOnly: boolean): void {
if (ethiopianOnly && !includesCustoms) {
throw new BadRequestException(
'"Ethiopian customs only" requires "Includes customs" to be enabled.',
);
}
}
/** Soft-delete a service type. */
async remove(id: string): Promise<void> {
await this.findById(id);