EDR Passenger Platform — E2E Test Matrix (Phase 1 deliverable)
Goal: find real issues, prioritizing pricing integrity and backoffice configuration.
Status: DRAFT for review. No tests written yet. Nothing runs against production.
Two systems were discovered that shape everything below:
- Two parallel fare systems.
fare-engine (integer "minor" math) is the live pricing pipeline. configurable-fare (raw-SQL, fare_configurations) is fully built but never called by the live path (fare-engine.calculate never reads fare_configurations). Assumption for this matrix: we target fare-engine as the system of record and treat configurable-fare as dormant (test only that it is not wired in). ⚠️ Confirm.
- The domain seed is disabled. Every step in
prisma/seed.ts main() (~L894) is commented out — pnpm prisma:seed creates nothing. The harness must re-enable/call the seeders or build fixtures.
Legend for Predicted: 🔴 = looks like a confirmed defect from static read (test will document/repro), 🟠 = suspicious, needs runtime verification, 🟢 = expected to pass (guard/happy-path).
The master invariant (Suite A drives everything)
For every booking flow, assert the chain is equal at every hop:
Any inequality is a finding. The explorers show this chain is broken by design in several places (client-supplied totals, pay-time recompute+overwrite, four different currency-conversion routines).
Suite A — Pricing integrity & client-trust (API-level, HIGHEST PRIORITY)
| ID |
Scenario |
Expected |
Targets (file:line) |
Predicted |
| A1 |
Book with reviewedTotalMinor: 1 on a real fare |
Server rejects / overrides with computed fare |
bookings.service.ts:863-895 |
🔴 books for 1 |
| A2 |
Book with every seatFareMinor: 0 |
Reject / override |
bookings.service.ts:863 |
🔴 books for 0 |
| A3 |
Round-trip with forged returnSeatFareMinor |
Reject / override |
bookings.service.ts:1065-1095 |
🔴 |
| A4 |
Guest booking with forged total |
Reject / override |
guest-booking.service.ts:206-245,494-540 |
🔴 |
| A5 |
loyaltyRedemptionPoints: 999999 on a 0-point account |
Reject; no discount; no negative total |
bookings.service.ts:1028; bookings.dto.ts:155 |
🔴 total→0, no deduction |
| A6 |
Confirm displayed==stored==intent==charged for a clean one-way ETB booking |
All equal |
whole chain |
🟠 baseline |
| A7 |
Same cross-check for USD/DJF display currency |
All equal, correct rounding |
payments.service.ts:250-267 |
🟠 DJF rounding suspect |
| A8 |
initiatePayment overwrites booking.totalMinor at pay time |
Read path must not mutate order amount |
payments.service.ts:167-185,209-218 |
🔴 mutates DB on read |
| A9 |
Payment intent amountMinor field carries major units across service boundary |
Consistent unit contract |
payments.service.ts:272-281 |
🟠 unit-confusion |
Suite B — Fare computation correctness (integration against fare-engine)
| ID |
Scenario |
Expected |
Targets |
Predicted |
| B1 |
insuranceFeeMinor semantics: multiplier vs flat fee |
One consistent meaning |
fare-engine.service.ts:130,154,167 vs schema:95 |
🔴 two meanings, same column |
| B2 |
Unit scale: /100 in code vs "×100000" schema comment |
Documented, consistent |
fare-engine.service.ts:129,153 vs schema:93 |
🟠 1000× ambiguity |
| B3 |
INTERNATIONAL 2× surcharge across all 4 fare sources |
Applied consistently |
fare-engine.service.ts:120,141 (missing in route/seat-class) |
🔴 inconsistent |
| B4 |
Global (tripId=null) FareRule that wins priority |
Used |
fare-engine.service.ts:139 |
🔴 matched then ignored |
| B5 |
Overlapping segment/schedule fare rules, no orderBy |
Deterministic pick |
fare-engine.service.ts:84; schema:1113 |
🔴 arbitrary DB order |
| B6 |
Free-child rule consistency: quote vs booking vs package |
Same rule everywhere |
fare-engine.service.ts:172 vs bookings.service.ts:1690 vs :1622 |
🔴 3 divergent rules |
| B7 |
Package round-trip child fare round(adult × 0.1) float |
Integer, single rule |
payments.service.ts:135,180; bookings.service.ts:34-40 |
🔴 float, 3rd rule |
| B8 |
Distance from nullable distanceKm float subtraction |
Guarded, integer-safe |
fare-engine.service.ts:46 |
🟠 |
Suite C — Currency / FX
| ID |
Scenario |
Expected |
Targets |
Predicted |
| C1 |
Missing USD→ETB rate row |
Reject / block, not silent 1.0 |
currency.service.ts:142-147 |
🔴 prices at parity, display path only warns |
| C2 |
Missing rate: display path returns 1.0 but charge path throws |
Same behavior both paths |
currency.service.ts:142-147 vs :108 |
🔴 divergence |
| C3 |
Future-dated FX rate |
Not applied until effective |
currency.service.ts:88-99,137 (no <= now filter) |
🔴 applies immediately |
| C4 |
Stale FX (>2 days) |
Blocked or refreshed |
currency.service.ts:149-154 |
🟠 only warns, still used |
| C5 |
Four conversion routines produce same result for same inputs |
Identical rounding |
fare-engine:196, currency:61,78, payments:733 |
🔴 divergent |
| C6 |
DJF (0-decimal) display vs charge rounding |
Consistent whole-franc |
format.ts:22-28 vs currency.service.ts:9-13 |
🔴 UI shows 2 decimals |
Suite D — Promos
| ID |
Scenario |
Expected |
Targets |
Predicted |
| D1 |
percentOff: 200 |
Reject (max 100) / clamp total at 0 |
promos.dto.ts:20; fare-engine.service.ts:185-192 |
🔴 negative total |
| D2 |
amountOffMinor > subtotal |
Clamp at 0 |
promos.dto.ts:26; fare-engine.service.ts:187,192 |
🔴 negative total |
| D3 |
Reuse one promo N times / across users |
Usage-limit enforced |
bookings.service.ts:1023-1029; no limits in schema |
🔴 unlimited |
| D4 |
percentOff: 0 legit promo |
Applies as 0%, not mislabeled FIXED |
fare-engine.service.ts:185; promos.service.ts:172 |
🟠 falsy bug |
| D5 |
validUntil as arbitrary string / past date |
Reject invalid, no dead promo |
promos.dto.ts:29-30 (@IsString) |
🔴 accepts Invalid Date |
| D6 |
Promo min-spend / max-cap |
Enforced |
schema:785 (fields absent) |
🔴 none exist |
Suite E — Excess baggage & supplementary charges
| ID |
Scenario |
Expected |
Targets |
Predicted |
| E1 |
Excess-baggage rate lookup by seat class |
Uses booking's class allowance |
excess-baggage.service.ts:53 (oldest global row) |
🔴 wrong allowance |
| E2 |
Baggage/supp charge to payment: /100 major units, DJF |
Per-currency rounding, correct unit |
excess-baggage.service.ts:166; supplementary-charges.service.ts:132 |
🔴 no conversion/rounding |
| E3 |
Negative maxWeightKg/maxPiecesCount allowance |
Reject |
excess-baggage.controller.ts:15-16 (no @Min) |
🔴 accepts negative |
| E4 |
markPaid stores providerTxnId |
Persisted |
excess-baggage.service.ts:186 |
🟠 discarded |
Suite F — Wallet & loyalty
| ID |
Scenario |
Expected |
Targets |
Predicted |
| F1 |
Top up another passenger's wallet with your JWT |
403 |
wallet.controller.ts:34-39 (no ownership check) |
🔴 credits freely |
| F2 |
Wallet top-up has payment backing |
Backed by real payment |
wallet.service.ts:50-56 |
🔴 free money |
| F3 |
GET /wallet/accounts public |
Auth required |
wallet.controller.ts:23-24 (isPublic) |
🔴 leaks balances |
| F4 |
Two concurrent WALLET bookings draining one balance |
One fails, no negative |
payments.service.ts:461-484 (no row lock) |
🔴 double-spend |
| F5 |
Loyalty accrual on non-ETB charge |
Points from actual charge currency |
payments.service.ts:1062,1067 |
🟠 uses ETB minor always |
| F6 |
Loyalty redemption deducts points / has balance |
Deducted, capped |
bookings.service.ts:1028 |
🔴 never deducted (=A5) |
Suite G — Booking/payment lifecycle & webhooks
| ID |
Scenario |
Expected |
Targets |
Predicted |
| G1 |
Webhook confirmedAmount < booking total (partial) |
Not confirmed |
intents.service.ts:541-548 |
🔴 confirms, mismatch only logged |
| G2 |
Pay a booking >20 min after creation (expired/cancelled) |
Reject |
payments.service.ts:809-848; bookings.service.ts:2123 |
🔴 re-confirms, re-issues tickets |
| G3 |
Duplicate webhook |
Idempotent |
webhook-processor.service.ts:44-58 |
🟢 handled |
| G4 |
Cancel a CONFIRMED booking → refund disbursed |
Refund paid to wallet/provider |
bookings.service.ts:2017-2027 |
🔴 stuck PENDING forever |
| G5 |
Refund amount floor(total × 0.8) flat |
Correct tiered policy |
bookings.service.ts:2021 |
🟠 flat 80%, float |
| G6 |
Seat-hold TTL (config) vs pending-expiry cron (hardcoded 20m) |
Consistent |
seats.service.ts:271 vs bookings.service.ts:2123 |
🔴 mismatch |
| G7 |
Payment amount validated against booking anywhere |
Validated |
passenger-api + payment-api |
🔴 never |
| G8 |
Booking create + seat confirm + tier increment atomic |
Single transaction |
bookings.service.ts:883-926 |
🟠 not atomic |
| G9 |
forceConfirmPayment admin-guarded |
Admin only |
payments.service.ts:989 |
🟠 verify guard |
Suite H — Backoffice config validation gaps (API-level, direct-to-API bypassing UI)
| ID |
Scenario |
Expected |
Targets |
Predicted |
| H1 |
Negative baseFareMinor fare rule |
Reject |
schedules.dto.ts:85,95 (no @Min) |
🔴 accepts (sibling DTO has @Min) |
| H2 |
Negative/zero seat-class basePrice |
Reject |
seat-classes.dto.ts:29 |
🔴 accepts |
| H3 |
Past departureAt schedule |
Reject |
schedules.service.ts:105 |
🔴 accepts |
| H4 |
Same train, two routes, overlapping time (same day) |
Reject double-booking |
schedules.service.ts:124-132 |
🔴 accepts |
| H5 |
Fare rule validUntil < validFrom; overlapping windows |
Reject |
schedules.dto.ts; no ordering/overlap check |
🔴 accepts |
| H6 |
Duplicate station code |
Reject (P2002) |
stations.service.ts:57-61 |
🟠 no catch (verify schema unique) |
| H7 |
PATCH /config arbitrary key/value (e.g. seat_hold_duration_minutes:-1) |
Validated |
system-config.controller.ts:34 (no DTO) |
🔴 stored raw |
| H8 |
Unsupported currency code (outside ETB/USD/DJF enum) |
400 not 500 |
currencies.dto.ts:5; currencies.service.ts:55 |
🟠 |
| H9 |
Station lat/lng out of ±90/±180 |
Reject |
stations.dto.ts:9-10 |
🟠 |
Suite I — Config propagation & delete/disable semantics
| ID |
Scenario |
Expected |
Targets |
Predicted |
| I1 |
Change exchange rate in backoffice → portal reflects it |
Propagates (note 5-min staleTime) |
portal/useCurrencies.ts:20 |
🟠 up to 5 min stale |
| I2 |
Change a fare in backoffice → next search reflects it |
Live (no server cache) |
fare-engine.service.ts:29,59 |
🟢 no cache |
| I3 |
Delete a station referenced by bookings |
Blocked or safe |
stations.service.ts:110-137 (ignores bookings) |
🔴 orphan/FK risk |
| I4 |
Delete a seat-class referenced by bookings/bookingSeat |
Blocked or safe |
seat-classes.service.ts:53-81 |
🔴 ignores bookings |
| I5 |
Schedule cascade delete fails midway |
Transactional, no partial delete |
schedules.service.ts:438-485 |
🟠 non-transactional |
| I6 |
Delete currency with active fares/rates |
Blocked |
currencies.service.ts:119-134 |
🔴 wipes rates → 1.0 fallback |
| I7 |
Config change mid-flight (edit/disable fare between quote and pay) |
Defined behavior |
booking freezes at create; pay never re-quotes |
🟠 client-trusted gap |
Suite J — Auth / authorization gaps
| ID |
Scenario |
Expected |
Targets |
Predicted |
| J1 |
Unauthenticated PUT/PATCH /fare-engine/exchange-rates |
401 |
fare-engine/currency.controller.ts:25,32 (no guard) |
🔴 anyone rewrites FX |
| J2 |
Non-admin authenticated user CRUDs /admin/fare-configurations |
403 |
configurable-fare.controller.ts (@Roles dead) |
🔴 RolesGuard never wired |
| J3 |
Non-admin CRUDs /admin/segment-fares |
403 |
segment-fare.controller.ts:15 |
🔴 |
| J4 |
Non-admin reads/writes /config |
403 |
system-config.controller.ts:23,32 |
🔴 |
| J5 |
Public exposure of /search, /currencies, /wallet/accounts |
Intended-public only |
search.controller.ts, wallet.controller.ts:24 |
🟠 balances shouldn't be public |
Suite K — Browser E2E (Playwright, portal + backoffice)
| ID |
Scenario |
Expected |
Layer |
| K1 |
Portal: search → results price == API displayAmountMinor |
UI math matches server |
portal (fare-utils.ts, results/page.tsx:609) |
| K2 |
Portal: review page total == what booking stores == charged |
No client-side divergence |
portal (review/page.tsx:160-181,478) |
| K3 |
Portal: DJF fare rendered whole-franc, matches charge |
Correct formatting |
format.ts:22-28 |
| K4 |
Backoffice: create fare → portal search shows new price |
End-to-end propagation |
backoffice→portal |
| K5 |
Backoffice: disable station → disappears from portal search |
Honored |
backoffice→portal |
| K6 |
Backoffice: create promo → apply in portal → correct discount, no negative |
End-to-end |
backoffice→portal |
| K7 |
Full happy-path booking (WALLET) through portal to ticket |
Issued, amounts consistent |
portal+api |
Harness plan (Phase 2 preview)
- API tests (supertest): reuse the
payments.e2e-spec.ts fixture-builder pattern (full Prisma object graph + teardown). Most target endpoints are isPublic, so auth is cheap. Wire a real config (test/jest-e2e.json currently won't even pick up in-src *.e2e-spec.ts).
- Browser tests (Playwright): greenfield — add runner + config. Portal has no server-side auth gate; backoffice needs
auth_token cookie + localStorage seeded.
- Payments: WALLET is fully offline-testable. Gateway flows driven by POSTing directly to
/webhooks/<provider> on payment-api (Telebirr/CBE/eBirr have loose signature gating; Card/Waafi need valid HMAC). SERVICE_AUTH_TOKEN unset in dev = internal endpoints unguarded.
- Seed: re-enable
prisma/seed.ts steps or invoke seeder fns from a test bootstrap. Needs stations, routes+stops (distanceKm), schedules, seat classes, fare rules, FX rates, promos.
- DB: resolved — the platform is ONE database, schema-separated. Target is
localhost:5432/edr_database?schema=passenger (as .env.example already said); the stale postgres-passenger:5434/edr_passenger claim is gone from CLAUDE.md. infrastructure/docker/docker-compose.db.dev.yml provisions it for dev; the e2e harness keeps its own hermetic Postgres on 5544.
Open decisions (blocking Phase 2)
- Environment — is there a dev/staging DB + running stack I should target, or should the harness stand up a local Postgres (Docker) + seed + run the APIs itself?
- Fare system — confirm
fare-engine is the system of record and configurable-fare is dormant.
- Emphasis — API-level abuse/integration tests (fast, high signal, covers ~90% of the leads above) vs. also full browser Playwright E2E (Suite K, slower, needs both web apps running).