From d7d9db9c3a82103fed228b9b12b06ef316d09837 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 15 Jul 2026 14:27:00 +0000 Subject: [PATCH 01/18] changes --- .../priority-configs.controller.ts | 18 ++ .../services/priority-configs.range.spec.ts | 229 ++++++++++++++++++ .../services/priority-configs.service.ts | 100 +++++++- .../contracts/ContractActionsToolbar.tsx | 6 +- .../contracts/ContractApprovalStepsCard.tsx | 54 ++++- .../ruleEngine/RuleEngineFormDialog.tsx | 9 +- .../src/hooks/rule-engine/useRuleEngine.ts | 25 +- .../ruleEngine/RuleEngineResourcePage.tsx | 54 ++++- .../src/pages/ruleEngine/config/resources.ts | 24 +- .../src/pages/ruleEngine/priorityRuleRange.ts | 60 +++++ 10 files changed, 554 insertions(+), 25 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 99eaabf3f..6424f013e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; @@ -24,6 +25,23 @@ export class PriorityConfigsController { return this.service.findAll(query); } + // Static route — must stay above `:id` (Express matches in declaration order). + @Get('next-range') + @RuleEngineView('priority-configs') + @ApiOperation({ + summary: + "Where the next contiguous range for a type (and currency) must start, plus the type's ceiling", + }) + nextRange( + @Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + @Query('currency') currency?: string, + ) { + if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) { + throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS'); + } + return this.service.nextRange(type, currency ?? null); + } + @Get(':id') @RuleEngineView('priority-configs') @ApiOperation({ summary: 'Get a priority config by ID' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts new file mode 100644 index 000000000..53cb7ed83 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.range.spec.ts @@ -0,0 +1,229 @@ +import { BadRequestException } from '@nestjs/common'; + +import { PriorityConfig } from '../entities/priority-config.entity'; +import { PriorityConfigsService } from './priority-configs.service'; + +/** + * Contiguous-range rules for priority configs: per type (per currency for + * CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range + * must start at the lowest uncovered wagon count. Caps: WAGON 50, + * CURRENCY 35, CUSTOMS 15. + */ +describe('PriorityConfigsService range validation', () => { + const rule = ( + type: PriorityConfig['type'], + min: number, + max: number, + currency: string | null = null, + id = `${type}-${min}-${max}-${currency ?? 'none'}`, + ): PriorityConfig => + ({ + id, + type, + label: `${min}-${max}`, + currency, + minWagonCount: min, + maxWagonCount: max, + }) as PriorityConfig; + + const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => { + const repository = { + findAll: jest.fn(async ({ where }: { where: { type: string } }) => + rules.filter((r) => r.type === where.type), + ), + findById: jest.fn(async (id: string) => + rules.find((r) => r.id === id) ?? null, + ), + }; + return new PriorityConfigsService( + repository as never, + undefined as never, // DisplayOrderService — unused by range validation + ); + }; + + const attempt = ( + svc: PriorityConfigsService, + input: Partial[0]>, + ) => + svc.assertNoRangeCollision({ + type: 'WAGON', + minWagonCount: 1, + maxWagonCount: 5, + ...input, + }); + + it('accepts the first WAGON range starting at 1', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }), + ).resolves.toBeUndefined(); + }); + + it('rejects a first range that does not start at 1', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects an exact duplicate (1–5 vs 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 1, + maxWagonCount: 5, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('rejects a partial overlap (4–7 after 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 4, + maxWagonCount: 7, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('rejects a gap (8–9 after 1–5) — next range must start at 6', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 8, + maxWagonCount: 9, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('accepts the contiguous continuation (6–10 after 1–5)', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 5)]), { + minWagonCount: 6, + maxWagonCount: 10, + }), + ).resolves.toBeUndefined(); + }); + + it('after deleting a middle rule, the next range must fill the lowest gap', async () => { + // Chain was 1–5, 6–10, 11–20; 6–10 deleted → next must start at 6. + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect( + attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }), + ).rejects.toThrow(/must start at 6/); + await expect( + attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }), + ).resolves.toBeUndefined(); + }); + + it('rejects a gap-fill that overruns into the next rule (6–15 into 11–20)', async () => { + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect( + attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }), + ).rejects.toThrow(/overlaps existing rule/); + }); + + it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => { + await expect( + attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }), + ).rejects.toThrow(/may not exceed 50/); + await expect( + attempt(serviceWith([]), { + type: 'CURRENCY', + currency: 'USD', + minWagonCount: 1, + maxWagonCount: 36, + }), + ).rejects.toThrow(/may not exceed 35/); + await expect( + attempt(serviceWith([]), { + type: 'CUSTOMS', + minWagonCount: 1, + maxWagonCount: 16, + }), + ).rejects.toThrow(/may not exceed 15/); + }); + + it('rejects any new rule once the chain covers the full range', async () => { + await expect( + attempt(serviceWith([rule('WAGON', 1, 50)]), { + minWagonCount: 51, + maxWagonCount: 51, + }), + ).rejects.toThrow(/may not exceed 50/); + await expect( + attempt(serviceWith([rule('CUSTOMS', 1, 15)]), { + type: 'CUSTOMS', + minWagonCount: 1, + maxWagonCount: 1, + }), + ).rejects.toThrow(/already cover the full 1–15 range/); + }); + + it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => { + const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]); + // ETB has no rules yet → starts at 1. + await expect( + attempt(svc, { + type: 'CURRENCY', + currency: 'ETB', + minWagonCount: 1, + maxWagonCount: 5, + }), + ).resolves.toBeUndefined(); + // USD must continue at 6. + await expect( + attempt(svc, { + type: 'CURRENCY', + currency: 'USD', + minWagonCount: 1, + maxWagonCount: 5, + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('excludes the rule being edited from its own contiguity check', async () => { + const existing = rule('WAGON', 6, 10, null, 'editing-me'); + const svc = serviceWith([rule('WAGON', 1, 5), existing]); + // Re-saving 6–10 (e.g. changing points) keeps min 6 — allowed. + await expect( + attempt(svc, { + minWagonCount: 6, + maxWagonCount: 12, + excludeId: 'editing-me', + }), + ).resolves.toBeUndefined(); + }); + + it('lets an upper rule keep its start while a lower gap exists', async () => { + // Chain 1–5, [gap 6–10], 11–20: editing 11–20 keeps min 11 — a lower gap + // must not block editing an upper rule's points or max. + const upper = rule('WAGON', 11, 20, null, 'upper'); + const svc = serviceWith([rule('WAGON', 1, 5), upper]); + await expect( + attempt(svc, { + minWagonCount: 11, + maxWagonCount: 25, + excludeId: 'upper', + }), + ).resolves.toBeUndefined(); + // But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6. + await expect( + attempt(svc, { + minWagonCount: 30, + maxWagonCount: 35, + excludeId: 'upper', + }), + ).rejects.toThrow(/must start at 6/); + }); + + it('reports the next-range prefill for the form', async () => { + const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]); + await expect(svc.nextRange('WAGON')).resolves.toEqual({ + nextMin: 6, + maxCap: 50, + }); + await expect( + serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'), + ).resolves.toEqual({ nextMin: null, maxCap: 15 }); + await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({ + nextMin: 1, + maxCap: 35, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 9aaf06985..ff3711e42 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -10,6 +10,31 @@ import { } from '../interfaces/priority-configs.repository.interface'; import { DisplayOrderService } from './display-order.service'; +/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */ +export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = { + WAGON: 50, + CURRENCY: 35, + CUSTOMS: 15, +}; + +/** + * Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range + * must start. Null when the chain is already complete up to the type's cap. + */ +function nextRangeStart( + rules: Pick[], +): number | null { + const cap = rules.length ? RANGE_CAPS[rules[0].type] : null; + const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount); + let next = 1; + for (const r of sorted) { + if (r.minWagonCount > next) break; // gap before this rule — fill it + next = Math.max(next, r.maxWagonCount + 1); + } + if (cap != null && next > cap) return null; + return next; +} + @Injectable() export class PriorityConfigsService { constructor( @@ -73,10 +98,13 @@ export class PriorityConfigsService { } /** - * No two rules of the same type (and, for CURRENCY rules, the same currency) - * may cover overlapping wagon-count ranges — a booking must match at most one - * rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial - * overlap (1–5 vs 4–7). Ranges are inclusive on both ends. + * Range rules per type (and, for CURRENCY rules, per currency): + * - ranges never overlap — a booking matches at most one rule per type; + * - ranges are contiguous from 1: a new range must START at the lowest + * wagon count not yet covered (after 1–5 the next is 6–…; deleting a + * middle rule opens a gap and the next create must fill it first); + * - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15. + * Ranges are inclusive on both ends. */ async assertNoRangeCollision(input: { type: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; @@ -90,13 +118,49 @@ export class PriorityConfigsService { 'Min wagon count cannot be greater than max wagon count', ); } - const siblings = await this.repository.findAll({ - where: { type: input.type }, - }); - const clash = siblings.find( + const cap = RANGE_CAPS[input.type]; + if (input.maxWagonCount > cap) { + throw new BadRequestException( + `${input.type} ranges may not exceed ${cap} — ` + + `${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`, + ); + } + + const siblings = ( + await this.repository.findAll({ where: { type: input.type } }) + ).filter( (s) => s.id !== input.excludeId && - (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) && + (input.type !== 'CURRENCY' || + (s.currency ?? null) === (input.currency ?? null)), + ); + + const expectedStart = nextRangeStart(siblings); + // An edited rule may always KEEP its current start (so a gap lower in the + // chain never blocks editing an upper rule's points/max) — or move down to + // fill that lowest gap. + const currentStart = input.excludeId + ? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null + : null; + if (expectedStart == null && currentStart == null) { + throw new BadRequestException( + `${input.type} rules already cover the full 1–${cap} range — ` + + 'delete or shrink an existing rule first.', + ); + } + if ( + input.minWagonCount !== expectedStart && + input.minWagonCount !== currentStart + ) { + throw new BadRequestException( + `The next ${input.type} range must start at ${expectedStart} ` + + `(ranges are contiguous — no gaps, no overlaps). ` + + `You entered ${input.minWagonCount}–${input.maxWagonCount}.`, + ); + } + + const clash = siblings.find( + (s) => input.minWagonCount <= s.maxWagonCount && input.maxWagonCount >= s.minWagonCount, ); @@ -109,6 +173,24 @@ export class PriorityConfigsService { } } + /** + * Where the next range for a type/currency must start, and the type's + * ceiling — feeds the create form so the min field is auto-filled and + * locked. `nextMin` is null when the chain already covers 1..cap. + */ + async nextRange( + type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + currency?: string | null, + ): Promise<{ nextMin: number | null; maxCap: number }> { + const siblings = ( + await this.repository.findAll({ where: { type } }) + ).filter( + (s) => + type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null), + ); + return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] }; + } + async remove(id: string): Promise { await this.findById(id); await this.repository.softDelete(id); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index a7187638e..0aeb624b3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query"; import { Button, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Check, + FileCheck, FilePen, FileSignature, MessageSquareWarning, RefreshCw, ShieldCheck, - Sparkles, XCircle, Zap, } from "lucide-react"; @@ -180,7 +180,7 @@ export function ContractActionsToolbar({ documentGenerated ? ( ) : ( - + ) } loading={mutations.generateContract.isPending} @@ -196,7 +196,7 @@ export function ContractActionsToolbar({ fullWidth variant="light" color="orange" - leftSection={} + leftSection={} loading={mutations.generateContract.isPending} onClick={() => mutations.generateContract.mutate()} > diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 044c729ee..eed6c9e3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck, X } from "lucide-react"; +import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({ const [confirmOpen, setConfirmOpen] = useState(false); const [pendingStep, setPendingStep] = useState(null); + const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); const [rejectStepRow, setRejectStepRow] = useState(null); @@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + // Approvers must review the GENERATED contract document before approving. If + // it has not been generated yet, block the approval and tell staff to generate + // it first (via "Generate contract" in Staff actions) — mirrors the server + // guard so the user sees a clear reason, not a generic failure toast. + const documentGenerated = Boolean(contract.contractGeneratedAt); + const openApprove = (step: Freight.IContractApprovalStep) => { + if (contract.status === "PENDING_APPROVAL" && !documentGenerated) { + setNeedsGenerateOpen(true); + return; + } setPendingStep(step); setConfirmOpen(true); }; @@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({ + setNeedsGenerateOpen(false)} + title={ + + + Generate the contract first + + } + radius="md" + centered + > + + + The contract document for{" "} + + {contract.reference} + {" "} + has not been generated yet. Approvers must review the generated + document before it can be approved. + + + Use{" "} + + Generate contract + {" "} + in the Staff actions panel — edit the articles first if needed — then + return here to approve. + + + + + + + = {}; for (const field of visibleFields) { - const raw = values[field.name]; + // Derived fields always submit their computed value — never stale state. + const raw = field.computeValue + ? (field.computeValue(values) ?? "") + : values[field.name]; if (field.type === "multiselect") { // Always the full replacement list — the API syncs the relation to it. payload[field.name] = Array.isArray(raw) ? raw : []; @@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({ } const isNumber = field.type === "number"; + const computed = field.computeValue ? field.computeValue(values) : undefined; return ( { const next = e.currentTarget.value; if (isNumber && next.trim().startsWith("-")) return; diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index b3a6c49fd..5ae7bac72 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -246,10 +246,13 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => { /** * Priority-rule approval workflow. Every create/update/delete of a priority * config is SUBMITTED as a change request; an approver applies or rejects it. - * Error toasts surface the backend message so range-collision rejections - * ("1–5 overlaps existing rule …") reach the user verbatim. + * Backend messages (range collision, gap, ceiling) surface verbatim — through + * `onErrorMessage` (the page shows them in a modal) or a toast as fallback. */ -export const usePriorityRuleWorkflow = (enabled: boolean) => { +export const usePriorityRuleWorkflow = ( + enabled: boolean, + onErrorMessage?: (message: string) => void, +) => { const qc = useQueryClient(); const backendMessage = (err: unknown, fallback: string) => { @@ -259,6 +262,12 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { return msg || fallback; }; + const showError = (err: unknown, fallback: string) => { + const message = backendMessage(err, fallback); + if (onErrorMessage) onErrorMessage(message); + else toast.error(message); + }; + const pending = useQuery({ queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, queryFn: () => ruleEngineService.listPriorityRuleChanges("PENDING"), @@ -270,6 +279,10 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { queryKey: QUERY_KEYS.RULE_ENGINE.priorityRuleChanges, }); await invalidateRuleEngineList(qc, "priority-configs"); + // The full order-list backs the auto-filled min field — keep it fresh too. + await qc.invalidateQueries({ + queryKey: QUERY_KEYS.RULE_ENGINE.orderList("priority-configs"), + }); }; const submit = useMutation({ @@ -279,7 +292,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change submitted for approval — the team has been notified"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to submit change")), + onError: (err) => showError(err, "Failed to submit change"), }); const approve = useMutation({ @@ -289,7 +302,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change approved and applied"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to approve change")), + onError: (err) => showError(err, "Failed to approve change"), }); const reject = useMutation({ @@ -299,7 +312,7 @@ export const usePriorityRuleWorkflow = (enabled: boolean) => { toast.success("Change rejected"); await invalidate(); }, - onError: (err) => toast.error(backendMessage(err, "Failed to reject change")), + onError: (err) => showError(err, "Failed to reject change"), }); return { pending, submit, approve, reject }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 95c4c22ac..af8b65a50 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -19,6 +19,7 @@ import { Navigate, useLocation, useParams } from "react-router-dom"; import { PageContainer, PageHeader } from "@/components/page"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection"; +import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls"; @@ -145,10 +146,13 @@ const RuleEngineResourcePage = () => { ); // Priority rules never mutate directly: changes are filed for approval and a - // pending queue renders above the table. + // pending queue renders above the table. Validation errors (range collision, + // gap, ceiling) surface in a modal so the text is impossible to miss. const isPriorityRules = config?.slug === "priority-configs"; + const [priorityError, setPriorityError] = useState(null); const priorityWorkflow = usePriorityRuleWorkflow( Boolean(isPriorityRules && canView), + setPriorityError, ); const editingId = editing?.id ? String(editing.id) : undefined; @@ -178,9 +182,36 @@ const RuleEngineResourcePage = () => { const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } = useWagonTypeOptions(usesWagonTypeField); + // Full rule list backing the auto-filled "min wagon count": the next range + // always continues the chain for the selected type (per currency), so the + // form needs every existing rule, not the current page. + const { data: allPriorityRules } = useRuleEngineOrderList( + config?.slug ?? DEFAULT_CONFIGURATION_SLUG, + Boolean(isPriorityRules && formOpen), + config?.orderConfig?.field, + ); + const formFields = useMemo(() => { if (!config) return []; return config.formFields.map((field) => { + if (isPriorityRules && field.name === "minWagonCount") { + return { + ...field, + // Editing keeps the rule's own start (a lower gap never forces it to + // move); creating always continues the chain / fills the lowest gap. + computeValue: (values: Record) => + editing?.minWagonCount != null + ? Number(editing.minWagonCount) + : nextPriorityRangeStart( + allPriorityRules ?? [], + String(values.type ?? ""), + !values.currency || values.currency === RULE_ENGINE_SELECT_NONE + ? null + : String(values.currency), + editingId, + ), + }; + } if (config.slug === "cargo-types" && field.name === "parentGroupId") { return { ...field, @@ -226,7 +257,7 @@ const RuleEngineResourcePage = () => { } return field; }); - }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]); + }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]); const rows = data?.items ?? []; const meta = data?.meta; @@ -452,6 +483,25 @@ const RuleEngineResourcePage = () => { /> ) : null} + setPriorityError(null)} + title="Cannot save priority rule" + centered + size="md" + > + + + {priorityError} + + + + + + + diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index bc17fb47a..4423b4f93 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -61,6 +61,13 @@ export interface FormFieldDef { * relation list (`wagonTypeIds` read from `record.wagonTypes`). */ getInitialValue?: (record: Record) => unknown; + /** + * Fully derived field: its value is computed from the live form values on + * every render and the input is locked. Used for the priority-rule min + * wagon count, which always continues the previous range for the selected + * type. Return null/undefined to leave the field empty (e.g. chain full). + */ + computeValue?: (values: Record) => number | string | null; } export interface RuleEngineOrderConfig { @@ -356,8 +363,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Select a currency", hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] }, }, - { name: "minWagonCount", label: "Min wagon count", type: "number", required: true }, - { name: "maxWagonCount", label: "Max wagon count", type: "number", required: true }, + { + name: "minWagonCount", + label: "Min wagon count", + type: "number", + required: true, + disabled: true, + description: "Auto-filled — continues the previous range for the selected type", + }, + { + name: "maxWagonCount", + label: "Max wagon count", + type: "number", + required: true, + description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15", + }, { name: "scorePoints", label: "Score points", type: "number", required: true }, { name: "isActive", label: "Active", type: "boolean" }, ], diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts new file mode 100644 index 000000000..f387fc8dc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/priorityRuleRange.ts @@ -0,0 +1,60 @@ +/** + * Client mirror of the backend's contiguous-range rules for priority configs + * (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per + * currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next + * range always starts at the lowest uncovered wagon count. The backend + * re-validates on submit AND on approval; this only drives the form prefill. + */ + +export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS"; + +/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */ +export const PRIORITY_RANGE_CAPS: Record = { + WAGON: 50, + CURRENCY: 35, + CUSTOMS: 15, +}; + +export interface PriorityRangeRule { + id?: unknown; + type?: unknown; + currency?: unknown; + minWagonCount?: unknown; + maxWagonCount?: unknown; +} + +/** + * Where the next range for `type` (+`currency`) must start, excluding + * `excludeId` (the rule being edited). Null when the chain already covers + * 1..cap — no further rule fits. + */ +export function nextPriorityRangeStart( + rules: PriorityRangeRule[], + type: string, + currency: string | null | undefined, + excludeId?: string, +): number | null { + const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType]; + if (!cap) return null; + + const scoped = rules + .filter( + (r) => + String(r.type ?? "") === type && + (excludeId === undefined || String(r.id ?? "") !== excludeId) && + (type !== "CURRENCY" || + String(r.currency ?? "") === String(currency ?? "")), + ) + .map((r) => ({ + min: Number(r.minWagonCount ?? 0), + max: Number(r.maxWagonCount ?? 0), + })) + .sort((a, b) => a.min - b.min); + + let next = 1; + for (const r of scoped) { + if (r.min > next) break; // gap before this rule — fill it first + next = Math.max(next, r.max + 1); + } + return next > cap ? null : next; +} From fc44eee25ece960f2cb1574302be31e2de027fdb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 15 Jul 2026 20:14:39 +0000 Subject: [PATCH 02/18] implement update train details feature: add DTO, service method, and UI modal for editing train name and run numbers --- .../2260000000000-AddClearanceFeePayment.ts | 44 ++++ .../bookings/booking-transition.service.ts | 6 + .../bookings/entities/booking.entity.ts | 5 + .../contracts/clearance-fee.service.ts | 215 ++++++++++++++++++ .../contract-booking.completion.spec.ts | 1 + .../contract-booking.consolidation.spec.ts | 1 + .../contracts/contract-booking.service.ts | 13 +- .../contracts/contract-clearance.service.ts | 5 + .../contracts/contract-notifier.service.ts | 20 ++ .../contracts/contract-pricing.service.ts | 33 ++- .../contracts/contract-transition.service.ts | 15 +- .../src/modules/contracts/contracts.module.ts | 2 + .../entities/contract-rate-snapshot.entity.ts | 7 + .../contracts/entities/contract.entity.ts | 6 + .../rule-engine/entities/rate-type.util.ts | 2 + .../rule-engine/entities/rate-unit.util.ts | 3 + .../rule-engine/entities/rate.entity.ts | 4 + .../trains/dto/update-train-details.dto.ts | 29 +++ .../modules/trains/entities/train.entity.ts | 2 +- .../trains/train-builder.controller.ts | 13 ++ .../modules/trains/train-builder.service.ts | 48 ++++ .../trainBuilder/EditTrainDetailsModal.tsx | 123 ++++++++++ .../bookings/booking-status.config.ts | 7 +- .../contracts/contract-status.config.ts | 12 + .../src/pages/ruleEngine/config/resources.ts | 4 + .../trainBuilder/TrainBuilderListPage.tsx | 26 +++ .../backoffice/src/services/api.ts | 13 ++ .../src/services/trainBuilder.service.ts | 11 + .../backoffice/src/types/booking.ts | 1 + .../ContractCustomerAction.tsx | 12 + .../deriveContractCustomerAction.ts | 19 ++ .../portal/src/pages/MyPortalPage/actions.ts | 27 ++- .../components/ActionNeededSection.tsx | 15 +- .../src/pages/MyPortalPage/constants.ts | 13 ++ .../BookingDetailPage/ReadonlyBookingView.tsx | 27 ++- .../clearance/BookingActionButton.tsx | 16 ++ .../bookings/clearance/bookingNextAction.ts | 6 + .../payments/PayClearanceFeeButton.tsx | 133 +++++++++++ .../pages/contracts/ContractDetailPage.tsx | 17 ++ .../src/pages/contracts/NewContractPage.tsx | 6 + .../contracts/NewShipmentRequestPage.tsx | 5 +- .../src/pages/contracts/contract-ui.tsx | 4 + packages/types/src/freight/contracts.ts | 7 + packages/types/src/freight/index.ts | 6 +- 44 files changed, 970 insertions(+), 14 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx diff --git a/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts new file mode 100644 index 000000000..c0dc2c818 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-AddClearanceFeePayment.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Prepaid customs clearance service fee (Path B): + * - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE + * fee line so it is billed via its own clearance invoice and excluded from + * shipment booking totals; + * - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee + * settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS); + * - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's + * fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS). + * All nullable/defaulted — existing rows are untouched and keep today's flow. + */ +export class AddClearanceFeePayment2260000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_rate_snapshots + ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE; + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS clearance_fee_paid_at; + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + DROP COLUMN IF EXISTS clearance_fee_paid_at; + `); + await queryRunner.query(` + ALTER TABLE freight.contract_rate_snapshots + DROP COLUMN IF EXISTS is_clearance; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 698759901..581ad917c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, forwardRef, Inject, Injectable, @@ -714,6 +715,11 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); + if (booking.status === "AWAITING_CLEARANCE_PAYMENT") { + throw new ConflictException( + "The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.", + ); + } assertBookingStatus(booking, [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 79d15069b..f74cb515e 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [ 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', // Post counter-sign document-clearance gate (GL workflow). + 'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', @@ -521,6 +522,10 @@ export class Booking extends BaseEntity { @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) clearanceCurrentPhase?: string | null; + /** When the prepaid customs clearance service fee settled (GENERAL + customs). */ + @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) + clearanceFeePaidAt?: Date | null; + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) dutyRequired?: boolean | null; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts new file mode 100644 index 000000000..3d5dc617b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts @@ -0,0 +1,215 @@ +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); + } + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 7dc676398..4f3deb513 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // milestoneService {} as never, // workflowService {} as never, // invoiceService + {} as never, // clearanceFeeService {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index c2e3a107b..f28468936 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { milestoneService as never, {} as never, // workflowService invoiceService as never, + {} as never, // clearanceFeeService {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f055a0c15..6078dee0f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -35,6 +35,7 @@ import { hasFreightPermission } from '../../common/freight-permission.util'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; +import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; @@ -78,6 +79,7 @@ export class ContractBookingService { private readonly milestoneService: ClearanceMilestoneService, private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, + private readonly clearanceFeeService: ClearanceFeeService, private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, @@ -492,6 +494,11 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, opts.contractRouteId); + // Prepay gate: each shipment request owes its own flat clearance service + // fee before the document step opens (the paid event advances the booking + // to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate. + const feeGate = await this.clearanceFeeService.gateApplies(contract); + const booking = await insertWithGeneratedReference( () => this.generateReference(), (reference) => @@ -501,7 +508,7 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: 'AWAITING_DOCUMENTS', + status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -540,6 +547,10 @@ export class ContractBookingService { contract.tradeDirection, ); + if (feeGate) { + await this.clearanceFeeService.issueForBooking(booking, contract); + } + return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 60eebf3da..d3bbaf098 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -489,6 +489,11 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); + if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') { + throw new ConflictException( + 'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.', + ); + } if ( contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && contract.status !== 'CLEARANCE_UNDER_REVIEW' diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 3834b55f0..575767a87 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -158,6 +158,26 @@ export class ContractNotifierService { }); } + /** Clearance service fee invoiced — customer must pay before document upload. */ + clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void { + const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`; + const msg = + `A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` + + `Please pay from the portal to unlock the clearance document upload.`; + void this.notifyContact(c, msg, 'CLEARANCE FEE DUE'); + this.inApp(c, 'Clearance fee due', msg); + } + + /** Clearance service fee settled — document upload is now open. */ + clearanceFeePaid(c: Contract, shipmentRef?: string): void { + const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`; + const msg = + `Your customs clearance service fee for ${scope} has been received. ` + + `You can now upload the clearance documents from the portal.`; + void this.notifyContact(c, msg, 'CLEARANCE FEE PAID'); + this.inApp(c, 'Clearance fee paid', msg); + } + // ── Clearance milestones needing customer action ────────────────────────── /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 286cd9a01..1646d638c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; @@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem { containerSize?: string | null; conditionalOn?: string | null; cargoTypeCode?: string | null; + /** + * Customs clearance service fee — billed separately in advance (before the + * clearance document step), never part of shipment booking totals. + */ + isClearance?: boolean; } /** The contract `pricing_breakdown` shape (doc §9.1). */ @@ -184,6 +189,31 @@ export class ContractPricingService { } } + // Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the + // contract and billed via its own clearance invoice: after counter-sign for + // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals. + // A customs contract may not proceed without a configured live rate. + if (contract.customsClearingEnabled) { + const clearance = liveRates.find( + (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD', + ); + if (!clearance || Number(clearance.rateValue) <= 0) { + 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.', + ); + } + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + label: + contract.contractKind === 'GENERAL' + ? 'Customs clearance service fee (per shipment request, prepaid)' + : 'Customs clearance service fee (prepaid)', + unit: toContractUnit(clearance.rateUnit), + unitPrice: convert(Number(clearance.rateValue)), + isClearance: true, + }); + } + return { displayMode: 'UNIT_RATES', currency, @@ -229,6 +259,7 @@ export class ContractPricingService { containerSize: line.containerSize ?? null, isSurcharge: !!line.conditionalOn, conditionalOn: line.conditionalOn ?? null, + isClearance: !!line.isClearance, }); } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 049db2a94..5fb935e74 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -24,6 +24,7 @@ import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractPricingService } from './contract-pricing.service'; +import { ClearanceFeeService } from './clearance-fee.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -89,6 +90,7 @@ export class ContractTransitionService { private readonly otpService: OtpService, private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, + private readonly clearanceFeeService: ClearanceFeeService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -866,8 +868,17 @@ export class ContractTransitionService { const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); - updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; - updates.clearanceStatus = 'AWAITING_DOCUMENTS'; + // Path B prepay gate: the customs clearance service fee is invoiced here + // and must settle before the document step opens (the paid event advances + // to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee. + if (await this.clearanceFeeService.gateApplies(contract)) { + await this.clearanceFeeService.issueForContract(contract); + updates.status = 'AWAITING_CLEARANCE_PAYMENT'; + updates.clearanceStatus = 'AWAITING_PAYMENT'; + } else { + updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; + updates.clearanceStatus = 'AWAITING_DOCUMENTS'; + } updates.clearanceCycleNumber = cycleNumber; } else { // No contract-level clearance gate — DOMESTIC, or any GENERAL contract diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 96bdf22b1..5177b6a39 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; +import { ClearanceFeeService } from './clearance-fee.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; @@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, + ClearanceFeeService, ContractNotifierService, ContractTransitionService, ContractClearanceService, diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts index eb0a607cc..52fcd437f 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts @@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity { /** is_hazardous | is_reefer when this is a conditional surcharge. */ @Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true }) conditionalOn?: string | null; + + /** + * Customs clearance service fee line — billed up front via a clearance + * invoice, excluded from shipment booking totals. + */ + @Column({ name: 'is_clearance', type: 'boolean', default: false }) + isClearance!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index f29b9093b..a526d632f 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [ 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', + 'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', @@ -84,6 +85,7 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; export const CONTRACT_CLEARANCE_STATUSES = [ 'NOT_APPLICABLE', + 'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking @@ -215,6 +217,10 @@ export class Contract extends BaseEntity { @Column({ name: 'clearance_cycle_number', type: 'int', default: 0 }) clearanceCycleNumber!: number; + /** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */ + @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) + clearanceFeePaidAt?: Date | null; + @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) pricingBreakdown?: Record | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 579f7da9d..5ecc006c1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -37,6 +37,8 @@ export function deriveRateType(input: { return 'DEMURRAGE'; case 'PIL_EXTRA_FEE': return 'PIL_EXTRA_FEE'; + case 'CUSTOMS_CLEARANCE': + return 'CUSTOMS_CLEARANCE'; } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index cef613412..b7ffdc485 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -30,6 +30,9 @@ export function allowedRateUnits(input: { return ['PER_CONTAINER', 'PER_TON']; case 'CANCELLATION': return ['FLAT', 'PER_INVOICE']; + case 'CUSTOMS_CLEARANCE': + // Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL). + return ['FLAT']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 50f8b3b99..d66358cc9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -21,6 +21,7 @@ export const RATE_TYPES = [ 'HAZARD_SURCHARGE', 'REEFER_SURCHARGE', 'PIL_EXTRA_FEE', + 'CUSTOMS_CLEARANCE', ] as const; export type RateType = typeof RATE_TYPES[number]; @@ -75,6 +76,9 @@ export const RATE_TRIGGERS = [ 'CANCELLATION', 'DEMURRAGE', 'PIL_EXTRA_FEE', + // Customs clearance service fee — billed up front via a clearance invoice, + // never auto-applied to booking pricing (matchesTrigger returns false). + 'CUSTOMS_CLEARANCE', ] as const; export type RateTrigger = typeof RATE_TRIGGERS[number]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts new file mode 100644 index 000000000..c91bb30a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-details.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Edit a built train's display identity: its name and its fixed import/export + * run numbers. Composition (yard, locomotives, wagons) has its own endpoints. + * Omitted fields keep their current value; an empty trainName clears the name. + */ +export class UpdateTrainDetailsDto { + @ApiPropertyOptional({ description: 'Display name; empty string clears it' }) + @IsOptional() + @IsString() + @MaxLength(100) + trainName?: string; + + @ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(20) + importTrainNumber?: string; + + @ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' }) + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(20) + exportTrainNumber?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 5b26c696a..493e564e8 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -45,7 +45,7 @@ export class Train extends BaseEntity { trainNumber?: string; @Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true }) - trainName?: string; + trainName?: string | null; @Column({ name: 'route_id', type: 'uuid', nullable: true }) routeId?: string; diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index eec68fcfc..9b7454f36 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; import { TrainBuilderService } from './train-builder.service'; @@ -59,6 +60,18 @@ export class TrainBuilderController { return this.trainBuilderService.setLocomotives(id, dto); } + @Patch(':id/details') + @FleetManage() + @ApiOperation({ + summary: "Edit the train's name and fixed import/export run numbers", + }) + updateDetails( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTrainDetailsDto, + ) { + return this.trainBuilderService.updateDetails(id, dto); + } + @Patch(':id/yard') @FleetManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 3b5ad16cd..50fa77afa 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -17,6 +17,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; @@ -331,6 +332,53 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Edit a built train's display identity: name and fixed import/export run + * numbers. Mirrors the build-time number rules — the pair may not collide + * with any other train's pair or legacy number (friendly 409 ahead of the + * partial unique indexes). Blocked while the train is out on a dispatched + * run, like every other composition edit. + */ + async updateDetails(id: string, dto: UpdateTrainDetailsDto) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + + const patch: Partial = {}; + if (dto.trainName !== undefined) { + patch.trainName = dto.trainName.trim() || null; + } + const importTrainNumber = dto.importTrainNumber?.trim(); + const exportTrainNumber = dto.exportTrainNumber?.trim(); + if (importTrainNumber) patch.importTrainNumber = importTrainNumber; + if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber; + + if (importTrainNumber || exportTrainNumber) { + const nextImport = importTrainNumber ?? train.importTrainNumber ?? ''; + const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? ''; + const numberClash: { code: string }[] = await manager.query( + `SELECT code FROM freight.trains + WHERE deleted_at IS NULL + AND id != $3 + AND (import_train_number IN ($1, $2) + OR export_train_number IN ($1, $2) + OR train_number IN ($1, $2)) + LIMIT 1`, + [nextImport, nextExport, train.id], + ); + if (numberClash.length) { + throw new ConflictException( + `Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`, + ); + } + } + + if (Object.keys(patch).length) { + await manager.getRepository(Train).update(train.id, patch); + } + }); + return this.getComposition(id); + } + /** * Relocate the train to another yard. The consist moves as one unit: every * coupled locomotive and wagon follows to the new yard (so their current diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx new file mode 100644 index 000000000..0a2371138 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/EditTrainDetailsModal.tsx @@ -0,0 +1,123 @@ +import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { Pencil } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useToast } from "@/hooks/use-toast"; +import { api } from "@/services/api"; +import type { BuiltTrainSummary } from "@/services/trainBuilder.service"; + +export interface EditTrainDetailsModalProps { + /** Train being edited; null closes the modal. */ + train: BuiltTrainSummary | null; + onClose: () => void; +} + +/** + * Edit a built train's display identity from the list: its name and its fixed + * import/export run numbers. Composition (yard, locomotives, wagons) is edited + * on the detail page. Number collisions come back as a 409 with the owning + * train's code and surface verbatim. + */ +const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => { + const { toast } = useToast(); + const [name, setName] = useState(""); + const [importNo, setImportNo] = useState(""); + const [exportNo, setExportNo] = useState(""); + + useEffect(() => { + if (train) { + setName(train.trainName ?? ""); + setImportNo(train.importTrainNumber ?? ""); + setExportNo(train.exportTrainNumber ?? ""); + } + }, [train]); + + const update = useMutation(api.trainBuilder.updateDetails.mutationOptions()); + + const handleSave = async () => { + if (!train) return; + try { + await update.mutateAsync({ + id: train.id, + payload: { + trainName: name.trim(), + // Numbers cannot be cleared — only replaced; empty inputs keep the + // current value (legacy trains may have none yet). + ...(importNo.trim() ? { importTrainNumber: importNo.trim() } : {}), + ...(exportNo.trim() ? { exportTrainNumber: exportNo.trim() } : {}), + }, + }); + toast({ title: `Train ${train.code} updated` }); + onClose(); + } catch (err) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Update failed"; + toast({ + title: "Could not update train", + description: String(message), + variant: "destructive", + }); + } + }; + + return ( + + + Edit train {train?.code ?? ""} + + } + centered + size="md" + radius="lg" + > + + setName(e.currentTarget.value)} + maxLength={100} + radius="md" + /> + + setImportNo(e.currentTarget.value)} + maxLength={20} + radius="md" + /> + setExportNo(e.currentTarget.value)} + maxLength={20} + radius="md" + /> + + + + + + + + ); +}; + +export default EditTrainDetailsModal; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index f87f96433..e5961240f 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -285,7 +285,12 @@ export const BOOKING_LIST_TABS = [ { key: "clearance", label: "Clearance", - statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"], + statuses: [ + "AWAITING_CLEARANCE_PAYMENT", + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + ], }, { key: "payment", diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts index 2277d702b..cf1f04c90 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts @@ -51,6 +51,10 @@ export const CONTRACT_STATUS_STYLES: Record = { label: "Active", color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]", }, + AWAITING_CLEARANCE_PAYMENT: { + label: "Clearance Fee Due", + color: "bg-orange-50 text-orange-700 border-orange-200", + }, AWAITING_CLEARANCE_DOCUMENTS: { label: "Awaiting Documents", color: "bg-amber-50 text-amber-700 border-amber-200", @@ -118,6 +122,7 @@ export const CONTRACT_STATUS_COLOR: Record = { SIGNED_CUSTOMER: "cyan", FULLY_EXECUTED: "indigo", CONTRACT_ACTIVE: "edr-green", + AWAITING_CLEARANCE_PAYMENT: "orange", AWAITING_CLEARANCE_DOCUMENTS: "yellow", CLEARANCE_UNDER_REVIEW: "yellow", CLEARANCE_READY_FOR_BOOKING: "edr-green", @@ -207,6 +212,13 @@ export const CONTRACT_STATUS_META: Record = { color: "text-[color:var(--freight-brand)]", stage: 3, }, + AWAITING_CLEARANCE_PAYMENT: { + title: "Clearance Fee Due", + description: + "Customer must pay the prepaid clearance service fee before uploading documents.", + color: "text-orange-600", + stage: 3, + }, AWAITING_CLEARANCE_DOCUMENTS: { title: "Awaiting Documents", description: "Customer is uploading pre-booking clearance documents.", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 4423b4f93..9e248eee2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -141,6 +141,7 @@ const RATE_TRIGGERS = [ { label: "Cancellation", value: "CANCELLATION" }, { label: "Demurrage", value: "DEMURRAGE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, + { label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" }, ]; const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); @@ -162,6 +163,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => { return ["PER_CONTAINER", "PER_TON"]; case "CANCELLATION": return ["FLAT", "PER_INVOICE"]; + case "CUSTOMS_CLEARANCE": + // Flat per clearance (ONE_TIME) / per shipment request (GENERAL). + return ["FLAT"]; case "CONSOLIDATION": case "SHIPPING_LINE": case "PIL_EXTRA_FEE": diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx index de6b96aa5..0d6b7a892 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx @@ -1,6 +1,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { + ActionIcon, Badge, Box, Button, @@ -15,6 +16,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Hammer, + Pencil, Ruler, Search, Train as TrainIcon, @@ -27,6 +29,7 @@ import { useNavigate } from "react-router-dom"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal"; +import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal"; import { directionColor, directionRowStyle, @@ -52,6 +55,7 @@ export default function TrainBuilderListPage() { const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL"); const [yardFilter, setYardFilter] = useState("ALL"); const [buildOpen, setBuildOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); const resetPage = useCallback(() => { setPagination((prev) => @@ -239,6 +243,26 @@ export default function TrainBuilderListPage() { ), }, + { + id: "actions", + header: "", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + { + // Row click navigates to the detail page — keep the edit local. + e.stopPropagation(); + setEditTarget(row.original); + }} + > + + + ), + }, ]; }, []); @@ -363,6 +387,8 @@ export default function TrainBuilderListPage() { onClose={() => setBuildOpen(false)} onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)} /> + + setEditTarget(null)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 88d922829..7a2a478c1 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -191,6 +191,7 @@ import { type BuiltTrainListResponse, type ScheduleConsist, type TrainComposition, + type UpdateTrainDetailsPayload, } from "./trainBuilder.service"; import { trainSchedulingService } from "./trainScheduling.service"; import { wagonTypesService, type WagonType } from "./wagon-types.service"; @@ -1842,6 +1843,18 @@ export const api = { () => TRAIN_BUILDER_INVALIDATIONS, ), + updateDetails: endpoint< + { id: string; payload: UpdateTrainDetailsPayload }, + TrainComposition + >( + "train-builder", + "updateDetails", + ({ id, payload }) => + trainBuilderService.updateDetails(id, payload).then((r) => r.data), + undefined, + () => TRAIN_BUILDER_INVALIDATIONS, + ), + assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>( "train-builder", "assignWagons", diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index f78792b81..62ae9f7a3 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -140,6 +140,14 @@ export interface BuildTrainPayload { notes?: string; } +/** Edit a built train's display identity; omitted fields keep their value. */ +export interface UpdateTrainDetailsPayload { + /** Empty string clears the name. */ + trainName?: string; + importTrainNumber?: string; + exportTrainNumber?: string; +} + /** Built train annotated for the schedule-creation picker. */ export interface AvailableTrain { id: string; @@ -241,6 +249,9 @@ export const trainBuilderService = { build: (payload: BuildTrainPayload) => apiClient.post(BASE, payload), setLocomotives: (id: string, locomotiveIds: string[]) => apiClient.put(`${BASE}/${id}/locomotives`, { locomotiveIds }), + /** Edit the train's name and fixed import/export run numbers. */ + updateDetails: (id: string, payload: UpdateTrainDetailsPayload) => + apiClient.patch(`${BASE}/${id}/details`, payload), /** Relocate the train — coupled locomotives and wagons move with it. */ setYard: (id: string, currentYardId: string) => apiClient.patch(`${BASE}/${id}/yard`, { currentYardId }), diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index c31118cc7..ec31e11fa 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -28,6 +28,7 @@ export const BOOKING_STATUSES = [ "CONTRACT_ACTIVE", "CONTRACT_CLOSED", // Post counter-sign document-clearance gate. + "AWAITING_CLEARANCE_PAYMENT", "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY", diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 86c37585c..019f83143 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -14,6 +14,7 @@ import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; +import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { api } from "@/services/api"; import { ContractClearanceAction } from "./ContractClearanceAction"; @@ -69,6 +70,17 @@ export function ContractCustomerAction({ ); } + if (action.type === "pay-clearance") { + return ( + + ); + } + if (action.type === "initiate") { return ( invoicesService.listForSource("booking", payItem!.targetId), + queryKey: [`${payItemSource}-invoices`, payItem?.targetId], + queryFn: () => + invoicesService.listForSource(payItemSource, payItem!.targetId), enabled: payItem !== null, }); const payableInvoiceId = payItemInvoices.find((inv) => @@ -182,6 +188,7 @@ export function ActionNeededSection({ navigate(`/contracts/${item.targetId}`); break; case "pay": + case "clearance-fee": setPayItem(item); break; case "sign": @@ -277,7 +284,9 @@ export function ActionNeededSection({ > {item.kind === "pay" ? "Pay now" - : item.kind === "duty" + : item.kind === "clearance-fee" + ? "Pay clearance fee" + : item.kind === "duty" ? "Pay duty & upload slip" : item.kind === "sign" ? "Sign" diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 7be5a0aed..b67b11315 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -165,6 +165,19 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + AWAITING_CLEARANCE_PAYMENT: { + stage: 3, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Clearance service fee due · pay to unlock document upload", + step: "edr-accent", + badgeLabel: "Clearance fee due", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight }, + }, AWAITING_DOCUMENTS: { stage: 3, icon: FileUp, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 04739b55d..244a38739 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,4 +1,4 @@ -import { Group, Tabs } from "@mantine/core"; +import { Group, Paper, Tabs, Text } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, FileText, LayoutGrid } from "lucide-react"; import { useState } from "react"; @@ -12,6 +12,7 @@ import { isPayable } from "@/pages/billing/invoice-ui"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; +import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { DocumentsTab } from "./components/DocumentsTab"; @@ -139,6 +140,8 @@ export function ReadonlyBookingView({ const isCustoms = Boolean(booking.customsClearingEnabled); const canSelfRebook = !isCustoms; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; + // Prepaid clearance service fee gate — document upload stays locked until paid. + const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT"; const isClearance = [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", @@ -237,6 +240,28 @@ export function ReadonlyBookingView({
+ {isAwaitingClearanceFee && ( + + +
+ + Customs clearance service fee due + + + Pay the clearance service fee to unlock the clearance + document upload. Global Logistics starts working on your + shipment once the fee is settled. + +
+ +
+
+ )} + {isClearance && } = { + PAY_CLEARANCE: CreditCard, UPLOAD_DOCUMENTS: Upload, FIX_DOCUMENTS: AlertCircle, SCHEDULE_OPERATION: ArrowRight, @@ -56,6 +59,19 @@ export function BookingActionButton({ if (!isChangesRequested && !action) return null; + // The prepaid clearance service fee has its own payment flow (method modal + + // provider redirect) — delegate to the self-contained pay button. + if (action?.kind === "PAY_CLEARANCE") { + return ( + + ); + } + const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const label = action ? action.label : "Update & resubmit"; // BOOK navigates to the booking form (cargo + day + window check) — the diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 1ebb047a6..2bdd976a9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -7,6 +7,7 @@ import type { Freight } from "@edr/types"; * to operation. */ export type BookingActionKind = + | "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed @@ -23,6 +24,11 @@ export interface BookingNextAction { } const ACTION_BY_STATUS: Record = { + AWAITING_CLEARANCE_PAYMENT: { + kind: "PAY_CLEARANCE", + label: "Pay clearance fee", + title: "Pay the clearance service fee", + }, AWAITING_DOCUMENTS: { kind: "UPLOAD_DOCUMENTS", label: "Upload documents", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx new file mode 100644 index 000000000..02cf37937 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx @@ -0,0 +1,133 @@ +import { Button, type ButtonProps } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { CreditCard } from "lucide-react"; +import { useState } from "react"; + +import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper"; +import { isPayable } from "@/pages/billing/invoice-ui"; +import { api } from "@/services/api"; +import { invoicesService } from "@/services/invoices.service"; +import { + paymentsService, + type PaymentMethod, +} from "@/services/payments.service"; +import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; + +/** + * Payment flow for the prepaid customs clearance service fee. The fee is its + * own `clearance`-source invoice — sourceId is the contract id (ONE_TIME, + * contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL + * shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it + * unlocks the clearance document upload; same modal + provider redirect as + * booking payment. + */ +export function useClearanceFeePayment(sourceId: string) { + const [modalOpen, setModalOpen] = useState(false); + + const { data: invoices = [] } = useQuery({ + queryKey: ["clearance-invoices", sourceId], + queryFn: () => invoicesService.listForSource("clearance", sourceId), + enabled: Boolean(sourceId), + }); + const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null; + + const mutation = useMutation({ + mutationFn: (method: PaymentMethod) => { + if (!payableInvoice) { + throw new Error( + "No payable clearance-fee invoice found yet. Please refresh or contact support.", + ); + } + return api.invoices.pay.call({ + id: payableInvoice.id, + payload: { method, platform: "web" }, + }); + }, + onSuccess: (data, method) => { + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ + invoiceId: payableInvoice!.id, + method, + }); + window.location.href = redirectUrl; + }, + }); + + const close = () => { + if (!mutation.isPending) { + setModalOpen(false); + mutation.reset(); + } + }; + + return { + invoice: payableInvoice, + modalOpen, + open: () => setModalOpen(true), + close, + processing: mutation.isPending, + error: mutation.isError + ? mutation.error instanceof Error + ? mutation.error.message + : "Could not start payment. Please try again." + : null, + confirm: (method: PaymentMethod) => mutation.mutate(method), + }; +} + +interface PayClearanceFeeButtonProps { + /** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */ + sourceId: string; + /** Fallback currency while the invoice is loading. */ + currency?: string; + label?: string; + size?: ButtonProps["size"]; + fullWidth?: boolean; +} + +/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */ +export function PayClearanceFeeButton({ + sourceId, + currency, + label = "Pay clearance fee", + size = "xs", + fullWidth, +}: PayClearanceFeeButtonProps) { + const pay = useClearanceFeePayment(sourceId); + + return ( + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 64c64ce26..1b5a29e2a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -72,6 +72,7 @@ import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; +import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -396,6 +397,9 @@ export default function ContractDetailPage() { // clearance is finalized. const canUploadClearance = CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; + // Prepaid clearance service fee gate (Path B) — the document step stays + // locked until the fee invoice settles. + const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT"; return ( @@ -531,6 +535,13 @@ export default function ContractDetailPage() { Global Logistics is creating your booking )} + {awaitingClearanceFee && ( + + )} {canUploadClearance && (
- + `; } - private buildStatusHtml(status: string, intentId: string): string { + private buildStatusHtml(rawStatus: string, rawIntentId: string): string { + const status = this.escapeHtml(rawStatus); + const intentId = this.escapeHtml(rawIntentId); return ` @@ -153,7 +182,8 @@ export class PaymentController { `; } - private buildErrorHtml(message: string): string { + private buildErrorHtml(rawMessage: string): string { + const message = this.escapeHtml(rawMessage); return ` diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index ddf09dea6..b0a3ad766 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -119,6 +119,26 @@ export class BookingInvoiceService { return this.billing.updateStatus(invoiceId, status, manager); } + /** + * Expire the booking's currently-open prepaid invoice when the booking is + * cancelled or rejected — the counterpart to the pay-window-expiry path + * (which also calls {@link BillingService.expirePayable}). Stops a terminated + * booking from leaving a payable invoice open. No-op when the booking has no + * open invoice (never invoiced, already paid/cancelled/expired). Pass a + * caller `manager` to enlist in its transaction. + */ + expireOpenInvoices( + bookingId: string, + manager?: EntityManager, + ): Promise { + return this.billing.expirePayable( + Freight.InvoiceSource.Booking, + bookingId, + "PREPAID", + manager, + ); + } + /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID @@ -138,7 +158,32 @@ export class BookingInvoiceService { ); return; } - // if (booking.paymentStatus === "PAID") return; + + // Idempotency + state-machine guard (restored). The prepaid-invoice paid + // event can be delivered more than once (retries / re-emit), and a booking + // may have moved on or been terminated between invoicing and settlement. + // Only advance one that is still awaiting payment: no-op when already PAID, + // and refuse to advance a booking in a terminal/advanced status + // (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never + // rewrite its status or re-run allocation. + if (booking.paymentStatus === "PAID" || booking.status === "PAID") { + return; + } + const TERMINAL_OR_ADVANCED_STATUSES: string[] = [ + "CANCELLED", + "REJECTED", + "EXPIRED", + "IN_TRANSIT", + "ARRIVED", + "COMPLETED", + "CONTRACT_CLOSED", + ]; + if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) { + this.logger.warn( + `Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`, + ); + return; + } await this.dataSource.transaction(async (mg) => { await mg.update( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 58351c080..091232611 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; +import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, @@ -128,11 +129,18 @@ export class BookingPricingService { const isEtbBooking = paymentCurrency === 'ETB'; const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + // H15: a booking created under a contract prices from that contract's FROZEN + // rate snapshots (the agreed rates), not the live rate of the day. Loaded + // once and threaded through the line builders; each rate code that has a + // snapshot uses it, and any code without one falls back to the live rate. + // Non-contract bookings resolve to null and keep the live-rate path. + const frozenRates = await this.loadFrozenContractRates(booking); + const lineItems: PriceLineItemDto[] = []; let total = 0; const { lineItems: baseLines, usedRates: baseRates } = - await this.computeBaseRailLinesWithRates(booking, evalInput); + await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); total += line.amount; @@ -141,7 +149,7 @@ export class BookingPricingService { // First / last mile trucking — billed per the rate's unit (km / container / // ton / flat), only for legs the booking actually carries. const { lineItems: mileLines, usedRates: mileRates } = - await this.computeFirstLastMileLines(booking, evalInput); + await this.computeFirstLastMileLines(booking, evalInput, frozenRates); for (const line of mileLines) { lineItems.push(line); total += line.amount; @@ -153,15 +161,14 @@ export class BookingPricingService { for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; - const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const rate = rateById.get(mod.rateId); const unit = rate?.rateUnit ?? 'FLAT'; const unitUsd = rate ? Number(rate.rateValue) : usdAmount; - const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an // explicit trigger (e.g. overweight tons) wins when present; otherwise - // derive from total ÷ unit price. + // derive from total ÷ unit price (the live unit price — a count, not a + // currency amount, so it is snapshot-independent). const quantity = unit === 'FLAT' || unit === 'PER_INVOICE' ? 1 @@ -171,6 +178,26 @@ export class BookingPricingService { ? Math.max(1, Math.round(usdAmount / unitUsd)) : 1; + // H15: bill the frozen contract surcharge rate (already in the booking + // currency) when this code has a snapshot; else keep the live amount. + const frozen = this.frozenRateByCode( + frozenRates, + mod.surchargeCode, + paymentCurrency, + ); + const unitAmount = frozen + ? Number(frozen.unitPrice) + : isEtbBooking + ? Math.round(unitUsd * usdToEtb) + : unitUsd; + const convertedAmount = frozen + ? isEtbBooking + ? Math.round(unitAmount * quantity) + : unitAmount * quantity + : isEtbBooking + ? Math.round(usdAmount * usdToEtb) + : usdAmount; + const item: PriceLineItemDto = { code: mod.surchargeCode, description: surchargeLabel(mod.surchargeCode), @@ -424,6 +451,7 @@ export class BookingPricingService { private async computeBaseRailLinesWithRates( booking: Booking, evalInput: BookingEvaluationInput, + frozenRates: Map | null = null, ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; @@ -453,15 +481,35 @@ export class BookingPricingService { if (!rate) continue; usedRatesMap.set(rate.id, rate); - const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); - const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const unitUsd = Number(rate.rateValue); + // H15: frozen contract rate for this container size, when present — its + // unitPrice is already in the booking currency (no USD→currency convert). + const frozen = await this.frozenRateForContainer( + frozenRates, + container.containerTypeId, + paymentCurrency, + ); + let amount: number; + let unitAmount: number; + if (frozen) { + unitAmount = Number(frozen.unitPrice); + amount = this.amountForUnit( + rate.rateUnit, + unitAmount, + container.quantity, + wagonCount, + ); + } else { + const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); + amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + } const label = await this.containerTypeLabel(container.containerTypeId); lines.push({ code: rateType, description: `${label} rail freight`, amount, - unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unitAmount, unit: rate.rateUnit, quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), currency: paymentCurrency, @@ -477,14 +525,31 @@ export class BookingPricingService { const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); const quantity = isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; - const usdAmount = this.amountForRate(fallback, quantity, wagonCount); - const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const unitUsd = Number(fallback.rateValue); + // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. + const frozen = isBulk + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency) + : null; + let amount: number; + let unitAmount: number; + if (frozen) { + unitAmount = Number(frozen.unitPrice); + amount = this.amountForUnit( + fallback.rateUnit, + unitAmount, + quantity, + wagonCount, + ); + } else { + const usdAmount = this.amountForRate(fallback, quantity, wagonCount); + amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + } lines.push({ code: rateType, description: isBulk ? 'Bulk rail freight' : 'Container rail freight', amount, - unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unitAmount, unit: fallback.rateUnit, quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, @@ -508,6 +573,7 @@ export class BookingPricingService { private async computeFirstLastMileLines( booking: Booking, evalInput: BookingEvaluationInput, + frozenRates: Map | null = null, ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [ { @@ -565,18 +631,34 @@ export class BookingPricingService { break; } - const usdAmount = value * quantity; + // H15: frozen mile rate (already in booking currency) when the contract + // has one; else the live USD rate converted as before. + const frozen = this.frozenRateByCode( + frozenRates, + leg.rateType, + paymentCurrency, + ); + let amount: number; + let unitAmount: number; + if (frozen) { + unitAmount = Number(frozen.unitPrice); + amount = isEtbBooking + ? Math.round(unitAmount * quantity) + : unitAmount * quantity; + } else { + const usdAmount = value * quantity; + amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value; + } // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). - if (!(usdAmount > 0)) continue; + if (!(amount > 0)) continue; - const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; - const unitUsd = value; usedRatesMap.set(rate.id, rate); lines.push({ code: leg.rateType, description: leg.label, amount, - unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unitAmount, unit: rate.rateUnit, quantity, currency: paymentCurrency, @@ -649,21 +731,93 @@ export class BookingPricingService { } private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { - const value = Number(rate.rateValue); - switch (rate.rateUnit) { + return this.amountForUnit( + rate.rateUnit, + Number(rate.rateValue), + quantity, + wagonCount, + ); + } + + /** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */ + private amountForUnit( + rateUnit: string, + unitValue: number, + quantity: number, + wagonCount: number, + ): number { + switch (rateUnit) { case 'PER_CONTAINER': - return value * quantity; + return unitValue * quantity; case 'PER_WAGON': - return value * wagonCount; + return unitValue * wagonCount; case 'PER_TON': - return value * quantity; + return unitValue * quantity; case 'FLAT': - return value; + return unitValue; default: - return value * quantity; + return unitValue * quantity; } } + // ── H15: frozen contract rate snapshots ──────────────────────────────────── + + /** + * Load a contract's frozen rate snapshots into a by-rate-code lookup, or null + * for a non-contract booking (or a contract with no snapshots). The pricing + * line builders prefer a matching snapshot's unit price over the live rate. + */ + private async loadFrozenContractRates( + booking: Booking, + ): Promise | null> { + if (!booking.contractId) return null; + const snapshots = await this.bookingsRepository.findContractRateSnapshots( + booking.contractId, + ); + if (!snapshots.length) return null; + const byCode = new Map(); + for (const snap of snapshots) byCode.set(snap.rateCode, snap); + return byCode; + } + + /** + * The frozen snapshot for a rate code, or null when there is none, its price + * is negative, or it is in a different currency than the booking (in which + * case the live-rate path is safer than a mis-converted frozen price). + */ + private frozenRateByCode( + frozenRates: Map | null, + code: string, + bookingCurrency: string, + ): ContractRateSnapshot | null { + const snap = frozenRates?.get(code); + if (!snap) return null; + if (snap.currency !== bookingCurrency) return null; + if (!(Number(snap.unitPrice) >= 0)) return null; + return snap; + } + + /** + * The frozen base-rail snapshot for a container line, matched by the + * container's size (CONTAINER_20FT / CONTAINER_40FT — the codes + * ContractPricingService freezes). Null when there is no snapshot. + */ + private async frozenRateForContainer( + frozenRates: Map | null, + containerTypeId: string, + bookingCurrency: string, + ): Promise { + if (!frozenRates) return null; + let sizeFt: number | null = null; + try { + sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null; + } catch { + return null; + } + if (!sizeFt) return null; + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); + } + private lineItemsSignature(items: PriceLineItemDto[]): string { return JSON.stringify( [...items] diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 581ad917c..69d505c3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -534,6 +534,10 @@ export class BookingTransitionService { "REJECTION", ); + // Stop the open-invoice leak: a cancelled booking must not leave a payable + // invoice open. Mirror the pay-window-expiry path (billing.expirePayable). + await this.invoiceService.expireOpenInvoices(bookingId); + const updated = await this.bookingsRepository.update(bookingId, { status: "CANCELLED", } as never); @@ -562,6 +566,10 @@ export class BookingTransitionService { "REJECTION", ); + // Stop the open-invoice leak: a rejected booking must not leave a payable + // invoice open. Mirror the pay-window-expiry path (billing.expirePayable). + await this.invoiceService.expireOpenInvoices(bookingId); + const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 317066cc4..72211925f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -6,6 +6,7 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; +import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -200,6 +201,19 @@ export class BookingsRepository extends BaseRepository { return Number(route?.km ?? 0); } + /** + * Frozen contract unit-rate snapshots for a contract (H15). A booking created + * under a contract prices from these agreed, frozen rates rather than the live + * rate of the day; the pricing service matches them by rate code. + */ + findContractRateSnapshots( + contractId: string, + ): Promise { + return this.dataSource + .getRepository(ContractRateSnapshot) + .find({ where: { contractId } }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever @@ -217,10 +231,12 @@ export class BookingsRepository extends BaseRepository { quantity: number; containersPerWagon: number; }, + manager?: EntityManager, ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; - const qb = this.repository + const repo = manager ? manager.getRepository(Booking) : this.repository; + const qb = repo .createQueryBuilder('b') .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') @@ -257,7 +273,18 @@ export class BookingsRepository extends BaseRepository { ); } - return qb.orderBy('b.createdAt', 'ASC').getOne(); + qb.orderBy('b.createdAt', 'ASC'); + + // H9: under the caller's transaction, take a write lock on the matched + // partner booking row (FOR UPDATE OF b — booking rows only, not the joined + // reference tables) so a concurrent consolidation cannot claim the same + // partner between this find and the pair write. Only when a transaction + // manager is supplied — a pessimistic lock requires an open transaction. + if (manager) { + qb.setLock('pessimistic_write', undefined, ['b']); + } + + return qb.getOne(); } /** Try each partial-wagon line until a complementary partner booking is found. */ @@ -268,9 +295,14 @@ export class BookingsRepository extends BaseRepository { quantity: number; containersPerWagon: number; }>, + manager?: EntityManager, ): Promise { for (const slot of slots) { - const partner = await this.findComplementaryConsolidationPartner(booking, slot); + const partner = await this.findComplementaryConsolidationPartner( + booking, + slot, + manager, + ); if (partner) return partner; } return null; @@ -308,6 +340,63 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** + * Race-safe pairing (H9): the transactional counterpart of + * {@link pairConsolidation}. Must run inside the caller's transaction + * (`manager`), which should already hold the partner-row write lock taken by + * {@link findComplementaryConsolidationPartner}. Re-reads both rows and + * re-asserts `consolidationPartnerId IS NULL` on each before writing; returns + * `false` (no write) when either booking was already paired by a concurrent + * flow, so the caller can fall back to parking. + */ + async pairConsolidationIfUnpaired( + bookingId: string, + partnerId: string, + manager: EntityManager, + ): Promise { + const repo = manager.getRepository(Booking); + // Sequential (one connection per transaction) — never Promise.all here. + const booking = await repo.findOne({ + where: { id: bookingId }, + select: { + id: true, + consolidationPartnerId: true, + consolidationResumeStatus: true, + }, + }); + const partner = await repo.findOne({ + where: { id: partnerId }, + select: { + id: true, + consolidationPartnerId: true, + consolidationResumeStatus: true, + }, + }); + + // Re-assert both are still unpaired before writing (the partner row is held + // under the finder's write lock, so its state is stable here). + if ( + !booking || + !partner || + booking.consolidationPartnerId != null || + partner.consolidationPartnerId != null + ) { + return false; + } + + await repo.update(bookingId, { + consolidationPartnerId: partnerId, + status: booking.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, + } as never); + await repo.update(partnerId, { + consolidationPartnerId: bookingId, + status: partner.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, + } as never); + return true; + } + /** * Park a booking that needs consolidation but has no partner yet. The optional * resumeStatus is where the booking returns once it pairs — pass it for a diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4aade3bf2..588e4f1ed 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -508,13 +508,28 @@ export class BookingsService { return { booking, messages }; } - const partner = await this.bookingsRepository.findConsolidationPartner( - booking, - slots, - ); + // H9: find + pair must be atomic. Run both inside one transaction where the + // finder holds a write lock on the candidate partner row and pairing + // re-asserts both rows are still unpaired before writing — otherwise two + // concurrent bookings can claim the same partner (or pair an + // already-paired booking). `didPair` is false when a concurrent flow won + // the partner, in which case we fall through to parking below. + const partner = await this.dataSource.transaction(async (manager) => { + const candidate = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + manager, + ); + if (!candidate) return null; + const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired( + booking.id, + candidate.id, + manager, + ); + return didPair ? candidate : null; + }); if (partner) { - await this.bookingsRepository.pairConsolidation(booking.id, partner.id); const paired = await this.findById(booking.id); messages.push( this.consolidationService.describePaired(partner.reference, slots), diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts index 6f73035b4..54e47abaf 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -1,6 +1,6 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm'; import { CreateCargoDto } from './dto/create-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto'; @@ -32,6 +32,7 @@ export class CargoesService { if (!container) { throw new NotFoundException(`Container ${dto.containerId} not found`); } + await this.assertContainerCapacity(container, dto.weight); if (dto.cargoTypeId) { const cargoType = await this.cargoTypeRepo.findOne({ @@ -121,6 +122,10 @@ export class CargoesService { throw new ConflictException('Cargo already loaded or delivered'); } + if (cargo.container) { + await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id); + } + cargo.status = 'LOADED'; cargo.loadedAt = new Date(); cargo.quantity = dto.quantity; @@ -137,13 +142,31 @@ export class CargoesService { } async unloadCargo(id: string): Promise { - const cargo = await this.findById(id); + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, + }); + if (!cargo) throw new NotFoundException('Cargo not found'); if (cargo.status !== 'LOADED') { throw new ConflictException('Cargo is not loaded'); } cargo.status = 'UNLOADED'; cargo.unloadedAt = new Date(); - return this.cargoRepo.save(cargo); + const saved = await this.cargoRepo.save(cargo); + + // loadCargo flips the container to LOADED; on unload, free it back to + // AVAILABLE once no other LOADED cargo still references the container. + if (cargo.containerId != null && cargo.container) { + const remaining = await this.cargoRepo.count({ + where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) }, + }); + if (remaining === 0) { + cargo.container.status = 'AVAILABLE'; + await this.containerRepo.save(cargo.container); + } + } + + return saved; } async deliverCargo(id: string, dto?: DeliverCargoDto): Promise { @@ -161,10 +184,13 @@ export class CargoesService { if (dto?.receiverName) cargo.receiverName = dto.receiverName; if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks; + // Exclude the cargo being delivered — it is still LOADED in the DB until the + // save below, so counting it would keep `remaining` > 0 and never free the + // container. const remaining = cargo.containerId != null ? await this.cargoRepo.count({ - where: { containerId: cargo.containerId, status: 'LOADED' }, + where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) }, }) : 0; if (remaining === 0 && cargo.container) { @@ -174,4 +200,34 @@ export class CargoesService { return this.cargoRepo.save(cargo); } + + /** + * Reject when placing `newWeightKg` on the container would exceed its max gross + * weight. All values are kilograms: cargoes.weight is kg (entity), and the + * container's tare_weight / max_gross_weight are kg (entity). Capacity check is + * tare + already-LOADED cargo + new cargo <= max gross weight. + */ + private async assertContainerCapacity( + container: Container, + newWeightKg: number, + excludeCargoId?: string, + ): Promise { + const qb = this.cargoRepo + .createQueryBuilder('c') + .select('COALESCE(SUM(c.weight), 0)', 'sum') + .where('c.containerId = :containerId', { containerId: container.id }) + .andWhere('c.status = :status', { status: 'LOADED' }); + if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId }); + const raw = await qb.getRawOne<{ sum: string }>(); + + const loadedKg = Number(raw?.sum ?? 0); + const tareKg = Number(container.tareWeight); + const maxGrossKg = Number(container.maxGrossWeight); + if (tareKg + loadedKg + newWeightKg > maxGrossKg) { + throw new BadRequestException( + `Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` + + `new ${newWeightKg}kg > max gross ${maxGrossKg}kg`, + ); + } + } } diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts index bf7c966aa..2f9d984e6 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.service.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -1,7 +1,7 @@ // apps/edr-freight-api/src/modules/container-management/containers.service.ts import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; @@ -18,6 +18,7 @@ export class ContainersService { private readonly wagonRepo: Repository, // ✅ use raw repository @InjectRepository(ContainerType) private readonly containerTypeRepo: Repository, + private readonly dataSource: DataSource, ) {} async create(dto: CreateContainerDto): Promise { @@ -115,24 +116,42 @@ export class ContainersService { if (container.status === 'LOADED') { throw new ConflictException('Cannot reassign a loaded container'); } + // Reject a container that is already placed on a wagon — it must be + // unassigned first, otherwise it would silently jump to another wagon. + if (container.wagonId) { + throw new ConflictException( + `Container ${containerId} is already assigned to wagon ${container.wagonId}`, + ); + } const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); - let position: number | null = dto.position ?? null; - if (position === null) { - const maxPos = await this.containerRepo - .createQueryBuilder('c') - .select('MAX(c.position)', 'max') - .where('c.wagonId = :wagonId', { wagonId: wagon.id }) - .getRawOne(); - position = (maxPos?.max ?? 0) + 1; - } + // The MAX(position)+1 allocation is check-then-act: two concurrent assigns can + // read the same MAX and collide on the same position. Do the read + save inside + // one transaction to narrow the race window. + // TODO: add a unique (wagon_id, position) DB index so the database itself + // rejects a colliding position even under concurrency. + return this.dataSource.transaction(async (manager) => { + const containerRepo = manager.getRepository(Container); - container.wagonId = wagon.id; - container.position = position; - container.status = 'AVAILABLE'; - return this.containerRepo.save(container); + let position: number | null = dto.position ?? null; + if (position === null) { + const maxPos = await containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne<{ max: number | null }>(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; + // Placing a container on a wagon does not make it AVAILABLE. The status enum + // (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON + // state, so leave the existing status unchanged rather than forcing AVAILABLE. + return containerRepo.save(container); + }); } async unassignFromWagon(containerId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts index 3d5dc617b..43ab9d22a 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts @@ -78,12 +78,21 @@ export class ClearanceFeeService { * 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; + // 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. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 5fb935e74..e258820d7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -793,17 +793,34 @@ export class ContractTransitionService { const contract = await this.contractsService.findById(contractId); if (dto.role === 'CUSTOMER') { + // H12(a): only the owning company's customer may sign — assert ownership + // before anything else (hidden as NotFound otherwise). A signing customer + // has no permission key, so this is the gate that binds the sign to the + // contract's company. + await this.contractsService.assertCustomerCanAccessContract( + options.signerUserId, + contract, + ); assertContractStatus(contract, ['CONTRACT_READY']); const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); if (existing) { throw new BadRequestException('Customer has already signed this contract'); } - // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) - // must be verified before the signature is applied. - if (!dto.otpPhone || !dto.otp) { + // Sudo-mode gate: a fresh, single-use OTP must be verified before the + // signature is applied. H12(b): verify against the CONTRACT COMPANY's + // registered phone — never the caller-supplied dto.otpPhone, which an + // attacker could point at their own phone to sign someone else's + // contract. The OTP is issued to the company's registered number. + const companyPhone = contract.company?.phone?.trim(); + if (!companyPhone) { + throw new BadRequestException( + 'The contract company has no registered phone on file to verify the signing OTP against', + ); + } + if (!dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp); + await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 2957124f8..6284cad34 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -524,12 +524,18 @@ export class ContractsController { @Post(':id/renew') @ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' }) - renew( + async renew( @Param('id', ParseUUIDPipe) id: string, @Body() _dto: RenewContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.renew(id, user?.id ?? user?.sub); + // H12(c): a customer may only renew a contract their company owns. Staff + // with bookings.view bypass, mirroring getContractView/downloadContractDocument. + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + return this.transitionService.renew(id, resolveAuthUserId(user)); } // ── Pre-booking clearance (Path B, doc §15.2.1) ──────────────────────────── @@ -544,10 +550,17 @@ export class ContractsController { @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' }) - uploadClearanceDocuments( + async uploadClearanceDocuments( @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, @UploadedFiles() files: Express.Multer.File[], ) { + // H12(c): only the owning company's customer may upload clearance docs. + // Staff with bookings.view bypass, mirroring the other contract handlers. + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } return this.clearanceService.uploadDocuments(id, files ?? []); } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index b20b99575..688e0b4c7 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; -const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const; +// Canonical UPPERCASE — everything downstream (booking gating, pricing +// surcharge, GL/portal booking forms) compares contract.equipmentReturn +// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized. +const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; export { CONTRACT_KINDS, @@ -161,6 +164,9 @@ export class CreateContractDto { @ApiPropertyOptional({ enum: EQUIPMENT_RETURNS }) @IsOptional() + @Transform(({ value }) => + typeof value === 'string' ? value.toUpperCase() : value, + ) @IsIn([...EQUIPMENT_RETURNS]) equipmentReturn?: string; diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index d86ee823a..e5b6ae146 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Get, Post, @@ -72,7 +73,30 @@ export class DriversController { @Post(':id/documents') @BookingStaff(FREIGHT_PERMS.drivers.update) @ApiConsumes('multipart/form-data') - @UseInterceptors(AnyFilesInterceptor()) + // Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits + // AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads. + @UseInterceptors( + AnyFilesInterceptor({ + limits: { fileSize: 10 * 1024 * 1024, files: 20 }, + fileFilter: (_req, file, cb) => { + const allowed = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + ]; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb( + new BadRequestException(`Unsupported file type: ${file.mimetype}`), + false, + ); + } + }, + }), + ) @ApiOperation({ summary: 'Upload driver documents (code driver_docs)' }) uploadDocuments( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index e0d876176..307e24985 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -6,22 +6,24 @@ import { Query, Res, } from "@nestjs/common"; -import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; -import { Public } from "@edr/api-common"; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; import { Response } from "express"; import { FilesService } from "./files.service"; @ApiTags("files") +@ApiBearerAuth() @Controller("files") export class FilesController { constructor(private readonly filesService: FilesService) {} @Get(":fileId") - // Public so the browser can load the bytes directly via /