Files
edr-platform/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
2026-07-06 11:37:57 +00:00

48 KiB
Raw Blame History

EDR Freight System — End-to-End Flow Map (FE → Backend)

Comprehensive flow documentation for the Freight Management slice of the EDR Platform: the two frontends (Customer Portal + Backoffice) and how every action reaches the freight API, the payment microservice, Postgres, MinIO and RabbitMQ.

Generated by mapping all ~45 API modules (~300 routes / 60 controllers) against both FE apps. Layered on purpose: a master business-flow view first, then per-domain endpoint-level charts.


0. How to read this document

  • Master view (§3) is the freight journey at business altitude — state transitions across domains.
  • Per-domain views (§5§13) drop to endpoint altitude — every Method /path, its guard, and the FE caller.
  • Diagrams are Mermaid. GitHub / VS Code (Markdown Preview Mermaid) render them inline.
  • Legend used throughout:
    • (P) Portal = @edr/freight-portal (customer users, port 5173)
    • (B) Backoffice = @edr/freight-backoffice (EDR employees, port 5183)
    • (green) API = @edr/freight-api (NestJS, port 3001)
    • (red) Payment = @edr/payment-api (NestJS microservice, port 3003)

1. System architecture & apps

Layer Package Port Base URL / notes
Customer Portal @edr/freight-portal 5173 axios utils/api.ts, baseURL VITE_BASE_API_URL, React Query
Backoffice @edr/freight-backoffice 5183 axios auth/http.ts, baseURL ${VITE_BASE_API_URL}/api, React Query
Freight API @edr/freight-api 3001 NestJS, global prefix /api, Postgres schema freight
Payment API @edr/payment-api 3003 NestJS, separate schema edr_payment, providers in @edr/payment-providers
Datastores 5433 Postgres edr_freight; MinIO (files); RabbitMQ (SMS/email/payment events)
flowchart LR
  subgraph FE["Frontends (React + Vite + React Query)"]
    P["(P) Customer Portal :5173"]
    B["(B) Backoffice :5183"]
  end

  subgraph EDGE["Freight API edge (NestJS :3001)"]
    direction TB
    CORS["CORS (reflect origin,<br/>credentials, IAM headers)"]
    JWT["JwtGuard (global APP_GUARD)<br/>+ HasActiveDelegationGuard"]
    PERM["FreightPermissionGuard<br/>(per-route perms)"]
    VP["ValidationPipe<br/>(implicitConversion OFF)"]
    RTI["ResponseTransformInterceptor<br/>→ { success, data }"]
    HEF["HttpExceptionFilter"]
  end

  subgraph DOM["Domain modules (~45)"]
    direction TB
    D1["Identity / Companies / Auth"]
    D2["Bookings + Contracts"]
    D3["Rule Engine"]
    D4["Train Scheduling"]
    D5["Warehouse"]
    D6["Field Ops (mile/import)"]
    D7["Billing"]
    D8["Notifications / Inbox"]
  end

  subgraph INFRA["Backing services"]
    PG[("Postgres<br/>schema: freight")]
    MINIO[("MinIO<br/>object store")]
    MQ{{"RabbitMQ"}}
    PAY["(red) Payment API :3003<br/>schema: edr_payment"]
  end

  P -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
  B -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
  CORS --> JWT --> PERM --> VP --> DOM
  DOM --> RTI
  DOM --> PG
  DOM --> MINIO
  DOM -->|"send-sms / send-email"| MQ
  DOM -->|"POST /payments/initiate<br/>x-service-token"| PAY
  PAY -->|"payment.succeeded webhook<br/>→ /api/internal/payments/mark-paid"| DOM
  PAY -.->|"or RabbitMQ payment events"| MQ --> DOM
  D8 -->|"socket.io push"| FE

2. The request pipeline (every FE call)

Both frontends wrap each API method in an endpoint(service, action, fn) helper feeding React Query. The axios client attaches the JWT and transparently refreshes on 401.

sequenceDiagram
  autonumber
  participant C as React component / hook
  participant Q as React Query
  participant AX as axios client (interceptors)
  participant API as Freight API (:3001)
  participant DB as Postgres

  C->>Q: useQuery / useMutation(endpoint)
  Q->>AX: call(input)
  AX->>AX: request interceptor →<br/>Authorization: Bearer [auth-token cookie]
  AX->>API: HTTP /api/<controller>/<path>
  API->>API: CORS → JwtGuard → PermissionGuard → ValidationPipe
  alt token valid & permitted
    API->>DB: repository query (schema freight)
    DB-->>API: rows
    API->>API: ResponseTransformInterceptor → { success:true, data }
    API-->>AX: 200 { success, data }
    AX->>AX: backoffice interceptor unwraps .data<br/>(portal returns raw envelope; callers read .data)
    AX-->>Q: payload
    Q-->>C: data (+ cache by queryKey)
  else 401 Unauthorized
    API-->>AX: 401
    AX->>API: POST /api/auth/refresh-token { refreshToken cookie }
    alt refresh ok
      API-->>AX: { token, refreshToken }
      AX->>AX: set cookies, retry original request (_retry)
      AX-->>Q: payload
    else refresh fails
      AX->>AX: clear cookies →<br/>portal: reject · backoffice: redirect /auth
    end
  else 4xx/5xx
    API->>API: HttpExceptionFilter → { success:false, message }
    API-->>AX: error
    AX-->>Q: throw → onError toast
  end

Auth model (important): SharedAuthModule (@tria-plc/api-common) registers JwtGuard + HasActiveDelegationGuard as global APP_GUARDsevery route is JWT-protected unless it carries @Public(). Fine-grained FreightPermissionGuard([perm]) decorators add permission checks on staff routes. Explicitly public endpoints: GET /api/files/:fileId, POST /api/otp/{send,verify}, GET /api/auth/check-availability, the fayda/verification/* + /callback endpoints, GET /api/payments/{checkout,receipt/:orderId}, and the service-to-service POST /api/internal/payments/mark-paid. Real login / JWT issuance lives in the external IAM package, not this repo. (Note: @edr/api-common's @Public and @tria-plc/api-common's @IsPublic both set the same "isPublic" metadata key the guard reads.)


3. MASTER FLOW — the freight journey (business altitude)

This is the spine. A shipment travels customer intake → pricing → approval → contract → customs clearance → operation request → payment → scheduling/allocation → first-mile → warehouse → train → arrival → warehouse → last-mile → delivery → tracking, branching on container vs bulk, import vs export, commercial vs government, and one-time vs contract.

flowchart TD
  start(["Customer signs up<br/>(IAM + Fayda + company onboarding)"]) --> mode{"Booking origin?"}

  mode -->|"One-time shipment"| draft["Create BOOKING (DRAFT) (P)"]
  mode -->|"Framework agreement"| cdraft["Create CONTRACT (DRAFT) (P)"]

  %% Contract branch
  cdraft --> cprice["Generate price → Submit → Approvals → Sign"]
  cprice --> cactive{"Contract type / customs?"}
  cactive -->|"Path A: transport-only,<br/>self-clearance"| bookA["Customer books directly<br/>POST /contracts/:id/bookings (P)"]
  cactive -->|"Path B: GENERAL + customs"| breq["Customer submits BookingRequest (P)<br/>→ GL accepts → GL creates booking (B)"]
  bookA --> draft2["Booking created under contract"]
  breq --> draft2

  %% Booking spine
  draft --> price["Generate price (P)<br/>(rule-engine: rates + surcharges)"]
  draft2 --> price
  price --> submit["Submit → SUBMITTED (P)"]
  submit --> intake["Staff accept → PENDING_APPROVAL (B)<br/>(instantiate approval steps)"]
  intake --> appr["Approval chain:<br/>LINE_STAFF → DIRECTOR → CEO (B)"]
  appr --> gen["Generate contract → CONTRACT_READY (B)"]
  gen --> sign["Customer signs (P) → Staff counter-signs (B)"]
  sign --> customs{"customsClearingEnabled?"}

  customs -->|"No"| ready["FULLY_EXECUTED"]
  customs -->|"Yes"| clr["AWAITING_DOCUMENTS →<br/>customer uploads docs (P) →<br/>GL review/finalize (B) → CLEARANCE_READY"]
  clr --> opreq["Customer requests operation (P)<br/>(binding schedule date)"]
  ready --> opreq
  opreq --> oprev{"Operations review (B)"}
  oprev -->|"Request changes"| clr
  oprev -->|"Accept: train"| inv["Invoice generated →<br/>enters day batch pool"]
  oprev -->|"Accept: road/truck"| road["ROAD_DISPATCH_PENDING<br/>(billed by KM)"]

  inv --> pay["Customer pays invoice (P)<br/>→ (red) gateway → PAID"]
  road --> pay
  pay --> gov{"Government?"}
  gov -->|"Yes"| expedite["governmentExpedite → PAID/Eligible (B)"]
  gov -->|"No"| eligible["schedulingStatus = Eligible"]
  expedite --> sched
  eligible --> sched

  subgraph JOURNEY["Physical movement"]
    direction TB
    sched["Train schedule: DRAFT → assign bookings →<br/>allocate wagons → finalize → SCHEDULED (B)"]
    fm{"EXPORT + first-mile?"}
    fmleg["First-mile leg: truck pickup →<br/>RECEIVED_TO_PORT (B)"]
    wh_in["Warehouse receive → inspect →<br/>STORED → READY_FOR_LOADING → LOADED (B)"]
    disp["Dispatch train → DISPATCHED<br/>(locos ASSIGNED, unpaid EXPIRED) (B)"]
    track["Checkpoints logged →<br/>tracking events (customer sees) (P)"]
    arrive["Arrive → ARRIVED<br/>(bookings IN_TRANSIT, wagons freed) (B)"]
    wh_out["Destination warehouse:<br/>unload → inspect → READY_FOR_PICKUP (B)"]
    lm{"IMPORT + last-mile?"}
    lmleg["Last-mile leg: truck delivery →<br/>DELIVERED (B)"]
    imp["Import customs finalization<br/>(declaration/duty/risk/release) (B)"]
  end

  sched --> fm
  fm -->|"Yes"| fmleg --> wh_in
  fm -->|"No"| wh_in
  wh_in --> disp --> track --> arrive --> wh_out
  wh_out --> imp
  imp --> lm
  lm -->|"Yes"| lmleg --> deliver
  lm -->|"No"| deliver

  deliver["Release order + gate clearance<br/>(warehouse fees must be PAID) (B)"]
  deliver --> pod["Customer approves delivery /<br/>POD captured → DELIVERED (P)"]
  pod --> complete(["Booking COMPLETED"])

  track -.->|"public timeline"| custview["Customer tracking page (P)"]

Cross-cutting truth: almost every hop between domains is fired by a domain event (@OnEvent), not a direct call. See §13 for the event web (e.g. booking.invoice.paid → advance booking → auto-create first-mile; warehouse inspection PASSED → auto-create last-mile; payment webhook → settle invoice).


4. Domain map (where each module lives in the journey)

flowchart LR
  subgraph INTAKE["Intake & Identity"]
    auth[auth / otp / verifayda]
    comp[companies]
    sig[signatures]
  end
  subgraph COMMERCIAL["Commercial"]
    bk[bookings]
    ct[contracts]
    re[rule-engine]
  end
  subgraph OPS["Rail Operations"]
    ts[train-scheduling]
    resch[scheduling-reschedule]
    fleet[trains/wagons/locomotives/wagon-types]
    track[tracking]
  end
  subgraph GROUND["Ground Operations"]
    fmlm[first-mile / last-mile]
    imp[import-operations / interchange-documents]
    dv[drivers / vehicles]
  end
  subgraph WH["Warehouse"]
    wh[warehouses + inventory + loadings + inspection + rules]
    whinv[warehouse-fee-invoices]
  end
  subgraph MONEY["Money"]
    bill[billing]
    paymod[payment]
    payapi[edr-payment-api]
  end
  subgraph PLATFORM["Platform / Config"]
    dd[dropdown-settings]
    fus[file-upload-settings]
    files[files / minio]
    fac[facilities / routes]
    notif[notifications / inbox]
    ov[overview]
    bo[backoffice/IAM]
  end

  INTAKE --> COMMERCIAL --> OPS --> GROUND --> WH --> MONEY
  re -.->|rates/approval/priority| COMMERCIAL
  COMMERCIAL -.->|invoices| MONEY
  WH -.->|storage invoices| MONEY
  GROUND -.->|mile invoices| MONEY
  PLATFORM -.-> COMMERCIAL

5. Identity, Access & Onboarding

5.1 Flow

flowchart TD
  su["Signup (external IAM)"] --> chk["GET /auth/check-availability (P)<br/>(email/phone taken?) @Public"]
  chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
  otp --> fayda{"Identity proofing?"}
  fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
  fstart --> fcb["Fayda redirect → GET /callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
  fcb --> onb
  fayda -->|"skip"| onb

  onb["POST /companies/onboarding/start (P)<br/>(draft company, placeholder TIN, PENDING)"]
  onb --> wiz["Wizard saves incrementally (P):<br/>PATCH /profile · /onboarding-step ·<br/>upload license & docs"]
  wiz --> reqs["GET /companies/onboarding/requirements<br/>(server-driven checklist)"]
  reqs --> comp["POST /companies/onboarding/complete<br/>→ profiles + company = PENDING"]
  comp --> review["Backoffice approves (B):<br/>PATCH /companies/company-profiles/:id/status<br/>→ mint reference, company → ACTIVE"]
  review --> book(["Can now book<br/>(assertCompanyProfileApprovedForBooking)"])

Company status: PENDING → ACTIVE (+ SUSPENDED, BLACKLISTED). Nationality (ethiopian/foreign) drives the required document set. Booking guards elsewhere 403 if the acting profile is not ACTIVE.

5.2 Endpoints

Method Path Action Guard FE
GET /api/auth/check-availability email/phone dedupe @Public (P) auth.service
GET /api/me enriched profile + permissionKeys + catalog JwtGuard (B) auth/api
POST /api/otp/send · /api/otp/verify send / verify 6-digit code @Public (P) auth.service
POST /api/fayda/verification/start start eSignet session (PKCE) @Public + OptionalJwt (B) verifayda.service
GET /api/fayda/verification/complete code→identity, upsert iam.users @Public (B) verifayda.service
GET /api/fayda/verification/status current user's Fayda link JwtGuard
GET /callback passive Fayda redirect ack (no /api) @Public popup postMessage
GET·PUT /api/me/signature reusable signature (MinIO, base64) JwtGuard (P)(B) signatures.service
GET /api/test_user1 · /api/test_user2 permission-guard demo PermissionGuard (B) demo pages
GET /api/companies/getInfo · /profile · /dashboard company info / KPIs JwtGuard (P) companies.service
POST /api/companies/fetch-etrade-info pull reg data by TIN JwtGuard (P)
PATCH /api/companies/profile update profile (JSONB attrs) JwtGuard (P)
POST /api/companies/onboarding/start · /complete onboarding lifecycle JwtGuard (P)
PATCH /api/companies/onboarding-step · /active-mode wizard state / mode switch JwtGuard (P)
GET /api/companies/onboarding/requirements server checklist JwtGuard (P)
POST·GET /api/companies/company-profiles/:id/license license file up/list JwtGuard (P)
POST /api/companies/company-profiles · /company-profile add operational profile(s) JwtGuard (P)
POST·GET·PATCH·DELETE /api/companies (+/:id) company CRUD @FreightAdmin writes (B) customers.service
GET /api/companies/stats KPI strip JwtGuard (B)
PATCH /api/companies/company-profiles/:id/status approve profile → mint ref @FreightAdmin (B)
GET·POST /api/companies/:companyId/documents company docs (signed URLs) JwtGuard (P)(B)
GET /api/notifications (+/unread-count) inbox list / unread JwtGuard (P)(B) notificationsApi
PATCH·POST /api/notifications/:id/read · /read-all mark read (WS re-emit) JwtGuard (P)(B)
WS NOTIFICATION_WS_NAMESPACE live push (server→client) WsAuth handshake (P)(B) useNotificationSocket

notifications module = SMS/email transport over RabbitMQ (no HTTP routes). Inbox fan-out writes one notifications row + WS push per recipient; HIGH priority also emails + SMSs (best-effort).


6. Bookings — the core state machine

stateDiagram-v2
  [*] --> DRAFT: create (P) (staff commercial auto price+submit)
  DRAFT --> SUBMITTED: submit (P)
  DRAFT --> PRICE_CHANGED_PENDING_CONFIRM: price moved
  PRICE_CHANGED_PENDING_CONFIRM --> SUBMITTED: confirm-submit (P)
  SUBMITTED --> REJECTED: customer reject (price) (P)
  SUBMITTED --> CHANGES_REQUESTED: staff request-changes (B)
  CHANGES_REQUESTED --> SUBMITTED: edit + resubmit (P)
  SUBMITTED --> PENDING_APPROVAL: staff/accept (B) (instantiate approval steps)
  SUBMITTED --> REJECTED: staff/reject (B)
  PENDING_APPROVAL --> APPROVED_PENDING_SIGNATURE: line-staff approve (B)
  APPROVED_PENDING_SIGNATURE --> APPROVED: director + ceo approve (B)
  PENDING_APPROVAL --> REJECTED: rejectStep (B)
  APPROVED --> CONTRACT_READY: contract/generate (B)
  CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
  SIGNED_CUSTOMER --> AWAITING_DOCUMENTS: counter-sign, IMPORT/EXPORT (B)
  SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, DOMESTIC (B)
  AWAITING_DOCUMENTS --> DOCUMENTS_UNDER_REVIEW: clearance/documents (P)
  DOCUMENTS_UNDER_REVIEW --> CLEARANCE_READY: review + finalize (B)
  CLEARANCE_READY --> OPERATION_REQUEST_PENDING: clearance/proceed (P)
  OPERATION_REQUEST_PENDING --> OPERATION_CHANGES_REQUESTED: review=REQUEST_CHANGES (B)
  OPERATION_CHANGES_REQUESTED --> OPERATION_REQUEST_PENDING: re-proceed (P)
  OPERATION_REQUEST_PENDING --> ROAD_DISPATCH_PENDING: accept=road (B) (invoice, KM billed)
  OPERATION_REQUEST_PENDING --> FULLY_EXECUTED: accept=train (B) (invoice)
  FULLY_EXECUTED --> SELECTED_FOR_BATCH: batch engine offers wagons (sys)
  SELECTED_FOR_BATCH --> PAID: pay invoice (P)
  ROAD_DISPATCH_PENDING --> PAID: pay invoice (P)
  PAID --> IN_TRANSIT: operations/start-transit (B)
  IN_TRANSIT --> COMPLETED: operations/complete (B)
  DRAFT --> PENDING_CONSOLIDATION: requestConsolidation (usually set on submit)
  DRAFT --> CANCELLED: cancel (B)
  PENDING_APPROVAL --> CANCELLED: cancel (B)
  OPERATION_REQUEST_PENDING --> CANCELLED: cancel (B)
  note right of PAID
    governmentExpedite (B) jumps
    gov bookings straight to PAID/Eligible
  end note
  note left of CANCELLED
    cancel is staff-only and allowed ONLY from
    DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,
    CHANGES_REQUESTED, PENDING_APPROVAL,
    CONTRACT_READY, OPERATION_REQUEST_PENDING
  end note
  REJECTED --> [*]
  CANCELLED --> [*]
  COMPLETED --> [*]

Accuracy notes (verified against code):

  • Counter-sign branch is keyed on trade direction, not a customs flag: IMPORT/EXPORTAWAITING_DOCUMENTS (even when customs is off — a lighter "without customs" clearance doc-set still applies); only DOMESTICFULLY_EXECUTED.
  • Domestic bookings skip the operation-request/clearance phase entirely — counter-sign enqueues them straight into the scheduling batch pipeline (enqueueScheduleProcessing).
  • Train operation/review accept sets FULLY_EXECUTED (day batch holding pool). SELECTED_FOR_BATCH is set later by the batch engine when a wagon offer/reservation is made — not at accept.
  • APPROVED_PENDING_SIGNATURE is a real intermediate (line-staff approves first, then director+CEO).
  • Full BOOKING_STATUSES has 35 values; this diagram is the live commercial subset (legacy statuses like WAGON_ASSIGNED, INVOICED, PNR_GENERATED are unused).

Key endpoints (customer (P) / staff (B), bk: = bookings: perms):

Method Path Action Guard
POST /api/bookings create in-body (gov→bk:staff_accept)
PATCH·DELETE /api/bookings/:id update / soft-delete DRAFT company-scoped
POST /api/bookings/:id/generate-price price preview (rule-engine) company-scoped
POST /api/bookings/:id/submit · /confirm-submit submit (rate snapshot) company-scoped
POST /api/bookings/:id/reject customer rejects price company-scoped
POST /api/bookings/:id/staff/accept → PENDING_APPROVAL bk:staff_accept
POST /api/bookings/:id/staff/request-changes · /staff/reject intake outcomes bk:request_changes / bk:reject
POST /api/bookings/:id/approval-steps/:stepId/approve · /reject approval chain role perms
POST /api/bookings/:id/contract/generate → CONTRACT_READY bk:generate_contract
POST /api/bookings/:id/contract/sign · /marketing/approve sign / counter-sign in-body / bk:sign_staff
GET /api/bookings/:id/contract/{view,document} HTML / PDF company-scoped
GET·POST /api/bookings/:id/clearance (+/documents,/review,/finalize,…) customs clearance bk:* / ct:clearance_*
POST /api/bookings/:id/clearance/proceed request operation company-scoped
POST /api/bookings/:id/operation/review accept/changes (+invoice) bk:operations
POST /api/bookings/:id/government-expedite gov shortcut → PAID bk:staff_accept
POST /api/bookings/:id/operations/start-transit · /complete transit lifecycle bk:operations
POST /api/bookings/:id/allocate-containers assign containers↔vehicles allocation:manage
POST·GET·DELETE /api/bookings/:id/consolidation pair/unpair wagon-share company-scoped
POST /api/bookings/:id/customer-truck-assignment external truck for pickup company-scoped
GET /api/bookings/:id/tracking consignment + tracking events company-scoped
GET /api/bookings · /list-summary · /queues/:queue lists (branch by perm) bk:view / bk:clearance_view

7. Contracts — framework agreements & booking paths

stateDiagram-v2
  [*] --> DRAFT: create (P)
  DRAFT --> SUBMITTED: submit (P) (freeze rate snapshots)
  SUBMITTED --> PENDING_APPROVAL: staff/accept (B)
  SUBMITTED --> CHANGES_REQUESTED: request-changes (B)
  CHANGES_REQUESTED --> SUBMITTED: resubmit (P)
  SUBMITTED --> REJECTED: reject (B)
  PENDING_APPROVAL --> APPROVED: approve chain (B)
  APPROVED --> CONTRACT_READY: contract/generate (B)
  CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
  SIGNED_CUSTOMER --> AWAITING_CLEARANCE_DOCUMENTS: counter-sign, IMPORT/EXPORT (not GENERAL+customs) (B)
  SIGNED_CUSTOMER --> CONTRACT_ACTIVE: counter-sign, GENERAL+customs or DOMESTIC (B)
  SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, ONE_TIME DOMESTIC (B)
  CONTRACT_ACTIVE --> [*]: renew → RENEWAL_DRAFT

Diagram shows the live path; CONTRACT_STATUSES has 24 values total (adds APPROVED_PENDING_SIGNATURE, CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, RENEWAL_SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED — see §15). GENERAL+customs skips the contract clearance cycle → CONTRACT_ACTIVE directly (clearance runs per-booking, Path B); only IMPORT/EXPORT one-time or self-clearance opens the contract-level AWAITING_CLEARANCE_DOCUMENTS cycle.

Two ways a contract spawns shipment bookings:

flowchart TD
  active["Contract ACTIVE / SELF_CLEARED"] --> path{"Contract path"}
  path -->|"Path A: transport-only"| a1["Ops reviews self-clearance docs (B)<br/>(ops-review → ops-finalize → SELF_CLEARED)"]
  a1 --> a2["Customer books directly (P)<br/>POST /contracts/:id/bookings"]
  path -->|"Path B: GENERAL + customs"| b1["Customer submits BookingRequest (P)<br/>POST /contracts/:id/booking-requests"]
  b1 --> b2["GL queue → accept (B)<br/>(ct:create_booking) → GL creates booking"]
  a2 --> cap["createUnderContract:<br/>window + capacity draw-down check"]
  b2 --> cap
  cap --> spawn(["New Booking under contract<br/>(bookings.contract_id)"])

Contract clearance is phased (ET vs DJ permissioned): declaration → duty advice → duty slip → transit permit → delivery/release order → T1 docs/close → final invoice → incidents. clearanceStatus is a separate axis (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS). ~60 contract routes total. Per-booking clearance actions (duty, risk, t1-documents, t1-close, final-invoice, second-duty, transport-document, station-assign, incidents) hang off /api/contracts/bookings/:bookingId/*; note declaration is a contract-level route (/api/contracts/:id/clearance/declaration), not a booking-scoped one.

Consignment / Cargo / Container (fleet-side records)

Entity Controller Guard Lifecycle Callers
Consignment /api/consignments (create/list/get) FleetView/Manage Pending → … (downstream shipment record; booking :id/tracking reads it) (P) read-only
Cargo /api/cargoes (+ /load /unload /deliver) FleetView/Manage PENDING → LOADED → UNLOADED / DELIVERED (POD) (B) cargoService
Container /api/containers (+ /assign-wagon /unassign-wagon) FleetView/Manage AVAILABLE → LOADED → IN_TRANSIT … (B) containerService

8. Rule Engine — the pricing & approval brain

RuleEngineService.evaluate(input) is injected into bookings & contracts pricing. One pass pulls all rule tables and returns { priorityScore, appliedModifiers, containerWeightResults, warnings, hardBlocked, requiresDirectorApproval }.

flowchart TD
  ev["evaluate(BookingEvaluationInput)"] --> rates["rates.findLiveRates()<br/>(only LIVE)"]
  rates --> sur{"surcharge triggers"}
  sur -->|"HAZARDOUS/REEFER/OVERWEIGHT/<br/>SHIPPING_LINE/CONSOLIDATION"| mods["appliedModifiers →<br/>surcharge line-items"]
  mods --> snap["snapshotRates → booking_rate_snapshots<br/>(freeze exact rate used)"]
  ev --> wlr["weight-limit-rules"]
  wlr --> block{"VGM > maxCapacityTons?"}
  block -->|Yes| hard["HARD BLOCK (400)"]
  block -->|"over maxVgm, within cap"| warn["warning + OVERWEIGHT surcharge"]
  ev --> appr["approval-rules →<br/>instantiateApprovalSteps<br/>(requiredRole/blocksRole/stepOrder)"]
  ev --> prio["priority-configs + serviceType bonus +<br/>GOVERNMENT_PRIORITY_BONUS → priorityScore"]
  prio --> schedorder["train-scheduling orders by priorityScore"]

Rate lifecycle: DRAFT → (submit) PENDING_APPROVAL → (CEO approve) LIVE. Only LIVE rates apply.

Resource Base path Guard Notes
approval-rules /api/approval-rules (+/chain,/reorder,/:id/move-order) ruleEngine.view/manage(approval-rules) ordered chain
cargo-types /api/cargo-types …(cargo-types) portal reads via bookings/reference-data
container-types /api/container-types …(container-types) portal indirect
priority-configs /api/priority-configs …(priority-configs) WAGON / CURRENCY bands
rates /api/rates (+/live,/:id/submit,/approve) …(rates) DRAFT→PENDING→LIVE
service-types /api/service-types …(service-types) includesFirstMile/LastMile flags
shipping-lines /api/shipping-lines …(shipping-lines) surcharge trigger
weight-limit-rules /api/weight-limit-rules …(weight-limit-rules) VGM hard-block
yards /api/yards …(yards) routes/warehouses read

Settings & files (reads open, writes @FreightAdmin): /api/dropdown-settings/*, /api/file-upload-settings/* (config that drives portal forms; server enforces required-doc presence at clearance, not size/MIME — those are client-side). GET /api/files/:fileId is @Public (browsers load <img>/<a> without a bearer); upload is direct multipart (multer memory → FilesService → MinIO putObject), presigned URLs used only for authenticated reads (300 s TTL). routes (route_milestones) and facilities are config readers with no permission decorator (behind the global JwtGuard only). NOTE: Note FacilitiesModule is never imported into AppModule, so /api/facilities is unmounted/dead.


9. Train Scheduling & Rail Operations

Two consist models: trains = static fleet inventory (never created by scheduling); train_sets = the operational consist scheduling builds per departure (≥2 locomotives + wagon-type slots, physical wagons pinned later).

stateDiagram-v2
  [*] --> DRAFT: create schedule (B) (≥2 locos, derive direction, freeze window rule)
  DRAFT --> DRAFT: assign-bookings / run-allocation / pin-wagons (B)
  DRAFT --> SCHEDULED: finalize (B) (bookings → Scheduled, ≥1 booking)
  SCHEDULED --> DISPATCHED: dispatch (B) (train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED)
  DISPATCHED --> DISPATCHED: recordCheckpoint (B) (corridor stations)
  DISPATCHED --> ARRIVED: arrive (B) (bookings IN_TRANSIT, locos+wagons freed, warehouse arrival automation)
  DRAFT --> CANCELLED: cancel (B)
  SCHEDULED --> CANCELLED: cancel (B)
  ARRIVED --> [*]

Reschedule (DRAFT/SCHEDULED only): preview merges current + incoming bookings, sorts by compareSchedulingPriority (government first, then priorityScore), greedily keeps those that still fit → retained; overflow → displaced; commercial displaced are readmitted if room remains. execute re-verifies, optionally sets new departure, unassigns displaced, re-assigns final, writes a scheduling_events audit row. Triggers: GOVERNMENT_PREEMPT, TRAIN_MAINTENANCE.

Endpoint groups (train-scheduling prefix, trainScheduling.view/manage):

Group Representative routes
Discovery (customer (P), unguarded) bookable-schedules, available-days, available-days-for-cargo, my-booking-windows, contracts/:id/booking-windows
Board (staff (B)) batch-board, batch-board/:id, eligible-bookings, `container
Build `container
Composition edits :id/wagons/:wagonId (remove slot), :id/container-items/:itemId, bookings/:bookingId/{mark-paid,expire,move-schedule}
Lifecycle schedules/:id/{finalize,dispatch,arrive,booking-window,doc-review-complete}, {container|bulk}/schedules/:id/cancel
Tracking schedules/:id/checkpoints (GET/POST) → writes train_checkpoint_events
Djibouti import ops :id/import-djibouti/* (gatepass, ready-for-loading, loaded, depart, load-list)
Reschedule :id/reschedule/preview, :id/reschedule/execute, :id/maintenance

Fleet master data (FleetView/Manage): /api/trains (+/:trainId/reorder-wagons), /api/wagons (+/assign-train,/unassign-train), /api/locomotives (+/decommission), /api/wagon-types (rule-engine guarded). Asset records (unguarded, keyed by road vehicleId): /api/maintenance/*, /api/fuel/*. fleet_events is an append-only audit read via drivers/:id/history & vehicles/:id/history.

Tracking (customer-facing): GET /api/tracking/:consignmentIdtracking_events timeline (location, ConsignmentStatus, occurredAt), consumed by portal TrackingPage. It is JWT-guarded (no permission decorator — not public). Distinct from staff train checkpoints (train_checkpoint_events, written by recordCheckpoint/arrive). NOTE: TrackingService.record() has no caller anywhere in the codebase — nothing writes tracking_events, so the customer tracking timeline is currently unpopulated; only the staff train_checkpoint_events store is written.


10. Warehouse — inventory lifecycle

Hierarchy Facility → Warehouse → Yard → Zone; every level tracks weight/volume/container capacity kept in sync by applyCapacityDelta. warehouse_inventory carries nullable FKs to booking, cargo, container — the join point between warehouse and the shipment.

stateDiagram-v2
  [*] --> RECEIVED: receive / bulkReceive (B)
  [*] --> UNLOADED: auto-unload-arrived (import train) (B)
  RECEIVED --> STORED: store (B) (allocation rule picks yard/zone)
  UNLOADED --> STORED: store (B)
  UNLOADED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
  RECEIVED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
  STORED --> RESERVED: reserve (B) (booking PAID)
  RESERVED --> READY_FOR_LOADING: mark-ready-for-loading (B) (inspection PASSED)
  READY_FOR_LOADING --> LOADED: load onto wagon (B) (+ warehouse_loadings)
  LOADED --> DISPATCHED: dispatch / bulk-dispatch-export (B)
  DISPATCHED --> UNLOADED_AT_DJIBOUTI_PORT: auto-unload-at-djibouti (B)
  READY_FOR_PICKUP --> DELIVERED: release → deliver (B) (POD, fees PAID via gate-clearance)
  READY_FOR_PICKUP --> STORED: re-store import item (B)
  READY_FOR_PICKUP --> DISPATCHED: dispatch out (B)
  UNLOADED_AT_DJIBOUTI_PORT --> [*]
  DELIVERED --> [*]

Export branch: receive → STORED → RESERVED (booking PAID) → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT. Note store() does not check inspection — RECEIVED → STORED is legal without it; inspection PASSED is enforced only at mark-ready-for-loading. Import branch: UNLOADED → inspect PASSED → READY_FOR_PICKUP (auto-creates last-mile if requested) → release → DELIVERED. Every transition writes warehouse_activity_log.

Storage billing is not a separate table — WarehouseInvoiceService is a thin layer over the central billing module (global Invoice rows, source='warehouse', sourceId=inventoryId). Fees = STORAGE_FEE + DEMURRAGE via warehouse_fee_rules (free-days grace, tiers, FX-converted). Unpaid warehouse fees block exit: gateClearance/release call findBlockingInvoice / assertClearanceAllowed.

Controller families (84 routes, all called by backoffice warehouse.service.ts; no per-route permission decorators → behind the global JwtGuard only): warehouses · warehouse-yards · warehouse-zones · warehouse-inventory (queries + 25 mutation actions incl. bulk + import/export queues, and the gate release warehouse-inventory/:id/gate-clearance) · warehouse-loadings · inspection (…/inspection-reports) · fee-invoices (…/generate-fee-invoice, warehouse-fee-invoices/* incl. pay, pay-online) · rules (warehouse-allocation-rules, warehouse-fee-rules, warehouse-allocation/preview).

Portal touches only: bookings/:id/warehouse-fee-invoices, invoice by-id/document/receipt/ pay-online, and bookings/:bookingId/approve-delivery (customer signs handover). Everything else (B).


11. Field Operations — first/last mile & import customs

flowchart TD
  binv["booking.invoice.paid event"] --> advance["advanceBookingOnPayment → booking PAID"]
  advance --> fmreq{"EXPORT + first-mile requested?"}
  fmreq -->|Yes| fmaccept["firstMileService.acceptBooking<br/>(auto-create leg)"]
  fmreq -->|No| skip1["—"]
  fmaccept --> fmleg

  subgraph FM["First-mile (EXPORT origin road leg)"]
    fmleg["READY_TO_TRANSIT"] --> fmveh["setVehicles → vehicle BUSY,<br/>SMS driver, fleet_events"]
    fmveh --> fmtransit["IN_TRANSIT (needs assigned vehicle)"]
    fmtransit --> fmdone["RECEIVED_TO_PORT (free vehicles)"]
    fmleg --> fminv["generate-invoice → FIRST_MILE fee<br/>(locks distances once invoiced)"]
  end

  whrcv["warehouse inspection PASSED (IMPORT only)"] --> lmaccept["lastMileService.acceptBooking(reference)"]
  lmaccept --> lmleg
  subgraph LM["Last-mile (IMPORT destination road leg)"]
    lmleg["READY_TO_TRANSIT"] --> lmtransit["IN_TRANSIT"] --> lmdone["DELIVERED (free vehicles)"]
    lmleg --> lminv["generate-invoice → LAST_MILE fee"]
  end

  subgraph IMP["Import customs finalization (per booking, timestamp-driven)"]
    direction TB
    up["upload docs (IM4/IM5/T1_CLOSURE/…)"] --> decl["record declaration serial"]
    decl --> notify["notify duties/taxes"]
    notify --> paid["mark duties paid (needs CUSTOMER_PAYMENT_SLIP)"]
    paid --> risk["assign risk (GREEN/YELLOW/BLUE/RED)"]
    risk --> rel["release-permitted (asserts T1 + release permit + declaration + risk + paid)"]
  end
Module Base Guard Terminal state
first-mile /api/first-mile (+/accept/:ref,/:id/{vehicles,distances,invoice}) trainScheduling.view/manage RECEIVED_TO_PORT
last-mile /api/last-mile (same shape) trainScheduling.view/manage DELIVERED
import-operations /api/import-operations/{customs,djibouti-incidents,empty-container-returns}/* none (global JwtGuard) completedAt
interchange-documents /api/interchange-documents (+/generate-from-schedule,/:id/{acknowledge,dispute,cancel}) none ACKNOWLEDGED / DISPUTED
drivers /api/drivers (+/:id/history) FleetView/Manage soft-delete
vehicles /api/vehicles (+/:id/history) FleetView/Manage soft-delete

Interchange documents are the rail↔port handover manifest — generated from a train schedule, snapshotting booking/container/cargo lines with per-item conditionStatus derived from warehouse inspection flags. Status DRAFT → GENERATED → ACKNOWLEDGED | DISPUTED | CANCELLED. All FE callers (B).

Mile trigger precision (verified): first-mile is created only on booking.invoice.paidadvanceBookingOnPaymentfirstMile.acceptBooking(bookingId) when EXPORT + first-mile requested. Last-mile is created only on IMPORT inspection PASSED (two call sites: warehouse-inspection.service and the bulk-inspect branch of warehouse-inventory.service) — not on warehouse receive. The IMPORT constraint is enforced by the warehouse caller, not inside lastMile.acceptBooking(reference). Auto-created legs enter at READY_TO_TRANSIT (the PAYMENT_PENDING entity default is bypassed).


12. Billing & Payment

12.1 Invoice lifecycle

Invoices are source-agnosticBillingService.generateInvoice() is the single factory called by domain services (never a controller): source ∈ {booking, warehouse, first_mile, last_mile} (the enum also defines an unused demurrage), numbered INV-YYYYMMDD-#####, dueAt = now + 14d. Initial status varies by source: booking invoices start DRAFT (issued at operation-accept via billing.updateStatus); the generateInvoice default is PENDING; warehouse + contract-GL invoices start ISSUED.

stateDiagram-v2
  [*] --> DRAFT: booking invoice (issued at operation-accept)
  [*] --> PENDING: generateInvoice default
  [*] --> ISSUED: warehouse / contract-GL invoice
  DRAFT --> ISSUED: billing.updateStatus
  PENDING --> PARTIALLY_PAID: recordPayment (partial, offline)
  ISSUED --> PARTIALLY_PAID: recordPayment (partial, offline)
  PENDING --> PAID: markInvoiceAsPaid (gateway, full)
  ISSUED --> PAID: markInvoiceAsPaid (gateway, full)
  PARTIALLY_PAID --> PAID: final payment
  PENDING --> EXPIRED: expirePayable (pay window lapses)
  ISSUED --> CANCELLED: cancelInvoice (no payments)
  PAID --> REFUNDED: markInvoiceAsRefunded (paidAmount>0)
  PAID --> [*]
  note right of PAID
    emits ${source}.invoice.paid
    (sources use first_mile / last_mile, underscores)
    → domain listeners advance booking / mile / warehouse
  end note
  note left of ISSUED
    OVERDUE exists in the enum but NO code sets it
    (no cron / setter) — effectively unused
  end note

OPEN_STATUSES = {Issued, Pending, PartiallyPaid, Overdue} are payable. Transitions lock the row (pessimistic_write); the event fires after commit for self-managed transitions, but inline before commit when the transition is enlisted in a caller-supplied transaction manager.

12.2 freight-api ↔ payment-api integration

sequenceDiagram
  autonumber
  participant U as Customer (P)
  participant FB as Freight billing (:3001)
  participant PA as Payment API (red) (:3003)
  participant PV as Provider (Telebirr/Waafi/…)
  participant OB as Payment outbox
  participant FI as Freight internal ctrl

  U->>FB: POST /billing/my-invoices/:id/pay
  FB->>FB: payInvoice → validate OPEN, balance>0
  FB->>PA: POST /payments/initiate (x-service-token)<br/>service=FREIGHT, referenceId=sourceId, amountMinor
  PA-->>FB: { intentId, clientAction (REDIRECT/LAUNCH_APP/COLLECT_OTP) }
  FB->>FB: store paymentId on invoice (correlation)
  FB-->>U: clientAction → redirect to provider
  U->>PV: authorize payment
  PV->>PA: webhook POST /webhooks/{provider}
  PA->>PA: verify signature, dedupe, intent state machine
  PA->>OB: write PaymentEvent (payment.succeeded)
  OB->>FI: POST /api/internal/payments/mark-paid (@Public, x-service-token)
  Note over OB,FI: or RabbitMQ → PaymentEventsConsumer
  FI->>FB: handlePaymentEvent → settleByPaymentId → markInvoiceAsPaid
  FB->>FB: emit booking.invoice.paid
  FB-->>U: invoice PAID (poll / notification)

NOTE: Demo shortcut in payInvoice: today, if the provider doesn't settle synchronously, freight self-fires handlePaymentEvent(payment.succeeded) inline (marked TODO/remove) — invoices settle at pay-time without a real webhook. Providers: Telebirr / CBE_BIRR / EBIRR (ET), Waafi / DMoney (DJ), CARD (intl), CAC_BANK (OTP). Payment DB is a separate edr_payment schema — no cross-schema FKs; referenceId is a soft link.

12.3 Endpoints

Method Path Facing Guard
GET /api/billing/invoices (+/:id,/:id/document,/receipt) (B) backoffice bookings.view
GET /api/billing/my-invoices (+/:id,/document,/receipt) (P) portal @CurrentUser ownership
POST /api/billing/my-invoices/:id/pay (P) portal ownership
POST /api/payments/initiate central JwtGuard (no permission — not public)
GET /api/payments/checkout redirect @Public
GET /api/payments/{summary,all} (B) backoffice bookings.view
GET /api/payments/by-company/:companyId/customer-view (B) none
GET /api/payments/intents/:bookingId · /receipt/:orderId reconcile / receipt none / @Public
POST /api/internal/payments/mark-paid (red) service→service @Public (NOTE: currently unauthenticated)
POST·GET·PUT /api/backoffice/organizations/:orgId/* (B) IAM user/role mgmt (NOT billing) @FreightAdmin

GET /api/overview* (7 tabs, bookings.view) is the backoffice dashboard aggregator over 11 repos.


13. Cross-cutting event web (@OnEvent)

The domains are stitched together by events, not direct calls. This is why the master flow "just happens".

flowchart LR
  pay["Payment webhook / demo shortcut"] --> settle["billing.settleByPaymentId → markInvoiceAsPaid"]
  settle --> ev1{{"${source}.invoice.paid"}}
  ev1 -->|source=booking| adv["booking-invoice: advanceBookingOnPayment → PAID"]
  adv --> fm["firstMile.acceptBooking (EXPORT + requested)"]
  adv --> batch["train-scheduling batch: markPaid → allocate"]
  ev1 -->|source=first_mile| e2{{"first_mile.invoice.paid"}} --> fmp["NOTE: intended: leg paid=true<br/>(listener typo 'firstmile.invoice.paid' → never fires)"]
  ev1 -->|source=last_mile| e3{{"last_mile.invoice.paid"}} --> lmp["last-mile → DELIVERED, paid=true"]
  ev1 -->|source=warehouse| e4{{"warehouse.invoice.paid"}} --> whp["settle warehouse fee → unblock gate"]

  arrive["train arrive"] --> whauto["warehouse arrival automation:<br/>auto-unload arrived bookings"]
  insp["warehouse inspection PASSED (IMPORT)"] --> lmacc["lastMile.acceptBooking → READY_FOR_PICKUP"]

  assign["mile setVehicles"] --> veh["vehicle BUSY + SMS driver + fleet_events"]
  release["mile terminal / delete"] --> free["vehicle FREE (releaseIfUnused)"]

  notify["notification-inbox.notify"] --> ws["WebSocket push (always)"]
  notify -->|HIGH priority| smsemail["+ SMS + email via RabbitMQ"]

14. Portal vs Backoffice — who does what

Capability (P) Portal (customer) (B) Backoffice (employee)
Identity / onboarding signup, OTP, Fayda, company onboarding, profile company approval, IAM user/role mgmt
Bookings create, price, submit, sign, upload docs, pay, approve delivery, track accept, approve chain, generate contract, clearance review, operation review, allocate, dispatch
Contracts create, submit, sign, booking-requests, self-clearance slips approve, generate, phased clearance, GL booking creation
Rule engine reads only via bookings/reference-data full CRUD (rates approval, approval rules, priorities)
Scheduling discover bookable days/schedules build/finalize/dispatch/arrive/reschedule trains
Warehouse pay storage fees, view invoices, approve handover full inventory lifecycle + fees + rules
Field ops (none direct) first/last mile, import customs, interchange docs, fleet
Billing pay own invoices, download docs/receipts invoice + payment dashboards, per-customer views
Notifications inbox + WS inbox + WS

15. Status / state reference

Entity States (happy path → terminal)
Company PENDING → ACTIVE (+ SUSPENDED, BLACKLISTED)
Booking DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → [IMPORT/EXPORT: AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → OPERATION_REQUEST_PENDING → (train) FULLY_EXECUTED → SELECTED_FOR_BATCH / (road) ROAD_DISPATCH_PENDING] · [DOMESTIC: → FULLY_EXECUTED → SELECTED_FOR_BATCH] → PAID → IN_TRANSIT → COMPLETED (branches: REJECTED, CANCELLED, CHANGES_REQUESTED, OPERATION_CHANGES_REQUESTED, PENDING_CONSOLIDATION). 35 statuses total; ~10 legacy ones unused.
Contract DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → AWAITING_CLEARANCE_DOCUMENTS / CONTRACT_ACTIVE / FULLY_EXECUTED (+ CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, RENEWAL_DRAFT/SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED, EXPIRED — 24 total). Separate clearanceStatus axis: NOT_APPLICABLE / AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW / CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS.
Rate DRAFT → PENDING_APPROVAL → LIVE (+ SUPERSEDED)
Train schedule DRAFT → SCHEDULED → DISPATCHED → ARRIVED (+ CANCELLED)
Warehouse inventory export: RECEIVED → STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT · import: UNLOADED → READY_FOR_PICKUP → DELIVERED (READY_FOR_PICKUP may also → STORED / DISPATCHED)
First-mile PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT (auto-created legs enter at READY_TO_TRANSIT)
Last-mile PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED (auto-created legs enter at READY_TO_TRANSIT)
Invoice DRAFT / PENDING / ISSUED → PARTIALLY_PAID → PAID (+ EXPIRED, CANCELLED, REFUNDED; OVERDUE defined but never set)
Interchange doc DRAFT → GENERATED → ACKNOWLEDGED / DISPUTED / CANCELLED
Empty container return RETURNED → ASSIGNED_STORAGE → DOCUMENTATION_CLEARED → WAGON_ALLOCATED → TRANSPORTED_TO_DJIBOUTI → HANDOVER_ISSUED → COMPLETED

16. Notable gaps & caveats (verified against code)

  • Counter-sign is direction-based, not customs-based (bookings and contracts): IMPORT/EXPORT open a clearance gate even with customs off; only DOMESTIC skips it. The customs flag only selects the clearance document set.
  • SELECTED_FOR_BATCH is set by the batch engine, not at operation-accept — train accept sets FULLY_EXECUTED first.
  • FacilitiesModule is never imported into AppModule/api/facilities is unmounted / dead (not reachable at all). tracking, maintenance, fuel carry no permission decorator but are mounted — behind the global JwtGuard, just not permission-gated.
  • tracking_events has no writer anywhereTrackingService.record() is never called, so the customer tracking timeline is unpopulated. Only staff train_checkpoint_events are written (by recordCheckpoint/arrive).
  • First-mile paid-flag listener is a dead code path@OnEvent("firstmile.invoice.paid") (no underscore) never fires because the emitted event is first_mile.invoice.paid; the first-mile paid flag is never flipped by settlement.
  • Invoice OVERDUE status is never set — no cron/setter transitions to it, though it is in the enum and OPEN_STATUSES.
  • POST /api/internal/payments/mark-paid is @Public with no service auth (ServiceAuthGuard removed; noted in code).
  • Demo settlement shortcut in billing.payInvoice bypasses real webhooks (TODO/remove) — invoices settle inline at pay-time.
  • Server does not validate upload size/MIME (client-side only); it enforces required-doc presence at clearance.
  • Dead / unwired FE calls: trains/:id/details (no route), portal consignments service hits /consignments without /api, and list endpoints bookings/my, queues/:queue, by-company/customer-view have no active caller.
  • NOTE: The repo CLAUDE.md states auth is stubbed/unwired — this is stale: auth is live via @tria-plc/api-common (global JwtGuard + HasActiveDelegationGuard APP_GUARDs).

Generated from apps/edr-freight-api, apps/edr-freight-web/{portal,backoffice}, apps/edr-payment-api on branch freight/feat/fixes-v1. Reflects code at scan time; regenerate after major module changes.