Merge pull request #541 from Tria-plc/staging

Staging
This commit is contained in:
Abubeker Yasin
2026-07-08 14:44:08 +03:00
committed by GitHub
1001 changed files with 102059 additions and 17474 deletions

View File

@@ -170,7 +170,12 @@ jobs:
- name: Build ${{ matrix.service }}
run: |
set -euo pipefail
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
# Tag with git SHA for rollback capability
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true
echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}"
- name: Deploy ${{ matrix.service }}
run: |

View File

@@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
### Runtime
The final image runs:
The final image uses Next.js `output: 'standalone'` and runs:
```bash
npx next start
node server.js
```
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`.
## Rollback Procedure
Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-<service>:<sha8>`).
### Rollback a single service
```bash
# 1. Find the last known-good image tag
docker images | grep passenger-api
# 2. Re-tag it as the current image
docker tag edr-passenger-main-passenger-api:<previous-sha> edr-passenger-main-passenger-api:latest
# 3. Restart the container from the previous image
docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate
```
### Rollback via re-run
Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit.
## Production Security Checklist
Before deploying to production, verify:
- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`)
- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10`
- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production)
- [ ] `NODE_ENV=production` is set
- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token
- [ ] No `.env` files are committed to the repository (`git status` should show none)
## Data Retention Policy
The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes:
| Table | Retention |
|---|---|
| `OtpCode` | 1 hour after expiry or verification |
| `FaydaVerificationSession` | 1 hour after expiry or completion |
| `AuditLog` | 365 days |
| `PaymentWebhookEvent` | 90 days |
| `GateValidationLog` | 180 days |
No manual intervention is required. Monitor the `TasksService` log output for purge counts.
## GitHub Actions Deployment Flow
@@ -147,9 +193,10 @@ For each service:
- Computes branch slug and sets:
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
- Runs:
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image.
- Builds the service image and tags it with the short git SHA.
- Runs `docker compose up -d <service> --force-recreate`.
- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy.
- Cleans `.npmrc`/`.npmrc_temp`.
## Branch/Environment Isolation

View File

@@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed
- 3 User accounts (Admin, Passenger, Agent)
- Fare rules for ADULT and CHILD passenger categories
- Currency exchange rates (ETB, DJF, USD)
- Baggage allowance rules
- Luggage allowance rules
- Notification templates
- Promotions and FAQ content
- Menu items and station crowd signals

View File

@@ -1,5 +1,7 @@
# Copy to .env for local/docker compose (not committed).
PORT=3001
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
GT06_TCP_PORT=5023
DB_HOST=localhost
DB_PORT=5433
DB_USER=postgres
@@ -49,14 +51,40 @@ MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
# Preset region so signed URLs are generated locally (no GetBucketLocation
# network call per sign). MinIO's default is us-east-1.
MINIO_REGION=us-east-1
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# --- Notification broker (RabbitMQ) ---------------------------------------------
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared
# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely
# (dev without a local broker).
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
SMS_QUEUE=sms_queue
# ── VeriFayda 2.0 (eSignet OIDC) identity verification ──────────────────────
# Disabled by default; /fayda/verification/start returns 503 until enabled.
FAYDA_ENABLED=false
FAYDA_CLIENT_ID=
FAYDA_AUTHORIZATION_ENDPOINT=
FAYDA_TOKEN_ENDPOINT=
FAYDA_USERINFO_ENDPOINT=
# Base64-encoded RSA private JWK used for the private_key_jwt client assertion
FAYDA_PRIVATE_KEY_BASE64=
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
EXPIRATION_TIME=15
ALGORITHM=RS256
EMAIL_QUEUE=email_queue

View File

@@ -40,4 +40,6 @@ RUN addgroup --system --gid 1001 nodejs \
COPY --from=deployer --chown=nestjs:nodejs /deploy .
USER nestjs
EXPOSE 3001
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
EXPOSE 5023
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,604 @@
# EDR Freight — Major Flow Variants (each self-contained)
The single master graph lives in [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md). This file breaks the
business logic into **one comprehensive, self-contained diagram per major scenario**, each organised with
phase **subgraphs** so it can be read on its own.
**Axes covered**
| Axis | Values |
| --------------- | -------------------------------------------------------------------------------------------- |
| Origin | **One-time booking** · **General contract** (Path A transport-only / Path B GENERAL+customs) |
| Trade direction | **Export** · **Import** · **Intercity / Domestic** |
| Customs | **With customs** · **Without customs** |
**Legend** — (P) Portal (customer) · (B) Backoffice (staff) · (sys) System/event · (green) rounded = success end · (red) rounded = fail end · <> decision.
**Which diagram do I read?**
```mermaid
flowchart LR
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef box fill:#e0e7ff,stroke:#3730a3,color:#111
A{"Origin?"}:::dec
A -->|"one-time"| B{"Trade direction?<br/>(gate is direction-driven, NOT a customs toggle)"}:::dec
A -->|"framework agreement"| C{"Contract type?"}:::dec
B -->|"DOMESTIC"| D1["§1 One-time · DOMESTIC (no gate)"]:::box
B -->|"IMPORT / EXPORT<br/>(with or without customs)"| D2["§2 One-time · IMPORT/EXPORT (gate)"]:::box
C -->|"transport-only (self-clearance)"| D3["§3 Contract · Path A"]:::box
C -->|"GENERAL + customs"| D4["§4 Contract · Path B"]:::box
D1 --> E{"Physical direction?"}:::dec
D2 --> E
D3 --> E
D4 --> E
E -->|"export"| F5["§5 EXPORT operations"]:::box
E -->|"import"| F6["§6 IMPORT operations"]:::box
E -->|"domestic"| F7["§7 INTERCITY operations"]:::box
```
> **How the two halves connect:** §1§4 are the **commercial** journeys (intake → approval → contract →
> clearance → operation → payment). §5§7 are the **physical** journeys (mile legs → warehouse → train →
> delivery). A shipment = one commercial variant **+** one physical variant. Each diagram fully details its
> own half and summarises the other so it stands alone.
---
## §1 — One-time booking · DOMESTIC (no clearance gate)
The commercial lifecycle when the counter-sign gate resolves to **no clearance** — which, in code, means
**trade direction = DOMESTIC** (not a customs toggle). Counter-sign goes straight to `FULLY_EXECUTED` and the
booking is enqueued **directly into the scheduling batch pipeline, skipping the operation-request/clearance
phase**. NOTE: Import/export bookings — _even with customs off_ — do **not** land here; they always hit the
clearance gate (§2, just with a lighter "without customs" document set).
```mermaid
flowchart TD
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
pre(["Company ACTIVE (approved profile)"]):::sys
subgraph DRAFT["1 · Create & price"]
direction TB
d1["POST /bookings → DRAFT<br/>reference, containers, cargo modifiers, files (P)"]:::port
d2["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges (P)"]:::port
dW{"weight-limit-rules"}:::dec
dWx(["HARD BLOCK 400 — VGM > capacity"]):::bad
d3["POST /bookings/:id/submit → SUBMITTED<br/>freeze booking_rate_snapshot (P)"]:::port
dP{"price moved?"}:::dec
d3c["confirm-submit → SUBMITTED (P)"]:::port
d1 --> d2 --> dW
dW -->|"over capacity"| dWx
dW -->|"ok / warn+surcharge"| d3 --> dP
dP -->|"yes"| d3c
dP -->|"no"| out1
d3c --> out1
d1 -.->|"delete draft"| ddx(["removed"]):::bad
d3 -.->|"reject price"| drx(["REJECTED"]):::bad
end
out1[" "]:::sys
subgraph INTAKE["2 · Staff intake & approval"]
direction TB
g{"government?"}:::dec
gexp["governmentExpedite → PAID + Eligible (B)"]:::back
i{"staff/accept | request-changes | reject (B)"}:::dec
ir["CHANGES_REQUESTED (B)"]:::back
irx(["REJECTED"]):::bad
ia["→ PENDING_APPROVAL<br/>instantiate approval steps + validity window (B)"]:::back
ac{"chain: LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
acx(["rejectStep → REJECTED"]):::bad
g -->|"yes"| gexp
g -->|"no"| i
i -->|"request-changes"| ir
i -->|"reject"| irx
i -->|"accept"| ia --> ac
ac -->|"rejectStep"| acx
end
subgraph SIGN["3 · Contract doc & sign (DOMESTIC → no gate)"]
direction TB
s1["contract/generate → CONTRACT_READY (B)"]:::back
s2["customer sign → SIGNED_CUSTOMER (P)"]:::port
s3["staff counter-sign (DOMESTIC) → FULLY_EXECUTED<br/>enqueueScheduleProcessing (no op-request) (B)(sys)"]:::back
s1 --> s2 --> s3
end
subgraph OPPAY["4 · Batch pipeline & payment"]
direction TB
fe["FULLY_EXECUTED enters day batch pool (sys)"]:::sys
b1["batch engine offers wagons → SELECTED_FOR_BATCH<br/>invoice generated (sys)"]:::sys
p1["customer pays → gateway → PAID (P)"]:::port
pexp(["pay window lapses → reservation EXPIRED"]):::bad
fe --> b1 --> p1
p1 -.->|"unpaid"| pexp
end
phys(["Physical execution:<br/>§7 intercity → COMPLETED (done)"]):::good
pre --> DRAFT
out1 --> INTAKE
ac -->|"APPROVED"| SIGN
SIGN --> OPPAY
p1 --> phys
gexp -.->|"gov → PAID/Eligible"| phys
```
> **Road-mode note:** a domestic booking billed by road (truck) instead of rail goes through
> `operation/review` → `ROAD_DISPATCH_PENDING` (the road branch shown in the master graph), not the rail
> batch pool above.
---
## §2 — One-time booking · IMPORT / EXPORT (clearance gate)
Every IMPORT/EXPORT one-time booking traverses the clearance gate — **whether or not customs is enabled**
(the customs flag only selects a heavier vs lighter `clearance_*` document set; both go through
`AWAITING_DOCUMENTS`). Commercial spine as §1 (phases 13) **plus** the gate: counter-sign → `AWAITING_DOCUMENTS`
→ document review loop → `CLEARANCE_READY`, phased ET/DJ actions, then operation-request → payment.
```mermaid
flowchart TD
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
a0(["Booking APPROVED & signed by customer<br/>(see §1 phases 13)"]):::sys
subgraph CS["Counter-sign with customs"]
direction TB
cs1["staff counter-sign (IMPORT/EXPORT) → AWAITING_DOCUMENTS (B)"]:::back
end
subgraph DOCS["5 · Document clearance gate"]
direction TB
x1["customer clearance/documents<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
x2{"GL clearance/review each doc (B)"}:::dec
x2q["doc Queried → customer re-uploads (B)"]:::back
x3["clearance/finalize (100% approved) → CLEARANCE_READY (B)"]:::back
x1 --> x2
x2 -->|"query"| x2q --> x1
x2 -->|"approve all"| x3
end
subgraph PHASED["6 · Phased ET / DJ clearance (as applicable)"]
direction TB
ph1["upload declaration (serial) (B)"]:::back
ph2["duty/tax advise → customer duty-slip (P)(B)"]:::back
ph3["transit permit (ET) (B)"]:::back
ph4["delivery order / release order (DJ) (B)"]:::back
ph5["T1 docs → T1 close (B)"]:::back
ph6["export release / finalize-pre-clearance (B)"]:::back
ph1 --> ph2 --> ph3 --> ph4 --> ph5 --> ph6
end
subgraph OPPAY2["7 · Operation request & payment"]
direction TB
o1["clearance/proceed → OPERATION_REQUEST_PENDING (P)"]:::port
o2{"operation/review (B)"}:::dec
o2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
om{"mode?"}:::dec
ot["accept=train: invoice → FULLY_EXECUTED<br/>→ batch offer → SELECTED_FOR_BATCH (B)(sys)"]:::back
orr["accept=road: invoice → ROAD_DISPATCH_PENDING (B)"]:::back
p1["customer pays → PAID (sys)(P)"]:::sys
pexp(["pay window lapses → EXPIRED"]):::bad
o1 --> o2
o2 -->|"request-changes"| o2c --> o1
o2 -->|"accept"| om
om -->|"train"| ot --> p1
om -->|"road"| orr --> p1
p1 -.->|"unpaid"| pexp
end
cancel(["CANCELLED — staff-only, only from<br/>OPERATION_REQUEST_PENDING here (not from<br/>AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW)"]):::bad
phys(["Physical execution:<br/>§5 export · §6 import → COMPLETED (done)"]):::good
a0 --> CS --> DOCS
x3 --> PHASED
ph6 --> OPPAY2
p1 --> phys
o1 -.->|"cancel"| cancel
```
---
## §3 — General contract · Path A (transport-only, self-clearance)
Framework agreement where the customer clears customs independently. After the contract is active and
operations verify self-clearance (`SELF_CLEARED`), the **customer books directly** under the contract; each
booking then runs the operation/payment/physical flow.
```mermaid
flowchart TD
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
pre(["Company ACTIVE"]):::sys
subgraph CTR["1 · Contract lifecycle"]
direction TB
c1["POST /contracts → DRAFT<br/>routes + cargo scope + unit rates (NO quantities) (P)"]:::port
c2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
c3{"staff/accept | request-changes | reject (B)"}:::dec
c3r["CHANGES_REQUESTED (B)"]:::back
c3x(["Contract REJECTED"]):::bad
c4["→ PENDING_APPROVAL (B)"]:::back
c4a{"approval chain (B)"}:::dec
c4x(["rejectStep → REJECTED"]):::bad
c5["generate-contract → CONTRACT_READY (B)"]:::back
c6["customer sign → SIGNED_CUSTOMER (P)"]:::port
c7["staff counter-sign (IMPORT/EXPORT self-clearance) →<br/>AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
c1 --> c2 --> c3
c3 -->|"request-changes"| c3r --> c2
c3 -->|"reject"| c3x
c3 -->|"accept"| c4 --> c4a
c4a -->|"reject"| c4x
c4a -->|"approve"| c5 --> c6 --> c7
c7 -.->|"lapse"| cexp(["EXPIRED"]):::bad
c7 -.->|"renew"| cren(["RENEWAL_DRAFT → new cycle"]):::bad
end
subgraph SELF["2 · Self-clearance verification"]
direction TB
o1["customer uploads self-clearance docs (P)"]:::port
o2{"ops-review each doc (B)"}:::dec
o2q["query → re-upload (B)"]:::back
o3["ops-finalize → clearanceStatus SELF_CLEARED (B)"]:::back
o1 --> o2
o2 -->|"query"| o2q --> o1
o2 -->|"approve"| o3
end
subgraph BK["3 · Book directly under contract"]
direction TB
b1["customer POST /contracts/:id/bookings (P)"]:::port
bv{"validate-shipment:<br/>window + capacity draw-down + pairing"}:::dec
bvx(["rejected: over capacity /<br/>20ft pairing hard-block"]):::bad
b2["Booking created under contract<br/>(bookings.contract_id) (sys)"]:::sys
b1 --> bv
bv -->|"fail"| bvx
bv -->|"ok"| b2
end
op(["Booking runs operation + payment<br/>(see §1 phase 4) then §5/§6/§7 → COMPLETED (done)"]):::good
pre --> CTR
c7 --> SELF
o3 --> BK
b2 --> op
```
---
## §4 — General contract · Path B (GENERAL + customs)
Framework agreement **with** customs. The customer cannot book directly — they submit a **BookingRequest**
(date + quantities only); GL Ethiopia accepts it and creates the booking, which then runs **per-booking
phased customs** on the `/contracts/bookings/:bookingId/*` surface.
```mermaid
flowchart TD
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
pre(["Company ACTIVE"]):::sys
subgraph CTR["1 · Contract lifecycle (customs)"]
direction TB
c1["POST /contracts → DRAFT → submit → approve → sign (P)(B)"]:::port
c7["staff counter-sign (GENERAL + customs) → CONTRACT_ACTIVE<br/>(contract clearance cycle SKIPPED — runs per-booking) (B)"]:::back
c1 --> c7
c1 -.->|"reject / lapse"| cx(["REJECTED / EXPIRED"]):::bad
end
subgraph REQ["2 · Booking request → GL creates booking"]
direction TB
r1["customer POST /contracts/:id/booking-requests<br/>(date + quantities, no per-unit data) (P)"]:::port
r2{"GL booking-request queue (B)"}:::dec
r2x(["reject / customer cancel →<br/>REJECTED / CANCELLED"]):::bad
r3["GL accept → GL creates booking under contract<br/>(ct:create_booking) (B)"]:::back
r1 --> r2
r2 -->|"reject/cancel"| r2x
r2 -->|"accept"| r3
end
subgraph GLC["3 · Per-booking GL clearance & milestones"]
direction TB
g1["station-assign (route + bind staff) (B)"]:::back
g2["declaration → duty advise (GREEN/YELLOW/RED risk) (B)"]:::back
g3["customer duty-slip → transit / delivery / release order (P)(B)"]:::back
g4["T1 docs → T1 close (B)"]:::back
g5["final-invoice → customer slip → confirm paid (P)(B)"]:::back
g6["second-duty (post-arrival import) → slip (P)(B)"]:::back
gi["incident reports (photos) as needed (B)"]:::back
g1 --> g2 --> g3 --> g4 --> g5 --> g6
g4 -.-> gi
end
op(["Booking runs operation + payment (see §1 phase 4)<br/>then §5/§6 physical → COMPLETED (done)"]):::good
pre --> CTR
c7 --> REQ
r3 --> GLC
g6 --> op
```
---
## §5 — EXPORT operations (physical execution)
Given a PAID, scheduled **export** booking: optional first-mile road leg → origin warehouse inbound → train
build & dispatch → corridor transit → Djibouti port unload → interchange handover. Cargo leaves the country
at the port; the booking's terminal here is **dispatched/handed-over at Djibouti**.
```mermaid
flowchart TD
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
pre(["Export booking PAID & Eligible<br/>(see §1/§2/§3/§4)"]):::sys
subgraph FM["1 · First-mile (if EXPORT + requested)"]
direction TB
fmq{"first-mile requested?"}:::dec
fm1["leg auto-created firstMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
fm2["setVehicles → vehicle BUSY, SMS driver, fleet_events (B)"]:::back
fm3["IN_TRANSIT (needs vehicle) → RECEIVED_TO_PORT (B)"]:::back
fm4["first-mile invoice (FIRST_MILE fee); distances lock once invoiced (B)"]:::back
fmq -->|"yes"| fm1 --> fm2 --> fm3 --> fm4
fmq -->|"no"| fmskip[" "]:::sys
end
subgraph WH["2 · Origin warehouse inbound"]
direction TB
w1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner SMS (B)"]:::back
w2{"inspection"}:::dec
w2f["FAILED / NEEDS_REVIEW → hold + re-inspect (B)"]:::back
w3["store (allocation rule → yard/zone) → STORED (B)"]:::back
w4["reserve (booking PAID) → RESERVED (B)"]:::back
w5["mark-ready-for-loading (inspection PASSED) → READY_FOR_LOADING (B)"]:::back
w6["load onto wagon → LOADED (+ warehouse_loadings) (B)"]:::back
w1 --> w2
w2 -->|"fail"| w2f --> w2
w2 -->|"PASSED"| w3 --> w4 --> w5 --> w6
end
subgraph SCHED["3 · Train build & schedule"]
direction TB
s1["schedule DRAFT (≥2 locos, derive EXPORT direction) (B)"]:::back
s2["assign-bookings + run-allocation (wagons) (B)"]:::back
s3["pin wagons → finalize → SCHEDULED (bookings Scheduled) (B)"]:::back
s3x["cancel schedule → bookings Eligible (B)"]:::back
s1 --> s2 --> s3
s3 -.->|"cancel"| s3x -.-> s1
s3 -.->|"gov preempt / maintenance"| sr["reschedule: retained/displaced/readmitted (B)"]:::back
sr -.-> s2
end
subgraph RUN["4 · Dispatch → Djibouti"]
direction TB
r1["dispatch → DISPATCHED<br/>train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED (B)"]:::back
r2["checkpoints (corridor) → train_checkpoint_events (B)"]:::back
rc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
r3["arrive → ARRIVED (bookings IN_TRANSIT, wagons/locos freed) (B)"]:::back
ru["export/auto-unload-at-djibouti →<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
ri["interchange document generate-from-schedule → GENERATED (B)"]:::back
ria{"port acknowledges?"}:::dec
r1 --> r2 --> r3 --> ru --> ri --> ria
r2 -.-> rc
end
done(["Export dispatched & handed over at Djibouti (done)"]):::good
disp(["interchange DISPUTED → remarks / re-issue"]):::bad
pre --> FM
fm4 --> WH
fmskip --> WH
w6 --> SCHED
s3 --> RUN
ria -->|"acknowledge"| done
ria -->|"dispute"| disp
```
---
## §6 — IMPORT operations (physical execution)
Given a PAID, scheduled **import** booking arriving by train from Djibouti: destination warehouse unload →
inspection → import customs finalization → optional last-mile → fee gate-clearance → release → delivery →
**COMPLETED**.
```mermaid
flowchart TD
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
pre(["Import booking PAID & scheduled<br/>(see §1/§2/§4)"]):::sys
subgraph RAIL["1 · Import train arrival"]
direction TB
t1["import train dispatched from Djibouti → DISPATCHED (B)"]:::back
t2["checkpoints → train_checkpoint_events (B)"]:::back
tc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
t3["arrive → ARRIVED (bookings IN_TRANSIT) → warehouse arrival automation (B)"]:::back
t1 --> t2 --> t3
t2 -.-> tc
end
subgraph WH["2 · Destination warehouse"]
direction TB
w1["import/auto-unload-arrived-bookings (assign WH/yard/zone) → UNLOADED (B)"]:::back
w2{"inspection PASSED?"}:::dec
w2f["FAILED → hold / re-inspect / djibouti-incident report (B)"]:::back
w3["→ READY_FOR_PICKUP (IMPORT) (B)"]:::back
w1 --> w2
w2 -->|"no"| w2f --> w2
w2 -->|"yes"| w3
end
subgraph CUST["3 · Import customs finalization (timestamp-driven)"]
direction TB
u1["upload docs (IM4/IM5/T1_CLOSURE/TRANSIT_PERMIT/…) (B)"]:::back
u2["record declaration serial (B)"]:::back
u3["notify duties/taxes (B)"]:::back
u4["mark duties paid (needs CUSTOMER_PAYMENT_SLIP) (B)"]:::back
u5["assign risk (GREEN/YELLOW/BLUE/RED) (B)"]:::back
u6{"release gates satisfied?<br/>T1 + release permit + declaration + risk + paid"}:::dec
u6x["blocked — missing gate → resolve (B)"]:::back
u7["release-permitted → completedAt (B)"]:::back
u1 --> u2 --> u3 --> u4 --> u5 --> u6
u6 -->|"no"| u6x --> u6
u6 -->|"yes"| u7
end
subgraph LM["4 · Last-mile (if requested)"]
direction TB
lq{"last-mile requested?"}:::dec
l1["leg auto-created lastMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
l2["setVehicles → IN_TRANSIT → DELIVERED (free vehicles) (B)"]:::back
l3["last-mile invoice (LAST_MILE fee) (B)"]:::back
lq -->|"yes"| l1 --> l2 --> l3
lq -->|"no"| lskip[" "]:::sys
end
subgraph DEL["5 · Release & delivery"]
direction TB
d0{"warehouse/storage fees fully PAID?"}:::dec
d0x["gate-clearance BLOCKED (findBlockingInvoice) (B)"]:::back
d0p["customer pays storage/demurrage online (P)"]:::port
d1["release order (DO) + gate-clearance → deliver (B)"]:::back
d2["customer approve-delivery (saved signature) → POD (P)"]:::port
d3["inventory DELIVERED, POD to cargo, container freed (sys)"]:::sys
d0 -->|"no"| d0x --> d0p --> d0
d0 -->|"yes"| d1 --> d2 --> d3
end
ecr(["empty-container-return chain (post-import):<br/>RETURNED → … → HANDOVER_ISSUED → COMPLETED"]):::sys
done(["Booking COMPLETED (done) (operations/complete)"]):::good
pre --> RAIL
t3 --> WH
w3 --> CUST
u7 --> LM
l3 --> DEL
lskip --> DEL
d3 --> done
done -.-> ecr
```
---
## §7 — INTERCITY / DOMESTIC operations (no cross-border customs)
Rail movement **between Ethiopian yards** (e.g. inland dry ports). No import/export customs, no Djibouti
port unload or interchange handover. Optional road mile legs if the service includes door delivery.
```mermaid
flowchart TD
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
pre(["Domestic booking PAID & Eligible<br/>(see §1 — customs OFF)"]):::sys
subgraph FMD["1 · Optional origin pickup (road)"]
direction TB
fq{"door pickup / first-mile requested?"}:::dec
f1["first-mile leg → vehicle assign → RECEIVED_TO_PORT (B)"]:::back
fq -->|"yes"| f1 --> fnext[" "]:::sys
fq -->|"no (drop at origin yard)"| fnext
end
subgraph WHO["2 · Origin warehouse"]
direction TB
w1["receive → RECEIVED (B)"]:::back
w2{"inspection PASSED?"}:::dec
w2f["hold + re-inspect (B)"]:::back
w3["store → STORED → reserve (PAID) → RESERVED (B)"]:::back
w4["ready-for-loading → LOADED onto wagon (B)"]:::back
w1 --> w2
w2 -->|"no"| w2f --> w2
w2 -->|"yes"| w3 --> w4
end
subgraph RUN["3 · Train (ET yard → ET yard)"]
direction TB
s1["schedule DRAFT (direction DOMESTIC) → assign → finalize → SCHEDULED (B)"]:::back
r1["dispatch → DISPATCHED (unpaid EXPIRED) (B)"]:::back
r2["checkpoints → train_checkpoint_events; customer tracking (JWT) (P)(B)"]:::back
r3["arrive → ARRIVED (bookings IN_TRANSIT) (B)"]:::back
s1 --> r1 --> r2 --> r3
end
subgraph WHD["4 · Destination warehouse & delivery"]
direction TB
d1["auto-unload arrived → UNLOADED / RECEIVED (B)"]:::back
di{"inspection PASSED?"}:::dec
dif["hold + re-inspect (B)"]:::back
d2["READY_FOR_PICKUP (B)"]:::back
fee{"storage fees paid?"}:::dec
feex["gate-clearance blocked → customer pays (P)"]:::port
d3["release order → deliver (B)"]:::back
lq{"door delivery / last-mile?"}:::dec
l1["last-mile leg → DELIVERED (B)"]:::back
d4["customer approve-delivery → POD → inventory DELIVERED (P)(sys)"]:::sys
d1 --> di
di -->|"no"| dif --> di
di -->|"yes"| d2 --> fee
fee -->|"no"| feex --> fee
fee -->|"yes"| d3 --> lq
lq -->|"yes"| l1 --> d4
lq -->|"no (pickup at yard)"| d4
end
done(["Booking COMPLETED (done)"]):::good
pre --> FMD
fnext --> WHO
w4 --> RUN
r3 --> WHD
d4 --> done
```
---
## Cross-reference
| Variant | Distinctive gate(s) | Terminal ends unique to it |
| --------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| §1 One-time · DOMESTIC | counter-sign → `FULLY_EXECUTED` (skips clearance **and** op-request; enters batch directly) | HARD BLOCK, price/intake/approval REJECTED, EXPIRED |
| §2 One-time · IMPORT/EXPORT | `AWAITING_DOCUMENTS` → review loop → phased ET/DJ → op-request (gate applies with **or without** customs) | + doc-query loop, CANCELLED (only from OPERATION_REQUEST_PENDING) |
| §3 Contract · Path A | `SELF_CLEARED` via ops-review; customer books direct | contract REJECTED/EXPIRED/RENEWAL, capacity/pairing block |
| §4 Contract · Path B | BookingRequest → GL creates booking; per-booking milestones | BookingRequest REJECTED/CANCELLED |
| §5 Export ops | first-mile → Djibouti unload → interchange handover | dispatched@Djibouti (success), interchange DISPUTED |
| §6 Import ops | import customs finalization gates → last-mile → gate-clearance | COMPLETED (+ empty-container-return chain) |
| §7 Intercity ops | ET→ET rail, no cross-border customs, optional mile legs | COMPLETED |
Full endpoint tables & per-domain state machines: [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md).
Single all-in-one branching graph: [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md).

View File

@@ -0,0 +1,260 @@
# 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.
```mermaid
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/>/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`](./FREIGHT_SYSTEM_FLOW.md). This file is the single end-to-end picture.

View File

@@ -0,0 +1,872 @@
# 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](https://mermaid.js.org). 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) |
```mermaid
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`.
```mermaid
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_GUARD`s***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/*` + `/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_.
```mermaid
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)
```mermaid
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
```mermaid
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
```mermaid
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); only `DOMESTIC` → `FULLY_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
```mermaid
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:**
```mermaid
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 }`.
```mermaid
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).
```mermaid
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|bulk/eligible-bookings`, `global-rules` |
| Build | `container|bulk/preview`, `container|bulk/schedules` (create), `:id/assign-bookings`, `:id/assign-unassigned-booking`, `:id/pin-wagons`, `:id/run-allocation`, `:id/run-batch` |
| 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.
```mermaid
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
```mermaid
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`.
```mermaid
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
```mermaid
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".
```mermaid
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 anywhere** — `TrackingService.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_GUARD`s).
---
*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.*

View File

@@ -6,7 +6,7 @@
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
"dev": "nest start --watch",
"dev": "nest start --watch --clearScreen false",
"prebuild": "pnpm run clean",
"build": "nest build",
"start": "node dist/main.js",
@@ -22,7 +22,9 @@
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
"seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts",
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
@@ -32,7 +34,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
@@ -47,9 +50,11 @@
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/platform-socket.io": "^11.1.27",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
"amqp-connection-manager": "^5.0.0",
@@ -61,12 +66,14 @@
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"handlebars": "^4.7.9",
"jose": "^5.10.0",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3",
"pg": "^8.13.0",
"puppeteer": "^24.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"typeorm": "^0.3.30"
},
"devDependencies": {
@@ -84,13 +91,16 @@
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2",
"@types/vorpal": "^1.12.8",
"jest": "^29.7.0",
"socket.io-client": "^4.8.3",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"vorpal": "^1.12.0"
},
"jest": {
"moduleFileExtensions": [

View File

@@ -0,0 +1,67 @@
# Contract seed driver
Creates freight contracts across every flow variant by driving the freight API
over HTTP end-to-end — from DRAFT through **both signatures** (customer sign +
staff counter-sign). It stops right after the staff counter-sign; no clearance
or booking steps are run.
## What it builds
20 real flows (movement × kind × customs × freight), each created twice → **40
contracts** on `all`.
| Movement | Kind | Customs | Freight | Count |
| --- | --- | --- | --- | --- |
| intercity (DOMESTIC) | one-time / general | without only¹ | bulk / container | 4 |
| import (IMPORT) | one-time / general | with / without | bulk / container | 8 |
| export (EXPORT) | one-time / general | with / without | bulk / container | 8 |
¹ intercity + customs is not a real combo — DOMESTIC has no clearance gate, so
the customs flag is ignored. Those four are skipped, leaving 20 (16 working + 4
`with-customs + bulk`).
The four `with-customs + bulk` flows are still built here: the known break is
downstream in clearance (customs output docs are container-only), which this
script does not reach, so all 20 reach a signed state.
## Terminal status after both signatures (by dimension)
- DOMESTIC one-time → `FULLY_EXECUTED`
- any GENERAL, and DOMESTIC general → `CONTRACT_ACTIVE`
- IMPORT/EXPORT one-time (customs or self-clearance) → `AWAITING_CLEARANCE_DOCUMENTS`
(fully signed; clearance not driven)
## Setup
```bash
cd apps/edr-freight-api/py
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill it in
```
Fill `.env`: customer + admin IAM credentials (admin should be a **super_admin**),
`OTP_PHONE`, and the Postgres connection (used only to read the sign-OTP).
## Run
```bash
python create_contracts.py # all 20 flows
python create_contracts.py intercity # only DOMESTIC flows (4)
python create_contracts.py import # only IMPORT flows (8)
python create_contracts.py import export # IMPORT + EXPORT (16)
```
Filters are by movement: `intercity`, `import`, `export` (pass one or many);
no arg or `all` runs everything.
## How auth + OTP work
- **Login**: `POST /api/auth/login` with `{ email, password }` returns a JWT
(`token`), sent as `Authorization: Bearer <token>`. MFA accounts are not
supported — the script errors out clearly if MFA is required.
- **Actors**: the customer token does create/submit/customer-sign; the admin
token does staff-accept/approve/generate/counter-sign.
- **Sign OTP**: customer sign needs a fresh 6-digit OTP. The script calls
`POST /api/otp/send { phone }`, reads the plaintext code from
`<schema>.otp_verifications` in Postgres, then signs within the 5-minute TTL.

View File

@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
Seed freight contracts across every flow variant, driven end-to-end over HTTP.
For each flow the script logs in, creates a DRAFT contract, and pushes it through
the lifecycle up to and INCLUDING both signatures (customer sign + staff
counter-sign). It STOPS after the staff counter-sign — no clearance, no booking.
Flow dimensions (3 x 2 x 2 x 2 = 24 combos, but only the 20 real ones are built):
movement : intercity(DOMESTIC) | import(IMPORT) | export(EXPORT)
kind : one-time(ONE_TIME) | general(GENERAL)
customs : without | with (customsClearingEnabled)
freight : bulk(BULK) | container(CONTAINER)
intercity + customs is dropped (DOMESTIC ignores customs → no real combo), which
removes 4 dead combos and leaves 20 flows (16 working + 4 customs+bulk whose
break is downstream in clearance). Each is created twice → 40 contracts on `all`.
CLI (filter by movement, pass one or many):
python create_contracts.py # all 20 flows
python create_contracts.py all # all 20 flows
python create_contracts.py intercity # only DOMESTIC flows
python create_contracts.py import # only IMPORT flows
python create_contracts.py import export # IMPORT + EXPORT flows
Config comes from .env (see .env.example). Requires: requests, psycopg,
python-dotenv (see requirements.txt).
"""
from __future__ import annotations
import base64
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import psycopg
import requests
from dotenv import load_dotenv
HERE = Path(__file__).resolve().parent
load_dotenv(HERE / ".env")
# --------------------------------------------------------------------------- #
# Config
# --------------------------------------------------------------------------- #
API_URL = os.getenv("FREIGHT_API_URL", "http://localhost:3001/api").rstrip("/")
CUSTOMER_EMAIL = os.getenv("CUSTOMER_EMAIL", "")
CUSTOMER_PASSWORD = os.getenv("CUSTOMER_PASSWORD", "")
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
# Phone the sign-OTP is sent to and read back from Postgres. Resolved at runtime
# from the customer's own IAM profile (GET /api/me → phoneNumber). OTP_PHONE is an
# optional override / fallback used only when the customer has no phone on file.
# The sign endpoint keys the OTP purely on this number, so it just has to be the
# same value for "send" and "read".
OTP_PHONE = os.getenv("OTP_PHONE", "")
OTP_PHONE_FALLBACK = os.getenv("OTP_PHONE_FALLBACK", "251900000000")
# DB connection used ONLY to read the plaintext sign-OTP from freight.otp_verifications.
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "edr_dev")
DB_USER = os.getenv("DB_USER", "postgres")
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
DB_SCHEMA = os.getenv("DB_SCHEMA", "freight")
WAAFI = HERE / "waafi.jpeg"
VALIDITY_DAYS = int(os.getenv("VALIDITY_DAYS", "365"))
CONTRACTS_PER_FLOW = int(os.getenv("CONTRACTS_PER_FLOW", "2"))
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "60"))
# --------------------------------------------------------------------------- #
# Flow matrix — the 20 real flows
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Flow:
movement: str # intercity | import | export
trade_direction: str # DOMESTIC | IMPORT | EXPORT
kind: str # ONE_TIME | GENERAL
customs: bool # customsClearingEnabled
freight: str # BULK | CONTAINER
@property
def label(self) -> str:
return (
f"{self.movement}+{'general' if self.kind == 'GENERAL' else 'one-time'}"
f"+{'with' if self.customs else 'no'}-customs"
f"+{self.freight.lower()}"
)
def build_flow_matrix() -> list[Flow]:
movements = [
("intercity", "DOMESTIC"),
("import", "IMPORT"),
("export", "EXPORT"),
]
kinds = ["ONE_TIME", "GENERAL"]
freights = ["BULK", "CONTAINER"]
flows: list[Flow] = []
for movement, direction in movements:
# DOMESTIC ignores customs (no clearance gate) → customs=True is not a
# real combo. Only build without-customs for intercity.
customs_options = [False] if direction == "DOMESTIC" else [False, True]
for kind in kinds:
for customs in customs_options:
for freight in freights:
flows.append(Flow(movement, direction, kind, customs, freight))
return flows
# --------------------------------------------------------------------------- #
# HTTP client
# --------------------------------------------------------------------------- #
class ApiError(RuntimeError):
def __init__(self, method: str, path: str, resp: requests.Response):
body = resp.text
try:
body = resp.json()
except Exception:
pass
super().__init__(f"{method} {path} -> {resp.status_code}: {body}")
self.status_code = resp.status_code
class Client:
"""Thin wrapper that carries a bearer token."""
def __init__(self, name: str, token: str | None = None):
self.name = name
self.token = token
def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
h: dict[str, str] = {}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
if extra:
h.update(extra)
return h
def get(self, path: str, params: dict | None = None) -> Any:
r = requests.get(
f"{API_URL}{path}",
headers=self._headers(),
params=params,
timeout=REQUEST_TIMEOUT,
)
if not r.ok:
raise ApiError("GET", path, r)
return r.json() if r.content else None
def post_json(self, path: str, body: dict | None = None) -> Any:
r = requests.post(
f"{API_URL}{path}",
headers=self._headers({"Content-Type": "application/json"}),
json=body or {},
timeout=REQUEST_TIMEOUT,
)
if not r.ok:
raise ApiError("POST", path, r)
return r.json() if r.content else None
def post_multipart(
self, path: str, data: dict[str, str], files: list[tuple] | None = None
) -> Any:
r = requests.post(
f"{API_URL}{path}",
headers=self._headers(), # requests sets multipart Content-Type
data=data,
files=files or [],
timeout=REQUEST_TIMEOUT,
)
if not r.ok:
raise ApiError("POST", path, r)
return r.json() if r.content else None
def login(email: str, password: str, who: str) -> Client:
r = requests.post(
f"{API_URL}/auth/login",
json={"email": email, "password": password},
timeout=REQUEST_TIMEOUT,
)
if not r.ok:
raise ApiError("POST", "/auth/login", r)
payload = r.json()
if payload.get("mfaRequired"):
raise RuntimeError(
f"{who} login requires MFA — this script cannot complete an MFA login. "
"Disable MFA for the seed account or supply a non-MFA account."
)
token = payload.get("token")
if not token:
raise RuntimeError(f"{who} login returned no token: {payload}")
return Client(who, token)
# --------------------------------------------------------------------------- #
# OTP — send + read from Postgres
# --------------------------------------------------------------------------- #
def resolve_otp_phone(customer: Client) -> str:
"""Phone the sign-OTP is sent to. Prefer the customer's own IAM profile phone
(GET /api/me → phoneNumber); fall back to OTP_PHONE, then OTP_PHONE_FALLBACK.
The value only has to be consistent between send + DB read."""
phone = ""
try:
me = customer.get("/me") or {}
phone = (me.get("phoneNumber") or "").strip()
except Exception:
pass
phone = phone or OTP_PHONE or OTP_PHONE_FALLBACK
if not phone:
raise RuntimeError(
"Could not resolve an OTP phone (customer has none, and neither "
"OTP_PHONE nor OTP_PHONE_FALLBACK is set)."
)
return phone
def send_otp(customer: Client, phone: str) -> None:
# POST /api/otp/send is @Public — no token needed, but sending one is harmless.
customer.post_json("/otp/send", {"phone": phone})
def read_otp_from_db(phone: str) -> str:
"""Read the freshest plaintext OTP for `phone` from freight.otp_verifications."""
dsn = (
f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} "
f"user={DB_USER} password={DB_PASSWORD}"
)
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(
f'SELECT otp FROM "{DB_SCHEMA}".otp_verifications '
"WHERE phone = %s ORDER BY updated_at DESC LIMIT 1",
(phone,),
)
row = cur.fetchone()
if not row:
raise RuntimeError(f"No OTP row found for phone {phone} in {DB_SCHEMA}.otp_verifications")
return str(row[0])
# --------------------------------------------------------------------------- #
# Reference-data lookups (yards / service types / cargo types)
# --------------------------------------------------------------------------- #
@dataclass
class RefData:
yards: list[dict] = field(default_factory=list)
service_types: list[dict] = field(default_factory=list)
cargo_types: list[dict] = field(default_factory=list)
def _as_items(resp: Any) -> list[dict]:
if isinstance(resp, list):
return resp
if isinstance(resp, dict):
return resp.get("items") or resp.get("data") or []
return []
def load_ref_data(client: Client) -> RefData:
ref = RefData(
yards=_as_items(client.get("/yards")),
service_types=_as_items(client.get("/service-types")),
cargo_types=_as_items(client.get("/cargo-types")),
)
if len(ref.yards) < 2:
raise RuntimeError(f"Need >=2 yards, got {len(ref.yards)}. Seed yards first.")
if not ref.service_types:
raise RuntimeError("No service types found. Seed service types first.")
if not ref.cargo_types:
raise RuntimeError("No cargo types found. Seed cargo types first.")
return ref
def pick_service_type(ref: RefData, wants_customs: bool) -> str:
"""Prefer a service type whose includesCustoms matches the flow's customs need."""
for st in ref.service_types:
if bool(st.get("includesCustoms")) == wants_customs:
return st["id"]
# Fall back to any — customsClearingEnabled on the contract still drives the flow.
return ref.service_types[0]["id"]
# --------------------------------------------------------------------------- #
# Contract payload builder
# --------------------------------------------------------------------------- #
def build_create_payload(flow: Flow, ref: RefData, idx: int) -> dict[str, str]:
"""Return multipart form fields. Booleans as 'true'/'false' strings; nested
arrays as JSON strings (implicit conversion is off in the API)."""
import json
origin = ref.yards[0]["id"]
destination = ref.yards[1]["id"]
service_type_id = pick_service_type(ref, flow.customs)
# Cargo scope: CONTAINER -> >=1 size row; BULK -> exactly one cargo-type row.
if flow.freight == "CONTAINER":
cargo_scope = [{"containerSize": "20ft"}]
if flow.kind == "GENERAL":
cargo_scope[0]["quantityCap"] = 10
else: # BULK
cargo_scope = [{"cargoTypeId": ref.cargo_types[0]["id"]}]
if flow.kind == "GENERAL":
cargo_scope[0]["quantityCap"] = 1000
# Routes: ONE_TIME -> exactly 1; GENERAL -> 1..N (one is fine).
routes = [{"originYardId": origin, "destinationYardId": destination, "sortOrder": 0}]
fields: dict[str, str] = {
"contractKind": flow.kind,
"tradeDirection": flow.trade_direction,
"freightType": flow.freight,
"serviceTypeId": service_type_id,
"paymentCurrency": "ETB",
"customsClearingEnabled": "true" if flow.customs else "false",
"contractType": "SPOT",
"cargoScope": json.dumps(cargo_scope),
"routes": json.dumps(routes),
}
if flow.customs:
fields["customsClearingAgent"] = "Seed Agent"
return fields
def signature_b64() -> str:
return base64.b64encode(WAAFI.read_bytes()).decode()
# --------------------------------------------------------------------------- #
# Lifecycle driver — create → submit → accept → approve → generate → sign x2
# --------------------------------------------------------------------------- #
def waafi_file_tuple(field_name: str) -> tuple:
return (field_name, (WAAFI.name, WAAFI.read_bytes(), "image/jpeg"))
def drive_flow(
flow: Flow, idx: int, customer: Client, admin: Client, ref: RefData, otp_phone: str
) -> dict[str, Any]:
result: dict[str, Any] = {"flow": flow.label, "n": idx, "status": None}
# S1 — create (customer, multipart, waafi attached as intake doc)
fields = build_create_payload(flow, ref, idx)
contract = customer.post_multipart(
"/contracts", data=fields, files=[waafi_file_tuple("intake_document")]
)
cid = contract["id"]
result["contractId"] = cid
result["reference"] = contract.get("reference")
# S2 — submit (customer). May go to PRICE_CHANGED_PENDING_CONFIRM → confirm.
contract = customer.post_json(f"/contracts/{cid}/submit")
if (contract or {}).get("status") == "PRICE_CHANGED_PENDING_CONFIRM":
contract = customer.post_json(f"/contracts/{cid}/confirm-submit")
# S3 — staff accept (admin) → PENDING_APPROVAL + approval chain
admin.post_json(f"/contracts/{cid}/staff/accept", {"validityDays": VALIDITY_DAYS})
# S4 — approve every pending step IN ORDER with its exact requiredRole (admin)
approve_all_steps(admin, cid)
# S5 — generate contract document (admin) → CONTRACT_READY
admin.post_json(f"/contracts/{cid}/contract/generate")
# S6 — customer sign (needs OTP) → SIGNED_CUSTOMER
send_otp(customer, otp_phone)
time.sleep(1.0) # let the OTP row land
otp = read_otp_from_db(otp_phone)
customer.post_json(
f"/contracts/{cid}/contract/sign",
{
"role": "CUSTOMER",
"signatureImageBase64": signature_b64(),
"signerDisplayName": "Seed Customer",
"consentText": "I agree.",
"otp": otp,
"otpPhone": otp_phone,
},
)
# S7 — staff counter-sign (admin) → FULLY_EXECUTED / CONTRACT_ACTIVE /
# AWAITING_CLEARANCE_DOCUMENTS depending on dimension. STOP HERE.
signed = admin.post_json(
f"/contracts/{cid}/contract/sign",
{
"role": "STAFF",
"signatureImageBase64": signature_b64(),
"signerDisplayName": "Seed Staff",
"consentText": "Countersigned.",
},
)
result["status"] = (signed or {}).get("status")
return result
def approve_all_steps(admin: Client, cid: str) -> None:
"""Read the contract, approve each PENDING approval step in order. Superadmin
can approve any role, but the endpoint still checks step.requiredRole == body,
so we echo the step's own requiredRole back."""
guard = 0
while True:
guard += 1
if guard > 12:
raise RuntimeError(f"Approval loop exceeded 12 iterations for {cid}")
contract = admin.get(f"/contracts/{cid}")
steps = contract.get("approvalSteps") or []
pending = [s for s in steps if s.get("status") == "PENDING"]
if not pending:
return
# findNextPendingApprovalStep orders by sequence; sort the same way.
pending.sort(key=lambda s: s.get("sequence", s.get("sortOrder", 0)))
step = pending[0]
admin.post_json(
f"/contracts/{cid}/approval-steps/{step['id']}/approve",
{"requiredRole": step["requiredRole"]},
)
# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
VALID_FILTERS = {"all", "intercity", "import", "export"}
def parse_filters(argv: list[str]) -> set[str]:
args = [a.lower() for a in argv[1:]]
if not args or "all" in args:
return {"intercity", "import", "export"}
unknown = set(args) - VALID_FILTERS
if unknown:
raise SystemExit(
f"Unknown filter(s): {', '.join(sorted(unknown))}. "
f"Valid: {', '.join(sorted(VALID_FILTERS))}"
)
return set(args)
def require_config() -> None:
missing = [
name
for name, val in [
("CUSTOMER_EMAIL", CUSTOMER_EMAIL),
("CUSTOMER_PASSWORD", CUSTOMER_PASSWORD),
("ADMIN_EMAIL", ADMIN_EMAIL),
("ADMIN_PASSWORD", ADMIN_PASSWORD),
]
if not val
]
if missing:
raise SystemExit(f"Missing required .env keys: {', '.join(missing)}")
if not WAAFI.exists():
raise SystemExit(f"Missing signature/upload image: {WAAFI}")
def main() -> None:
require_config()
wanted = parse_filters(sys.argv)
flows = [f for f in build_flow_matrix() if f.movement in wanted]
total = len(flows) * CONTRACTS_PER_FLOW
print(f"API : {API_URL}")
print(f"Filters : {', '.join(sorted(wanted))}")
print(f"Flows : {len(flows)} x {CONTRACTS_PER_FLOW} = {total} contracts\n")
print("Logging in...")
customer = login(CUSTOMER_EMAIL, CUSTOMER_PASSWORD, "customer")
admin = login(ADMIN_EMAIL, ADMIN_PASSWORD, "admin")
otp_phone = resolve_otp_phone(customer)
print(f"OTP phone: {otp_phone}")
print("Loading reference data...")
ref = load_ref_data(admin)
results: list[dict] = []
for flow in flows:
for n in range(1, CONTRACTS_PER_FLOW + 1):
tag = f"[{flow.label} #{n}]"
try:
res = drive_flow(flow, n, customer, admin, ref, otp_phone)
print(f" OK {tag} {res['reference']} -> {res['status']}")
results.append(res)
except Exception as exc: # noqa: BLE001 — report and continue
print(f" FAIL {tag} {exc}")
results.append({"flow": flow.label, "n": n, "error": str(exc)})
ok = [r for r in results if not r.get("error")]
bad = [r for r in results if r.get("error")]
print(f"\nDone. {len(ok)} created, {len(bad)} failed, {total} attempted.")
if bad:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,114 @@
-- ============================================================================
-- Production DB drift check + fix for the batch/window flow.
--
-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which
-- queries freight.invoices.payments (a jsonb ledger added by migration
-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on
-- production (snapshot/restore drift — the migration can read as "applied" in
-- freight.migrations while the DDL never took effect), every reserve() throws
-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass,
-- and you see exactly:
-- * only ONE booking gets a pay window (the loop dies after the first reserve
-- whose invoice sync throws), and
-- * reservations never expire cleanly (the settle path hits the same query).
--
-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2
-- (idempotent, additive — safe to run even if partially applied).
-- ============================================================================
-- ---------------------------------------------------------------------------
-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing,
-- production has the drift and STEP 2 is required.
-- ---------------------------------------------------------------------------
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name = 'invoices'
AND column_name IN (
'payments', 'subtotal_amount', 'tax_amount',
'paid_amount', 'balance_amount', 'paid_at'
)
ORDER BY column_name;
-- Also confirm the enum has the partial-payment statuses:
SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status;
-- Expect ISSUED and PARTIALLY_PAID to be present.
-- ---------------------------------------------------------------------------
-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns.
-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so
-- re-running is safe. Wrapped so the enum additions (which cannot run inside a
-- transaction block with immediate use) are applied first, then the columns.
-- ---------------------------------------------------------------------------
-- Enum values (no-op if they already exist).
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';
-- Money-tracking + payments ledger columns.
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
-- Backfill derived money fields for existing rows (only rows not already set).
UPDATE freight.invoices
SET subtotal_amount = total_amount,
balance_amount = total_amount
WHERE subtotal_amount = 0 AND balance_amount = 0;
UPDATE freight.invoices
SET paid_amount = total_amount,
balance_amount = 0,
paid_at = COALESCE(paid_at, updated_at)
WHERE status = 'PAID' AND paid_amount = 0;
-- ---------------------------------------------------------------------------
-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should
-- now be present. After this, deploy the freight_feature/usermanagement branch
-- and the batch will reserve ALL fitting bookings + expire non-payers + top up.
-- ---------------------------------------------------------------------------
-- ============================================================================
-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid
-- invoices.payments can hide OTHER columns the batch flow selects. reserve()
-- and settleReserved() load the FULL Booking entity, so ANY missing booking
-- column throws mid-loop (e.g. we already hit
-- `column Booking.consolidation_resume_status does not exist`). This lists every
-- booking column the entity expects that is MISSING from production — expect
-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied.
-- ============================================================================
WITH expected(col) AS (
SELECT unnest(ARRAY[
'reference','customer_id','company_id','company_profile_id','is_government',
'government_institution','train_id','status','contract_id','contract_route_id',
'booking_type','contract_kind','created_by_role','created_by_user_id',
'scheduled_date','estimated_shipment_date','expires_at','total_amount',
'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason',
'contract_validity_days','contract_valid_from','contract_valid_until',
'payment_status','contract_type','service_type_id','customs_clearing_enabled',
'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id',
'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id',
'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity',
'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at',
'pricing_breakdown','locked_at','priority_score','consolidation_partner_id',
'consolidation_resume_status','wagons_required','scheduling_status',
'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id',
'loaded_at','arrived_at','payment_deadline','selected_for_batch_at',
'gl_station_yard_id','clearance_current_phase','duty_required',
'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason',
'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at'
])
)
SELECT e.col AS missing_booking_column
FROM expected e
LEFT JOIN information_schema.columns c
ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col
WHERE c.column_name IS NULL
ORDER BY e.col;
-- If any rows come back, tell me which columns — I'll give you the exact
-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent).

View File

@@ -0,0 +1,3 @@
requests>=2.31
psycopg[binary]>=3.1
python-dotenv>=1.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,10 +1,17 @@
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
import { EventEmitterModule } from "@nestjs/event-emitter";
import { DataSource, DataSourceOptions } from "typeorm";
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -12,6 +19,7 @@ import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -30,6 +38,7 @@ import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from "./modules/otp/otp.module";
@@ -42,6 +51,7 @@ import {
EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
@@ -59,26 +69,36 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module';
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
import { RoutesModule } from "./modules/routes/routes.module";
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
import { OverviewModule } from "./modules/overview/overview.module";
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
import { FuelModule } from "./modules/fuel/fuel.module";
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { LoggerMiddleware } from "./logger.middleware";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -92,7 +112,27 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
}
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
return dataSource.initialize();
await dataSource.initialize();
// The remote edr_dev DB sits behind a connection pooler/proxy that rejects
// the Postgres `options` startup parameter (08P01). Instead of setting
// search_path at connect time, apply it per physical connection: the pg
// Pool emits `connect` for every new client (initial fill, pool growth,
// reconnect), so every backend session gets the schema search order.
const pool = (dataSource.driver as { master?: unknown }).master as
| { on?: (event: string, cb: (client: unknown) => void) => void }
| undefined;
if (pool?.on) {
pool.on("connect", (client) => {
(client as { query: (sql: string) => Promise<unknown> })
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
.catch(() => {
/* connection will be validated on first real query */
});
});
}
return dataSource;
},
}),
SharedAuthModule,
@@ -115,6 +155,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
TrackingModule,
BillingModule,
NotificationsModule,
NotificationInboxModule,
FileUploadSettingsModule,
DropdownSettingsModule,
OtpModule,
@@ -133,13 +174,22 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
OverviewModule,
VehiclesModule,
DriversModule,
FuelModule,
MaintenanceModule,
ComplianceModule,
IncidentsModule,
ProcurementModule,
GpsTrackingModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
FleetHistoryModule,
],
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
PricingDataSeeder,
@@ -156,12 +206,14 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
@@ -183,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.pricingDataSeeder.run();
@@ -207,4 +260,8 @@ export class AppModule implements OnApplicationBootstrap {
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
}
}

View File

@@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
/** Container allocation on a booking (allocate-containers endpoint). */
export const AllocationManage = () =>
BookingStaff(FREIGHT_PERMS.allocation.manage);

View File

@@ -1,20 +1,33 @@
import type { ScheduleTradeDirection } from '@edr/types';
import { YardCountry, type ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null };
/** Derive booking/schedule trade direction from origin and destination yard countries. */
/**
* Derive trade direction from origin and destination yard countries.
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country =
* DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now).
* Comparison is strict against the YardCountry enum values the yards table is
* constrained to; the trim/case fold only shields legacy rows.
*/
export function deriveTradeDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim().toLowerCase();
const destinationCountry = destinationYard.country?.trim().toLowerCase();
const origin = normalizeCountry(originYard.country);
const destination = normalizeCountry(destinationYard.country);
if (originCountry === 'djibouti') {
if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) {
return 'IMPORT';
}
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) {
return 'EXPORT';
}
return 'DOMESTIC';
}
function normalizeCountry(country: string | null | undefined): YardCountry | null {
const folded = country?.trim().toLowerCase();
if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA;
if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI;
return null;
}

View File

@@ -44,7 +44,6 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
const iamEntities = [
DefaultPosition,
@@ -105,9 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
schema: "public",
extra: {
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
},
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
// proxies fronting the remote edr_dev DB) reject with
// `08P01 unsupported startup parameter in options: search_path`.
// The search_path is instead applied per-connection via a pool `connect`
// handler in app.module.ts (see setPoolSearchPath).
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
@@ -116,8 +118,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
freightMigrationsGlob,
],
migrationsRun: true,
migrationsTransactionMode: "each",
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging: process.env.NODE_ENV === "development",
logging:
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
};
});

View File

@@ -0,0 +1,126 @@
import { registerAs } from '@nestjs/config';
export interface FaydaJwk {
kty: 'RSA';
use?: string;
kid?: string;
alg?: string;
n: string;
e: string;
d: string;
p?: string;
q?: string;
dp?: string;
dq?: string;
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export interface FaydaConfig {
enabled: boolean;
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
webRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
claimsLocales: string;
sessionTtlMinutes: number;
}
const REQUIRED_VARS = [
'FAYDA_CLIENT_ID',
'FAYDA_AUTHORIZATION_ENDPOINT',
'FAYDA_TOKEN_ENDPOINT',
'FAYDA_USERINFO_ENDPOINT',
'FAYDA_PRIVATE_KEY_BASE64',
] as const;
function decodePrivateJwk(base64: string): FaydaJwk {
let jwk: unknown;
try {
const json = Buffer.from(base64, 'base64').toString('utf8');
jwk = JSON.parse(json);
} catch (err) {
throw new Error(
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
);
}
if (!jwk || typeof jwk !== 'object') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
}
const candidate = jwk as Partial<FaydaJwk>;
if (candidate.kty !== 'RSA') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
}
if (!candidate.n || !candidate.e || !candidate.d) {
throw new Error(
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
);
}
return candidate as FaydaJwk;
}
export default registerAs('fayda', (): FaydaConfig => {
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
// `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
// are needed so the matching essential claims aren't rejected as out-of-scope.
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
if (!enabled) {
return {
enabled: false,
clientId: process.env.FAYDA_CLIENT_ID ?? '',
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
};
}
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
);
}
if (!redirectUri) {
throw new Error(
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
);
}
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
}
return {
enabled: true,
clientId: process.env.FAYDA_CLIENT_ID!,
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: sessionTtl,
};
});

View File

@@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder {
serviceType: this.valueOrDash(
contract.serviceType?.serviceName ?? contract.serviceType?.code,
),
scheduledDate: this.formatDate(contract.estimatedShipmentDate),
// Estimated shipment date was removed from the contract wizard; the
// binding scheduled date is set per-booking, not on the contract.
scheduledDate: this.formatDate(null),
contractType: this.valueOrDash(contract.contractType),
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—',

View File

@@ -0,0 +1,21 @@
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
this.logger.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
);
});
next();
}
}

View File

@@ -38,7 +38,8 @@ async function bootstrap() {
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
});
app.setGlobalPrefix("api");
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
app.setGlobalPrefix("api", { exclude: ["callback"] });
// enableImplicitConversion is OFF: class-transformer's implicit boolean
// coercion turns any non-empty multipart/form-data string (including the
// literal "false") into `true`, silently corrupting flags like isHazardous

View File

@@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
);
// Create indexes for service_types
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
columnNames: ["is_active"],
}),
);
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
columnNames: ["display_order"],
}),
);
const table = await queryRunner.getTable("freight.service_types");
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) {
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
columnNames: ["is_active"],
}),
);
}
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) {
await queryRunner.createIndex(
"freight.service_types",
new TableIndex({
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
columnNames: ["display_order"],
}),
);
}
// Create cargo_types table
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(

View File

@@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration
{ name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'free_days', type: 'int', default: 0 },
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'tiers', type: 'jsonb', default: "'[]'" },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },

View File

@@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`);
await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(),
ADD COLUMN IF NOT EXISTS invoice_number varchar(64),
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_profile_id uuid,
ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB',
ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
ADD COLUMN IF NOT EXISTS source varchar(255),
ADD COLUMN IF NOT EXISTS source_id varchar(255),
ADD COLUMN IF NOT EXISTS type varchar(255),
ADD COLUMN IF NOT EXISTS issued_at timestamptz,
ADD COLUMN IF NOT EXISTS payment_id uuid,
ADD COLUMN IF NOT EXISTS due_at timestamptz,
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
`,
);
await queryRunner.query(`
UPDATE freight.invoices
SET due_at = COALESCE(due_at, issued_at, created_at, now())
WHERE due_at IS NULL;
`);
await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE contype = 'p'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_invoices_invoice_number'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_company'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company
FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_company_profile'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile
FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_payment'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment
FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
`CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
`CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
`CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`,
);
await queryRunner.query(`
CREATE TABLE freight.invoice_lines (
CREATE TABLE IF NOT EXISTS freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL,
charge_type varchar NOT NULL,
@@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`);
await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
`CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
);
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous
* or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item
* count for PER_ITEM). These two columns hold that amount on the booking; they
* stay 0 for container freight (which tracks it per line on booking_container)
* and for bulk cargo with no hazardous/reefer portion. The existing
* is_hazardous / is_reefer booleans remain the surcharge trigger.
*/
export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface {
name = 'AddBulkHazmatReeferQuantity1828000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`,
);
}
}

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Extend `freight.invoices` into the billing record of record for every source
* (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be
* centralized onto it instead of the parallel `warehouse_fee_invoices` table.
*
* Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`),
* a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID`
* statuses the warehouse flow uses.
*
* Matches billing/entities/invoice.entity.ts. All columns are additive with
* defaults, so existing booking/demurrage rows are unaffected.
*/
export class ExtendInvoicesForPartialPayment1828000000000
implements MigrationInterface
{
name = "ExtendInvoicesForPartialPayment1828000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long
// as the value is not referenced in the same transaction (it is not here).
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`,
);
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`,
);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
`);
// Backfill existing rows: subtotal mirrors the total (no tax was modeled),
// the outstanding balance is the full total for unpaid invoices.
await queryRunner.query(`
UPDATE freight.invoices
SET subtotal_amount = total_amount,
balance_amount = total_amount;
`);
// Already-settled invoices: fully paid, zero balance, stamped from updated_at.
await queryRunner.query(`
UPDATE freight.invoices
SET paid_amount = total_amount,
balance_amount = 0,
paid_at = updated_at
WHERE status = 'PAID';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS payments,
DROP COLUMN IF EXISTS paid_at,
DROP COLUMN IF EXISTS balance_amount,
DROP COLUMN IF EXISTS paid_amount,
DROP COLUMN IF EXISTS tax_amount,
DROP COLUMN IF EXISTS subtotal_amount;
`);
// Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are
// left on freight.invoices_status_enum (harmless, unused after down).
}
}

View File

@@ -0,0 +1,247 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Fold warehouse fee invoices into the central billing system.
*
* Warehouse fee invoices are no longer a standalone aggregate: each becomes a
* global `freight.invoices` row (`source = 'warehouse'`, `source_id =
* inventory_id`) with its items as `freight.invoice_lines`. The warehouse
* service is now a thin layer over `BillingService`. This migration backfills the
* existing rows (preserving ids, numbers, status, amounts and payment history),
* then drops the two legacy tables.
*
* Rows that cannot be billed centrally — no company to bill (`company_id` /
* `company_profile_id` underivable from the customer or the booking) — are not
* migrated; they could never have been charged through the gateway and are
* dropped with the table.
*/
export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface {
name = 'CentralizeWarehouseInvoices1829000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name = 'invoices'
AND column_name = 'booking_id'
) THEN
ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name = 'invoices'
AND column_name = 'amount'
) THEN
ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL;
END IF;
END $$;
`);
// 1. Invoice headers. Keep the same id so items still link, and so any
// external reference to the invoice id stays valid.
await queryRunner.query(`
INSERT INTO freight.invoices (
id, invoice_number, company_id, company_profile_id,
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
currency, status, source, source_id, type,
issued_at, paid_at, payments, payment_id, due_at,
created_at, updated_at, deleted_at
)
SELECT
fee.id,
fee.invoice_number,
COALESCE(fee.customer_id, b.company_id),
COALESCE(
b.company_profile_id,
(SELECT cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
AND cp.deleted_at IS NULL
ORDER BY cp.created_at ASC
LIMIT 1)
),
fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount,
fee.currency,
fee.status::freight.invoices_status_enum,
'warehouse',
fee.inventory_id,
fee.invoice_type,
fee.issued_at,
fee.paid_at,
COALESCE(fee.payments, '[]'::jsonb),
NULL,
COALESCE(fee.due_date, fee.issued_at, fee.created_at),
fee.created_at, fee.updated_at, fee.deleted_at
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.bookings b ON b.id = fee.booking_id
WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL
AND COALESCE(
b.company_profile_id,
(SELECT cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id)
AND cp.deleted_at IS NULL
ORDER BY cp.created_at ASC
LIMIT 1)
) IS NOT NULL
ON CONFLICT (id) DO NOTHING;
`);
// 2. Invoice lines — only for items whose parent invoice migrated. Warehouse
// fee fields (fee_rule_id / chargeable_days / free_days) move into the
// line's jsonb metadata.
await queryRunner.query(`
INSERT INTO freight.invoice_lines (
id, invoice_id, charge_type, description, quantity, unit_rate, amount,
currency, metadata, created_at, updated_at, deleted_at
)
SELECT
item.id,
item.invoice_id,
item.fee_type,
item.description,
item.quantity,
item.unit_rate,
item.amount,
item.currency,
jsonb_build_object(
'feeRuleId', item.fee_rule_id,
'chargeableDays', item.chargeable_days,
'freeDays', item.free_days
),
item.created_at, item.updated_at, item.deleted_at
FROM freight.warehouse_fee_invoice_items item
JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
// 3. Drop the legacy tables (items first — FK to invoices).
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Recreate the legacy tables …
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
invoice_number varchar(40) NOT NULL,
booking_id uuid,
customer_id uuid,
inventory_id uuid NOT NULL,
facility_id uuid,
warehouse_id uuid,
yard_id uuid,
zone_id uuid,
invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES',
status varchar(20) NOT NULL DEFAULT 'DRAFT',
subtotal_amount numeric(14,2) NOT NULL DEFAULT 0,
tax_amount numeric(14,2) NOT NULL DEFAULT 0,
total_amount numeric(14,2) NOT NULL DEFAULT 0,
paid_amount numeric(14,2) NOT NULL DEFAULT 0,
balance_amount numeric(14,2) NOT NULL DEFAULT 0,
currency varchar(8) NOT NULL DEFAULT 'USD',
period_start timestamptz,
period_end timestamptz,
issued_at timestamptz,
due_date timestamptz,
paid_at timestamptz,
cancelled_at timestamptz,
payments jsonb NOT NULL DEFAULT '[]',
notes text,
CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id),
CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number)
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
invoice_id uuid NOT NULL,
fee_rule_id uuid,
fee_type varchar(32) NOT NULL,
description varchar(255) NOT NULL,
quantity numeric(12,2) NOT NULL DEFAULT 1,
unit_rate numeric(14,2) NOT NULL DEFAULT 0,
amount numeric(14,2) NOT NULL DEFAULT 0,
currency varchar(8) NOT NULL DEFAULT 'USD',
chargeable_days int,
free_days int,
CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id),
CONSTRAINT "FK_warehouse_fee_invoice_items_invoice"
FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`,
);
// … then copy the warehouse-source invoices back, deriving the typed FKs and
// period from the linked inventory item.
await queryRunner.query(`
INSERT INTO freight.warehouse_fee_invoices (
id, created_at, updated_at, deleted_at, invoice_number,
booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id,
invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes
)
SELECT
i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number,
inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id,
i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount,
i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at,
CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END,
i.payments, NULL
FROM freight.invoices i
LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
WHERE i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
await queryRunner.query(`
INSERT INTO freight.warehouse_fee_invoice_items (
id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type,
description, quantity, unit_rate, amount, currency, chargeable_days, free_days
)
SELECT
l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id,
NULLIF(l.metadata->>'feeRuleId', '')::uuid,
l.charge_type,
COALESCE(l.description, ''),
l.quantity, l.unit_rate, l.amount, l.currency,
NULLIF(l.metadata->>'chargeableDays', '')::int,
NULLIF(l.metadata->>'freeDays', '')::int
FROM freight.invoice_lines l
JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse'
ON CONFLICT (id) DO NOTHING;
`);
// Remove the migrated rows from the central tables.
await queryRunner.query(`
DELETE FROM freight.invoice_lines
WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse');
`);
await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface {
name = 'PhasedClearanceCycleMeta1829000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`,
);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Admin-configurable minimum days between today and export RO vessel departure. */
export class SeedRoVesselMinDays1829000000001 implements MigrationInterface {
name = 'SeedRoVesselMinDays1829000000001';
private readonly code = 'ro_vessel_min_days';
private readonly options: Array<{ value: string; label: string }> = [
{ value: '2', label: '2 days' },
{ value: '3', label: '3 days' },
];
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
VALUES ($1, $2, $3, false)
RETURNING id;`,
[
this.code,
'RO vessel minimum lead time (days)',
'Minimum days between today and the vessel departure date on an export Release Order.',
],
);
const settingId = inserted[0].id;
for (let i = 0; i < this.options.length; i++) {
const opt = this.options[i];
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, $4);`,
[settingId, opt.value, opt.label, i],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
this.code,
]);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BookingClearanceMeta1829000000002 implements MigrationInterface {
name = 'BookingClearanceMeta1829000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`,
);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
* window closes before settlement (e.g. a booking whose `paymentDeadline`
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
* `OVERDUE` (still payable).
*
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
* not referenced in this same transaction, so it is PG 12+ safe.
*/
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
name = "AddExpiredInvoiceStatus1830000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
);
}
public async down(): Promise<void> {
// Postgres cannot drop individual enum values; EXPIRED is left on
// freight.invoices_status_enum (harmless, unused after down).
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface {
name = 'DropCargoTypeShowFreeTextBox1830000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP COLUMN IF EXISTS show_free_text_box
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false
`);
}
}

View File

@@ -0,0 +1,72 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface {
name = 'RouteStatusAndSegmentKm1830000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE'
`);
await queryRunner.query(`
UPDATE freight.routes
SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_routes_name"
`);
await queryRunner.query(`
ALTER TABLE freight.routes DROP COLUMN IF EXISTS name
`);
await queryRunner.query(`
ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status)
`);
await queryRunner.query(`
ALTER TABLE freight.route_milestones
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS name varchar(120)
`);
await queryRunner.query(`
UPDATE freight.routes SET name = id::text WHERE name IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true
`);
await queryRunner.query(`
UPDATE freight.routes
SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END
`);
await queryRunner.query(`
ALTER TABLE freight.routes DROP COLUMN IF EXISTS status
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_routes_status"
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name)
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface {
name = 'PreClearanceFinalizedAt1830000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface {
name = 'AddWarehouseFeeRuleTiers1831000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
DROP COLUMN IF EXISTS tiers;
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface {
name = 'AddCustomerTruckAssignmentToBookings1832000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS customer_truck_arrived_at,
DROP COLUMN IF EXISTS customer_truck_assigned_at,
DROP COLUMN IF EXISTS customer_truck_container_number,
DROP COLUMN IF EXISTS customer_truck_type,
DROP COLUMN IF EXISTS customer_truck_driver_name,
DROP COLUMN IF EXISTS customer_truck_plate_number
`);
}
}

View File

@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateFuelTables1840000000000 implements MigrationInterface {
name = "CreateFuelTables1840000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const fuelPurchasesExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`,
);
if (!fuelPurchasesExists.length) {
await queryRunner.query(`
CREATE TABLE freight.fuel_purchases (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
purchase_date timestamptz NOT NULL,
liters numeric(10, 2) NOT NULL,
cost_per_liter numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
fuel_station varchar(255) NULL,
payment_method varchar(50) DEFAULT 'CASH',
odometer_reading numeric(10, 2) NULL,
driver_id uuid NULL,
receipt_number varchar(255) NULL,
notes text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`,
);
}
const fuelConsumptionExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`,
);
if (!fuelConsumptionExists.length) {
await queryRunner.query(`
CREATE TABLE freight.fuel_consumption (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
month date NOT NULL,
total_liters numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
total_distance_km numeric(10, 2) NOT NULL,
fuel_efficiency_km_per_l numeric(10, 2) NULL,
number_of_purchases integer DEFAULT 0,
average_cost_per_liter numeric(10, 2) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
);
`);
await queryRunner.query(
`CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`);
}
}

View File

@@ -0,0 +1,82 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateMaintenanceTables1850000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create maintenance_schedules table
const scheduleTableExists = await queryRunner.query(`
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules'
)
`);
if (!scheduleTableExists[0].exists) {
await queryRunner.query(`
CREATE TABLE "freight"."maintenance_schedules" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"vehicle_id" uuid NOT NULL,
"maintenance_type" varchar NOT NULL,
"description" varchar NOT NULL,
"scheduled_date" timestamptz NOT NULL,
"completed_date" timestamptz,
"estimated_cost" numeric(14,2),
"actual_cost" numeric(14,2),
"status" varchar NOT NULL DEFAULT 'SCHEDULED',
"odometer_reading" numeric,
"service_provider" varchar,
"notes" text,
"next_due_km" numeric,
"next_due_date" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")`
);
}
// Create maintenance_costs table
const costsTableExists = await queryRunner.query(`
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'freight' AND table_name = 'maintenance_costs'
)
`);
if (!costsTableExists[0].exists) {
await queryRunner.query(`
CREATE TABLE "freight"."maintenance_costs" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"vehicle_id" uuid NOT NULL,
"maintenance_schedule_id" uuid,
"incurred_date" timestamptz NOT NULL,
"cost_amount" numeric(14,2) NOT NULL,
"cost_type" varchar NOT NULL,
"description" varchar NOT NULL,
"service_provider" varchar,
"invoice_number" varchar,
"notes" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
PRIMARY KEY ("id"),
CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id")
REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL
)
`);
await queryRunner.query(
`CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")`
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`);
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add paid column to first_mile and last_mile tables to track invoice payment status.
*/
export class AddPaidToFirstAndLastMile1860000000000
implements MigrationInterface
{
name = "AddPaidToFirstAndLastMile1860000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.first_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
`);
await queryRunner.query(`
ALTER TABLE freight.last_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.first_mile
DROP COLUMN IF EXISTS paid;
`);
await queryRunner.query(`
ALTER TABLE freight.last_mile
DROP COLUMN IF EXISTS paid;
`);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface {
name = "AddBookingWindowGlobalRules1861000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3,
ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24,
ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8,
ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30,
ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60,
ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS import_window_lead_days,
DROP COLUMN IF EXISTS export_booking_lead_hours,
DROP COLUMN IF EXISTS window_open_hour,
DROP COLUMN IF EXISTS window_duration_hours,
DROP COLUMN IF EXISTS doc_review_minutes,
DROP COLUMN IF EXISTS payment_window_minutes,
DROP COLUMN IF EXISTS reopen_delay_minutes;
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* arriveSchedule used to release only the primary locomotive of a train set, leaving
* secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out
* on a dispatched train — release every ASSIGNED locomotive that is not attached to a
* currently-DISPATCHED schedule.
*/
export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface {
name = "ReleaseStuckAssignedLocomotives1861000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.locomotives l
SET status = 'AVAILABLE'
WHERE l.status = 'ASSIGNED'
AND NOT EXISTS (
SELECT 1
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN (
SELECT tsl.train_set_id, tsl.locomotive_id
FROM freight.train_set_locomotives tsl
WHERE tsl.deleted_at IS NULL
UNION
SELECT t.id AS train_set_id, t.locomotive_id
FROM freight.train_sets t
WHERE t.locomotive_id IS NOT NULL
) loco ON loco.train_set_id = tset.id
WHERE ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND loco.locomotive_id = l.id
);
`);
}
public async down(): Promise<void> {
// Data fix — not reversible.
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddScheduleWindowPhases1862000000000 implements MigrationInterface {
name = "AddScheduleWindowPhases1862000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN window_phase varchar(20) NULL,
ADD COLUMN window_opens_at timestamptz NULL,
ADD COLUMN window_closes_at timestamptz NULL,
ADD COLUMN doc_review_ends_at timestamptz NULL,
ADD COLUMN doc_review_completed_at timestamptz NULL,
ADD COLUMN payment_phase_ends_at timestamptz NULL,
ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0;
`);
await queryRunner.query(`
CREATE INDEX idx_train_schedules_window_phase
ON freight.train_schedules (window_phase)
WHERE window_phase IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS window_phase,
DROP COLUMN IF EXISTS window_opens_at,
DROP COLUMN IF EXISTS window_closes_at,
DROP COLUMN IF EXISTS doc_review_ends_at,
DROP COLUMN IF EXISTS doc_review_completed_at,
DROP COLUMN IF EXISTS payment_phase_ends_at,
DROP COLUMN IF EXISTS booking_cycle_no;
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateBookingBatchOffers1863000000000 implements MigrationInterface {
name = "CreateBookingBatchOffers1863000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE freight.booking_batch_offers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
offered_wagons integer NOT NULL,
total_wagons integer NOT NULL,
offered_lines jsonb NULL,
offered_weight_tons numeric(12, 3) NOT NULL,
offered_amount numeric(14, 2) NOT NULL,
offered_pricing_breakdown jsonb NULL,
invoice_id uuid NULL,
payment_deadline timestamptz NOT NULL,
status varchar(10) NOT NULL DEFAULT 'OFFERED',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
`);
await queryRunner.query(
`CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add location_id column to vehicles table to track vehicle base location.
*/
export class AddLocationToVehicles1870000000000 implements MigrationInterface {
name = "AddLocationToVehicles1870000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS location_id uuid;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS location_id;
`);
}
}

View File

@@ -0,0 +1,269 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Repairs schema drift on databases that were originally built by TypeORM
* `synchronize` (at an older entity snapshot) and never had their migration
* history recorded. Such databases have `freight.migrations` empty while most
* of the schema already exists, so a from-scratch migration run aborts on the
* first non-idempotent statement and never reaches the columns/tables added
* after synchronize was last used.
*
* The deployment procedure for those databases is:
* 1. Baseline every pre-existing migration into `freight.migrations`.
* 2. Run migrations — this file is the only pending one and back-fills the
* objects the drift scan found missing.
*
* Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is
* also safe on a clean database where the earlier migrations already created
* these objects — it simply no-ops.
*/
export class RepairSynchronizeDrift1870000000000
implements MigrationInterface
{
name = 'RepairSynchronizeDrift1870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// --- enum types (derived from entities that never had a source migration) ---
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.consignments_cargo_type_enum AS ENUM (
'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.consignments_status_enum AS ENUM (
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.tracking_events_status_enum AS ENUM (
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
// --- missing tables ---
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments (
id uuid NOT NULL DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL,
tracking_number varchar(64) NOT NULL,
cargo_type freight.consignments_cargo_type_enum NOT NULL,
weight_kg numeric(12, 2) NOT NULL,
status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING',
origin_station varchar(128) NOT NULL,
destination_station varchar(128) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_consignments PRIMARY KEY (id),
CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events (
id uuid NOT NULL DEFAULT gen_random_uuid(),
consignment_id uuid NOT NULL,
location varchar(256) NOT NULL,
status freight.tracking_events_status_enum NOT NULL,
occurred_at timestamptz NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_tracking_events PRIMARY KEY (id)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
purchase_date timestamptz NOT NULL,
liters numeric(10, 2) NOT NULL,
cost_per_liter numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
fuel_station varchar(255) NULL,
payment_method varchar(50) DEFAULT 'CASH',
odometer_reading numeric(10, 2) NULL,
driver_id uuid NULL,
receipt_number varchar(255) NULL,
notes text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
month date NOT NULL,
total_liters numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
total_distance_km numeric(10, 2) NOT NULL,
fuel_efficiency_km_per_l numeric(10, 2) NULL,
number_of_purchases integer DEFAULT 0,
average_cost_per_liter numeric(10, 2) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
maintenance_type varchar NOT NULL,
description varchar NOT NULL,
scheduled_date timestamptz NOT NULL,
completed_date timestamptz,
estimated_cost numeric(14,2),
actual_cost numeric(14,2),
status varchar NOT NULL DEFAULT 'SCHEDULED',
odometer_reading numeric,
service_provider varchar,
notes text,
next_due_km numeric,
next_due_date timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
PRIMARY KEY (id)
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
maintenance_schedule_id uuid,
incurred_date timestamptz NOT NULL,
cost_amount numeric(14,2) NOT NULL,
cost_type varchar NOT NULL,
description varchar NOT NULL,
service_provider varchar,
invoice_number varchar,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
PRIMARY KEY (id),
CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id)
REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications (
id uuid NOT NULL DEFAULT gen_random_uuid(),
phone varchar NOT NULL,
otp varchar NOT NULL,
verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_otp_verifications PRIMARY KEY (id),
CONSTRAINT uq_otp_verifications_phone UNIQUE (phone)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
offered_wagons integer NOT NULL,
total_wagons integer NOT NULL,
offered_lines jsonb NULL,
offered_weight_tons numeric(12, 3) NOT NULL,
offered_amount numeric(14, 2) NOT NULL,
offered_pricing_breakdown jsonb NULL,
invoice_id uuid NULL,
payment_deadline timestamptz NOT NULL,
status varchar(10) NOT NULL DEFAULT 'OFFERED',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`);
// --- missing columns on existing tables ---
await queryRunner.query(`ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz;`);
await queryRunner.query(`ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME',
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40),
ADD COLUMN IF NOT EXISTS duty_required boolean,
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`);
await queryRunner.query(`ALTER TABLE freight.cargoes
ADD COLUMN IF NOT EXISTS receiver_name varchar,
ADD COLUMN IF NOT EXISTS delivered_at timestamp,
ADD COLUMN IF NOT EXISTS delivery_remarks text;`);
await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles
ADD COLUMN IF NOT EXISTS duty_required boolean,
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
ADD COLUMN IF NOT EXISTS current_phase varchar(40),
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
await queryRunner.query(`ALTER TABLE freight.first_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.last_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.route_milestones
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`);
await queryRunner.query(`ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`);
await queryRunner.query(`ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL,
ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase
ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`);
await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24,
ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8,
ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30,
ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60,
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`);
}
public async down(): Promise<void> {
// No-op: this migration only repairs drift by additively creating objects
// that other migrations own. Rolling it back would drop objects those
// migrations legitimately created. Revert individual feature migrations
// instead if needed.
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add FREE and BUSY statuses to vehicle status enum.
*/
export class AddVehicleStatuses1880000000000 implements MigrationInterface {
name = "AddVehicleStatuses1880000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// Create enum type if it doesn't exist
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN
CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE');
ELSE
-- Add values if enum already exists but doesn't have them
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE';
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE';
END IF;
END $$;
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Note: Postgres cannot drop individual enum values, so the down migration is a no-op
// The enum values FREE and BUSY will remain but will be unused after downgrade
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Split the mixed vehicle status into two fields:
* - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE)
* - availability: assignment state (FREE, BUSY)
*
* Existing FREE/BUSY statuses are moved to availability and the status is
* normalized back to ACTIVE.
*/
export class SeparateVehicleAvailability1890000000000 implements MigrationInterface {
name = "SeparateVehicleAvailability1890000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
`);
await queryRunner.query(`
UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY'
`);
await queryRunner.query(`
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
`);
await queryRunner.query(`
UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY')
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Fold availability back into status before dropping the column
await queryRunner.query(`
UPDATE freight.vehicles SET status = availability
WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY')
`);
await queryRunner.query(`
ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add code, power_plate_no and trailer_plate_no columns to vehicles.
* These fields existed in the DTO and UI form but had no entity columns,
* so submitted values were silently dropped.
*/
export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface {
name = "AddVehicleCodeAndPlates1890000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS code varchar,
ADD COLUMN IF NOT EXISTS power_plate_no varchar,
ADD COLUMN IF NOT EXISTS trailer_plate_no varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS code,
DROP COLUMN IF EXISTS power_plate_no,
DROP COLUMN IF EXISTS trailer_plate_no
`);
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Session store for the VeriFayda 2.0 OIDC verification flow (ported from
* passenger-api). One row per started verification; `state` is the
* single-use CSRF token linking the eSignet redirect back to the session.
*/
export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface {
name = "AddFaydaVerificationSessions1890000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
state varchar NOT NULL UNIQUE,
code_verifier varchar NOT NULL,
purpose varchar NOT NULL DEFAULT 'VERIFY',
platform varchar NOT NULL DEFAULT 'WEB',
save_to_account boolean NOT NULL DEFAULT false,
status varchar NOT NULL DEFAULT 'PENDING',
error_code varchar,
error_description text,
iam_user_id uuid,
expires_at timestamptz NOT NULL,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_EXPIRES_AT"
ON freight.fayda_verification_sessions (expires_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID"
ON freight.fayda_verification_sessions (iam_user_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Track Fayda identity verification on drivers: whether the driver's
* identity was verified through VeriFayda and the OIDC subject it was
* verified against.
*/
export class AddDriverFaydaVerification1890000000003 implements MigrationInterface {
name = "AddDriverFaydaVerification1890000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS fayda_sub varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS fayda_verified,
DROP COLUMN IF EXISTS fayda_sub
`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Allow more than one vehicle per last-mile delivery. Junction table joins
* last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as
* the first assignment so nothing is lost.
*/
export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface {
name = "AddLastMileVehicleAssignments1890000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE,
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE"
ON freight.last_mile_vehicle_assignments (vehicle_id)
`);
// Backfill: existing single-vehicle assignments become the first row
await queryRunner.query(`
INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id)
SELECT id, vehicle_id FROM freight.last_mile
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Store the driver's gender. Prefilled from the Fayda VERIFY response
* (Male/Female) but editable; nullable so existing rows and manual,
* non-Fayda driver records stay valid.
*/
export class AddDriverGender1890000000005 implements MigrationInterface {
name = "AddDriverGender1890000000005";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD COLUMN IF NOT EXISTS gender varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS gender
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Enforce one driver record per verified Fayda identity. A unique index on
* fayda_sub blocks a second driver from being created against the same Fayda
* OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected.
*/
export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface {
name = "AddDriverFaydaSubUnique1890000000006";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
ON freight.drivers (fayda_sub)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"
`);
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Make driver uniqueness soft-delete aware. The original table used plain
* column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted
* rows, so deleting a driver then re-adding the same email/phone/license/Fayda
* identity failed at the DB with a raw 500 — even though the service's own
* (deleted_at-excluding) duplicate check saw nothing. Replace them with partial
* unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches
* what the service enforces and freed values become reusable after deletion.
*/
export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface {
name = "DriverUniquePartialSoftDelete1890000000007";
public async up(queryRunner: QueryRunner): Promise<void> {
// Drop the full-table unique constraints from CreateDriversTable...
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP CONSTRAINT IF EXISTS drivers_email_key,
DROP CONSTRAINT IF EXISTS drivers_phone_number_key,
DROP CONSTRAINT IF EXISTS drivers_license_number_key
`);
// ...and the plain fayda_sub unique index from 1890000000006.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`);
// Re-add each as a partial unique index scoped to non-deleted rows.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE"
ON freight.drivers (email) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE"
ON freight.drivers (phone_number) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE"
ON freight.drivers (license_number) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE"
ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
ON freight.drivers (fayda_sub)
`);
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD CONSTRAINT drivers_email_key UNIQUE (email),
ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number),
ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number)
`);
}
}

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle
* status/availability transitions, first/last-mile vehicle assignments + mile
* status changes). Queried by vehicle_id or driver_id to build a per-record
* timeline. Populated going forward — existing records have no back-history.
*/
export class AddFleetEvents1890000000008 implements MigrationInterface {
name = "AddFleetEvents1890000000008";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.fleet_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type varchar NOT NULL,
vehicle_id uuid,
driver_id uuid,
first_mile_id uuid,
last_mile_id uuid,
from_value varchar,
to_value varchar,
label varchar,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE"
ON freight.fleet_events (vehicle_id, created_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER"
ON freight.fleet_events (driver_id, created_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Container number carried by each vehicle on a last-mile delivery. Auto-filled
* from the booking's container number when present, else entered by the operator
* at assignment time.
*/
export class AddLastMileAssignmentContainerNumber1890000000009
implements MigrationInterface
{
name = "AddLastMileAssignmentContainerNumber1890000000009";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS container_number varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS container_number
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Per-vehicle actual distance on a last-mile delivery. A booking served by
* several trucks records each truck's km; the record's total (last_mile.exact_km)
* is their sum and drives the invoice.
*/
export class AddLastMileAssignmentDistance1890000000010
implements MigrationInterface
{
name = "AddLastMileAssignmentDistance1890000000010";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS distance_km
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Support email as a second OTP channel alongside phone (e.g. signup lets the
* user choose which one to verify). `phone` becomes nullable since an
* email-channel row has none, and `email` is added as a nullable unique column
* mirroring `phone`'s shape.
*/
export class AddEmailToOtpVerifications1900000000000
implements MigrationInterface
{
name = "AddEmailToOtpVerifications1900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// The table lives in the `freight` schema (the OtpVerification entity pins
// schema: "freight"). An earlier version of this migration targeted
// `public.otp_verifications`, which does not exist there — leaving the real
// freight table without an `email` column and OTP send failing with
// `column OtpVerification.email does not exist`. Target `freight` explicitly.
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ALTER COLUMN phone DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
DROP COLUMN IF EXISTS email
`);
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ALTER COLUMN phone SET NOT NULL
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings.
* Tracking only — does not gate dispatch.
*/
export class AddLoadingStatusToTrainScheduleBookings1900000000000
implements MigrationInterface
{
name = "AddLoadingStatusToTrainScheduleBookings1900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
DROP COLUMN IF EXISTS loading_status
`);
}
}

View File

@@ -0,0 +1,126 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Simplify the rate + weight-limit configuration model:
*
* 1. Drop the effective_from / effective_to validity window from both
* `rates` and `weight_limit_rules`. Rates are now activated purely by
* the approval workflow (status = LIVE) and weight limits are always
* active for their container + direction. No time-travel scheduling.
*
* 2. Enforce "one rate per pattern" with partial unique indexes so the same
* configuration (e.g. FIRST_MILE for a given container type) cannot be
* duplicated. NULL scope columns are COALESCE-normalised because Postgres
* treats NULLs as distinct in a plain unique index.
*
* This migration is destructive on the date columns — existing effective_*
* values are dropped.
*/
export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface {
name = 'SimplifyRatesAndWeightLimitRules1900000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. De-duplicate existing data so the unique indexes can be created ──
// Keep the most recently-created row per pattern, soft-delete the rest.
await queryRunner.query(`
WITH ranked AS (
SELECT id,
row_number() OVER (
PARTITION BY rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
rate_unit
ORDER BY created_at DESC, id DESC
) AS rn
FROM freight.rates
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'
)
UPDATE freight.rates r
SET deleted_at = now()
FROM ranked
WHERE r.id = ranked.id AND ranked.rn > 1;
`);
await queryRunner.query(`
WITH ranked AS (
SELECT id,
row_number() OVER (
PARTITION BY container_type_id, trade_direction
ORDER BY created_at DESC, id DESC
) AS rn
FROM freight.weight_limit_rules
WHERE deleted_at IS NULL
)
UPDATE freight.weight_limit_rules w
SET deleted_at = now()
FROM ranked
WHERE w.id = ranked.id AND ranked.rn > 1;
`);
// ── 2. Drop the effective-date indexes + columns ───────────────────────
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`);
// Indexes created by TypeORM's @Index carry generated hashed names — drop
// any index that references the effective_from column defensively.
await queryRunner.query(`
DO $$
DECLARE idx record;
BEGIN
FOR idx IN
SELECT indexname FROM pg_indexes
WHERE schemaname = 'freight'
AND tablename IN ('rates', 'weight_limit_rules')
AND indexdef ILIKE '%effective_from%'
LOOP
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname);
END LOOP;
END $$;
`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`);
// ── 3. One-rate-per-pattern partial unique indexes ─────────────────────
// The unit is part of the identity so a surcharge can legitimately carry two
// rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON),
// while still blocking a true duplicate (same rateType + scope + unit).
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern"
ON freight.weight_limit_rules (container_type_id, trade_direction)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`);
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`);
await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`);
await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`);
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`,
);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2)
* to numeric(6,4). The UI now lets staff enter the booking-window duration in
* minutes / hours / days and converts to the column's native hours unit; a
* 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min).
* Four decimals store sub-minute durations exactly (0.0667h → 4.00 min).
*/
export class WidenWindowDurationHoursPrecision1910000000000
implements MigrationInterface
{
name = "WidenWindowDurationHoursPrecision1910000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_duration_hours TYPE numeric(6, 4);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_duration_hours TYPE numeric(4, 2);
`);
}
}

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Snapshot the booking-window rule onto each train schedule.
*
* A schedule's window (open time + reopen cycles) must be frozen to the rule it
* was created with: a later global-rules edit applies only to FUTURE schedules,
* while an already-open schedule keeps its base rule. Previously the batch board
* recomputed windows from the LIVE global config, so editing the rule redrew the
* board for open schedules (a synthetic grid that no longer matched the window
* the customer was shown). These columns give the board a per-schedule rule to
* derive its display windows from.
*
* Existing rows are backfilled from the current global-rules singleton — the best
* available base, since they never stored one. Their stamped windowOpensAt/
* windowClosesAt are still real, so only projected reopen cycles rely on the
* backfill.
*/
export class AddScheduleWindowRuleSnapshot1920000000000
implements MigrationInterface
{
name = "AddScheduleWindowRuleSnapshot1920000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_window_open_hour integer,
ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4),
ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer,
ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer,
ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer;
`);
// Backfill from the global-rules singleton so pre-existing schedules render.
await queryRunner.query(`
UPDATE freight.train_schedules ts
SET
rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour),
rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours),
rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes),
rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days),
rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours)
FROM freight.train_scheduling_global_rules r
WHERE ts.rule_window_open_hour IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_window_open_hour,
DROP COLUMN IF EXISTS rule_window_duration_hours,
DROP COLUMN IF EXISTS rule_reopen_delay_minutes,
DROP COLUMN IF EXISTS rule_import_window_lead_days,
DROP COLUMN IF EXISTS rule_export_booking_lead_hours;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a hard per-unit weight ceiling to weight limit rules.
*
* maxVgmTons stays the soft "overweight" threshold (surcharge + warning);
* max_capacity_tons is the absolute ceiling above which a booking cannot be
* created at all. Null means no ceiling (existing behavior).
*/
export class AddMaxCapacityToWeightLimitRules1930000000000
implements MigrationInterface
{
name = "AddMaxCapacityToWeightLimitRules1930000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS max_capacity_tons;
`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Allow more than one vehicle per first-mile pickup. Junction table joins
* first_mile ⇄ vehicles, with each truck's container number + actual distance;
* existing single vehicle_id values are backfilled as the first assignment so
* nothing is lost. Mirrors the last-mile vehicle-assignment schema.
*/
export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface {
name = "AddFirstMileVehicleAssignments1940000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE,
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
container_number varchar,
distance_km numeric(10,2),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE"
ON freight.first_mile_vehicle_assignments (vehicle_id)
`);
// Backfill: existing single-vehicle assignments become the first row
await queryRunner.query(`
INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id)
SELECT id, vehicle_id FROM freight.first_mile
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`);
}
}

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Replace load-type string matching with a real wagon-type foreign key.
*
* Before this migration, train scheduling picked a wagon type by matching
* strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
* and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
* `cargo_types` and `container_types` so scheduling resolves the wagon type
* through the relation instead.
*
* The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
* never ship in bulk have no wagon type, and forcing one onto them is
* meaningless. Scheduling enforces the requirement at run time (it throws when a
* scheduled bulk cargo type or a container type in the batch has no wagon type).
*
* Backfill reproduces the old hardcoded resolution one final time so existing
* bulk cargo + container rows are not left unset. After this, the runtime map is
* removed — the FK is the single source of truth.
*/
export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
implements MigrationInterface
{
name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Columns + FKs ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD CONSTRAINT fk_cargo_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD CONSTRAINT fk_container_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
ON freight.cargo_types (wagon_type_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
ON freight.container_types (wagon_type_id);
`);
// ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
// COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
// COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
const cargoCodeToWagon: Record<string, string> = {
COFFEE: "KW2",
GRAIN: "KW2",
WHEAT: "KW2",
SORGHUM: "KW2",
CORN: "KW2",
FERTILIZER: "PW2",
SUGAR: "PW2",
COAL: "KW3",
STEEL: "CW3",
ORE: "CW3",
};
for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
await queryRunner.query(
`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = $1
AND UPPER(TRIM(ct.code)) = $2
AND ct.wagon_type_id IS NULL;
`,
[wagonCode, cargoCode],
);
}
// Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
await queryRunner.query(`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'CW3'
AND ct.wagon_type_id IS NULL
AND ct.unit_of_measure = 'PER_TON';
`);
// All container types → the old container default wagon NW5.
await queryRunner.query(`
UPDATE freight.container_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'NW5'
AND ct.wagon_type_id IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
`);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Multi-truck customer (self-haul) assignment. Replaces the single
* booking.customer_truck_* fields with a per-booking list of trucks, each
* carrying 12 containers and tracking its own arrival. The legacy
* booking.customer_truck_* columns are kept as a synced booking-level flag
* (any truck assigned / all trucks arrived) so the warehouse exit-gate and
* delivery-approval logic keep working.
*/
export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface {
name = 'AddCustomerTruckAssignments1950000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
plate_number varchar(32) NOT NULL,
driver_name varchar(120) NOT NULL,
truck_type varchar(60) NOT NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
arrived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customer_truck_containers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE,
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
container_number varchar(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`,
);
// One container number can be loaded onto exactly one truck per booking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number"
ON freight.customer_truck_containers (booking_id, container_number)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Vehicle Compliance & Expiry Alerts.
* - Adds expiry-tracking columns to freight.vehicles.
* - Creates freight.compliance_records for per-document compliance tracking.
*/
export class AddVehicleCompliance1950000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Vehicle expiry / compliance columns.
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS vin VARCHAR,
ADD COLUMN IF NOT EXISTS ownership VARCHAR,
ADD COLUMN IF NOT EXISTS insurance_expiry DATE,
ADD COLUMN IF NOT EXISTS registration_expiry DATE,
ADD COLUMN IF NOT EXISTS next_inspection_date DATE;
`);
// Compliance records table.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.compliance_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL REFERENCES freight.vehicles(id),
type VARCHAR NOT NULL,
document_number VARCHAR,
issued_date DATE,
expiry_date DATE NOT NULL,
status VARCHAR NOT NULL DEFAULT 'VALID',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_vehicle_id ON freight.compliance_records(vehicle_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_expiry_date ON freight.compliance_records(expiry_date);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_compliance_records_type ON freight.compliance_records(type);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.compliance_records CASCADE;`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS vin,
DROP COLUMN IF EXISTS ownership,
DROP COLUMN IF EXISTS insurance_expiry,
DROP COLUMN IF EXISTS registration_expiry,
DROP COLUMN IF EXISTS next_inspection_date;
`);
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add the daily booking-desk close hour.
*
* The import booking window used to reopen only within the same EAT calendar day
* as its close; a cycle whose reopen crossed midnight died at CLOSED_FOR_DAY with
* capacity still free. The window now runs a daily office range [openHour,
* closeHour): a not-yet-full train pauses at closeHour and resumes the next
* morning at openHour, every day until it fills or departs. openHour === closeHour
* means a 24-hour desk.
*
* `window_close_hour` on the global-rules singleton is the live config; the
* matching `rule_window_close_hour` snapshot on each schedule freezes it at
* creation so the batch board keeps drawing the window the customer was shown.
* Both default/backfill to 17:00 (5 PM), the previous implicit office close.
*/
export class AddWindowCloseHour1950000000000 implements MigrationInterface {
name = "AddWindowCloseHour1950000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS window_close_hour integer NOT NULL DEFAULT 17;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_window_close_hour integer;
`);
// Backfill the snapshot from the global-rules singleton so pre-existing
// schedules keep projecting reopen cycles.
await queryRunner.query(`
UPDATE freight.train_schedules ts
SET rule_window_close_hour = COALESCE(ts.rule_window_close_hour, r.window_close_hour)
FROM freight.train_scheduling_global_rules r
WHERE ts.rule_window_close_hour IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_window_close_hour;
`);
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS window_close_hour;
`);
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* In-app notification inbox. One row per recipient per logical notification;
* producers fan out by inserting many rows. Indexed for the two hot queries:
* unread-count (recipient + is_read) and the newest-first list (recipient +
* created_at). Enum-like columns are stored as varchar to avoid PG enum churn.
*/
export class CreateNotifications1950000000000 implements MigrationInterface {
name = "CreateNotifications1950000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_user_id uuid NOT NULL,
audience varchar(20) NOT NULL,
type varchar(48) NOT NULL DEFAULT 'GENERIC',
title varchar(200) NOT NULL,
body text NOT NULL,
link varchar,
data jsonb,
priority varchar(12) NOT NULL DEFAULT 'NORMAL',
is_read boolean NOT NULL DEFAULT false,
read_at timestamptz,
channels_sent jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_UNREAD"
ON freight.notifications (recipient_user_id, is_read)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_CREATED"
ON freight.notifications (recipient_user_id, created_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_CREATED"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_UNREAD"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.notifications`);
}
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-container receive tracking. A booking's containers arrive individually
* (on separate self-haul trucks), so each container unit tracks whether it has
* been received into the port and, once staff confirm it, the GRN it belongs to.
* A single GRN covers the containers received together — so if the whole booking
* arrives at once, all its units share one GRN (per-booking GRN).
*/
export class AddContainerReceiptToBookingContainerUnits1960000000000
implements MigrationInterface
{
name = 'AddContainerReceiptToBookingContainerUnits1960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_container_units
ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS received_at timestamptz,
ADD COLUMN IF NOT EXISTS grn_number varchar(100)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`);
await queryRunner.query(`
ALTER TABLE freight.booking_container_units
DROP COLUMN IF EXISTS received_to_port,
DROP COLUMN IF EXISTS received_at,
DROP COLUMN IF EXISTS grn_number
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Accident & Incident register for the fleet. Tracks accidents, breakdowns,
* traffic violations, thefts and other incidents against a vehicle, driver
* and/or booking, with severity, damage estimate, insurance claim tracking and
* a lifecycle status. Queried by driver_id for per-driver incident history.
*/
export class AddIncidents1960000000000 implements MigrationInterface {
name = 'AddIncidents1960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.incidents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
driver_id uuid,
booking_id uuid,
type varchar NOT NULL,
severity varchar NOT NULL,
occurred_at timestamptz NOT NULL,
location varchar,
description text NOT NULL,
damage_estimate numeric(14,2),
status varchar NOT NULL DEFAULT 'REPORTED',
insurance_claim_number varchar,
reported_by varchar
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_DRIVER"
ON freight.incidents (driver_id, occurred_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_VEHICLE"
ON freight.incidents (vehicle_id, occurred_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Import self-haul trucks are weighed on leaving. The customer does not
* pre-specify what an import truck takes — staff register the containers loaded
* and the weighed gross when the truck departs. These columns capture that.
*/
export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface {
name = 'AddCustomerTruckDeparture1970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2),
ADD COLUMN IF NOT EXISTS departed_at timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
DROP COLUMN IF EXISTS gross_weight_kg,
DROP COLUMN IF EXISTS departed_at
`);
}
}

View File

@@ -0,0 +1,93 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMaintenanceDepth1970000000000 implements MigrationInterface {
name = 'AddMaintenanceDepth1970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
title VARCHAR NOT NULL,
description TEXT,
status VARCHAR NOT NULL DEFAULT 'OPEN',
priority VARCHAR NOT NULL DEFAULT 'MEDIUM',
assigned_to VARCHAR,
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
labor_cost NUMERIC(14, 2),
parts_cost NUMERIC(14, 2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR NOT NULL,
sku VARCHAR,
category VARCHAR,
quantity_in_stock INT NOT NULL DEFAULT 0,
reorder_level INT NOT NULL DEFAULT 0,
unit_cost NUMERIC(14, 2),
location VARCHAR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warranties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL,
component VARCHAR NOT NULL,
provider VARCHAR,
start_date DATE,
expiry_date DATE NOT NULL,
coverage_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_work_orders_vehicle_id_status" ON freight.work_orders (vehicle_id, status)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_parts_category" ON freight.parts (category)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_warranties_vehicle_id_expiry_date" ON freight.warranties (vehicle_id, expiry_date)`,
);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.work_orders
ADD CONSTRAINT "FK_work_orders_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warranties
ADD CONSTRAINT "FK_warranties_vehicle_id"
FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warranties CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.parts CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.work_orders CASCADE`);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Structured import handover records. Replaces the ad-hoc handover notes so a
* booking can carry one handover (single truck) or several (one per truck when
* multiple trucks are used). Timing differs by mile type:
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
*/
export class AddBookingHandovers1980000000000 implements MigrationInterface {
name = 'AddBookingHandovers1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
truck_plate varchar(32),
mile_type varchar(20) NOT NULL,
reference varchar(100) NOT NULL,
generated_at timestamptz NOT NULL DEFAULT now(),
signed_at timestamptz,
signed_by_user_id uuid,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
);
// At most one live handover per (booking, customer truck). EDR trucks (which
// aren't customer_truck_assignments) and per-booking handovers are de-duped
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
ON freight.booking_handovers (booking_id, truck_assignment_id)
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
}
}

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProcurement1980000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.vendors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
name varchar NOT NULL,
type varchar,
contact_person varchar,
phone varchar,
email varchar,
address varchar,
is_active boolean NOT NULL DEFAULT true
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_acquisitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid,
vendor_id uuid,
acquisition_type varchar NOT NULL,
acquisition_date date NOT NULL,
cost numeric(14,2),
useful_life_months integer,
salvage_value numeric(14,2),
lease_start date,
lease_end date,
monthly_payment numeric(14,2),
status varchar NOT NULL DEFAULT 'ACTIVE',
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_acquisitions_vehicle_date
ON freight.asset_acquisitions(vehicle_id, acquisition_date);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.asset_disposals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
vehicle_id uuid NOT NULL,
disposal_date date NOT NULL,
method varchar NOT NULL,
sale_price numeric(14,2),
buyer varchar,
notes text
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_disposals_vehicle_date
ON freight.asset_disposals(vehicle_id, disposal_date);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_disposals CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_acquisitions CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.vendors CASCADE;`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
* freezes its trade direction from the yard countries:
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
* and contracts for now).
*
* Existing yard rows are normalized case-insensitively; anything mentioning
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
* these two countries). A CHECK constraint keeps future writes honest.
*/
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
name = 'YardCountryEnumAndRouteDirection1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.yards
SET country = CASE
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
ELSE 'Ethiopia'
END
`);
await queryRunner.query(`
ALTER TABLE freight.yards
DROP CONSTRAINT IF EXISTS chk_yards_country,
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
UPDATE freight.routes r
SET direction = CASE
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
ELSE 'DOMESTIC'
END
FROM freight.yards o, freight.yards d
WHERE o.id = r.origin_yard_id
AND d.id = r.destination_yard_id
`);
// Orphan origin/destination (deleted yard) — no way to classify; park as
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
await queryRunner.query(`
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ALTER COLUMN direction SET NOT NULL,
DROP CONSTRAINT IF EXISTS chk_routes_direction,
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.routes
DROP CONSTRAINT IF EXISTS chk_routes_direction,
DROP COLUMN IF EXISTS direction
`);
await queryRunner.query(`
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double-handling fee support. warehouse_fee_rules.basis: how a
* DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM
* (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from
* the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's
* unit of measure), so no new booking column is needed.
*/
export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface {
name = 'AddDoubleHandlingBasisAndMachinery1990000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`,
);
// machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it
// if a prior version of this migration added it.
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Per-km haulage rate on a vehicle (mainly trucks) plus the currency it's
* quoted in (ETB | USD, default ETB).
*/
export class AddVehiclePricePerKm1990000000000 implements MigrationInterface {
name = "AddVehiclePricePerKm1990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS price_per_km numeric(14,2),
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS price_per_km,
DROP COLUMN IF EXISTS currency
`);
}
}

View File

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Segment corridor bookings: a booking may ride only part of a train's route
* (its own origin→destination leg), so dispatch/arrival become per-booking
* facts and wagon capacity is consumed per leg instead of per whole route.
*
* - bookings.loaded_at / arrived_at (+ by-user): operator-confirmed load at
* the booking's origin yard and unload at its destination yard. Clearance
* gates read arrived_at, not the train's actual_arrival_at.
* - train_set_wagons.board_yard_id / alight_yard_id: the leg a consist slot
* occupies; NULL/NULL = whole route (legacy). Non-overlapping legs coexist
* without consuming each other's capacity.
* - wagon_movements: auditable ledger of every physical wagon relocation
* (loaded leg / empty reposition / manual correction) with the acting user.
*/
export class SegmentCorridorBookings1990000000000 implements MigrationInterface {
name = 'SegmentCorridorBookings1990000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS loaded_at timestamptz,
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid,
ADD COLUMN IF NOT EXISTS arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS arrived_by_user_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
ADD COLUMN IF NOT EXISTS board_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS alight_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_movements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
wagon_id uuid NOT NULL REFERENCES freight.wagons(id) ON DELETE CASCADE,
from_yard_id uuid REFERENCES freight.yards(id),
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
train_schedule_id uuid REFERENCES freight.train_schedules(id) ON DELETE SET NULL,
booking_id uuid REFERENCES freight.bookings(id) ON DELETE SET NULL,
kind varchar(30) NOT NULL,
moved_by_user_id uuid,
occurred_at timestamptz NOT NULL DEFAULT now(),
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_wagon_occurred" ON freight.wagon_movements (wagon_id, occurred_at);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_schedule" ON freight.wagon_movements (train_schedule_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_movements;`);
await queryRunner.query(`
ALTER TABLE freight.train_set_wagons
DROP COLUMN IF EXISTS board_yard_id,
DROP COLUMN IF EXISTS alight_yard_id;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS loaded_at,
DROP COLUMN IF EXISTS loaded_by_user_id,
DROP COLUMN IF EXISTS arrived_at,
DROP COLUMN IF EXISTS arrived_by_user_id;
`);
}
}

View File

@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Moves service-level priority off the service_types table and onto the
* admin-managed priority_configs table as a new CUSTOMS rule type.
*
* - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs).
* - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be
* null, same as WAGON).
* - Seeds the two customs wagon-count tiers: 110 → 7 pts, 1153 → 15 pts.
* CUSTOMS rules apply only when the booking's service type includesCustoms.
*/
export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT priority_configs_type_check
CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS'));
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT chk_currency_for_type CHECK (
(type = 'WAGON' AND currency IS NULL) OR
(type = 'CURRENCY' AND currency IS NOT NULL) OR
(type = 'CUSTOMS' AND currency IS NULL)
);
`);
await queryRunner.query(`
INSERT INTO freight.priority_configs
(type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order)
VALUES
('CUSTOMS', 'With customs 110 wagons', NULL, 1, 10, 7, true, 1),
('CUSTOMS', 'With customs 1153 wagons', NULL, 11, 53, 15, true, 2);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS';
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT chk_currency_for_type CHECK (
(type = 'WAGON' AND currency IS NULL) OR
(type = 'CURRENCY' AND currency IS NOT NULL)
);
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
`);
await queryRunner.query(`
ALTER TABLE freight.priority_configs
ADD CONSTRAINT priority_configs_type_check
CHECK (type IN ('WAGON', 'CURRENCY'));
`);
await queryRunner.query(`
ALTER TABLE freight.service_types
ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0;
`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* GPS tracking: physical trackers (gps_devices, one denormalized latest fix per
* device for the live map) + append-only fix history (gps_positions).
*/
export class AddGpsTracking2000000000000 implements MigrationInterface {
name = "AddGpsTracking2000000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
imei varchar(20) NOT NULL UNIQUE,
name varchar,
vehicle_id uuid REFERENCES freight.vehicles(id),
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
last_seen_at timestamptz,
last_lat numeric(10,6),
last_lng numeric(10,6),
last_speed numeric(6,2),
last_course int,
last_fix_at timestamptz,
voltage_level int,
gsm_level int,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE"
ON freight.gps_devices (vehicle_id)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
imei varchar(20) NOT NULL,
vehicle_id uuid,
lat numeric(10,6) NOT NULL,
lng numeric(10,6) NOT NULL,
speed numeric(6,2) NOT NULL DEFAULT 0,
course int NOT NULL DEFAULT 0,
satellites int NOT NULL DEFAULT 0,
positioned boolean NOT NULL DEFAULT false,
gps_time timestamptz NOT NULL,
alarm int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME"
ON freight.gps_positions (device_id, gps_time)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
ON freight.gps_positions (vehicle_id, gps_time)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
}
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Truck detention support.
* - last_mile.arrived_at / delivered_at: the detention window for an EDR
* last-mile vehicle. The clock runs from arrival at destination; the customer
* has a grace period (default 3h) to clear/return, after which detention
* accrues per truck per day until delivered_at (or now, if still out).
* - warehouse_fee_rules.free_hours: configurable grace window (hours) for a
* TRUCK_DETENTION_FEE rule; null/0 falls back to the 3-hour default.
*/
export class AddTruckDetentionTiming2000000000000 implements MigrationInterface {
name = 'AddTruckDetentionTiming2000000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS arrived_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS delivered_at timestamptz`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS free_hours int`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS free_hours`);
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS delivered_at`);
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS arrived_at`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds bookings.consolidation_resume_status: the status a booking parked in
* PENDING_CONSOLIDATION returns to once it pairs with a wagon partner.
*
* Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged).
* Contract-drawdown bookings (GL shipments) set it to the status
* createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or
* AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow
* instead of wrongly moving them to SUBMITTED.
*/
export class AddConsolidationResumeStatus2010000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS consolidation_resume_status;
`);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER /
* TANKER / FLATBED / …), so different truck types carry different detention
* rates. Null = applies to any truck type.
*/
export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface {
name = 'AddFeeRuleVehicleType2010000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair: AddEmailToOtpVerifications1900000000000 originally altered
* `public.otp_verifications`, but the OtpVerification entity pins
* schema: "freight". On any DB where that migration already ran (and is recorded
* as executed, so it won't run again), the real `freight.otp_verifications` table
* never got the `email` column and `phone` was never made nullable — so OTP send
* dies with `column OtpVerification.email does not exist`.
*
* This migration re-applies the change against the correct schema. Idempotent
* (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the
* freight table is absent.
*/
export class RepairOtpEmailSchema2020000000000 implements MigrationInterface {
name = "RepairOtpEmailSchema2020000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("freight.otp_verifications");
if (!exists) return;
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ALTER COLUMN phone DROP NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("freight.otp_verifications");
if (!exists) return;
await queryRunner.query(`
ALTER TABLE freight.otp_verifications
DROP COLUMN IF EXISTS email
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in
* kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo
* weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes
* and is NOT touched; truck gross weight has no data yet. Runs exactly once
* (tracked by TypeORM) — re-running would divide again.
*/
export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface {
name = 'WarehouseCapacityKgToTons2020000000000';
private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones'];
private readonly columns = ['capacity_weight', 'current_weight', 'max_weight'];
public async up(queryRunner: QueryRunner): Promise<void> {
for (const table of this.tables) {
for (const column of this.columns) {
await queryRunner.query(
`UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`,
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
for (const table of this.tables) {
for (const column of this.columns) {
await queryRunner.query(
`UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`,
);
}
}
}
}

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds train_schedules.reference: a human-facing unique schedule number
* S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN).
*
* - Adds the nullable column.
* - Backfills existing rows: within each created-at year, numbers rows by
* created_at ascending (oldest → S-<year>-00001). Deterministic order.
* - Adds a partial unique index (NULLs allowed so a future insert can stage
* the row before the app stamps its reference).
*/
export class AddTrainScheduleReference2030000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS reference VARCHAR(20);
`);
// Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's
// own created-at year as the reference year so historical rows keep a
// sensible number.
await queryRunner.query(`
WITH numbered AS (
SELECT
id,
EXTRACT(YEAR FROM created_at)::int AS yr,
ROW_NUMBER() OVER (
PARTITION BY EXTRACT(YEAR FROM created_at)
ORDER BY created_at ASC, id ASC
) AS seq
FROM freight.train_schedules
WHERE reference IS NULL
)
UPDATE freight.train_schedules ts
SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0')
FROM numbered
WHERE ts.id = numbered.id;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference
ON freight.train_schedules (reference)
WHERE reference IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.ux_train_schedules_reference;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference;
`);
}
}

View File

@@ -0,0 +1,22 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { CheckAvailabilityService } from "./check-availability.service";
@ApiTags("auth")
@Controller("auth")
@Public()
export class CheckAvailabilityController {
constructor(
private readonly checkAvailabilityService: CheckAvailabilityService,
) {}
@Get("check-availability")
@ApiOperation({
summary: "Check whether an email and/or phone number is already registered",
})
check(@Query("email") email?: string, @Query("phone") phone?: string) {
return this.checkAvailabilityService.check({ email, phone });
}
}

View File

@@ -0,0 +1,47 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
export interface CheckAvailabilityQuery {
email?: string;
phone?: string;
}
export interface CheckAvailabilityResult {
emailTaken: boolean;
phoneTaken: boolean;
}
@Injectable()
export class CheckAvailabilityService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async check({
email,
phone,
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
if (!email && !phone) {
throw new BadRequestException("email or phone is required");
}
const matches = await this.userRepository.find({
where: [
...(email ? [{ email }] : []),
...(phone ? [{ phoneNumber: phone }] : []),
],
select: { id: true, email: true, phoneNumber: true },
});
return {
emailTaken: email ? matches.some((user) => user.email === email) : false,
phoneTaken: phone
? matches.some((user) => user.phoneNumber === phone)
: false,
};
}
}

View File

@@ -1,10 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
controllers: [FreightMeController],
providers: [FreightMeService],
imports: [TypeOrmModule.forFeature([User])],
controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
})
export class FreightAuthModule {}

View File

@@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
// Subpath imports (not the package root) so ts-jest can resolve them when this
// file lands in a spec's compile graph via the notification recipients chain.
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity";
import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -40,6 +44,21 @@ export class BackofficeService {
private readonly dataSource: DataSource,
) {}
/**
* IAM user ids of every current employee across all organizations — used by
* the notification recipients resolver's `allBackoffice` selector.
*/
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({
where: { isCurrent: true },
});
return [
...new Set(
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
),
];
}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,

View File

@@ -1,19 +1,31 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingView } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
@FreightAdmin()
@BookingView()
@ApiBearerAuth()
export class BillingController {
constructor(private readonly billingService: BillingService) { }
constructor(private readonly billingService: BillingService) {}
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
findAll() {
return this.billingService.findAll();
@ApiOperation({
summary: "List invoices (paginated, filterable by company/status/search)",
})
findAll(@Query() query: FilterInvoiceDto) {
return this.billingService.findAllPaginated(query);
}
@Get("invoices/:id")
@@ -21,4 +33,26 @@ export class BillingController {
findById(@Param("id", ParseUUIDPipe) id: string) {
return this.billingService.findById(id);
}
@Get("invoices/:id/document")
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
sendPdf(res, filename, buffer);
}
@Get("invoices/:id/receipt")
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.receipt(id);
sendPdf(res, filename, buffer);
}
}
/** Stream a generated PDF as a file download. */
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
res.send(buffer);
}

View File

@@ -3,7 +3,9 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { PaymentController } from "./payment.controller";
import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
@@ -16,8 +18,9 @@ import { CompaniesModule } from "../companies/companies.module";
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
forwardRef(() => PaymentModule),
CompaniesModule,
DocumentsModule,
],
controllers: [BillingController, PortalBillingController],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService],
})

View File

@@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) {
}
function makeEvents() {
return { emit: jest.fn() };
// BillingService emits via both emit() and emitAsync() (the post-commit async
// listener path) — the mock must provide both.
return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
}
function generateInput(overrides: Record<string, unknown> = {}) {
@@ -76,6 +78,7 @@ describe("BillingService.generateInvoice", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
});
@@ -88,7 +91,7 @@ describe("BillingService.generateInvoice", () => {
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
@@ -115,12 +118,14 @@ describe("BillingService.generateInvoice", () => {
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
@@ -134,6 +139,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -141,9 +147,24 @@ describe("BillingService.markInvoiceAsPaid", () => {
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
{
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
paidAt: expect.any(Date),
paidAmount: 1500,
balanceAmount: 0,
payments: [
{
amount: 1500,
method: "GATEWAY",
reference: "pay-1",
paidAt: expect.any(String),
metadata: null,
},
],
},
);
expect(events.emit).toHaveBeenCalledWith(
expect(events.emitAsync).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({
invoiceId: "inv-1",
@@ -171,80 +192,188 @@ describe("BillingService.markInvoiceAsPaid", () => {
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
});
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
};
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(open),
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const dataSource = {
manager: mg,
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
};
const service = new BillingService(
{ manager: mg } as never,
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
return { service, mg, events };
}
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
id: "inv-1",
status: Freight.InvoiceStatus.Issued,
source: "warehouse",
sourceId: "inv-item-1",
totalAmount: 1000,
paidAmount: 0,
balanceAmount: 1000,
payments: [],
paidAt: null,
...overrides,
});
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
const { service, mg, events } = serviceFor(openInvoice());
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
expect(updated.paidAmount).toBe(400);
expect(updated.balanceAmount).toBe(600);
expect(updated.payments).toHaveLength(1);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.PartiallyPaid,
paidAmount: 400,
balanceAmount: 600,
}),
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.anything(),
expect(events.emit).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
const updated = await service.recordPayment("inv-1", { amount: 600 });
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
expect(updated.balanceAmount).toBe(0);
expect(updated.paidAt).toBeInstanceOf(Date);
expect(mg.update).toHaveBeenCalled();
expect(events.emitAsync).toHaveBeenCalledWith(
"warehouse.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
);
});
it("is a no-op (returns null) when the source has no open invoice", async () => {
const mg = {
findOne: jest.fn().mockResolvedValue(null),
it("rejects a non-positive amount", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects a payment that exceeds the outstanding balance", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(
service.recordPayment("inv-1", { amount: 1500 }),
).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
);
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
});
/**
* Regression: `expirePayable` (batch settle path, called when a payment window
* lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
* The bug passed `dataSource.manager` (the non-transactional default) into the
* transition, so runTransition skipped opening a transaction and the lock threw
* `An open transaction is required for pessimistic lock` — aborting the whole
* settle pass (the "settle/reserve one booking at a time" symptom). The locked
* write MUST run inside dataSource.transaction.
*/
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
};
const build = (lookupResult: Record<string, unknown> | null) => {
const defaultManager = {
findOne: jest.fn().mockResolvedValue(lookupResult),
update: jest.fn().mockResolvedValue(undefined),
};
const txManager = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const transaction = jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{ manager: defaultManager, transaction } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never,
{} as never,
{} as never,
);
return { service, defaultManager, txManager, transaction };
};
const settled = await service.settlePayable(
it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
const { service, transaction, txManager, defaultManager } = build(openInvoice);
await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
"prepaid",
);
expect(settled).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
expect(transaction).toHaveBeenCalledTimes(1);
expect(txManager.findOne).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
);
expect(txManager.update).toHaveBeenCalled();
// The default manager only does the initial lock-free lookup, never a locked read.
for (const call of defaultManager.findOne.mock.calls) {
expect(call[1]).not.toHaveProperty("lock");
}
});
it("is a no-op (no transaction) when there is no open invoice", async () => {
const { service, transaction } = build(null);
const result = await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
expect(result).toBeNull();
expect(transaction).not.toHaveBeenCalled();
});
});

View File

@@ -1,15 +1,28 @@
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { Freight, PaymentReferenceType } from "@edr/types";
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { Invoice } from "./entities/invoice.entity";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { InvoiceRepository } from "./invoice.repository";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { CompaniesService } from "../companies/companies.service";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
} from "./documents/invoice-document.service";
import { InvoiceLine } from "./entities/invoice-line.entity";
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
import { InvoiceLineRepository } from "./invoice-line.repository";
import { nextDailyInvoiceNumber } from "./invoice-numbering.util";
import { applySettlement, round2 } from "./invoice-settlement.util";
import { InvoiceRepository } from "./invoice.repository";
/** Options forwarded to the payment gateway when settling an invoice. */
export interface PayInvoiceOptions {
@@ -20,13 +33,25 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
amount: number;
method?: string | null;
reference?: string | null;
/** When the settlement occurred; defaults to now. */
paidAt?: Date;
metadata?: Record<string, unknown> | null;
}
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
@@ -56,7 +81,11 @@ export interface GenerateInvoiceInput {
companyProfileId: string;
lines: InvoiceLineInput[];
currency?: string;
/** Explicit total; defaults to the sum of line amounts. */
/** Explicit pre-tax subtotal; defaults to the sum of line amounts. */
subtotalAmount?: number;
/** Tax applied on top of the subtotal; defaults to 0. */
taxAmount?: number;
/** Explicit total; defaults to `subtotalAmount + taxAmount`. */
totalAmount?: number;
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
dueAt?: Date;
@@ -95,7 +124,8 @@ export class BillingService {
@Inject(forwardRef(() => PaymentService))
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
) { }
private readonly invoiceDocuments: InvoiceDocumentService,
) {}
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -104,9 +134,56 @@ export class BillingService {
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/**
* Paginated invoice list for the backoffice — optionally narrowed to a
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
async findAllPaginated(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{ items: Invoice[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
@@ -115,6 +192,89 @@ export class BillingService {
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
}
// ── Documents (central PDF) ──────────────────────────────────────────────────
/** Sealed PDF invoice for any source, rendered by the shared document service. */
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "INVOICE"),
);
}
/** Sealed PDF receipt; available once any payment has been recorded. */
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
const invoice = await this.findById(id);
if (Number(invoice.paidAmount) <= 0) {
throw new BadRequestException(
"A receipt is available only after payment is recorded.",
);
}
return this.invoiceDocuments.render(
this.toDocumentModel(invoice, "RECEIPT"),
);
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
kind: "INVOICE" | "RECEIPT",
): InvoiceDocumentModel {
const title = invoice.source
? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1)
: "EDR";
const totals: InvoiceDocumentModel["totals"] = [
{ label: "Subtotal", amount: Number(invoice.subtotalAmount) },
];
if (Number(invoice.taxAmount) > 0) {
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
}
totals.push({
label: "Total",
amount: Number(invoice.totalAmount),
grand: true,
});
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
return {
kind,
title,
documentNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt ?? invoice.createdAt,
status: invoice.status,
currency: invoice.currency,
summary: [
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
{ label: "Currency", value: invoice.currency },
{
label: "Issued",
value: invoice.issuedAt
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
: null,
},
{
label: "Due",
value: invoice.dueAt
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
: null,
},
],
categoryHeader: "Charge type",
lines: invoice.lines.map((l) => ({
description: l.description ?? l.chargeType,
category: l.chargeType,
quantity: l.quantity,
unitRate: l.unitRate,
amount: l.amount,
currency: l.currency,
})),
totals,
};
}
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
/** Resolve the customer's company id from their IAM user id (null if none). */
@@ -127,19 +287,43 @@ export class BillingService {
}
}
/** Every invoice billed to a company, newest first, with billing relations. */
findByCompany(companyId: string): Promise<Invoice[]> {
/**
* Every invoice billed to a company, newest first, with billing relations.
* Optionally narrow to a single source record (e.g. a booking's invoices) via
* `{ source, sourceId }`.
*/
findByCompany(
companyId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
return this.invoices.findAll({
where: { companyId },
where: {
companyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
relations: { company: true, companyProfile: true },
order: { createdAt: "DESC" },
});
}
/** Invoices for a batch of source records (e.g. many last-mile legs), so a
* list can show which records already have an invoice without N+1 queries. */
findBySourceIds(source: string, sourceIds: string[]): Promise<Invoice[]> {
if (!sourceIds.length) return Promise.resolve([]);
return this.invoices.findAll({
where: { source, sourceId: In(sourceIds) },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(userId: string): Promise<Invoice[]> {
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId) : [];
return companyId ? this.findByCompany(companyId, filter) : [];
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
@@ -157,36 +341,43 @@ export class BillingService {
/**
* Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges whichever open invoice the source currently has
* (see {@link payInvoice}).
* ownership, then charges the invoice directly by ID (see {@link payInvoice}).
*/
async payInvoiceForUser(
id: string,
userId: string,
opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId);
return this.payInvoice(
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
await this.findByIdForUser(id, userId);
return this.payInvoice(id, opts);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.document(id);
}
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
async receiptForUser(
id: string,
userId: string,
): Promise<{ filename: string; buffer: Buffer }> {
await this.findByIdForUser(id, userId);
return this.receipt(id);
}
// ── Generation ───────────────────────────────────────────────────────────────
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
const now = new Date();
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
const prefix = `FRT-${ymd}-`;
const [row] = await mg.query(
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
FROM freight.invoices WHERE invoice_number LIKE $1`,
[`${prefix}%`],
);
const next = Number(row?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`;
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
return nextDailyInvoiceNumber(mg, {
table: "freight.invoices",
code: "INV",
});
}
/**
@@ -204,6 +395,7 @@ export class BillingService {
input: GenerateInvoiceInput,
manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run);
}
@@ -230,14 +422,17 @@ export class BillingService {
};
});
const totalAmount =
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
const subtotalAmount =
input.subtotalAmount ??
lines.reduce((sum, l) => sum + Number(l.amount), 0);
const taxAmount = input.taxAmount ?? 0;
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
const dueAt =
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
@@ -250,7 +445,12 @@ export class BillingService {
type: input.type,
companyId: input.companyId,
companyProfileId: input.companyProfileId,
totalAmount,
subtotalAmount: round2(subtotalAmount),
taxAmount: round2(taxAmount),
totalAmount: round2(totalAmount),
paidAmount: 0,
balanceAmount: round2(totalAmount),
payments: [],
currency,
status,
issuedAt: issued ? new Date() : null,
@@ -274,28 +474,182 @@ export class BillingService {
// ── State transitions ────────────────────────────────────────────────────────
/**
* Mark an invoice paid and link the gateway payment, then emit
* `${source}.invoice.paid`. Full-payment only — no partial settlement.
* No-op when the invoice is already paid. Pass `manager` to enlist in a
* caller's transaction.
* Run `fn` inside a transaction and only emit its returned domain event
* after commit. When the caller passes their own `manager`, they own commit
* timing — `fn`'s event fires inline as soon as it resolves (the outer
* transaction may still roll back afterwards; this is the caller's
* documented tradeoff). When no `manager` is given, this opens its own
* transaction and defers the emit until after that transaction commits, so
* listeners (e.g. booking advancement) can never observe an invoice change
* that then rolls back.
*/
private async runTransition<T>(
manager: EntityManager | undefined,
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
): Promise<T> {
if (manager) {
const { result, emit } = await fn(manager);
emit?.();
return result;
}
let pending: (() => void) | undefined;
const result = await this.dataSource.transaction(async (mg) => {
const out = await fn(mg);
pending = out.emit;
return out.result;
});
pending?.();
return result;
}
/**
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
* append the settlement to the `payments` ledger, link the gateway payment,
* then emit `${source}.invoice.paid`. Full-payment only — no partial
* settlement. No-op when the invoice is already paid. Pass `manager` to
* enlist in a caller's transaction; otherwise locks the row for update and
* emits only after commit (see {@link runTransition}).
*/
async markInvoiceAsPaid(
invoiceId: string,
paymentId: string | null = null,
manager?: EntityManager,
settlement: { providerTxnId?: string; paidAt?: Date } = {},
): Promise<Invoice | null> {
return this.transition(
invoiceId,
Freight.InvoiceStatus.Paid,
"paid",
{ paymentId: paymentId ?? undefined },
manager,
);
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
return { result: invoice };
}
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
const settledAmount = round2(
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
);
const entry: InvoicePayment = {
amount: settledAmount,
method: "GATEWAY",
reference: settlement.providerTxnId ?? paymentId ?? null,
paidAt: paidAt.toISOString(),
metadata: null,
};
const payments = [...(invoice.payments ?? []), entry];
const patch = {
status: Freight.InvoiceStatus.Paid,
paymentId,
paidAt,
paidAmount: invoice.totalAmount,
balanceAmount: 0,
payments,
};
await mg.update(Invoice, { id: invoiceId }, patch as never);
const updated = { ...invoice, ...patch } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent("paid", updated),
};
});
}
/**
* Record a (possibly partial) settlement against an invoice and sync its
* status. Appends to the `payments` ledger, recomputes `paidAmount` /
* `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the
* balance reaches zero — PAID, stamping `paidAt` and emitting
* `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash
* at the warehouse counter); gateway settlement goes through
* {@link markInvoiceAsPaid}.
*
* Throws when the invoice is missing, cancelled, refunded, already fully
* paid, `amount` is not positive, or `amount` exceeds the outstanding
* balance. Pass `manager` to enlist in a caller's transaction; otherwise
* locks the row for update and emits only after commit (see
* {@link runTransition}).
*/
async recordPayment(
invoiceId: string,
input: RecordPaymentInput,
manager?: EntityManager,
): Promise<Invoice> {
if (!(input.amount > 0)) {
throw new BadRequestException(
"Payment amount must be greater than zero.",
);
}
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Cannot pay a cancelled invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Refunded) {
throw new BadRequestException("Cannot pay a refunded invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
);
}
const at = input.paidAt ?? new Date();
const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount,
invoice.paidAmount,
input.amount,
);
const status = fullyPaid
? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = {
amount: round2(input.amount),
method: input.method ?? null,
reference: input.reference ?? null,
paidAt: at.toISOString(),
metadata: input.metadata ?? null,
};
const payments = [...(invoice.payments ?? []), entry];
const patch = {
paidAmount,
balanceAmount,
status,
payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
};
await mg.update(Invoice, { id: invoice.id }, patch as never);
const updated = { ...invoice, ...patch } as Invoice;
return {
result: updated,
emit: fullyPaid
? () => this.emitInvoiceEvent("paid", updated)
: undefined,
};
});
}
/**
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded.
* No-op when already refunded. Throws when the invoice has no recorded
* payment (nothing to refund).
*/
async markInvoiceAsRefunded(
invoiceId: string,
@@ -307,12 +661,20 @@ export class BillingService {
"refunded",
{},
manager,
(invoice) => {
if (!(Number(invoice.paidAmount) > 0)) {
throw new BadRequestException(
"Cannot refund an invoice with no recorded payment.",
);
}
},
);
}
/**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled.
* No-op when already cancelled. Throws when the invoice has payments
* recorded against it (refund it instead).
*/
async cancelInvoice(
invoiceId: string,
@@ -324,16 +686,23 @@ export class BillingService {
"cancelled",
{},
manager,
(invoice) => {
if (Number(invoice.paidAmount) > 0) {
throw new BadRequestException(
"Cannot cancel an invoice that has payments recorded against it.",
);
}
},
);
}
/**
* Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
* in the target status. Throws when the invoice does not exist.
*
* Note: the event fires in-process synchronously. When a `manager` from an
* outer transaction is passed, listeners run before that transaction commits.
* `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
* when it is already in the target status. Throws when the invoice does not
* exist or `guard` rejects the current state. Pass `manager` to enlist in a
* caller's transaction; otherwise locks the row for update and emits only
* after commit (see {@link runTransition}).
*/
private async transition(
invoiceId: string,
@@ -341,17 +710,27 @@ export class BillingService {
event: string,
extra: { paymentId?: string },
manager?: EntityManager,
guard?: (invoice: Invoice) => void,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.status === status) return invoice;
return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId },
lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === status) return { result: invoice };
guard?.(invoice);
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated);
return updated;
const updated = { ...invoice, ...extra, status } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent(event, updated),
};
});
}
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
@@ -369,22 +748,29 @@ export class BillingService {
status: invoice.status,
paymentId: invoice.paymentId ?? null,
};
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
this.events
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
.catch((err) =>
this.logger.error(
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
),
);
}
// ── Payment reconciliation (by source) ───────────────────────────────────────
/**
* The invoice a gateway payment should settle for a source record, or null if
* none. This is the billing document of record for "what is owed" — callers
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
* recomputing from the source's own total, so discounts/penalties/adjustments
* carried on the invoice are honored.
* The invoice a source record already has open, or null if it needs a new
* one. This is the idempotency check every `ensureInvoiceFor*` (booking,
* first-mile, last-mile) runs before generating — it must see DRAFT
* invoices too, not just issued ones, otherwise a source that already has
* an unissued draft gets a second, duplicate invoice minted alongside it
* instead of that draft being reused and then issued.
*
* Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid,
* non-cancelled) invoice.
* invoice is currently open. Returns the most recent matching draft-or-open
* (unpaid, non-cancelled) invoice.
*/
findPayable(
source: Freight.InvoiceSource,
@@ -395,7 +781,7 @@ export class BillingService {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
@@ -403,74 +789,140 @@ export class BillingService {
}
/**
* Settle a source's currently-open invoice as paid and link the gateway
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
* settlement. No-op (returns null) when the source has no open invoice.
*
* Type-blind by design: settles whichever invoice is due; any per-type reaction
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
* Pass the caller's transaction `manager` to enlist in its DB transaction.
*
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
* for callers that settle by source rather than by gateway intent id.
* Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid,
* non-cancelled) invoice.
*/
async settlePayable(
findInvoice(
source: Freight.InvoiceSource,
sourceId: string,
paymentId: string | null,
manager?: EntityManager,
type?: string,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) },
return this.dataSource.getRepository(Invoice).findOne({
where: {
source,
sourceId,
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
}
/**
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
* No-op (returns null) when the source has no paid invoice.
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
* (already paid/cancelled/expired).
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
* the batch engine) to enlist in its DB transaction.
*/
async refundPayable(
async expirePayable(
source: Freight.InvoiceSource,
sourceId: string,
type?: string,
manager?: EntityManager,
): Promise<Invoice | null> {
// Lookup can use the default manager (no lock). But the pessimistic-lock write
// inside `transition` NEEDS an open transaction: pass the caller's `manager`
// through untouched (undefined when there is no caller txn) so `runTransition`
// opens its own. Passing `this.dataSource.manager` here made `runTransition`
// treat it as an already-open transaction and skip wrapping — the lock then
// threw `An open transaction is required for pessimistic lock`, aborting the
// whole settle pass (the "reservations settle/reserve one at a time" symptom).
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
return this.transition(
invoice.id,
Freight.InvoiceStatus.Expired,
"expired",
{},
manager,
);
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
*/
async syncPayableDueDate(
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
}
/**
* Force an invoice to `status`, including issuing a still-DRAFT invoice
* (stamping `issuedAt`) — unlike the other transitions here, this is a
* blunt admin/workflow override, not a settlement. No-op when the invoice
* is missing or already terminal (paid/cancelled/refunded/expired).
*/
async updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
id: invoiceId,
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
},
});
if (!invoice) return;
await mg.update(
Invoice,
{ id: invoice.id },
{ status, issuedAt: invoice.issuedAt ?? new Date() },
);
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**
* Charge a source's open invoice through the payment gateway. Billing is the
* single place that turns "what is owed" (the invoice) into a payment intent —
* the domain never talks to the payment service directly. Resolves the open
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on
* the invoice (the settlement correlation key), and returns the client action.
* Charge an invoice through the payment gateway. Billing is the single place
* that turns "what is owed" (the invoice) into a payment intent — the domain
* never talks to the payment service directly. Resolves the invoice by ID,
* opens an intent for `invoice.balanceAmount` (so partial payments are honored),
* records the intent id on the invoice (the settlement correlation key), and
* returns the client action.
*
* When the provider settles synchronously, the invoice is settled inline here —
* after the intent id is stored — so the `payment.succeeded` correlation can
* never fire before the link exists. Throws when the source has no open invoice.
* never fire before the link exists. Throws when the invoice is not found or
* not in an open/payable status.
*/
async payInvoice(
source: Freight.InvoiceSource,
sourceId: string,
invoiceId: string,
opts: {
method?: string;
platform?: "web" | "mobile";
@@ -479,21 +931,31 @@ export class BillingService {
failureUrl?: string;
} = {},
): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId);
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
});
if (!invoice) {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
throw new NotFoundException(
`Invoice ${invoiceId} not found or not in a payable status`,
);
}
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");
}
const result = await this.payment.initiate({
referenceId: sourceId,
referenceId: invoice.sourceId,
source: invoice.source,
// Gateway reference type derives from the invoice source by convention
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
// the domain never supplies it. New sources add their uppercased value to
// the PaymentReferenceType enum.
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
// Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice
// link is the intent id (`paymentId`); per-source post-payment reactions live
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace("-", "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -502,12 +964,27 @@ export class BillingService {
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
//
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
if (result.immediateSuccess) {
await this.settleByPaymentId(
result.intentId,
@@ -528,8 +1005,8 @@ export class BillingService {
*/
async settleByPaymentId(
paymentId: string,
_providerTxnId?: string,
_paidAt?: Date,
providerTxnId?: string,
paidAt?: Date,
): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) },
@@ -537,6 +1014,9 @@ export class BillingService {
});
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId);
return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
providerTxnId,
paidAt,
});
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { InvoiceDocumentService } from "./invoice-document.service";
import { PdfRenderService } from "./pdf-render.service";
/**
* Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the
* billing payment graph.
*/
@Module({
providers: [PdfRenderService, InvoiceDocumentService],
exports: [PdfRenderService, InvoiceDocumentService],
})
export class DocumentsModule {}

View File

@@ -0,0 +1,315 @@
import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service";
import {
PdfColor,
assembleSinglePagePdf,
lineOp,
rectOp,
sealOp,
textOp,
textOpRight,
wrapText,
} from "./styled-pdf.util";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
/** One billed line on the document (charge type / fee type agnostic). */
export interface InvoiceDocumentLine {
description: string | null;
/** Optional categorisation column (e.g. "Fee type" / "Charge type"). */
category?: string | null;
quantity?: number | null;
unitRate?: number | null;
amount?: number | null;
currency?: string | null;
}
/** A labelled total row in the totals box; mark `grand` for the headline total. */
export interface InvoiceDocumentTotal {
label: string;
amount: number;
grand?: boolean;
}
/**
* Source-agnostic description of a printable invoice/receipt. Each billing
* source maps its own entity onto this shape; the renderer owns the layout so
* every EDR invoice document looks identical regardless of source.
*/
export interface InvoiceDocumentModel {
kind: InvoiceDocumentKind;
/** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */
title: string;
documentNumber: string;
issuedAt?: Date | string | null;
status: string;
currency: string;
/** Free-form summary grid (label/value pairs). */
summary: Array<{ label: string; value: string | null }>;
/** Header for the line-item category column; column hidden when omitted. */
categoryHeader?: string;
lines: InvoiceDocumentLine[];
totals: InvoiceDocumentTotal[];
/** Override the round seal text; defaults from kind/status. */
sealText?: string;
}
/**
* Central invoice/receipt PDF renderer shared by every billing source. Turns a
* {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it
* via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in
* `WarehouseInvoiceService`; it now serves all invoices.
*/
@Injectable()
export class InvoiceDocumentService {
constructor(private readonly pdf: PdfRenderService) {}
async render(
model: InvoiceDocumentModel,
): Promise<{ filename: string; buffer: Buffer }> {
const html = this.buildHtml(model);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, {
label: `${model.title} ${kindLabel}`,
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
}),
};
}
/**
* Vector-drawn styled invoice/receipt used when headless Chromium is
* unavailable. Mirrors the HTML layout closely enough to pass as the same
* document. Single A4 page; long summaries / line lists are capped to fit.
*/
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
const currency = (cur?: string | null) =>
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
const money = (amount: unknown, cur?: string | null) =>
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const showCategory = Boolean(model.categoryHeader);
const ops: string[] = [];
// ── Header ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
const titleSize = heading.length > 34 ? 18 : 22;
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
ops.push(
textOpRight(
`Status ${model.status}`,
559,
748,
8.5,
"F1",
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
),
);
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
// ── Seal ──────────────────────────────────────────────────────────────
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
// ── Summary grid (two columns) ────────────────────────────────────────
let y = 700;
const colX = [36, 300];
const colW = 250;
model.summary.slice(0, 16).forEach((row, i) => {
const x = colX[i % 2];
if (i % 2 === 0 && i > 0) y -= 27;
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
});
y -= 34;
// ── Line-item table ───────────────────────────────────────────────────
const qtyR = 402;
const rateR = 486;
const amtR = 555;
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
if (showCategory) {
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
}
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
y -= 18;
const descChars = showCategory ? 44 : 66;
for (const item of model.lines) {
if (y < 190) break; // leave room for totals + footer
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
const rowH = Math.max(18, descLines.length * 10 + 8);
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
descLines.forEach((line, k) => {
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
});
if (showCategory) {
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
}
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
y -= rowH;
}
// ── Totals ────────────────────────────────────────────────────────────
let ty = y - 16;
for (const total of model.totals) {
if (ty < 88) break;
if (total.grand) {
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
ty -= 24;
} else {
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
ty -= 17;
}
}
// ── Footer ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
return assembleSinglePagePdf(ops);
}
/** Truncate to `max` chars with an ellipsis. */
private clip(value: string, max: number): string {
const text = String(value ?? "");
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) =>
String(value ?? "-")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const money = (amount: unknown, currency = model.currency) =>
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
.join("");
const itemRows = model.lines
.map(
(item) => `<tr>
<td>${esc(item.description)}</td>
${showCategory ? `<td>${esc((item.category ?? "").replace(/_/g, " "))}</td>` : ""}
<td class="num">${esc(item.quantity ?? 0)}</td>
<td class="num">${esc(money(item.unitRate, item.currency ?? model.currency))}</td>
<td class="num">${esc(money(item.amount, item.currency ?? model.currency))}</td>
</tr>`,
)
.join("");
const totalRows = model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
)
.join("");
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</title>
<style>
body { font-family: Arial, sans-serif; color: #0f172a; margin: 0; }
.doc { padding: 16px 8px; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 16px; }
.brand { font-size: 13px; color: #475569; text-transform: uppercase; letter-spacing: .08em; }
h1 { margin: 8px 0 0; font-size: 30px; }
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 18px; }
th { text-align: left; background: #f8fafc; color: #475569; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; font-size: 12px; }
td.num, th.num { text-align: right; }
.totals { margin-left: auto; width: 330px; margin-top: 18px; }
.total-row { display: flex; justify-content: space-between; border-bottom: 1px solid #e2e8f0; padding: 8px 0; font-size: 13px; }
.grand { font-size: 16px; font-weight: 800; }
.footer { margin-top: 34px; display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}</h1>
</div>
<div class="meta">
Document no.
<strong>${esc(model.documentNumber)}</strong>
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>
<tr>
<th>Description</th>
${showCategory ? `<th>${esc(model.categoryHeader)}</th>` : ""}
<th class="num">Qty</th>
<th class="num">Rate</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
${itemRows}
</tbody>
</table>
<div class="totals">${totalRows}</div>
<div class="footer">
<div class="line">Prepared by EDR finance</div>
<div class="line">Authorized seal / signature</div>
</div>
</div>
</body>
</html>`;
}
safeFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
}
}

View File

@@ -0,0 +1,160 @@
import { existsSync } from "fs";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="edr-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
</style>`;
export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */
label?: string;
/**
* Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
* header). When omitted, a generic single-page fallback is produced.
*/
fallback?: (preparedHtml: string) => Buffer;
}
/**
* Generic HTML → PDF renderer shared by every document producer (invoices,
* receipts, warehouse release orders). Renders via headless Chromium when
* available and degrades to a caller-supplied (or generic) hand-built PDF
* otherwise. This is pure infrastructure — it knows nothing about invoices.
*/
@Injectable()
export class PdfRenderService {
private readonly logger = new Logger(PdfRenderService.name);
async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise<Buffer> {
const label = opts.label ?? "document";
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import("puppeteer");
const launchOptions: import("puppeteer").LaunchOptions = {
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
await page.emulateMediaType("print");
await new Promise((resolve) => setTimeout(resolve, 250));
const pdf = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`);
}
this.logger.log(
`${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (error) {
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
if (this.isValidPdf(fallback)) {
this.logger.warn(
`Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`,
);
return fallback;
}
throw new InternalServerErrorException(
`${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`,
);
}
}
private injectPdfPrintStyles(html: string): string {
if (html.includes("edr-pdf-print-fix")) return html;
if (html.includes("</head>")) {
return html.replace("</head>", `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
];
return candidates.find((path) => existsSync(path));
}
isValidPdf(buffer: Buffer): boolean {
return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-";
}
/** Minimal valid one-page PDF carrying a plain-text rendering of the document. */
private genericFallbackPdf(html: string): Buffer {
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 900);
const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40);
const stream =
"BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" +
lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") +
"ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n";
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
}

Some files were not shown because too many files have changed in this diff Show More