Namespaces the OAuth landing path in all three places it exists: the API's
ack controller, both web apps' routes, and the redirect_uri env values.
A bare /callback claimed a generic top-level path in every app for one
provider's redirect.
The API side needed care. The ack controller moves to @Controller
('fayda/callback'), and the global-prefix exclusion has to name that exact
path — setGlobalPrefix's exclude is an exact route match, not a subtree, so
excluding "fayda" would have left /fayda/callback served at
/api/fayda/callback and 404ing at the registered redirect_uri, while
reading as though it covered everything under /fayda. Naming the full path
also keeps /api/fayda/verification/* prefixed, which every client calls.
Also drops a stale comment on the portal's callback route describing the
popup that no longer exists, and records why the route is public: behind
RequireAuth the onboarding gate redirects to /portal before the code+state
exchange can run.
NOT verified at runtime — this changes route registration, so boot the API
and confirm GET /fayda/callback answers un-prefixed and
/api/fayda/verification/start still resolves before relying on it.
Deploying this requires registering the new redirect_uri with eSignet
first; FAYDA_WEB_REDIRECT_URI, FAYDA_PORTAL_REDIRECT_URI and any mobile
client must be updated in step or verification breaks with a redirect_uri
mismatch.
48 KiB
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, port5173) - (B) Backoffice =
@edr/freight-backoffice(EDR employees, port5183) - (green) API =
@edr/freight-api(NestJS, port3001) - (red) Payment =
@edr/payment-api(NestJS microservice, port3003)
- (P) Portal =
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_GUARDs — every 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/* + /fayda/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 /fayda/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 | /fayda/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/EXPORT→AWAITING_DOCUMENTS(even when customs is off — a lighter "without customs" clearance doc-set still applies); onlyDOMESTIC→FULLY_EXECUTED.- Domestic bookings skip the operation-request/clearance phase entirely — counter-sign enqueues them straight into the scheduling batch pipeline (
enqueueScheduleProcessing).- Train
operation/reviewaccept setsFULLY_EXECUTED(day batch holding pool).SELECTED_FOR_BATCHis set later by the batch engine when a wagon offer/reservation is made — not at accept.APPROVED_PENDING_SIGNATUREis a real intermediate (line-staff approves first, then director+CEO).- Full
BOOKING_STATUSEShas 35 values; this diagram is the live commercial subset (legacy statuses likeWAGON_ASSIGNED,INVOICED,PNR_GENERATEDare 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_STATUSEShas 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_ACTIVEdirectly (clearance runs per-booking, Path B); only IMPORT/EXPORT one-time or self-clearance opens the contract-levelAWAITING_CLEARANCE_DOCUMENTScycle.
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/:consignmentId → tracking_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, invoiceby-id/document/receipt/pay-online, andbookings/: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.paid →
advanceBookingOnPayment → firstMile.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-agnostic — BillingService.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-fireshandlePaymentEvent(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 separateedr_paymentschema — no cross-schema FKs;referenceIdis 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/EXPORTopen a clearance gate even with customs off; onlyDOMESTICskips it. The customs flag only selects the clearance document set. SELECTED_FOR_BATCHis set by the batch engine, not at operation-accept — train accept setsFULLY_EXECUTEDfirst.FacilitiesModuleis never imported intoAppModule—/api/facilitiesis unmounted / dead (not reachable at all).tracking,maintenance,fuelcarry no permission decorator but are mounted — behind the global JwtGuard, just not permission-gated.tracking_eventshas no writer anywhere —TrackingService.record()is never called, so the customer tracking timeline is unpopulated. Only stafftrain_checkpoint_eventsare written (byrecordCheckpoint/arrive).- First-mile paid-flag listener is a dead code path —
@OnEvent("firstmile.invoice.paid")(no underscore) never fires because the emitted event isfirst_mile.invoice.paid; the first-milepaidflag is never flipped by settlement. - Invoice
OVERDUEstatus is never set — no cron/setter transitions to it, though it is in the enum andOPEN_STATUSES. POST /api/internal/payments/mark-paidis@Publicwith no service auth (ServiceAuthGuard removed; noted in code).- Demo settlement shortcut in
billing.payInvoicebypasses 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), portalconsignmentsservice hits/consignmentswithout/api, and list endpointsbookings/my,queues/:queue,by-company/customer-viewhave no active caller. - NOTE: The repo
CLAUDE.mdstates auth is stubbed/unwired — this is stale: auth is live via@tria-plc/api-common(globalJwtGuard+HasActiveDelegationGuardAPP_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.