Files
edr-platform/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
Nathnael 3a69b961d4 refactor(freight): move the Fayda callback to /fayda/callback
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.
2026-08-04 12:43:25 +00:00

15 KiB

EDR Freight — One Master Flow (signup → every end)

Single comprehensive graph of the entire freight business logic: from customer signup through every branch to every terminal state. Actor-coloured, endpoint-labelled.

Legend (P) Portal (customer) · (B) Backoffice (staff) · (sys) System/auto (event, cron, service-to-service) Rounded green = success end · Red = failure/terminal end · Diamond = decision · Hexagon = domain event.

flowchart TD
  classDef start fill:#e0e7ff,stroke:#3730a3,color:#111
  classDef port fill:#dbeafe,stroke:#2563eb,color:#111
  classDef back fill:#fef3c7,stroke:#b45309,color:#111
  classDef sys fill:#dcfce7,stroke:#15803d,color:#111
  classDef dec fill:#f8fafc,stroke:#475569,color:#111
  classDef good fill:#86efac,stroke:#166534,color:#062e14
  classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a

  %% ================= PHASE 1: IDENTITY & ONBOARDING =================
  S0(["Customer visits portal"]):::start
  S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
  S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
  S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/fayda/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
  S2 -->|"No"| S4
  S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
  S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
  S4b --> S5["POST /companies/onboarding/complete<br/>re-validate → company+profiles = PENDING (P)"]:::port
  S5 --> S6{"Backoffice reviews profile<br/>PATCH /company-profiles/:id/status (B)"}:::dec
  S6 -->|"Reject / suspend"| S6x(["SUSPENDED / BLACKLISTED<br/>cannot transact"]):::bad
  S6 -->|"Approve"| S7["Mint reference (EX-#####),<br/>company → ACTIVE (B)"]:::back
  S7 --> S8{"Start a shipment?"}:::dec
  S8 -->|"idle"| S8x(["No booking (dormant account)"]):::bad
  S8 -->|"Yes"| MODE

  %% ================= PHASE 2: COMMERCIAL ORIGIN =================
  MODE{"Booking origin?"}:::dec
  MODE -->|"One-time shipment"| B0
  MODE -->|"Framework agreement"| C1

  %% ---- Contract track ----
  C1["Create CONTRACT DRAFT<br/>POST /contracts (routes + cargo scope) (P)"]:::port
  C1 --> C1d{"Abandon draft?"}:::dec
  C1d -->|"delete"| C1x(["Contract removed (soft-delete)"]):::bad
  C1d -->|"continue"| C2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
  C2 --> C2p{"Price changed?"}:::dec
  C2p -->|"Yes"| C2c["confirm-submit → SUBMITTED (P)"]:::port
  C2p -->|"No"| C3
  C2c --> C3
  C3{"Staff intake<br/>POST /contracts/:id/staff/* (B)"}:::dec
  C3 -->|"request-changes"| C3r["CHANGES_REQUESTED (B)"]:::back
  C3r -->|"edit + resubmit"| C2
  C3 -->|"reject"| C3x(["Contract REJECTED"]):::bad
  C3 -->|"accept"| C4["→ PENDING_APPROVAL<br/>instantiate approval steps (B)"]:::back
  C4 --> C4a{"Approval chain<br/>line → director → ceo (B)"}:::dec
  C4a -->|"rejectStep"| C4x(["Contract REJECTED"]):::bad
  C4a -->|"all approve"| C5["generate-contract → CONTRACT_READY (B)"]:::back
  C5 --> C6["Customer sign → SIGNED_CUSTOMER<br/>POST /contracts/:id/contract/sign (P)"]:::port
  C6 --> C7{"Staff counter-sign branch"}:::dec
  C7 -->|"customs on"| C7a["AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
  C7 -->|"GENERAL"| C7b["CONTRACT_ACTIVE (B)"]:::back
  C7 -->|"ONE_TIME"| C7c["FULLY_EXECUTED (B)"]:::back
  C7a --> CPATH
  C7b --> CPATH
  C7c --> CPATH
  C7b -.->|"renew"| C7renew(["RENEWAL_DRAFT → new cycle"]):::bad
  C7b -.->|"lapse"| C7exp(["Contract EXPIRED"]):::bad

  CPATH{"How are shipments booked<br/>under the contract?"}:::dec
  CPATH -->|"Path A: transport-only"| CPA["Ops review self-clearance<br/>ops-review → ops-finalize → SELF_CLEARED (B)<br/>then customer books direct<br/>POST /contracts/:id/bookings (P)"]:::port
  CPATH -->|"Path B: GENERAL + customs"| CPB["Customer submits BookingRequest<br/>POST /contracts/:id/booking-requests (P)"]:::port
  CPB --> CPBq{"GL queue decision (B)"}:::dec
  CPBq -->|"reject / customer cancels"| CPBx(["BookingRequest REJECTED / CANCELLED"]):::bad
  CPBq -->|"accept → GL creates booking"| B0u
  CPA --> B0u["Booking created UNDER contract<br/>(window + capacity draw-down check) (sys)"]:::sys
  B0u --> BFLOW

  %% ---- One-time booking ----
  B0["Create BOOKING DRAFT<br/>POST /bookings (reference, containers,<br/>cargo modifiers, files) (P)"]:::port
  B0 --> B0d{"Abandon draft?"}:::dec
  B0d -->|"delete"| B0x(["Booking removed (soft-delete)"]):::bad
  B0d -->|"continue"| BFLOW
  B0 -.->|"consolidation"| BCONS(["PENDING_CONSOLIDATION<br/>waits for partner shipment<br/>(shares a wagon) → rejoins"]):::sys
  BCONS -.-> BFLOW

  %% ================= PHASE 3: PRICING & SUBMIT =================
  BFLOW["Configure shipment<br/>freight type + trade direction"]:::sys
  BFLOW --> FT{"Freight type?"}:::dec
  FT -->|"CONTAINER"| DIR
  FT -->|"BULK"| DIR
  DIR{"Trade direction?"}:::dec
  DIR -->|"EXPORT"| B1
  DIR -->|"IMPORT"| B1
  DIR -->|"DOMESTIC"| B1
  B1["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges<br/>HAZARDOUS / REEFER / OVERWEIGHT /<br/>SHIPPING_LINE / CONSOLIDATION (P)"]:::port
  B1 --> B1w{"weight-limit-rules check"}:::dec
  B1w -->|"VGM > maxCapacity"| B1x(["HARD BLOCK (400)<br/>cannot submit"]):::bad
  B1w -->|"over maxVgm, within cap"| B1warn["warning + OVERWEIGHT surcharge"]:::sys
  B1w -->|"ok"| B2
  B1warn --> B2
  B2["POST /bookings/:id/submit → SUBMITTED<br/>create booking_rate_snapshot (P)"]:::port
  B2 --> B2p{"Price moved since draft?"}:::dec
  B2p -->|"Yes → PRICE_CHANGED_PENDING_CONFIRM"| B2c["confirm-submit → SUBMITTED (P)"]:::port
  B2p -->|"No"| GOV
  B2c --> GOV
  B2 -.->|"customer rejects price"| B2x(["Booking REJECTED"]):::bad

  %% ================= PHASE 4: INTAKE & APPROVAL =================
  GOV{"Government booking?"}:::dec
  GOV -->|"Yes"| GEXP["governmentExpedite →<br/>PAID + schedulingStatus Eligible (B)"]:::back
  GOV -->|"No (commercial)"| BI{"Staff intake<br/>POST /bookings/:id/staff/* (B)"}:::dec
  BI -->|"request-changes"| BIr["CHANGES_REQUESTED (B)"]:::back
  BIr -->|"edit + resubmit"| B2
  BI -->|"reject"| BIx(["Booking REJECTED"]):::bad
  BI -->|"accept"| BA["→ PENDING_APPROVAL<br/>instantiate approval steps<br/>(set validity window) (B)"]:::back
  BA --> BAc{"Approval chain<br/>LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
  BAc -->|"rejectStep"| BAx(["Booking REJECTED"]):::bad
  BAc -->|"all approve → APPROVED"| BC1

  %% ================= PHASE 5: CONTRACT DOC & SIGN =================
  BC1["contract/generate → CONTRACT_READY (B)"]:::back
  BC1 --> BC2["Customer sign → SIGNED_CUSTOMER<br/>POST /bookings/:id/contract/sign (P)"]:::port
  BC2 --> BC3{"Staff counter-sign:<br/>trade direction?"}:::dec
  BC3 -->|"IMPORT / EXPORT<br/>(clearance gate, even if customs off)"| CL1
  BC3 -->|"DOMESTIC"| FEXD["counter-sign → FULLY_EXECUTED<br/>enqueue batch (skips clearance + op-request) (sys)(B)"]:::back
  FEXD --> FEB

  %% ================= PHASE 6: CUSTOMS CLEARANCE =================
  CL1["AWAITING_DOCUMENTS → customer uploads<br/>POST /bookings/:id/clearance/documents<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
  CL1 --> CL2{"GL reviews each doc<br/>clearance/review (B)"}:::dec
  CL2 -->|"Query"| CL2q["doc queried → customer re-uploads (B)"]:::back
  CL2q --> CL1
  CL2 -->|"Approve all"| CL3["finalize (100% approved) → CLEARANCE_READY (B)"]:::back
  CL3 --> CLph["Phased ET/DJ (as applicable):<br/>declaration → duty advise → duty slip →<br/>transit permit → delivery/release order →<br/>T1 docs/close → export release (sys)(B)"]:::back
  CLph --> OP1

  %% ================= PHASE 7: OPERATION REQUEST =================
  OP1["clearance/proceed: pick binding schedule day<br/>→ OPERATION_REQUEST_PENDING (P)"]:::port
  OP1 --> OP2{"Operations review<br/>POST /bookings/:id/operation/review (B)"}:::dec
  OP2 -->|"REQUEST_CHANGES"| OP2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
  OP2c --> OP1
  OP2 -->|"ACCEPT"| OPM{"Operation mode?"}:::dec
  OPM -->|"TRAIN (rail)"| OP3t["invoice generated → FULLY_EXECUTED<br/>(day batch pool) (B)"]:::back
  OPM -->|"ROAD (truck)"| OP3r["invoice generated →<br/>ROAD_DISPATCH_PENDING (billed by KM) (B)"]:::back
  OP3t --> FEB["batch engine offers wagons →<br/>SELECTED_FOR_BATCH (sys)"]:::sys

  %% ================= PHASE 8: INVOICE & PAYMENT =================
  GEXP --> SCH
  FEB --> PAY1
  OP3r --> PAY1
  PAY1["Invoice (source=booking, INV-YYYYMMDD-#####, due +14d)<br/>booking invoice starts DRAFT → ISSUED at operation-accept (sys)"]:::sys
  PAY1 --> PAY2["Customer pays<br/>POST /billing/my-invoices/:id/pay →<br/>billing.payInvoice → payment-api initiate (P)"]:::port
  PAY2 --> PAYp{"Provider result<br/>(Telebirr/CBE/EBirr/Waafi/DMoney/Card/CAC)"}:::dec
  PAYp -->|"FAILED"| PAYf["invoice stays OPEN (retry)"]:::sys
  PAYf --> PAY2
  PAYp -->|"pay window lapses"| PAYexp(["Booking/reservation EXPIRED"]):::bad
  PAYp -->|"SUCCEEDED"| PAYok["webhook → payment outbox →<br/>POST /internal/payments/mark-paid →<br/>settleByPaymentId → invoice PAID (sys)"]:::sys
  PAYok --> EVT1{{"booking.invoice.paid event"}}:::sys
  EVT1 --> PAID["Booking → PAID"]:::sys
  PAYok -.->|"post-pay"| PAYref(["REFUNDED (terminal)"]):::bad
  PAID --> FMQ0
  EVT1 -.->|"if EXPORT + first-mile"| FM1
  PAID --> SCH

  %% ================= PHASE 9: SCHEDULING & ALLOCATION =================
  SCH["schedulingStatus = Eligible (sys)"]:::sys
  SCH --> SC2["Train schedule DRAFT<br/>POST /train-scheduling/{container|bulk}/schedules<br/>≥2 locomotives, derive direction (B)"]:::back
  SC2 --> SC3["assign-bookings + run-allocation<br/>(wagon_booking_allocations) (B)"]:::back
  SC3 --> SC4["pin physical wagons → finalize → SCHEDULED<br/>bookings → Scheduled (B)"]:::back
  SC4 -.->|"cancel schedule"| SC4x["bookings back to Eligible (B)"]:::back
  SC4x -.-> SC2
  SC4 -.->|"gov preempt / maintenance"| RESCH["reschedule: retained / displaced /<br/>readmitted (priority: gov first) (B)"]:::back
  RESCH -.-> SC3
  SC4 --> FMQ0

  %% ================= PHASE 10: FIRST-MILE (export origin road leg) =================
  FMQ0{"EXPORT + first-mile requested?"}:::dec
  FMQ0 -->|"Yes"| FM1["first-mile leg auto-created<br/>firstMile.acceptBooking (READY_TO_TRANSIT) (sys)"]:::sys
  FMQ0 -->|"No"| WO1
  FM1 --> FM2["setVehicles → vehicle BUSY, SMS driver,<br/>fleet_events (B)"]:::back
  FM2 --> FM3["IN_TRANSIT (needs vehicle) →<br/>RECEIVED_TO_PORT (free vehicles) (B)"]:::back
  FM3 --> FM4["first-mile invoice (FIRST_MILE fee) (B)"]:::back
  FM4 --> WO1

  %% ================= PHASE 11: WAREHOUSE ORIGIN (export) =================
  WO1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner (B)"]:::back
  WO1 --> WO2{"inspection outcome"}:::dec
  WO2 -->|"FAILED / NEEDS_REVIEW"| WO2f["hold + re-inspect (B)"]:::back
  WO2f --> WO2
  WO2 -->|"PASSED"| WO3["store (allocation rule picks yard/zone) → STORED (B)"]:::back
  WO3 --> WO4["reserve (booking PAID) → RESERVED (B)"]:::back
  WO4 --> WO5["mark-ready-for-loading → READY_FOR_LOADING (B)"]:::back
  WO5 --> WO6["load onto wagon → LOADED<br/>(+ warehouse_loadings) (B)"]:::back
  WO6 --> TR1

  %% ================= PHASE 12: DISPATCH & TRANSIT =================
  TR1["dispatch → DISPATCHED<br/>assign train_number, locos ASSIGNED,<br/>window CLOSED, unpaid reservations EXPIRED (B)"]:::back
  TR1 --> TR2["record checkpoints (corridor stations) →<br/>train_checkpoint_events (B)"]:::back
  TR2 --> TRC["Customer tracking page<br/>GET /tracking/:consignmentId (JWT) (P)"]:::port
  TR2 --> TR3["arrive (final checkpoint) → ARRIVED<br/>bookings IN_TRANSIT, locos+wagons freed,<br/>warehouse arrival automation (B)"]:::back
  TR3 --> WD1

  %% ================= PHASE 13: WAREHOUSE DEST + IMPORT CUSTOMS =================
  WD1["destination warehouse: auto-unload arrived<br/>→ UNLOADED / RECEIVED (B)"]:::back
  WD1 --> WD2{"inspection PASSED?"}:::dec
  WD2 -->|"No"| WD2f["hold + re-inspect / incident report (B)"]:::back
  WD2f --> WD2
  WD2 -->|"Yes"| DIRW{"trade direction at destination"}:::dec
  DIRW -->|"IMPORT"| WD3["READY_FOR_PICKUP (B)"]:::back
  DIRW -->|"EXPORT (Djibouti)"| WDX["auto-unload-at-djibouti → DISPATCHED /<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
  WD3 --> IMP1["Import customs finalization:<br/>upload docs → declaration → notify duties →<br/>duties paid (needs slip) → assign risk →<br/>release-permitted (all gates) (sys)(B)"]:::back
  IMP1 --> LMQ
  WDX --> ICD["interchange document (handover manifest)<br/>generate-from-schedule → GENERATED →<br/>ACKNOWLEDGED / DISPUTED (B)"]:::back
  ICD --> DE1

  %% ================= PHASE 14: LAST-MILE (import destination road leg) =================
  LMQ{"IMPORT + last-mile requested?"}:::dec
  LMQ -->|"Yes"| LM1["last-mile leg auto-created<br/>(IMPORT inspection PASSED only) (sys)"]:::sys
  LMQ -->|"No"| DE1
  LM1 --> LM2["setVehicles → IN_TRANSIT → DELIVERED<br/>(free vehicles) (B)"]:::back
  LM2 --> LM3["last-mile invoice (LAST_MILE fee) (B)"]:::back
  LM3 --> DE1

  %% ================= PHASE 15: DELIVERY & COMPLETION =================
  DE1{"Warehouse/storage fees fully PAID?"}:::dec
  DE1 -->|"No"| DE1x["gate-clearance BLOCKED<br/>findBlockingInvoice / assertClearanceAllowed (B)"]:::back
  DE1x --> DE1p["Customer pays storage/demurrage<br/>warehouse-fee-invoices/:id/pay-online (P)"]:::port
  DE1p --> DE1
  DE1 -->|"Yes"| DE2["release order (DO) + gate-clearance →<br/>deliver (B)"]:::back
  DE2 --> DE3["Customer approves delivery (saved signature)<br/>POST /warehouse-inventory/bookings/:id/approve-delivery (P)"]:::port
  DE3 --> DE4["inventory DELIVERED, POD to cargo,<br/>container freed, capacity released (sys)"]:::sys
  DE4 --> DONE(["Booking COMPLETED (done) <br/>operations/complete"]):::good

  %% ================= GLOBAL EXITS =================
  GEXIT(["CANCELLED — POST /bookings/:id/cancel (staff-only)<br/>ONLY from DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,<br/>CHANGES_REQUESTED, PENDING_APPROVAL, CONTRACT_READY,<br/>OPERATION_REQUEST_PENDING"]):::bad
  B2 -.->|"cancel"| GEXIT
  BA -.->|"cancel"| GEXIT
  OP1 -.->|"cancel"| GEXIT

Reading notes

  • Solid arrows = the primary progression. Dotted arrows = optional / event-driven / exit hops (consolidation, reschedule, cancel, the first-mile event branch).
  • Every terminal is a rounded red or green node: SUSPENDED/BLACKLISTED, dormant, booking/contract removed, REJECTED (customer price, staff intake, approval step, GL booking-request), HARD BLOCK (VGM), EXPIRED (pay window / contract), REFUNDED, RENEWAL_DRAFT, CANCELLED, and the single success end COMPLETED (done) .
  • Branch axes captured: identity-proofing (Fayda / skip), origin (one-time vs contract Path A / Path B), freight type (container / bulk), trade direction (import / export / domestic), customer type (government expedite vs commercial approval chain), customs on/off, operation mode (rail / road), provider outcome (success / fail-retry / expire / refund), first-mile (export), last-mile (import), Djibouti export unload + interchange handover.
  • Actors: (P) portal customer, (B) backoffice staff, (sys) system (events like booking.invoice.paid, warehouse.invoice.paid, auto leg creation, payment webhook/outbox settlement).

Endpoint-level tables, per-domain state machines, and the payment-microservice sequence live in FREIGHT_SYSTEM_FLOW.md. This file is the single end-to-end picture.