From d7d9db9c3a82103fed228b9b12b06ef316d09837 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 15 Jul 2026 14:27:00 +0000 Subject: [PATCH 01/20] 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 7bbd34f159d5ec58d97d0d18ba5b25b1593b6cd6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 15 Jul 2026 19:18:35 +0300 Subject: [PATCH 02/20] Currency issue resolution --- .../src/modules/bookings/bookings.service.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index cffa01c4b..563a095aa 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -321,7 +321,7 @@ export class BookingsService { bookingRef: b.bookingRef, status: b.status, totalMinor: b.totalMinor, - currency: b.currency || 'ETB', + currency: b.currency || null, displayCurrency: b.displayCurrency ?? null, displayTotalMinor: b.displayTotalMinor ?? null, adultCount: b.adultCount, @@ -554,7 +554,8 @@ export class BookingsService { const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB', + totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true, @@ -577,7 +578,7 @@ export class BookingsService { const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, bookingRef: b.bookingRef, status: b.status, - totalMinor: b.totalMinor, currency: b.currency || 'ETB', + totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, contactPhone: b.contactPhone, bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId, @@ -723,7 +724,7 @@ export class BookingsService { bookingRef: b.bookingRef, status: b.status, totalMinor: b.totalMinor, - currency: b.currency || 'ETB', + currency: b.currency || b.displayCurrency, displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, contactEmail: b.contactEmail, @@ -1314,7 +1315,7 @@ export class BookingsService { combinedBaseFareMinor: combinedBase, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, - currency: 'ETB', displayCurrency, displayTotalMinor, + currency: displayCurrency, displayTotalMinor, }, }; } @@ -1513,7 +1514,7 @@ export class BookingsService { combinedBaseFareMinor: combinedBase, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, - currency: 'ETB', displayCurrency, displayTotalMinor, + currency: displayCurrency, displayTotalMinor, }, }; } @@ -1810,7 +1811,7 @@ export class BookingsService { bookingRef: pkgBooking.bookingRef, status: pkgBooking.status, totalMinor: pkgBooking.totalMinor, - currency: pkgBooking.currency || 'ETB', + currency: pkgBooking.currency || pkgBooking.displayCurrency, adultCount: pkgBooking.passengerCount, childCount: 0, displayCurrency: pkgBooking.displayCurrency, @@ -1870,7 +1871,8 @@ export class BookingsService { return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB', + totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: booking.displayCurrency, adultCount: booking.adultCount, childCount: booking.childCount, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined, bookingType: booking.bookingType, @@ -1973,7 +1975,7 @@ export class BookingsService { await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } }); this.eventEmitter.emit('booking.cancelled', { booking, refundAmount }); await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'DELETE', entityType: 'Booking', entityId: booking.id, oldData: { bookingRef, status: booking.status }, newData: { status: 'CANCELLED', reason, refundAmount } }); - return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' }; + return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency}; } async update(id: string, dto: any) { From 8bda06b2267c1ae3ff585d430dc44831ca9b7a44 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 15 Jul 2026 19:50:32 +0300 Subject: [PATCH 03/20] Currency issue resolution --- .../src/modules/bookings/bookings.service.ts | 5 +- .../modules/bookings/guest-booking.service.ts | 385 +++++++++--------- 2 files changed, 198 insertions(+), 192 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 563a095aa..2a9de63d2 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -881,6 +881,7 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor, + currency: displayCurrency, adultCount, childCount, displayCurrency, @@ -1058,6 +1059,7 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', totalMinor, + currency: displayCurrency, adultCount, childCount, displayCurrency, @@ -1250,6 +1252,7 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', totalMinor, + currency: displayCurrency, adultCount, childCount, displayCurrency, @@ -1459,7 +1462,7 @@ export class BookingsService { destinationStationId: dto.leg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', - totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, + totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, // Outbound transit leg-2 leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 259beb404..821db5177 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -18,7 +18,7 @@ function generateRef(): string { } // Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx) -const ETH_MOBILE_PREFIXES = ['911','912','913','914','915','916','917','921','922','923','924','930','931','932','933','934','935','936','937','938','939','961','962','963','964']; +const ETH_MOBILE_PREFIXES = ['911', '912', '913', '914', '915', '916', '917', '921', '922', '923', '924', '930', '931', '932', '933', '934', '935', '936', '937', '938', '939', '961', '962', '963', '964']; function generateEthiopianPhone(): string { const prefix = ETH_MOBILE_PREFIXES[Math.floor(Math.random() * ETH_MOBILE_PREFIXES.length)]; @@ -50,7 +50,7 @@ export class GuestBookingService { private passengerAuthService: PassengerAuthService, private fareEngine: FareEngineService, private eventEmitter: EventEmitter2, - ) {} + ) { } async createGuestBooking(dto: CreateGuestBookingDto, req?: any) { // Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline. @@ -70,8 +70,8 @@ export class GuestBookingService { }); } } - if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req); - if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req); + if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req); + if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req); return this.createGuestOneWayBooking(dto, req); } @@ -123,8 +123,8 @@ export class GuestBookingService { let nationality = passenger.nationality; const isEthiopian = passenger.nationality === 'Ethiopian' || - passenger.nationality === 'ETHIOPIAN' || - passenger.idDocumentType === IdDocumentType.NATIONAL_ID; + passenger.nationality === 'ETHIOPIAN' || + passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (passenger.idDocumentNumber) { @@ -278,13 +278,14 @@ export class GuestBookingService { destinationStationId: dto.destinationStationId, status: 'PENDING_PAYMENT', totalMinor: resolvedTotalMinor, + currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, bookingType: 'ONE_WAY', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), - userAgent: dto.deviceId, + userAgent: dto.deviceId, contactEmail: firstPassenger.email || null, contactPhone: firstPassenger.phone || null, seats: { @@ -350,7 +351,7 @@ export class GuestBookingService { this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), ]); if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired or not found'); - if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found'); + if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired or not found'); // Validate passengers have returnSeatId for (const p of dto.passengers) { @@ -369,7 +370,7 @@ export class GuestBookingService { }), ]); if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); - if (!returnSchedule) throw new NotFoundException('Return schedule not found'); + if (!returnSchedule) throw new NotFoundException('Return schedule not found'); if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); @@ -379,19 +380,19 @@ export class GuestBookingService { const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; return { stationId, sequence: seq, station }; }; - const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)]; - const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)]; - const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0]; - const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1]; - const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0]; - const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1]; + const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)]; + const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)]; + const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0]; + const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1]; + const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0]; + const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1]; if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); - if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); + if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`; - const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; - const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; - const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`; + const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; + const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; + const returnFullRoute = `${returnSchedule.originStation.code}-${returnSchedule.destinationStation.code}`; // Process passengers (verify identity once — same person travels both legs) const passengersData: any[] = []; @@ -409,16 +410,16 @@ export class GuestBookingService { let nationality = passenger.nationality; const isEthiopian = passenger.nationality === 'Ethiopian' || - passenger.nationality === 'ETHIOPIAN' || - passenger.idDocumentType === IdDocumentType.NATIONAL_ID; + passenger.nationality === 'ETHIOPIAN' || + passenger.idDocumentType === IdDocumentType.NATIONAL_ID; if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) { if (passenger.idDocumentNumber) { const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); - passengerName = verification.passengerData?.fullName || passengerName; + passengerName = verification.passengerData?.fullName || passengerName; verifaydaVerified = true; - verifaydaData = verification.passengerData?.profileData; + verifaydaData = verification.passengerData?.profileData; } nationality = 'Ethiopian'; } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { @@ -450,7 +451,7 @@ export class GuestBookingService { returnBaseFare = tier.priceMinor - halfMinor; paidChildrenCount = childCount; outboundChildUnitFare = Math.round(outboundBaseFare * 0.1); - returnChildUnitFare = Math.round(returnBaseFare * 0.1); + returnChildUnitFare = Math.round(returnBaseFare * 0.1); } else { const primaryNationality = passengersData[0]?.nationality; [outboundBaseFare, returnBaseFare] = await Promise.all([ @@ -459,11 +460,11 @@ export class GuestBookingService { ]); paidChildrenCount = Math.max(0, childCount - 1); outboundChildUnitFare = outboundBaseFare; - returnChildUnitFare = returnBaseFare; + returnChildUnitFare = returnBaseFare; } - const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount; - const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount; - const combinedBaseFareMinor = outboundTotalBase + returnTotalBase; + const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount; + const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount; + const combinedBaseFareMinor = outboundTotalBase + returnTotalBase; let discountMinor = 0; if (dto.promoCode) { @@ -490,16 +491,16 @@ export class GuestBookingService { let outboundFareMinor: number; let returnFareMinor: number; if (p.category === PassengerCategory.ADULT) { - outboundFareMinor = p.seatFareMinor ?? outboundBaseFare; - returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare; + outboundFareMinor = p.seatFareMinor ?? outboundBaseFare; + returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare; } else if (isPackageRoundTrip) { outboundFareMinor = 0; - returnFareMinor = 0; + returnFareMinor = 0; } else { if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare; - if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } - else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare; + if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; } + else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare; } return { ...p, outboundFareMinor, returnFareMinor }; }); @@ -527,69 +528,70 @@ export class GuestBookingService { // Create booking with outbound seats; return seats confirmed separately const outboundSeatIds = dto.passengers.map(p => p.seatId).filter((id): id is string => !!id); - const returnSeatIds = dto.passengers.map(p => p.returnSeatId!); + const returnSeatIds = dto.passengers.map(p => p.returnSeatId!); const booking = await this.prisma.booking.create({ data: { - bookingRef: generateRef(), - passengerId: guestPassengerId, - scheduleId: dto.scheduleId, - originStationId: dto.originStationId, - destinationStationId: dto.destinationStationId, - status: 'PENDING_PAYMENT', - bookingType: 'ROUND_TRIP', + bookingRef: generateRef(), + passengerId: guestPassengerId, + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP', totalMinor, + currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, - returnScheduleId: dto.returnScheduleId, - returnOriginStationId: dto.returnOriginStationId, - returnDestinationStationId: dto.returnDestinationStationId, - returnHoldId: dto.returnHoldId, + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnHoldId: dto.returnHoldId, returnSeatClassId, - returnLegStatus: 'NEITHER_USED', + returnLegStatus: 'NEITHER_USED', ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), - userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersWithFares.map((p) => ({ - seat: { connect: { id: p.seatId } }, - leg: 1, - scheduleId: dto.scheduleId, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, + seat: { connect: { id: p.seatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, passengerCategory: p.category, - idDocumentType: p.idDocumentType, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.outboundFareMinor, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.outboundFareMinor, displayCurrency, })), ...passengersWithFares.map((p) => ({ - seat: { connect: { id: p.returnSeatId } }, - leg: 2, - scheduleId: dto.returnScheduleId, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, + seat: { connect: { id: p.returnSeatId } }, + leg: 2, + scheduleId: dto.returnScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, passengerCategory: p.category, - idDocumentType: p.idDocumentType, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.returnFareMinor, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.returnFareMinor, displayCurrency, })), ], }, } as any, include: { - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); @@ -608,16 +610,16 @@ export class GuestBookingService { iamUserId, fareBreakdown: { outboundBaseFareMinor: outboundBaseFare, - returnBaseFareMinor: returnBaseFare, + returnBaseFareMinor: returnBaseFare, adultCount, childCount, - freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1), + freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1), paidChildrenCount, combinedBaseFareMinor, discountMinor, - taxesFeesMinor: taxesMinor, + taxesFeesMinor: taxesMinor, totalMinor, - currency: 'ETB', + currency: displayCurrency, displayCurrency, displayTotalMinor, }, @@ -658,9 +660,9 @@ export class GuestBookingService { } const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); - const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); + const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); - const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule'); if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule'); @@ -695,9 +697,9 @@ export class GuestBookingService { passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } - const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId; + const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId; const primaryNationality = passengersData[0]?.nationality; - const paidChildrenCount = Math.max(0, childCount - 1); + const paidChildrenCount = Math.max(0, childCount - 1); const [leg1BaseFare, leg2BaseFare] = await Promise.all([ this.getBaseFare(dto.scheduleId, dto.seatClassId, @@ -710,9 +712,9 @@ export class GuestBookingService { primaryNationality, dto.transitStationId, dto.leg2DestinationStationId), ]); - const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; - const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount; - const combinedBase = leg1Total + leg2Total; + const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; + const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount; + const combinedBase = leg1Total + leg2Total; let discountMinor = 0; if (dto.promoCode) { @@ -734,62 +736,63 @@ export class GuestBookingService { // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 const booking = await this.prisma.booking.create({ data: { - bookingRef: generateRef(), - passengerId: guestPassengerId, - scheduleId: dto.scheduleId, - originStationId: dto.originStationId, - destinationStationId: dto.leg2DestinationStationId, - status: 'PENDING_PAYMENT', - bookingType: 'TRANSIT', + bookingRef: generateRef(), + passengerId: guestPassengerId, + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.leg2DestinationStationId, + status: 'PENDING_PAYMENT', + bookingType: 'TRANSIT', totalMinor, + currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, - leg2ScheduleId: dto.leg2ScheduleId, - leg2OriginStationId: dto.transitStationId, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, - leg2SeatClassId: leg2SeatClassId, - userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + leg2SeatClassId: leg2SeatClassId, + userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map(p => ({ - seat: { connect: { id: p.seatId } }, - leg: 1, - scheduleId: dto.scheduleId, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, + seat: { connect: { id: p.seatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, passengerCategory: p.category, - idDocumentType: p.idDocumentType, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0), + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0), displayCurrency, })), ...passengersData.map(p => ({ - seat: { connect: { id: p.leg2SeatId! } }, - leg: 2, - scheduleId: dto.leg2ScheduleId, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, + seat: { connect: { id: p.leg2SeatId! } }, + leg: 2, + scheduleId: dto.leg2ScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, passengerCategory: p.category, - idDocumentType: p.idDocumentType, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0), + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0), displayCurrency, })), ], }, } as any, include: { - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); @@ -821,15 +824,15 @@ export class GuestBookingService { private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto, req?: any) { if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || - !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || - !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { + !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || + !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { throw new BadRequestException( 'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields', ); } for (const p of dto.passengers) { - if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`); - if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`); + if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`); + if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`); if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`); } @@ -840,19 +843,19 @@ export class GuestBookingService { this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }), ]); - if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired'); - if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired'); + if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired'); + if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired'); if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired'); if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ - this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), - this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), - this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), - this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), ]); - if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found'); - if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found'); + if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found'); + if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found'); if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); @@ -860,16 +863,16 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); - const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); - const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); - const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); + const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId); - const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); + const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); - const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId); - if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found'); - if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found'); + const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId); + if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found'); + if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found'); if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found'); @@ -901,21 +904,21 @@ export class GuestBookingService { passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } - const nat = passengersData[0]?.nationality; - const paidChildren = Math.max(0, childCount - 1); - const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId; - const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId; - const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; + const nat = passengersData[0]?.nationality; + const paidChildren = Math.max(0, childCount - 1); + const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId; + const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId; + const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ - this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId), - this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId), - this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId), - this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId), + this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId), + this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId), + this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId), + this.getBaseFare(dto.returnLeg2ScheduleId!, retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId), ]); const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + - (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren; + (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren; let discountMinor = 0; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); @@ -923,8 +926,8 @@ export class GuestBookingService { discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } - const taxesMinor = Math.round(combinedBase * 0.05); - const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + const taxesMinor = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) @@ -933,58 +936,58 @@ export class GuestBookingService { const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req); const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ - seat: { connect: { id: seatId } }, + seat: { connect: { id: seatId } }, leg, scheduleId, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, passengerCategory: p.category, - idDocumentType: p.idDocumentType, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0), + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0), displayCurrency, }); const booking = await this.prisma.booking.create({ data: { - bookingRef: generateRef(), - passengerId: guestPassengerId, - scheduleId: dto.scheduleId, - originStationId: dto.originStationId, - destinationStationId: dto.returnLeg2DestinationStationId, - status: 'PENDING_PAYMENT', - bookingType: 'ROUND_TRIP_TRANSIT', - totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, - leg2ScheduleId: dto.leg2ScheduleId, - leg2OriginStationId: dto.transitStationId, - leg2DestinationStationId: dto.leg2DestinationStationId, - leg2SeatClassId: obL2ClassId, - returnScheduleId: dto.returnScheduleId, - returnOriginStationId: dto.returnOriginStationId, - returnDestinationStationId: dto.returnDestinationStationId, - returnSeatClassId: retL1ClassId, - returnLeg2ScheduleId: dto.returnLeg2ScheduleId, - returnLeg2OriginStationId: dto.returnTransitStationId, - returnLeg2DestStationId: dto.returnLeg2DestinationStationId, - returnLeg2SeatClassId: retL2ClassId, - returnLegStatus: 'NEITHER_USED', - userAgent: dto.deviceId, - contactEmail: passengersData[0]?.email || null, - contactPhone: passengersData[0]?.phone || null, + bookingRef: generateRef(), + passengerId: guestPassengerId, + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.returnLeg2DestinationStationId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP_TRANSIT', + totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId: obL2ClassId, + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnSeatClassId: retL1ClassId, + returnLeg2ScheduleId: dto.returnLeg2ScheduleId, + returnLeg2OriginStationId: dto.returnTransitStationId, + returnLeg2DestStationId: dto.returnLeg2DestinationStationId, + returnLeg2SeatClassId: retL2ClassId, + returnLegStatus: 'NEITHER_USED', + userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ - ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), - ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)), - ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)), - ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)), + ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), + ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)), + ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)), + ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!, 4, dto.returnLeg2ScheduleId!, retL2Fare)), ], }, } as any, include: { - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: true } } } }, schedule: { include: { originStation: true, destinationStation: true, train: true } }, }, }); @@ -1006,14 +1009,14 @@ export class GuestBookingService { fareBreakdown: { outboundLeg1FareMinor: obL1Fare, outboundLeg2FareMinor: obL2Fare, - returnLeg1FareMinor: retL1Fare, - returnLeg2FareMinor: retL2Fare, + returnLeg1FareMinor: retL1Fare, + returnLeg2FareMinor: retL2Fare, adultCount, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount: paidChildren, combinedBaseFareMinor: combinedBase, discountMinor, taxesFeesMinor: taxesMinor, totalMinor, - currency: 'ETB', displayCurrency, displayTotalMinor, + currency: displayCurrency, displayCurrency, displayTotalMinor, }, }; } @@ -1042,7 +1045,7 @@ export class GuestBookingService { const guestPassenger = await this.prisma.passenger.create({ data: {} }); await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } }); await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } }); - + return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false }; } @@ -1052,7 +1055,7 @@ export class GuestBookingService { if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') { gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null; } - + await this.prisma.travelerProfile.create({ data: { passengerId, @@ -1130,8 +1133,8 @@ export class GuestBookingService { }), ]); - const premiumMinor = seatClass?.premiumMinor ?? 0; - const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0; + const premiumMinor = seatClass?.premiumMinor ?? 0; + const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0; const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality }, From 2ffb2f9f6037e9a2ae9edd5b967eefbbefa972ed Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 15 Jul 2026 20:05:55 +0300 Subject: [PATCH 04/20] remove auth --- .../src/app/booking/auth-check/page.tsx | 5 +++- .../portal/src/components/AppSidebar.tsx | 30 ++++++++++--------- .../portal/src/components/BottomTabBar.tsx | 20 +++++++------ 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx index d9c7f92a3..e38c07d90 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/auth-check/page.tsx @@ -4,7 +4,8 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useBookingStore } from '@/lib/booking-store'; -import { UserPlus, LogIn, ChevronLeft } from 'lucide-react'; +import { UserPlus, ChevronLeft } from 'lucide-react'; +// import { LogIn } from 'lucide-react'; // TODO: re-enable auth — used by commented-out SignIn/Register button function Tooltip({ children, content }: { children: React.ReactNode; content: string[] }) { const [visible, setVisible] = useState(false); @@ -95,6 +96,7 @@ export default function AuthCheckPage() { + {/* TODO: re-enable auth — SignIn or Register button commented out until auth integration + */}
diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index 0acb5d9ea..08874193a 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -192,20 +192,22 @@ export default function AppSidebar() { )}
) : ( -
- - Sign in - - - Register - -
+ // TODO: re-enable auth — Sign in / Register links commented out until auth integration + //
+ // + // Sign in + // + // + // Register + // + //
+ null )} diff --git a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx index ee4cdccad..8017f3087 100644 --- a/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx +++ b/apps/edr-passenger-web/portal/src/components/BottomTabBar.tsx @@ -1,9 +1,10 @@ 'use client'; -import { Home, Phone, Ticket, User } from 'lucide-react'; +import { Home, Phone, Ticket } from 'lucide-react'; +// import { User } from 'lucide-react'; // TODO: re-enable auth — used by commented-out Sign in tab import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { useAuthStore } from '@/lib/auth-store'; +// import { useAuthStore } from '@/lib/auth-store'; // TODO: re-enable auth // The linear, one-screen-at-a-time booking flow — each of these pages already // has its own sticky mobile CTA bar (and the mobile step strip at the top), @@ -21,7 +22,7 @@ const LINEAR_FLOW_PREFIXES = [ export default function BottomTabBar() { const pathname = usePathname() ?? ''; - const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + // const isAuthenticated = useAuthStore((s) => s.isAuthenticated); // TODO: re-enable auth const isInLinearFlow = LINEAR_FLOW_PREFIXES.some((p) => pathname.startsWith(p)); if (isInLinearFlow) return null; @@ -30,12 +31,13 @@ export default function BottomTabBar() { { href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' }, { href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') }, { href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') }, - { - href: isAuthenticated ? '/profile' : '/login', - label: isAuthenticated ? 'Account' : 'Sign in', - icon: User, - match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), - }, + // TODO: re-enable auth — auth login/register tab commented out until auth integration + // { + // href: isAuthenticated ? '/profile' : '/login', + // label: isAuthenticated ? 'Account' : 'Sign in', + // icon: User, + // match: (p: string) => p.startsWith('/profile') || p.startsWith('/login') || p.startsWith('/register'), + // }, ]; return ( From 2d2fcfbda9de3e1845bfb36193d427e8572dddfe Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 15 Jul 2026 21:11:23 +0300 Subject: [PATCH 05/20] Seat block issue resolution --- .../src/modules/seats/seats.service.ts | 27 +++++++++++++------ .../backoffice/src/app/seats/page.tsx | 25 ++++++++++------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index c85b49bbf..340900fdd 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -224,16 +224,27 @@ export class SeatsService { } } - const availability = await this.segmentsService.getSeatAvailabilityMap( - scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY, - ); + const [availability, persistedSeats] = await Promise.all([ + this.segmentsService.getSeatAvailabilityMap( + scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY, + ), + this.prisma.seat.findMany({ + where: { id: { in: seatIds } }, + select: { id: true, status: true }, + }), + ]); + + const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status])); - // Every requested seat defaults to AVAILABLE — this also guards against a stale - // persisted Seat.status column (e.g. a leftover 'BOOKED'/'BLOCKED' value) bleeding - // through getSeatMap's own fallback, since that fallback only triggers when this - // map has no entry at all for a given seat. for (const seatId of seatIds) { - statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE'); + const persisted = persistedStatus.get(seatId); + // BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins — + // always honour them regardless of hold/booking state. + if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') { + statusMap.set(seatId, persisted!); + } else { + statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE'); + } } return statusMap; diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 413383b7a..9c9ee3286 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -35,6 +35,7 @@ export default function SeatsPage() { queryKey: ['seatmap', selectedSchedule], queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null), enabled: !!selectedSchedule, + staleTime: 0, }); const { data: coachTypesData } = useQuery({ @@ -49,6 +50,7 @@ export default function SeatsPage() { const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({ queryKey: ['routeCoaches', selectedRoute], + staleTime: 0, queryFn: async () => { if (!selectedRoute) return null; const template: any[] = await routeCoachTemplatesApi.get(selectedRoute); @@ -79,10 +81,15 @@ export default function SeatsPage() { enabled: !!selectedRoute, }); + const invalidateSeatData = () => { + queryClient.refetchQueries({ queryKey: ['seatmap', selectedSchedule] }); + queryClient.refetchQueries({ queryKey: ['routeCoaches', selectedRoute] }); + }; + const blockMutation = useMutation({ mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + invalidateSeatData(); setShowBlockModal(false); setSelectedSeat(null); setBlockReason(''); @@ -92,14 +99,14 @@ export default function SeatsPage() { const unblockMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.unblock(seatId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + invalidateSeatData(); }, }); const removeSeatMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.removeSeat(seatId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + invalidateSeatData(); setShowRemoveModal(false); setSelectedSeat(null); }, @@ -108,7 +115,7 @@ export default function SeatsPage() { const undoRemoveMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.undoRemove(seatId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + invalidateSeatData(); }, }); @@ -116,7 +123,7 @@ export default function SeatsPage() { mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) => seatsApi.setMaintenance(seatId, reason), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + invalidateSeatData(); setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); @@ -125,7 +132,7 @@ export default function SeatsPage() { const clearMaintenanceMutation = useMutation({ mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }), + onSuccess: () => invalidateSeatData(), }); const schedules = schedulesData?.items || schedulesData?.data || []; @@ -139,7 +146,7 @@ export default function SeatsPage() { return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason }))); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + invalidateSeatData(); setShowBlockCoachModal(false); setSelectedCoach(null); setBlockCoachReason(''); @@ -153,7 +160,7 @@ export default function SeatsPage() { return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId))); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['seatmap'] }); + invalidateSeatData(); setShowUnblockCoachModal(false); setCoachToUnblock(null); }, @@ -1015,7 +1022,7 @@ function SeatIcon({ const color = getSeatColor(status); const canBlock = status === 'AVAILABLE'; const canUnblock = status === 'BLOCKED'; - const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED'; + const canMaintenance = false; const canClearMaintenance = status === 'UNDER_MAINTENANCE'; return ( From 9cf71e7e7a56ed515392eccfc3342a162ad9e597 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 15 Jul 2026 21:23:10 +0300 Subject: [PATCH 06/20] feat: ( payment ) add waafi webhook log --- .../src/modules/webhooks/handlers/waafi-webhook.service.ts | 6 ++++++ .../src/modules/webhooks/webhooks.controller.ts | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts index 232957f79..3cd0b4c1c 100644 --- a/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts +++ b/apps/edr-payment-api/src/modules/webhooks/handlers/waafi-webhook.service.ts @@ -45,6 +45,12 @@ export class WaafiWebhookService { const mapped = this.provider.mapWebhookStatus(payment.status); + this.logger.log( + `Waafi authorization: ref=${payment.reference_id} txn=${payment.transaction_id} ` + + `rawStatus=${payment.status} mapped=${mapped} ` + + `signatureValid=${signatureValid} (fresh=${this.isFresh(timestamp)}) eventId=${eventId ?? "n/a"}`, + ); + await this.processor.process({ provider: this.provider.method, // X-Webhook-Event-Id is unique per event; fall back to a derived id if absent. diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index f1e4fa6b0..6c923e8b4 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -127,10 +127,14 @@ export class WebhooksController { @Req() req: { rawBody?: Buffer }, ) { - this.logger.log("\n\n\n\nWaafi payment notification callback (Djibouti)\n\n\n\n"); this.logger.log( `Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`, ); + this.logger.log(`Waafi webhook headers: ${JSON.stringify(headers)}`); + this.logger.log(`Waafi webhook payload: ${JSON.stringify(payload)}`); + this.logger.log( + `Waafi webhook raw body: ${req.rawBody?.toString("utf8") ?? "(none)"}`, + ); try { // HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON. const rawBody = req.rawBody?.toString("utf8") ?? ""; From f185da2163555f39c4e5deedf05022d51df8e535 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 15 Jul 2026 21:28:35 +0300 Subject: [PATCH 07/20] Update seatmap for blocked seats --- .../src/modules/bookings/bookings.service.ts | 2 + .../portal/src/app/booking/detail/page.tsx | 2 + .../portal/src/app/booking/review/page.tsx | 63 ++++++++++++++----- .../portal/src/app/booking/seats/page.tsx | 2 +- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 2a9de63d2..a6703f55e 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1879,6 +1879,8 @@ export class BookingsService { adultCount: booking.adultCount, childCount: booking.childCount, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined, bookingType: booking.bookingType, + packageId: (booking as any).packageId ?? null, + isPackageBooking: !!(booking as any).packageId, returnLegStatus: (booking as any).returnLegStatus ?? null, outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, returnBoardedAt: (booking as any).returnBoardedAt ?? null, diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 025c212bf..c0c9f56bb 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -427,6 +427,7 @@ function BookingDetailContent() { + {!booking.isPackageBooking && booking.bookingType !== "PACKAGE" && (

Fare breakdown @@ -481,6 +482,7 @@ function BookingDetailContent() { ); })}

+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 10051f0d3..95d390182 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -52,6 +52,7 @@ export default function ReviewPage() { const [timeLeft, setTimeLeft] = useState(''); const [seatDetails, setSeatDetails] = useState>({}); const [fareBreakdown, setFareBreakdown] = useState(null); + const [returnFareBreakdown, setReturnFareBreakdown] = useState(null); const [computedTotal, setComputedTotal] = useState(0); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; @@ -173,16 +174,27 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => // Prefers displayFareMinor (converted) from the fare breakdown API when available. // Falls back to raw ETB seat fares (which are always in minor units). const getPassengerSeatFare = (p: any, index?: number): number | null => { + if (isRoundTrip) { + // Seat-specific fares (set during seat selection) cover each leg separately — use them first. + if (p.outboundSeatFareMinor != null || p.inboundSeatFareMinor != null) { + if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2; + return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0); + } + // No seat-specific fares: fall back to the per-leg fare-breakdown totals for both legs. + if (!isPackageBooking && fareBreakdown?.passengers && returnFareBreakdown?.passengers && index != null) { + const obLine = fareBreakdown.passengers[index]; + const retLine = returnFareBreakdown.passengers[index]; + const obFare = obLine?.displayFareMinor ?? obLine?.fareMinor; + const retFare = retLine?.displayFareMinor ?? retLine?.fareMinor; + if (obFare != null && retFare != null) return obFare + retFare; + } + return null; + } if (!isPackageBooking && fareBreakdown?.passengers && index != null) { const line = fareBreakdown.passengers[index]; const displayFare = line?.displayFareMinor ?? line?.fareMinor; if (displayFare != null) return displayFare; } - if (isRoundTrip) { - if (p.outboundSeatFareMinor == null && p.inboundSeatFareMinor == null) return null; - if (isPackageBooking) return (p.outboundSeatFareMinor ?? 0) * 2; - return (p.outboundSeatFareMinor ?? 0) + (p.inboundSeatFareMinor ?? 0); - } if (p.seatFareMinor == null) return null; return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor; }; @@ -541,6 +553,22 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => const result: any = await apiClient.get(`/search/fare-breakdown?${params}`); setFareBreakdown(result); + + // For round-trips, also fetch the return leg's fare breakdown so the review page + // can display and send the correct combined total (outbound + return per passenger). + if (isRoundTrip && inboundSchedule) { + const returnScheduleId = (inboundSchedule as any).id; + const returnParams = new URLSearchParams({ + scheduleId: returnScheduleId, + originStationId: searchCriteria.destinationStationId, + destinationStationId: searchCriteria.originStationId, + passengers: passengersParam, + displayCurrency: displayCurrencyCode, + ...(searchCriteria.promoCode ? { promoCode: searchCriteria.promoCode } : {}), + }); + const returnResult: any = await apiClient.get(`/search/fare-breakdown?${returnParams}`); + setReturnFareBreakdown(returnResult); + } } catch (err) { } })(); @@ -566,7 +594,11 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => const isFreeChild = line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)); if (isFreeChild) return sum; const seatFare = getPassengerSeatFare(p, i); - const displayFare = line?.displayFareMinor ?? line?.fareMinor; + // For round-trips the fallback must combine both legs; for one-way it's the single-leg fare. + const obFare = line?.displayFareMinor ?? line?.fareMinor; + const retLine = returnFareBreakdown?.passengers?.[i]; + const retFare = retLine?.displayFareMinor ?? retLine?.fareMinor; + const displayFare = isRoundTrip && retFare != null ? (obFare ?? 0) + retFare : obFare; return sum + (seatFare ?? displayFare ?? 0); }, 0); @@ -586,26 +618,27 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => ? isPkgFreeChild(i) : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); - // Per-leg fares for round trips — use converted amounts from fareBreakdown when available - const displayFare = line?.displayFareMinor ?? line?.fareMinor; + // Per-leg fares for round trips — prefer seat-specific fares, then per-leg breakdowns. + const retLine = returnFareBreakdown?.passengers?.[i]; + const obBreakdownFare = line?.displayFareMinor ?? line?.fareMinor; + const retBreakdownFare = retLine?.displayFareMinor ?? retLine?.fareMinor; const outboundFare: number | null = isRoundTrip ? (isPackageBooking ? (packageTierPriceMinor ?? null) - : (displayFare != null - ? Math.round(displayFare / 2) - : ((p as any).outboundSeatFareMinor ?? null))) + : ((p as any).outboundSeatFareMinor ?? obBreakdownFare ?? null)) : null; const inboundFare: number | null = isRoundTrip ? (isPackageBooking ? (packageTierPriceMinor ?? null) - : (displayFare != null - ? Math.round(displayFare / 2) - : ((p as any).inboundSeatFareMinor ?? null))) + : ((p as any).inboundSeatFareMinor ?? retBreakdownFare ?? null)) : null; const seatFare = getPassengerSeatFare(p, i); + const combinedDisplayFare = isRoundTrip && retBreakdownFare != null + ? (obBreakdownFare ?? 0) + retBreakdownFare + : obBreakdownFare; const passengerTotal = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare))) - : (isFreeChild ? 0 : (seatFare ?? displayFare ?? 0)); + : (isFreeChild ? 0 : (seatFare ?? combinedDisplayFare ?? 0)); return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index a143f7a1e..b38e3aee9 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -52,7 +52,7 @@ const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => ? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700" : bed.status === "AVAILABLE" ? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700" - : bed.status === "BOOKED" + : bed.status === "BOOKED" || bed.status === "BLOCKED" ? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700" : "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700" }`} From 63accc8bd321673b415cec287601de9e4a82b775 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Wed, 15 Jul 2026 22:03:29 +0300 Subject: [PATCH 08/20] Fix booking detail for roundtrip --- .../portal/src/app/booking/detail/page.tsx | 359 +++++++----------- 1 file changed, 143 insertions(+), 216 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index c0c9f56bb..a54ce795b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -412,6 +412,103 @@ function BookingDetailContent() { return displayTotal / etbTotal; })(); + // Flight-style origin → train → destination timeline for a single leg's schedule. + // Shared by both the pending-payment "Trip Summary" card and the confirmed booking's + // "Journey Details" card so a round trip's outbound and return legs render identically — + // each of those cards calls this once per leg instead of hardcoding booking.schedule only. + const renderJourneyTimeline = (schedule: any) => ( +
+
+
+
+
+
+
+
+
+ {schedule?.departureAt ? formatTime(schedule.departureAt) : "--:--"} +
+
+ {schedule?.departureAt + ? `${format(toZonedDate(new Date(schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(schedule.departureAt)}` + : "N/A"} +
+
+ {schedule?.origin?.name} +
+
+ {schedule?.origin?.city} +
+
+ +
+
+
+ + + + Train {schedule?.trainNumber} +
+
+
+ +
+
+ {schedule?.arrivalAt ? formatTime(schedule.arrivalAt) : "--:--"} +
+
+ {schedule?.arrivalAt + ? `${format(toZonedDate(new Date(schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(schedule.arrivalAt)}` + : "N/A"} +
+
+ {schedule?.destination?.name} +
+
+ {schedule?.destination?.city} +
+
+
+
+ ); + + // Renders one or both legs' timelines with an "Outbound Journey"/"Return Journey" + // heading pair when the booking has a return leg, or a single "Your Journey" heading + // for one-way bookings — used by both the pending-payment and confirmed views. + const renderJourneyLegs = () => ( + <> +
+
+ + {isRoundTripBooking ? "Outbound Journey" : "Your Journey"} + + {booking.passengers?.[0]?.seat?.seatClass && ( + + {booking.passengers[0].seat.seatClass} + + )} +
+ {renderJourneyTimeline(booking.schedule)} + + {isRoundTripBooking && booking.returnSchedule && ( +
+
+
+ + Return Journey + +
+ {renderJourneyTimeline(booking.returnSchedule)} +
+ )} + + ); + // Order summary card — mirrors /booking/payment's OrderSummary: fare breakdown per // passenger, Total with a loading spinner while a currency conversion is in flight, and // a note confirming what will actually be charged once a payment method is selected. @@ -586,137 +683,62 @@ function BookingDetailContent() { Trip Summary -
-
- - Your Journey - - {booking.passengers?.[0]?.seat?.seatClass && ( - - {booking.passengers[0].seat.seatClass} - - )} -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {booking.schedule?.departureAt - ? formatTime(booking.schedule.departureAt) - : "--:--"} -
-
- {booking.schedule?.departureAt - ? `${format(toZonedDate(new Date(booking.schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}` - : "N/A"} -
-
- {booking.schedule?.origin?.name} -
-
- {booking.schedule?.origin?.city} -
-
- - {/* Journey Info */} -
-
-
- - - - - Train {booking.schedule?.trainNumber} - -
- {booking.schedule?.trainName && ( - - {booking.schedule.trainName} - - )} -
-
- - {/* Destination */} -
-
- {booking.schedule?.arrivalAt - ? formatTime(booking.schedule.arrivalAt) - : "--:--"} -
-
- {booking.schedule?.arrivalAt - ? `${format(toZonedDate(new Date(booking.schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}` - : "N/A"} -
-
- {booking.schedule?.destination?.name} -
-
- {booking.schedule?.destination?.city} -
-
-
-
+ {renderJourneyLegs()}
- {booking.passengers?.length || 0} Passenger(s) + {groupedPassengers.length} Passenger(s)
- {booking.passengers?.map( - (passenger: any, idx: number) => ( -
-
-
- {passenger.fullName} -
-
- {passenger.category} • Coach{" "} - {passenger.seat?.coach} -
+ {groupedPassengers.map((passenger: any, idx: number) => ( +
+
+
+ {passenger.fullName}
-
-
- Seat {passenger.seat?.number} -
-
- {passenger.seat?.seatClass} -
+
+ {passenger.category}
- ), - )} + {isRoundTripBooking ? ( +
+ {( + [ + { legLabel: "Outbound", seat: passenger.outboundSeat }, + { legLabel: "Return", seat: passenger.returnSeat }, + ] as const + ).map(({ legLabel, seat }) => ( +
+
+ {legLabel} +
+
+ Seat {seat?.number ?? "--"} +
+
+ {seat?.seatClass} +
+
+ ))} +
+ ) : ( +
+
+ Seat {passenger.outboundSeat?.number} +
+
+ {passenger.outboundSeat?.seatClass} +
+
+ )} +
+ ))}
@@ -994,102 +1016,7 @@ function BookingDetailContent() { Journey Details -
-
- - Your Journey - - {booking.passengers?.[0]?.seat?.seatClass && ( - - {booking.passengers[0].seat.seatClass} - - )} -
- - {/* Flight-style timeline */} -
- {/* Left column: Timeline with dots and line */} -
- {/* Origin dot */} -
- {/* Vertical line */} -
- {/* Destination dot */} -
-
- - {/* Right column: Content */} -
- {/* Origin */} -
-
- {booking.schedule?.departureAt - ? formatTime(booking.schedule.departureAt) - : "--:--"} -
-
- {booking.schedule?.departureAt - ? `${format(toZonedDate(new Date(booking.schedule.departureAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.departureAt)}` - : "N/A"} -
-
- {booking.schedule?.origin?.name} -
-
- {booking.schedule?.origin?.city} -
-
- - {/* Journey Info */} -
-
-
- - - - - Train {booking.schedule?.trainNumber} - -
- {booking.schedule?.trainName && ( - - {booking.schedule.trainName} - - )} -
-
- - {/* Destination */} -
-
- {booking.schedule?.arrivalAt - ? formatTime(booking.schedule.arrivalAt) - : "--:--"} -
-
- {booking.schedule?.arrivalAt - ? `${format(toZonedDate(new Date(booking.schedule.arrivalAt)), "EEE, MMM d")} · ${getTimePeriod(booking.schedule.arrivalAt)}` - : "N/A"} -
-
- {booking.schedule?.destination?.name} -
-
- {booking.schedule?.destination?.city} -
-
-
-
+ {renderJourneyLegs()}
From 7725b0d5d4e383733d03a31d10c216b842ec200f Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 15 Jul 2026 23:14:05 +0300 Subject: [PATCH 09/20] Update payments.service.ts --- apps/edr-passenger-api/src/modules/payments/payments.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 36d3fbd1d..5bd068255 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -261,7 +261,7 @@ export class PaymentsService { // Booking is in ETB — convert to the provider's settlement currency. chargeAmount = await this.currencyService.convertMinorToChargeMajor( booking.totalMinor, - 'ETB', + booking.currency, chargeCurrency, ); } From fc44eee25ece960f2cb1574302be31e2de027fdb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 15 Jul 2026 20:14:39 +0000 Subject: [PATCH 10/20] 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 /