# 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:

```
portal displayed price  ==  API fare-quote  ==  amount stored on booking (totalMinor/displayTotalMinor)
                        ==  amount sent to payment-api (intent)  ==  amount actually charged (webhook)
                        ==  amount used for loyalty accrual  ==  refund basis on cancel
```

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:** ⚠️ doc drift — CLAUDE.md says `postgres-passenger:5434/edr_passenger`; actual `.env.example` says `localhost:5432/edr_database?schema=passenger`; no compose file provisions it. **Need target confirmed.**

## Open decisions (blocking Phase 2)

1. **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?
2. **Fare system** — confirm `fare-engine` is the system of record and `configurable-fare` is dormant.
3. **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).
