Adding all the tests and fixes to the passengers app

This commit is contained in:
Muluhabt
2026-07-21 13:45:49 +03:00
parent 8ce2d2d874
commit f2ae9c883f
3316 changed files with 15548 additions and 37 deletions

View File

@@ -34,6 +34,21 @@ Two structural facts frame everything:
≥ 30000. Matrix A1A4.
- **Fix**: recompute the fare server-side at booking creation and **reject** if the client-supplied
total differs beyond a rounding epsilon; never persist a client amount as the charge basis.
- **Resolution (authenticated paths)** ✅ — `bookings.service.ts` now guards both `createOneWayBooking`
and `createRoundTripBooking` with `assertTotalNotUnderAuthoritative(resolvedTotalMinor,
fareCalculation.totalMinor)`: a booking whose ETB charge basis falls below the server-recomputed
authoritative fare (net of promo/loyalty/free-child) by more than a 1% FX-rounding tolerance is
rejected with `BadRequestException` and nothing is persisted. It's a **floor** (not equality) so
legitimate berth surcharges — which only raise the total — still pass. Proven by
`e2e-ui/specs/portal/ua13-forged-total.spec.ts` (now asserts a 4xx + no 1-minor booking; red before
the guard, green after).
- **Resolution (guest paths)** ✅ — `guest-booking.service.ts` now applies the identical
`assertTotalNotUnderAuthoritative` floor guard to both the one-way and round-trip guest booking
creation paths (authoritative ETB fare captured before the client-driven per-seat/reviewed branches
overwrite the total). Proven by `e2e-ui/specs/guest/ua14-forged-seat-fare.spec.ts` (forged
`seatFareMinor=0` + `reviewedTotalMinor=0` now rejected with a 4xx and no 0-minor booking persisted;
red before the guard, green after). C-1 is now closed on all four booking-creation paths
(authenticated one-way/round-trip + guest one-way/round-trip).
### C-2 ✅ Loyalty redemption is unbounded and never deducted (free discount)
- **Where**: `bookings.service.ts:1705,1707` (and `:1028,:1249,:1451`); DTO `bookings.dto.ts:155`.
@@ -67,6 +82,18 @@ Two structural facts frame everything:
booking in full; short payments are undetectable. Matrix G1/G7.
- **Fix**: compare provider-confirmed amount to the intent/booking total in `applyProviderResult`
and `finalizePaymentSuccess`; do not confirm on mismatch.
- **Resolution (passenger side)** ✅ — `payments.service.ts` `handlePaymentEvent` (the consumer of
the payment service's `mark-paid` relay — the passenger-side settlement entry point) now compares
the provider-settled `event.amountMinor` against the booking's display-currency total
(`displayTotalMinor`, i.e. the amount the passenger was quoted) before materializing the intent or
finalizing. A short payment (below the expected amount beyond a 1% rounding tolerance) is refused
with `{ processed: false, reason: 'amount-mismatch' }` and the booking is left unconfirmed — no
ticket. Amount-only by design: the display↔charge-currency divergence for USD/DJF (UA-1b/2/3) is
tracked separately, so the guard compares against `displayTotalMinor` to stay correct for both ETB
and the currently-diverging currencies. Proven by `e2e-ui/specs/portal/ua15-telebirr-shortpay.spec.ts`
(forged `amountMinor:1` now leaves the booking unconfirmed; red before the guard, green after). The
**payment-side** `intents.service.ts` mismatch (`applyProviderResult`) lives in `edr-payment-api`
and is out of scope for the passenger-app fix.
### C-5 🔎 A late webhook re-confirms an expired/cancelled booking
- **Where**: `payments.service.ts:809-848` (`finalizePaymentSuccess` never reads `booking.status`);
@@ -99,27 +126,69 @@ Two structural facts frame everything:
- **Fix**: implement disbursement (wallet credit or provider refund) and move `refundStatus`
through `PROCESSING → COMPLETED`; reconcile stuck PENDING rows.
### C-8 🔎 Unauthenticated exchange-rate writes ✅ (guard metadata verified)
- **Where**: `fare-engine/currency.controller.ts:25` (`PUT`), `:32` (`PATCH`) — no `@UseGuards`;
only `:42` DELETE is `@PassengerAdmin()`.
- **Repro (verified)**: `auth-gaps.e2e-spec.ts``upsert`/`update` handlers have **0** guards,
`remove` has ≥1.
- **Expected**: FX writes are admin-only. **Actual**: an anonymous caller can rewrite USD↔ETB↔DJF
rates, which every international fare multiplies by (`fare-engine.service.ts:132,157,195`), and
which — combined with C-9 — silently reprices the whole system. Matrix J1.
### C-8 ✅ Exchange-rate writes are missing the ADMIN check (any passenger can rewrite FX) — CORRECTED
- **⚠️ Corrected by live testing** — the original claim (*unauthenticated* FX writes) was a **false
positive**: `@tria-plc/api-common`'s `SharedAuthModule` registers a **global `APP_GUARD` = JwtGuard**
(`shared-auth.module` `APP_GUARD`), so anonymous requests get **401**. The metadata-only J1 check
saw no *method-level* guard and wrongly concluded "unauthenticated". The real defect is
**authorization**, not authentication.
- **Where**: `fare-engine/currency.controller.ts:25` (`PUT`), `:32` (`PATCH`) — authenticated but
**no `@PassengerAdmin`** (only `:42` DELETE has it).
- **Repro (verified live)**: `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11) —
anon → **401**, but a **regular passenger token → 200** rewrites the live USD↔ETB rate.
- **Expected**: FX writes are admin-only. **Actual**: any logged-in user (incl. a passenger) can
rewrite USD↔ETB↔DJF rates, which every international fare multiplies by
(`fare-engine.service.ts:132,157,195`). Needs a valid login (not anonymous), so **HIGH, not
CRITICAL** — but a single passenger can still distort all international pricing. Same class as C-9.
- **Fix**: add `@PassengerAdmin()` (or `@PassengerStaff([currencies.manage])`) to `PUT`/`PATCH`.
- **Resolution** ✅ — `fare-engine/currency.controller.ts` now decorates both `@Put()` and
`@Patch(':id')` with `@PassengerAdmin()` + `@ApiBearerAuth('IAM-auth')`, matching the existing
`@Delete` handler. `@PassengerAdmin()` is the repo's established guard decorator (`JwtGuard` +
`PassengerPermissionGuard(admin)`) — no new auth code, and no `@edr/auth` placeholder needed since
the permission infra already exists and the seeded staff admin carries the permission. The sibling
`/currencies` write surfaces (`currency.controller.ts`, `currencies.controller.ts`) were already
guarded, so `/fare-engine/exchange-rates` was the sole gap. Proven by
`e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-11): anon → 401, regular passenger PUT
and PATCH → **403**, staff admin → 200; red before the guard, green after.
### C-9 🔎 `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired)
### C-9 `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired) — verified live
- **Where**: `common/roles.guard.ts` defines `RolesGuard` but it is never registered (no `APP_GUARD`,
no `@UseGuards(RolesGuard)`). So `@Roles(...)` is inert on:
`configurable-fare.controller.ts:20,111,187` (create/activate/delete fare configs + feature
no `@UseGuards(RolesGuard)`). The global `JwtGuard` (SharedAuthModule) does authN but NOT authZ, so
`@Roles(...)` is inert on: `configurable-fare.controller.ts:20,111,187` (fare configs + feature
toggle), `segments/segment-fare.controller.ts:15` (`/admin/segment-fares`),
`system-config.controller.ts:23,32` (`GET/PATCH /config`).
- **Expected**: these are admin-only. **Actual**: any authenticated IAM user (incl. a passenger who
obtained a token) can CRUD fare configuration and system config. Matrix J2J4.
- **Repro (verified live)**: a **regular passenger token**`PATCH /config` (`@Roles('ADMIN')`) →
**HTTP 200** (wrote admin-only system config); `POST /admin/fare-configurations` → 400 (reached DTO
validation, i.e. it passed the role guard). So any authenticated user bypasses the ADMIN gate.
- **Expected**: these are admin-only. **Actual**: any authenticated IAM user (incl. a passenger) can
CRUD fare configuration and system config. Matrix J2J4.
- **Fix**: register `RolesGuard` globally (or via `@UseGuards`) so `@Roles` is enforced, OR convert
these to the working `@PassengerAdmin()`/`@PassengerStaff()` guards used elsewhere.
### C-10 ✅ Authenticated `POST /bookings` is BROKEN (passengerId resolution regression)
- **Where**: `bookings.controller.ts:528-532` overrides `passengerId` with the JWT user id
(`req.user.id`, the iamUserId — "never trust the request body", added in commit `25fdf88a`).
`bookings.service.ts:773` resolves an iamUserId → Passenger ONLY when it is **non-UUID**. IAM user
ids are UUIDs, and registration creates `Passenger.id ≠ iamUserId` (`passenger-auth.service.ts:225`
— only `iamUserId` is set; `id` auto-generates). So the resolver never fires and `booking.create`
(`bookings.service.ts:905`) uses the iamUserId directly as `passengerId`.
- **Repro**: ✅ verified two ways — (1) live browser: the full UI booking flow returns **HTTP 400
P2003** on `Booking_passengerId_fkey` for a logged-in passenger whose `Passenger.id ≠ iamUserId`
(the realistic case); (2) deterministic API test `test/authed-booking-passengerid.e2e-spec.ts`
`create()` with a UUID iamUserId fails the FK, while `create()` with the real `Passenger.id`
succeeds (control). The UI suite only goes green because `seed-ui.ts` deliberately sets
`Passenger.id == iamUserId`.
- **Expected**: every IAM-authenticated passenger can book. **Actual**: every authenticated
`POST /bookings` fails with a foreign-key error; only the guest path (`/bookings/guest`, which
creates a fresh passenger) works. This is a **regression** — before `25fdf88a`, the controller
used the frontend-supplied `passengerId` (the real `Passenger.id`), which worked.
- **Fix**: resolve the passenger by iamUserId unconditionally (`passenger.findUnique({ where: {
iamUserId } })`) in the controller or service — drop the UUID-format gate at `bookings.service.ts:773`
— and pass the resolved `Passenger.id` to `booking.create`. (Keep the "don't trust the body"
intent; just translate the identity correctly.)
- **⚠️ Confirm the deployment window**: verify whether `25fdf88a` is already in production. If so,
authenticated bookings are down platform-wide; if it's only on `dev`, this is a pre-release blocker.
---
## HIGH — pricing is wrong or exploitable
@@ -137,6 +206,18 @@ Two structural facts frame everything:
the fare ~100×; `pricing-currency.e2e-spec.ts` (C2b) — silent 1.0 vs `getRateOrThrow` throwing.
- **Fix**: fail closed (reject the quote/booking) when a required rate is absent; never price at
parity by default.
- **Resolution** ✅ — `currency.service.ts` `getExchangeRate` no longer substitutes `1.0` on a missing
rate; it logs and throws `BadRequestException` (`No exchange rate configured for X->Y`), matching
`getRateOrThrow`. Fare pricing therefore fails closed: with the `USD→ETB` pair deleted the fare
engine (`fare-engine.service.ts:157`) throws, so the search returns **no priced class** for the
affected currency (the per-seat-class fare error is caught in `search.service.ts:1041`, so the trip
is listed without a fare rather than 500ing), and an authoritative `calculateFare` on the booking
path — which does not swallow the error — rejects the booking. No path prices at parity by default.
Proven by `e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (PB-10): with the rate present the
USD search returns a priced fare; with it deleted the search returns an empty `faresByClass` instead
of a ~100×-collapsed fare (red before the fix, green after). Note: `getExchangeRate` still resolves
only the *direct* rate (no inverse/bridge) — unifying it with `getRateOrThrow` is the separate H-3
cleanup; failing closed here is strictly safer than the old silent 1.0.
### H-3 ✅ Display path and charge path diverge on the same FX state (100×)
- **Where**: `getExchangeRate` (`:131`, no inverse fallback) vs `getRateOrThrow` (`:81`, inverse +
@@ -214,6 +295,37 @@ Two structural facts frame everything:
multi-child parties. Matrix B6/B7.
- **Fix**: one shared fare function used by quote, booking, and payment.
### H-13 ✅ A valid promo is silently dropped in the browser flow (customer overcharged)
- **Where**: `GET /search/fare-breakdown` (`search.service.ts:940-970`) computes the discount into a
SEPARATE `discountMinor` / discounted `totalMinor`, but returns per-passenger `displayFareMinor`
**undiscounted**. The review page (`portal/src/app/booking/review/page.tsx:587`) reduces the
per-passenger fares and sends their sum as `reviewedTotalMinor` — i.e. the **undiscounted
subtotal** — ignoring `discountMinor`. Promo only enters via the `?promoCode=` URL param (no UI
input).
- **Repro**: ✅ verified in-browser — `e2e-ui/specs/portal/ua8-promo-drop.spec.ts`: with a valid 10%
promo, the breakdown shows `discountMinor > 0` and `totalMinor < subtotalMinor`, yet the booking is
stored at the full `subtotalMinor`.
- **Expected**: the discounted total is booked and charged. **Actual**: the customer is charged full
price despite a valid promo — a silent overcharge (and a broken promo feature). Matrix D / UA-8.
- **Fix**: book the breakdown's discounted `totalMinor` (not the client-summed per-pax undiscounted
fares); or return discounted per-pax fares. Best combined with C-1 (server recomputes the
authoritative total, promo included, and rejects a client mismatch).
- **Resolution (authed one-way)** ✅ — `bookings.service.ts` `createOneWayBooking` now applies the
authoritative promo discount server-side. The portal still forwards `promoCode` in the booking body,
so `calculateFare` already computes `discountMinor` — the total-resolution branches simply never
subtracted it. When the total comes from a client-summed subtotal (per-seat sum or
`reviewedTotalMinor`, both undiscounted), the code now subtracts `fareCalculation.discountMinor`
(converted to display currency for the display total) so the stored/charged `totalMinor` =
`subtotal discount`. The engine-fallback branch already booked the discounted `totalMinor`, so it
is excluded (via a `usedClientSubtotal` flag) to avoid double-subtracting; no-op when no promo
applies (`discountMinor === 0`), so UA-11 (expired promo) and the non-promo specs are unaffected.
This composes with the C-1 floor guard: after the discount is applied the resolved total equals the
authoritative fare, so the guard passes. Proven by `e2e-ui/specs/portal/ua8-promo-drop.spec.ts`
(booking now stored at `subtotal discount`; red before the fix, green after). The **round-trip**
and **guest** paths share the same latent frontend drop but have no UI spec yet — tracked for a
follow-up; the guest service additionally still overrides its discounted total with
`reviewedTotalMinor` (see the PROMO REALITY note in `docs/ui-e2e-test-matrix.md`).
---
## MEDIUM — backoffice config accepts invalid data / unsafe deletes
@@ -225,6 +337,14 @@ Two structural facts frame everything:
- **Repro (verified)**: `config-validation.e2e-spec.ts` (H1/H2) — negative values pass validation;
the guarded sibling rejects them.
- **Fix**: add `@Min(0)` to every money DTO field.
- **Resolution (seat-class base price)** ✅ — `seat-classes.dto.ts` `CreateSeatClassDto.basePrice` and
`insuranceFeeMinor` now carry `@Min(0)`; because `UpdateSeatClassDto extends PartialType(...)` the
constraint applies to `PATCH /seat-classes/:id` too. A negative `basePrice` is rejected with 400 at
the DTO layer (matching the backoffice form's `min=0`), so it never reaches the DB. Proven by
`e2e-ui/specs/propagation/pb-config-propagation.spec.ts` (BC-7): `basePrice:-500` → 400, a valid
write still succeeds (red before the `@Min`, green after). The other money DTOs named above
(`schedules.dto.ts` `CreateFareRuleDto`/`CreateSegmentFareRuleDto.baseFareMinor`) are not exercised
by a UI spec and remain a follow-up for full M-1 closure.
### M-2 ✅ Promo bounds/date not validated
- **Where**: `promos.dto.ts:20` (`percentOff` no `@Max(100)`/`@Min(0)`), `:29` (`validUntil`
@@ -233,12 +353,26 @@ Two structural facts frame everything:
`validUntil:"not-a-real-date"` both pass.
- **Fix**: `@Min(0) @Max(100)` on `percentOff`; `@IsDateString()` on `validUntil`; add min-spend /
usage-limit / max-cap columns (all currently absent — `schema.prisma:785`).
- **Resolution (percentOff bounds)** ✅ — `promos.dto.ts` `CreatePromotionDto.percentOff` now carries
`@Min(0) @Max(100)` and `amountOffMinor` carries `@Min(0)`, so `POST /promos` with `percentOff:200`
is rejected with 400 at the DTO layer while a valid ≤100% promo still saves. Proven by
`e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-8): `percentOff:200` → 400, `percentOff:50`
→ 201 (red before the bounds, green after). The `validUntil` `@IsDateString` tightening and the
missing min-spend/usage-limit/max-cap columns remain a follow-up (not exercised by BC-8).
### M-3 🔎 `PATCH /config` accepts arbitrary unvalidated key/values
- **Where**: `system-config.controller.ts:34` (no DTO) → `system-config.service.ts:56-59` stores a
raw `Record<string,string>`. Setting `seat_hold_duration_minutes = -1` or `"abc"` is persisted.
Matrix H7.
- **Fix**: a whitelisted, typed DTO with per-key numeric/range validation.
- **Resolution** ✅ — `system-config.dto.ts` adds `UpdateSystemConfigDto`, a whitelisted body listing
every known config key, each `@Type(() => Number) @IsInt() @Min(...)` (seat-hold bounded 1..60,
throttle limits/TTLs `@Min(1)`, hour windows `@Min(0)`). The controller now accepts the DTO (so the
global whitelisting ValidationPipe strips unknown keys and enforces the ranges) and persists the
validated values back as strings. `PATCH /config {seat_hold_duration_minutes:"-1"}` (or `"abc"`) is
rejected with 400; a sane value still stores. Proven by
`e2e-ui/specs/backoffice/config-validation.spec.ts` (BC-9): `-1` → 400, `15` → stored (red before
the DTO, green after).
### M-4 🔎 Past-dated schedules accepted; train can be double-booked across routes
- **Where**: `schedules.service.ts:105` only checks `arrivalAt > departureAt` (no "future" check);

141
docs/SOLUTIONS.md Normal file
View File

@@ -0,0 +1,141 @@
# EDR Passenger — Solutions
Concrete fixes for the confirmed findings in `docs/ISSUES.md`. Ordered by priority. Each references
the exact site and the intended change. Code sketches are illustrative, not drop-in patches.
**Test coverage backing these:** 28 automated tests (25 API `jest` + 3 UI Playwright) reproduce the
✅ findings. Fix a finding → its 🔴 test flips from "bug present" to failing; update the test to
assert the corrected behavior.
---
## P0 — deploy blockers (money creation/theft, broken booking)
### C-10 — Authenticated `POST /bookings` is broken
`bookings.service.ts:773` resolve unconditionally; delete the UUID-format gate:
```ts
// BEFORE: resolves only when passengerId is NOT a UUID (never fires for real IAM ids)
if (dto.passengerId && !dto.passengerId.match(/^[0-9a-f-]{36}$/i)) { }
// AFTER: always translate the authenticated identity → the Passenger.id
if (dto.passengerId) {
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId: dto.passengerId }, select: { id: true },
});
if (passenger) dto = { ...dto, passengerId: passenger.id };
// else: leave as-is only if it already IS a Passenger.id (guest/admin paths)
}
```
Keep the controller's "don't trust the body" intent (`bookings.controller.ts:528`) — it's correct to
take identity from the JWT; the service just has to map iamUserId → Passenger.id. Regression test:
`test/authed-booking-passengerid.e2e-spec.ts`.
### C-1 — Booking total is client-controlled
`bookings.service.ts:863-899`: stop trusting `reviewedTotalMinor`/`seatFareMinor`. Recompute the fare
server-side and reject a mismatch:
```ts
const server = fareCalculation.totalMinor;
if (dto.reviewedTotalMinor != null && Math.abs(dto.reviewedTotalMinor - server) > 1) {
throw new BadRequestException('Price changed — please review the updated fare');
}
resolvedTotalMinor = server; // never persist a client amount as the charge basis
```
Apply the same to `guest-booking.service.ts:206-245`.
### C-2 — Loyalty redemption unbounded + never deducted
`bookings.service.ts:1705` (and `:1028/:1249/:1451`): validate + debit inside the booking transaction:
```ts
const acct = await tx.loyaltyAccount.findUnique({ where: { passengerId } });
const pts = Math.min(dto.loyaltyRedemptionPoints ?? 0, acct?.pointsBalance ?? 0, MAX_REDEEM);
const loyaltyMinor = pts * POINTS_TO_MINOR;
await tx.loyaltyLedgerEntry.create({ data: { accountId: acct.id, delta: -pts, reason: 'REDEEMED', balanceAfter: acct.pointsBalance - pts } });
await tx.loyaltyAccount.update({ where: { id: acct.id }, data: { pointsBalance: { decrement: pts } } });
```
### C-3 — Wallet top-up: no ownership, no backing
- `wallet.controller.ts:34`: enforce `req.user` owns `:passengerId` (or is admin) before top-up.
- `wallet.service.ts:50`: only credit after a confirmed `PaymentIntent` (a top-up is a purchase).
- `wallet.controller.ts:23-24`: remove `@IsPublic()` from `GET /wallet/accounts`.
### C-4 — Payment amount never validated
- `intents.service.ts:541-548`: on `confirmedAmountMinor !== intent.amountMinor`, do NOT mark
SUCCEEDED — set a `AMOUNT_MISMATCH` state and alert. Populate `confirmedAmountMinor` in each
webhook handler (e.g. `waafi-webhook.service.ts:63`).
- `payments.service.ts:809` `finalizePaymentSuccess`: assert the settled amount equals
`booking.totalMinor` before confirming.
### C-5 — Late webhook resurrects an expired/cancelled booking
`payments.service.ts:809` `finalizePaymentSuccess`: refuse to confirm unless the booking is still
`PENDING_PAYMENT`; route a late success to the refund/again-available flow:
```ts
if (booking.status !== 'PENDING_PAYMENT') { await this.refundLatePayment(intent); return { alreadyFinalized: true }; }
```
### C-6 — Wallet double-spend (no row lock)
`payments.service.ts:461-484`: lock the row or use an atomic conditional update:
```ts
const res = await tx.$executeRaw`UPDATE passenger."WalletAccount"
SET "balanceMinor" = "balanceMinor" - ${total}
WHERE "passengerId" = ${booking.passengerId} AND "balanceMinor" >= ${total}`;
if (res === 0) return { success: false }; // insufficient / lost the race
```
Regression test: `critical-repro.e2e-spec.ts` (C-6, barrier-forced interleave).
### C-7 — Refund computed but never disbursed
`bookings.service.ts:2017-2027`: on `booking.cancelled`, actually disburse — credit the wallet or call
the provider refund — and drive `refundStatus PENDING → PROCESSING → COMPLETED`. Add a reconciliation
sweep for stuck `PENDING` rows.
### C-8 — Exchange-rate writes missing the ADMIN check (any passenger can write FX)
`fare-engine/currency.controller.ts:25,32`: add `@PassengerAdmin()` (+ `@ApiBearerAuth`) to the `PUT`
and `PATCH` handlers, matching the already-guarded `DELETE`. (Not unauthenticated — the global
JwtGuard requires a token; the gap is the missing *authorization*. Verified live: passenger → 200.)
### C-9 — `@Roles('ADMIN')` is dead
Register the guard globally so `@Roles` is enforced:
```ts
// app.module.ts providers
{ provide: APP_GUARD, useClass: RolesGuard }
```
…or convert `configurable-fare` / `segment-fare` / `system-config` controllers to the working
`@PassengerAdmin()`/`@PassengerStaff()` guards.
---
## P1 — pricing correctness (HIGH)
- **H-1 promo → negative total** (`fare-engine.service.ts:192`): `totalEtbMinor = Math.max(0, subtotal - discount)`; DTO `@Min(0) @Max(100)` on `percentOff`, `@Min(0)` on `amountOffMinor` (`promos.dto.ts:20,26`).
- **H-2 missing FX → 1.0** (`currency.service.ts:142`): remove the silent `return 1.0` — throw / block the quote so it fails closed.
- **H-3/H-4 FX divergence & unit confusion** (`currency.service.ts`): collapse the 4 routines into one `convert(fromMinor, from, to): {minor|major}` with one rounding + one fallback policy; give it a branded `Minor`/`Major` return type and audit every `amountMinor` assignment across the payment boundary.
- **H-5 `percentOff:0` treated as FIXED** (`fare-engine.service.ts:185`): use `promo.percentOff != null ? … : promo.amountOffMinor`.
- **H-6 `insuranceFeeMinor` dual meaning** (`fare-engine.service.ts:130,154,167`): split into `insuranceMultiplierBps` and `insuranceFeeMinor`; use one consistently.
- **H-7 domestic ETB fare × USD→ETB rate** (`fare-engine.service.ts:157`): don't apply a currency conversion to a domestic base fare — separate the minor-unit scaling from FX.
- **H-8 baggage ignores seat class** (`excess-baggage.service.ts:53`): `findFirst({ where: { seatClassId } })`.
- **H-9 baggage/supp skip conversion + DJF rounding** (`excess-baggage.service.ts:166`, `supplementary-charges.service.ts:132`): route through `convertMinorToChargeMajor`.
- **H-10 future-dated FX applied now** (`currency.service.ts:88,137`): add `effectiveDate: { lte: new Date() }` to the rate lookups.
- **H-11 non-deterministic fare resolution** (`fare-engine.service.ts:84,298`): add `orderBy: { validFrom: 'desc' }`; include `validFrom` in `SegmentFareRule`'s unique key (`schema.prisma:1113`) to allow dated versions.
- **H-12 divergent free-child rules** (`fare-engine.service.ts:172` vs `bookings.service.ts:1690` vs `:1622`): extract ONE `computeFare()` used by quote, booking, and payment.
- **H-13 promo silently dropped → overcharge** (`review/page.tsx:587`, `search.service.ts:940-970`): book the breakdown's discounted `totalMinor`, not the client-summed undiscounted per-pax fares — or return discounted per-pax fares. Fold into the C-1 fix (server recomputes the authoritative total incl. promo).
---
## P2 — config validation & safety (MEDIUM) / UI (LOW)
- **M-1 negative fares**: add `@Min(0)` to `baseFareMinor` (`schedules.dto.ts:85,95`) and `basePrice` (`seat-classes.dto.ts:29`).
- **M-2 promo bounds/date**: `@Min(0) @Max(100)` on `percentOff`, `@IsDateString()` on `validUntil` (`promos.dto.ts`); add min-spend / usage-limit / max-cap columns.
- **M-3 `PATCH /config` arbitrary**: replace the raw body with a whitelisted, typed DTO with per-key range checks (`system-config.controller.ts:34`).
- **M-4 past-date / double-booked schedules** (`schedules.service.ts:105,124`): reject past `departureAt`; widen the overlap check to the train across all routes.
- **M-5 deletes ignore references** (`stations`/`seat-classes`/`currencies`/`schedules` services): add referential guards before delete/disable; wrap the schedule cascade (`schedules.service.ts:438-485`) in a transaction.
- **M-6 non-atomic booking write** (`bookings.service.ts:883-926`): wrap create + confirmSeats + tier increment in one `$transaction`.
- **L-1 DJF shown with 2 decimals** (`portal/src/utils/format.ts:22`): format per `CHARGE_CURRENCY_DECIMALS` (DJF = 0).
- **L-2 client-side fare math** (`portal/src/utils/fare-utils.ts`, `review/page.tsx`): render only server-computed amounts; never submit a client-derived total (ties to C-1).
- **L-3 loyalty accrual currency** (`payments.service.ts:1062`): accrue from the actual charged amount/currency, not ETB minor.
---
## Suggested sequencing
1. **C-10, C-3, C-8, C-9** — quickest high-impact (a few lines each): unblock authenticated booking, stop free wallet credit, guard FX writes, enforce roles.
2. **C-1, C-2, C-4, C-5, C-6, C-7** — the money-integrity core (needs transactions + validation).
3. **H-2, H-3, H-4, H-7** — the FX/units foundation others compound on.
4. **H-1, H-5, H-8..H-12, M-1, M-2** — pricing correctness + validation.
5. **M-3..M-6, L-1..L-3** — config safety + UI consistency.

137
docs/TESTING.md Normal file
View File

@@ -0,0 +1,137 @@
# EDR Passenger — Testing Runbook
How to run **everything**: the API bug-hunt harness and the UI (browser) harness, view the reports,
run a single test, and troubleshoot. Both are hermetic (their own Postgres on 5544 — never prod).
- **Findings:** `docs/ISSUES.md` · **Fixes:** `docs/SOLUTIONS.md`
- **Test plans:** `docs/e2e-test-matrix.md` (API) · `docs/ui-e2e-test-matrix.md` (UI)
---
## 0. Prerequisites (one time)
- **Docker Desktop** running (the harness starts Postgres + RabbitMQ containers).
- **Node ≥ 20**, **pnpm 11** (`corepack enable` if needed).
- Install deps once: `pnpm install` (from repo root).
That's it — no manual DB, env, or auth setup. The scripts handle migrations, seeding, and auth.
---
## 1. Run the API harness (fast — no browser)
Covers pricing math, FX, wallet/refund/payment integrity, config validation, auth gaps, and the
authenticated-booking regression. **25 tests.**
```bash
bash e2e/run.sh # infra + migrate + run + open HTML report
# or: pnpm test:e2e:passenger
```
Flags: `bash e2e/run.sh --down` (tear DB down after) · `--no-open` (don't open the browser).
Report → `apps/edr-passenger-api/e2e-report/index.html`.
**Run a single API suite / test:**
```bash
cd apps/edr-passenger-api
npx jest --config ./test/jest-e2e.json test/pricing-fare-engine.e2e-spec.ts
npx jest --config ./test/jest-e2e.json -t "double-spend" # by test name
```
(The test DB must be up — run `bash e2e/prepare.sh` once if you skipped `e2e/run.sh`.)
---
## 2. Run the UI harness (browser — Playwright)
Covers the real portal booking flow (search → pay → confirm) with the price cross-check, plus
backoffice auth. **One command boots the whole stack** (Postgres + RabbitMQ + passenger-api +
portal + backoffice), seeds a bookable trip, mints passenger + staff auth, runs, and opens the report.
```bash
pnpm test:e2e:ui # = bash e2e-ui/run.sh (turnkey)
```
First run takes ~12 min (it builds `@edr/types` and boots the Next.js apps). If the stack is already
running, it reuses it. Report → `e2e-ui-report/index.html`.
**Run a subset / single UI test** (stack already up):
```bash
pnpm test:e2e:ui:only --project=portal # just the portal booking tests
pnpm test:e2e:ui:only --project=backoffice
npx playwright test -c e2e-ui/playwright.config.ts ua1 # by file name
```
**Watch it run in a real browser** (headed) or step through it:
```bash
npx playwright test -c e2e-ui/playwright.config.ts --project=portal --headed
npx playwright test -c e2e-ui/playwright.config.ts --project=portal --debug # Playwright Inspector
npx playwright show-report e2e-ui-report # open a past report
npx playwright show-trace test-results/**/trace.zip # trace of a failed run
```
Projects: `portal` (logged-in passenger), `guest` (no auth), `backoffice` (staff), `propagation`
(Track B — staff writes config via API → passenger portal reads; 4 tests).
---
## 3. Run absolutely everything
```bash
bash e2e/run.sh --no-open # API: 25 tests
pnpm test:e2e:ui # UI: 9 tests (boots the stack)
```
Or the standalone hermetic API DB only: `bash e2e/prepare.sh` then `pnpm --filter @edr/passenger-api test:e2e`.
---
## 4. What each harness contains
| Harness | Location | What it proves |
| --- | --- | --- |
| API | `apps/edr-passenger-api/test/*.e2e-spec.ts` + `e2e/` | fare/FX math, promo/negative-total, wallet double-spend, refund-never-paid, FX-write authz gap, DTO validation gaps, **C-10 authed-booking FK regression** |
| UI | `e2e-ui/` | **UA-1** booking money cross-check; **UA-13** 🔴 client-forged total (C-1); **UA-8** 🔴 promo dropped (H-13); **Track B** — fare change propagates live (PB-2), **C-8** passenger rewrites FX, **M-1** negative price accepted; smokes |
A test name with **🔴** encodes buggy behavior — when it **passes**, the bug is present. After you
apply a fix from `docs/SOLUTIONS.md`, flip that test to assert the corrected behavior.
Seed for the UI flow: `apps/edr-passenger-api/test/fixtures/seed-ui.ts` (bookable Train/Schedule/
Coach/Seats + WALLET/TELEBIRR payment methods + promos + funded wallet). Standalone:
`DATABASE_URL=…5544 npx ts-node test/fixtures/seed-ui.ts`.
---
## 5. Teardown
```bash
docker compose -f e2e/docker-compose.yml down # stops + wipes the test DB + RabbitMQ
```
The dev app processes (api/portal/backoffice) started by Playwright's `webServer` stop with the run;
if you booted them manually, `lsof -ti :4000 :5174 :5184 | xargs kill`.
---
## 6. Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| `Cannot find module '@edr/types'` on API boot | Types not built → `pnpm --filter @edr/types build` (the run scripts do this). |
| API boot hangs on `AmqpConnection … ECONNREFUSED` | RabbitMQ not up → `docker compose -f e2e/docker-compose.yml up -d rabbitmq-e2e`. |
| `EADDRINUSE :::4000` | A stale API instance is bound → `lsof -ti :4000 | xargs kill -9`, then re-run. |
| Backoffice test redirects to `/login` | Staff storageState missing/expired → it's re-minted every run by `global-setup`; ensure `SEED_PASSENGER_STAFF=true` in `apps/edr-passenger-api/.env`. |
| Portal booking 400 `Booking_passengerId_fkey` | **This is finding C-10** (real bug). The harness seeds `Passenger.id == iamUserId` to work around it — see `docs/ISSUES.md` C-10. |
| Docker daemon not running | `open -a Docker`, wait ~15s, re-run. |
| Ports differ | api 4000, portal 5174, backoffice 5184, payment 3003, Postgres 5544, RabbitMQ 5672. Override via `PORTAL_URL` / `BACKOFFICE_URL` / `API_URL` / `DATABASE_URL` env. |
---
## 7. Coverage status & what's next
- **Done:** full hermetic harness, 34 green tests (25 API + 9 UI). UA-1 keystone + UA-8/UA-13 abuse
rows, Track B propagation (PB-2, C-8, M-1), both auth roles, `BookingFlow` page-object.
- **Next (Track A):** more `bookOneAdult` variations — UA-2 (USD), UA-6 (round-trip); multi-passenger
free-child (UA-4/5) + gateway/DJF (UA-3) need helper extensions (per-pax form, forged webhook).
- **Next (Track B):** PB-5 (disable station→gone), PB-10 (delete FX→1.0 fallback), config-mid-flight.
See `docs/ui-e2e-test-matrix.md` for the full row-by-row plan.

259
docs/ui-e2e-test-matrix.md Normal file
View File

@@ -0,0 +1,259 @@
# EDR Passenger Platform — Playwright UI E2E: Scenario Matrix + Phase 2 Harness Plan
**Phase 1 synthesis (MAPPING ONLY).** Consolidates the four mapping passes (portal booking, backoffice config, API/network contracts, auth+seed gaps) plus the adversarial review into a reviewable plan. Ties every scenario to an existing finding in `docs/ISSUES.md` (C-/H-/M-/L-) and `docs/e2e-test-matrix.md` (Suites AK). **No tests written, no code changed, stack not run.**
Two framing facts inherited from Phase 1: (a) `fare-engine` is the live pricing pipeline; `configurable-fare` is dormant. (b) The domain seed (`prisma/seed.ts`) is disabled — the harness must build fixtures. Two blockers discovered this pass that flip several scenarios from "green/repro" to "invalid as written": **(1) the portal never applies promo discounts to the booked total** (§2 note), and **(2) both web apps have ZERO `data-testid`** (`grep -rn data-testid src` → 0 in both). Section 6 is the prerequisite testid checklist.
Ports: portal 5174, backoffice 5184, passenger-api 4000 (bare paths, no `/v1` except IAM `/v1/auth/*`), payment-api 3003 (`/webhooks/*`). Test DB port 5544 (`.env.test`).
---
## 1. Master assertion recipe — capture price at every hop
Each UI test drives the browser but asserts the **money chain** via (a) Playwright network interception (`page.route` / `page.waitForResponse`), (b) direct DB reads against the 5544 test DB (Prisma client or SQL), and (c) DOM text assertions on rendered price nodes. The master invariant (matrix "Suite A"), trimmed to links that actually have backing in the maps:
```
portal card price (displayAmountMinor)
── [BREAK] on-select stored fare == Math.min(baseFareMinor) (results/page.tsx:320) ──
== /search/fare-breakdown displayFareMinor
== review computedTotal (reviewedTotalMinor sent, review/page.tsx:587)
== Booking.totalMinor/displayTotalMinor
== PaymentIntent.amountMinor
== charged amount (WALLET debit OR gateway webhook)
```
> **Removed from the stated invariant (over-claimed):** `loyalty accrual` — no §1.2 DB read captures a `LoyaltyAccount.pointsBalance` increment and no green row asserts accrual vs price; and `refund basis` — there is **no refund endpoint anywhere in the API map** and no refund scenario. If a loyalty-accrual assertion is wanted, add a `LoyaltyAccount.pointsBalance` read to a green WALLET row (§1.2) and re-add only that link. Refund is out of scope until a refund surface is mapped (see §7).
### 1.1 Network interception targets (exact method + path, in flow order)
| Hop | Method + Path (passenger-api :4000) | Capture for assertion | Source |
|---|---|---|---|
| Fayda gate | `GET /config/fayda-status` (confirm prefix — see §5.2) | `enabled` — must be `false` to expose manual passenger form | system-config.controller.ts:12,16 |
| Stations load | `GET /stations` | station list (search inventory) | portal search page.tsx:606 |
| Search | `POST /search` | body `{originStationId,destinationStationId,date,adultCount,childCount,nationality,journeyType,returnDate?}`; resp `outbound[].coachTypes[].classes[].{displayAmountMinor,baseFareMinor}` | results/page.tsx:234; search.controller.ts:13 |
| **On-select stored fare** | (client-side, no request) | `minFare = Math.min(...classes.map(c => c.baseFareMinor))`**`baseFareMinor`, NOT the card's `displayAmountMinor`**; diverges for USD/DJF and flows downstream as `baseFareAdult` | results/page.tsx:320,821 |
| Promo (URL-injected) | `POST /promos/validate` `{code}`**note: no in-portal "apply promo" input**; promo enters via `?promoCode=``searchCriteria.promoCode` | discount echo (does NOT reach booked total, see §2) | results/page.tsx:142 |
| Save passengers | `POST /passengers/save-details` | `{passengers[],userId,deviceId}` | passengers/page.tsx:1062 |
| Seatmap | `GET /seats/seatmap/{scheduleId}?coachTypeId=&journeyDirection=` | seat fares (`displayAmountMinor??baseFareMinor`) | seats/page.tsx:352 |
| Hold | `POST /seats/hold` `{scheduleId,origin,dest,journeyDirection,passengers:[{passengerId,seatId}]}` | resp `{holdId,expiresAt}`**capture `expiresAt`** for PB-9 | seats/page.tsx:617; seats.controller.ts:164 |
| Fare breakdown | `GET /search/fare-breakdown?scheduleId=&...&passengers=<JSON>&displayCurrency=[&promoCode]` | resp per-pax `{fareMinor,displayFareMinor,isFree}` (**undiscounted**) + separate top-level `discountMinor`/`totalMinor` (**ignored by portal**) | review/page.tsx:550,559,574; search.service.ts:906-975 |
| Create booking | `POST /bookings` (auth) **or** `POST /bookings/guest` | body `reviewedTotalMinor` (undiscounted per-pax sum), per-pax `seatFareMinor`; resp `{bookingId/pnr,totalMinor}` | review/page.tsx:209,394,457; bookings.controller.ts:364/181 |
| Booking amount | `GET /payments/booking-amount?bookingId=&currency=` | resp `{amount (MAJOR, plain /100), currency}` — portal ×100; currency is driven by the **PaymentMethod.currency**, not the booking | payment/page.tsx:68,73 |
| Initiate | `POST /payments/initiate` `{bookingId,method,paymentMethodId,payerAccount?,platform}` | resp `clientAction{type,url}` **and `merchantOrderId`** (required to key the forged webhook) | payment/page.tsx:123,149; payments.controller.ts:108 |
| Confirm (CAC) | `POST /payments/{bookingId}/confirm` `{otp}` | — | payment/page.tsx:174 |
| Poll intent | `GET /payments/intents/{bookingId}` | status transitions | confirmation/page.tsx:125 |
| Ticket | `GET /bookings/{bookingId}` | `{status,totalMinor,payment:{amountMinor,currency},tickets[].barcodePayload}` | confirmation/page.tsx:105 |
**Payment methods are DB-driven and must be seeded.** The portal renders only `PaymentMethod` rows where `enabled=true` (`payment/page.tsx:593`); `getSupportedPaymentMethods` returns enabled rows from the DB (`payments.controller.ts:273`). `seed-core.ts` seeds **none** → the pay page is empty and **every Track A row (WALLET included) hangs before paying**. See §5.4.
**WALLET path** (fully offline, no payment-api/webhook): `POST /payments/initiate {method:"WALLET"}` short-circuits server-side to `finalizePaymentSuccess`, debiting `booking.totalMinor` directly (payments.service.ts:461-523). Best UI settlement path for green tests. **Note:** WALLET produces **no `edr_payment.payment_intent`** and **bypasses the charge-currency conversion** — so the DJF whole-franc rounding is not observable here (see UA-3/UA-17, §2).
**Settlement injection for gateway tests** (no real gateway):
- **Forge webhook** to payment-api :3003 — `POST /webhooks/telebirr` or `/webhooks/dmoney` (both `signatureValid=true` hardcoded) with `merch_order_id = <captured merchantOrderId>`, `trade_status=success`. Card/Waafi require valid HMAC — avoid. A TELEBIRR initiate returns `clientAction REDIRECT` and the portal does `window.location.href = url` (`payment/page.tsx:149`) → the test must `page.route`-abort that navigation to the non-existent gateway, forge the webhook, then drive to `/booking/confirmation`.
- **Direct internal** — `POST /internal/payments/mark-paid` on :4000 with `{version:1,eventType:"payment.succeeded",service:"PASSENGER",referenceType:"BOOKING",referenceId:<bookingId>,...}`. `ServiceAuthGuard` returns true when `SERVICE_AUTH_TOKEN` unset (dev). Fastest deterministic settlement — but it will **not** reproduce a *late* webhook race (C-5, see UA-15) nor the charge-currency conversion (DJF, see UA-3).
### 1.2 DB reads to assert (test DB 5544)
- **passenger.Booking** (schema.prisma:510): `totalMinor`(:520), `currency`(:519), `displayCurrency`(:523), `displayTotalMinor`(:524), `status`(:518 → `"CONFIRMED"` on settle, payments.service.ts:846), `paidAt`(:550), `bookingType`, `returnLegStatus`.
- **passenger.BookingSeat**: `fareMinor`, `displayCurrency`, `displayFareMinor` (:597-599).
- **passenger.PaymentIntent** (:621): `amountMinor` **Float** (:624 — assert numeric, not int-exact), `currency`, `status`, `method`, `merchantOrderId`(unique), `paidAt`.
- **edr_payment.payment_intent** (payment-api source of truth): `amount_minor`/`confirmed_amount_minor` **double precision** (migration 1782000000000). Assert as numeric. **Scope: gateway rows only (UA-15)** — WALLET creates no payment-api intent.
- **WALLET extras**: `WalletLedgerEntry` DEBIT of `totalMinor` w/ `relatedBookingId`; `WalletAccount.balanceMinor` decremented; ticket row / `GET /tickets/{bookingRef}`.
### 1.3 Currency-formatting assertion (the L-1 target)
Portal renders every price through `formatFare(amountMinor, code)` = `` `${code} ${(amountMinor/100).toFixed(2)}` `` (fare-utils.ts:86) — **always /100, always 2 decimals**. So DJF renders `DJF 1234.56`. Whole-franc rounding lives on the **charge conversion** (`currency.service.ts:9-13`, `CHARGE_CURRENCY_DECIMALS`; `payments.service.ts:223-298`), which **WALLET short-circuits past**.
- **ETB / USD**: assert DOM shows 2dp; assert `renderedMajor*100 == amountMinor`.
- **DJF (WALLET)**: can only assert the **shape** mismatch — DOM shows 2dp (`DJF x.yy`) while `GET /payments/booking-amount` returns whole-franc-less major via plain `/100`. No settled 0-decimal `amount_minor` exists on this path.
- **DJF (gateway / forged-telebirr)**: the real L-1 settle-side repro — assert DOM 2dp vs the charge-currency-converted, whole-franc `amount_minor`/`confirmed_amount_minor`. UA-3/UA-17 must route here to observe it.
---
## 2. TRACK A — Booking combinations matrix (pruned cross-product)
Axes: booking type {one-way, round-trip} × pax mix {1A, 2A, 1A+1C-free, 1A+2C (1 free/1 paid), 2A+3C} × class/berth {Economy Regular, Economy Bed} × nationality/currency {Ethiopian→ETB / LOCAL, Djiboutian→DJF / LOCAL, Other→USD / INTERNATIONAL} × promo {none, %valid, expired} × payment {WALLET, forged-telebirr}. Pruned to meaningful, finding-bearing rows.
> **PROMO REALITY (blocking correction).** `GET /search/fare-breakdown` returns **undiscounted per-pax `displayFareMinor`** and puts the discount only in *separate* top-level `discountMinor`/`totalMinor` (`search.service.ts:906-975`). The portal review page **ignores** that top-level total and client-reduces the per-pax fares (`review/page.tsx:587`), sending that **undiscounted** sum as `reviewedTotalMinor`. The (guest) booking service then **overrides its own discounted total with `reviewedTotalMinor` when `>0`** and clamps its fallback with `Math.max(0,…)` (`guest-booking.service.ts:240-244,487,536-537,744`). Consequences: **through the browser, a valid promo is silently dropped and `Booking.totalMinor` = full price**, and a negative total is **not reproducible via UI**. Promo enters only via `?promoCode=` URL param (no selector); lookup is `findUnique({where:{code}})` (`search.service.ts:941`) — seed codes must be unique and exact. Whether the **authed** `/bookings` path shares the same override+clamp is unverified (§7).
| ID | Scenario | Key inputs | Price cross-check expectation | Finding tie-in |
|---|---|---|---|---|
| **UA-1** | One-way, 1 adult, Economy Regular, Ethiopian/ETB, WALLET, no promo | ETB LOCAL regular class | Baseline green: card price == fare-breakdown == reviewedTotalMinor == Booking.totalMinor == PaymentIntent.amountMinor == wallet DEBIT. All equal, 2dp. (Optionally assert `LoyaltyAccount.pointsBalance` accrual here if the accrual link is kept.) | matrix A6 (baseline) |
| **UA-1b** | One-way, 1 adult, **Other/USD** — assert card `displayAmountMinor` vs on-select stored `baseFareMinor` | nationality OTHER → USD; INTL class | **First chain break:** assert the value stored on select (`Math.min(baseFareMinor)`, results:320) equals what flows to review, and flag if it differs from the card's `displayAmountMinor` (results:821) | div #1 (new); H-3/H-4 |
| **UA-2** | One-way, 1 adult, Other/USD, INTERNATIONAL Regular, WALLET | OTHER → displayCurrency USD | Chain equal in USD; assert USD 2dp; verify INTL 2× surcharge NOT silently dropped on seat-class base path | H-3/H-4, matrix B3 |
| **UA-3** | One-way, 1 adult, **Djiboutian/DJF**, **forged-telebirr** | DJIBOUTIAN → DJF; gateway path | **DJF displayed 2dp (`DJF x.yy`) but charged whole-franc** — assert DOM-2dp vs settled `amount_minor` (0-decimal). Must be a **gateway** row (WALLET bypasses the charge conversion). | **L-1** ✅, matrix C6/K3 |
| **UA-3w** | One-way, 1 adult, Djiboutian/DJF, WALLET (shape-only) | DJF, WALLET | Assert only the DOM-2dp vs `booking-amount`-major **shape** mismatch (no settle-side rounding on WALLET). | L-1 (partial) |
| **UA-4** | One-way, **1A + 1 child ≤5yr (free)**, ETB, WALLET | childCount 1, DOB<5yr | Child shows "CHILD - FREE"; free child excluded from total; `fare-breakdown.isFree==true` agrees with client `fare-utils.isFirstChild` | **H-12**, matrix B6 |
| **UA-5** | One-way, **1A + 2 children** (first free, second paid), ETB, WALLET | childCount 2 | Second child paid; client reduce (review:587) == breakdown sum; assert booking vs quote free-child count agree (quote uses min(child,adult); booking uses child-1) | **H-12**, matrix B6 |
| **UA-6** | Round-trip, 1 adult, Economy Regular, ETB, WALLET | ROUND_TRIP, outbound+inbound holds | Total == outbound+inbound; per-leg split `Math.round(fareMinor/2)` (review:481) reconciles to `outbound/inboundSeatFareMinor`; both `holdId` + `returnHoldId` present | div #6; matrix A3 |
| **UA-7** | Round-trip, 2 adults, **Economy Bed / berth**, INTERNATIONAL/USD, WALLET | bed seat-class; berth seats (`bedPosition`) | Berth priced as separate class; `getSeatFare` bedPosition match (seats:433) == breakdown; INTL berth surcharge consistent | requires **berth seed** (§5); matrix B3 |
| **UA-8** | One-way, 1 adult, ETB, **valid % promo via `?promoCode=`**, WALLET | valid `percentOff:10` in URL | ✅ FIXED — the browser still sends the undiscounted `reviewedTotalMinor`, but the authed `bookings.service` recomputes the authoritative fare and applies the promo, so `Booking.totalMinor` = `subtotal discount`. | H-13 fixed & guarded; matrix D |
| **UA-11** | One-way, 1 adult, ETB, **expired promo** (validUntil past, active:true) via URL, WALLET | expired code | Promo rejected/ignored; total unaffected; UI shows no discount (consistent with UA-8 drop). | matrix D5 |
| **UA-13** | One-way, 1 adult, ETB, **client-forged low total** (intercept `POST /bookings`, rewrite `reviewedTotalMinor:1` + every `seatFareMinor:1`) | mutate body via `page.route` | ✅ FIXED — server recomputes the authoritative fare and REJECTS the underpayment (4xx); no booking persisted | **C-1** fixed & guarded, matrix A1 |
| **UA-14** | One-way guest, forged per-pax `seatFareMinor:0` (+ `reviewedTotalMinor:0`) | intercept `/bookings/guest` | ✅ FIXED — server recomputes the authoritative fare and REJECTS the free-ride underpayment (4xx); no booking persisted | **C-1** fixed & guarded, matrix A2/A4 |
| **UA-15** | One-way, 1 adult, ETB, **forged-telebirr short-pay** | booking total in the thousands; forge `/internal/payments/mark-paid` success with `amountMinor:1` | ✅ FIXED — the server compares the settled amount to the booking's display total and REFUSES a short payment; booking stays unconfirmed, no ticket | **C-4** fixed & guarded, matrix G1/G7 |
| **UA-16** | Round-trip, 2A+3C, mixed, ETB, WALLET | max pax spread | Stress free-child + per-leg split + total reduce; every backed hop equal | H-12, div #6 |
**Moved to the API-level harness (no valid browser path):**
- **UA-9 / UA-10 / UA-17** — over-100% `percentOff:150`, fixed `amountOffMinor > subtotal`, DJF×promo negative total. Not reproducible via UI: `reviewedTotalMinor` is positive-undiscounted and the server clamps to 0 (`guest-booking.service.ts`). Keep as API-only for **H-1**.
- **UA-12** — loyalty over-redeem (**C-2**). No browser path: `grep loyalty|redeem` across `portal/src/app/booking/**` + `booking-store.ts` → zero hits; `loyaltyRedemptionPoints` exists only on `POST /search/fare-quote`, which the portal never calls (it uses fare-breakdown, no loyalty param). Keep as API-only.
> Payment method: default all rows to WALLET (deterministic, offline). UA-3 and UA-15 use forged-telebirr. Rows tagged ✅ have an existing API-level repro in `docs/ISSUES.md`; the UI test proves the defect surfaces through the real browser flow (closing the "Suite K not yet run" gap, ISSUES.md L285-290).
---
## 3. TRACK B — Config→portal propagation matrix
Each row: change made in backoffice UI (:5184) → API write → portal read (:5174) → propagation + staleTime → predicted finding. Backoffice self-refreshes immediately (each mutation invalidates its own React-Query key). Staleness only bites the **portal**.
| ID | Config change (backoffice UI) | Write endpoint | Portal read path | Propagation + staleTime | Predicted |
|---|---|---|---|---|---|
| **PB-1** | `/currencies` → edit ETB↔USD rate | `PATCH /currencies/{id}` `{rate}` | `GET /currencies` via useCurrencies.ts:19 | **staleTime 5min** — up to 5 min stale in portal | 🟠 matrix I1; ties H-2/H-3 |
| **PB-2** | `/tariff-rates` Tab1 → edit seat-class base | `PATCH /seat-classes/{id}` `{basePrice}` (**field `basePrice`**) | next `POST /search` (staleTime:0) + `GET /search/fare-breakdown` | Live, no cache | 🟢 matrix I2/K4; **field-name split** (basePrice vs baseFareMinor) — verify which fare-engine reads (§7) |
| **PB-3** | `/tariff-rates` Tab2/3 → route/segment fare override | `POST /schedules/routes/{routeId}/fare-rules` / `POST /schedules/segment-fares` | `POST /search` results | Live | 🟠 segment rule may not bite: engine matches `dto.nationality` or null; seeder writes 'LOCAL'/'INTERNATIONAL' → won't match (§5 note); matrix B5 / H-11 |
| **PB-4** | `/stations` → add station | `POST /stations` | `GET /stations` (SearchWidget staleTime 60s; root prefetch raw fetch) | ≤60s stale in SearchWidget; prefetch uncached | 🟠 matrix K5 |
| **PB-5** | `/stations` → **disable station** (isOperational=false) | `PATCH /stations/{id}` | `GET /stations` (portal passes **no operational filter**) | Only disappears if API omits non-operational server-side — **verify** (§7) | 🔴/🟠 matrix K5/I3 |
| **PB-6** | `/classes` → create seat class | `POST /fleet/classes` `{baseFareMinor,...}` (**field `baseFareMinor`**, different endpoint than PB-2) | `POST /search` + seats page | Live (search staleTime:0) | 🟠 **two seat-class stores** (`/seat-classes` vs `/fleet/classes`) — confirm which live search reads (§7) |
| **PB-7** | `/promos` (URL, nav commented) → create promo | `POST /promos` | `POST /promos/validate {code}` at results:142 | On demand | 🔴 **field-name mismatch**: UI sends `discountType/discountValue/isActive`; DTO expects `percentOff/amountOffMinor/active` → possibly inert promo. Verify; own finding. matrix K6 |
| **PB-8** | `/schedules` → create schedule for search date | `POST /schedules` | `POST /search` | Live | 🟢 must satisfy all 9 searchability rules (§5); matrix I2 |
| **PB-9** | `/settings` → change seat-hold TTL | `PATCH /config` (raw, no RQ, no invalidate) | **no portal read path** — config is ignored by the hold | Server-side runtime | 🔴 **reframed:** capture `expiresAt` from `POST /seats/hold` and assert it does **NOT** track the config value (hold uses a fixed TTL — reconcile 15-min `seats.service.ts:~272` vs the "20-min" claim). **G6** |
| **PB-10** | `/currencies` → **delete** a rate pair | `DELETE /currencies/{id}` | `POST /search` (USD/Other) faresByClass | ✅ FIXED — `getExchangeRate` fails closed (throws) instead of substituting 1.0; the USD search returns NO priced class (no bogus ~100×-underpriced fare), and a booking would be rejected too | **M-5 / H-2** fixed & guarded, matrix I6 |
**Deferred config surfaces (mapped, out of Phase 2 scope — stated so the matrix doesn't read as complete):** `/fare-management` (schedule-scoped `FareRule`, fare-source #3), `/pricing` (`/admin/segment-fares`, the dead-`@Roles` route), and `/routes` fare-rule CRUD beyond PB-3.
---
## 4. HIGH-VALUE bug-class scenarios (concrete steps)
### 4A. Config-mid-flight (edit/disable between quote and pay) — matrix I7
- **BC-1**: Portal: search → results → select → hold → `/booking/review` (fare frozen). Second (backoffice) context: `PATCH /seat-classes/{id}` to triple the base. Back in portal: **Confirm**. **Assert** booking created at the *frozen* review price (`reviewedTotalMinor`), not the new one — booking never re-quotes; payment never re-validates. (ties C-1/L-2)
- **BC-2**: Same, but **disable the station** mid-flight (PB-5). Assert the in-flight booking still completes (no re-validation of station operational state).
### 4B. Delete-referenced (M-5 / matrix I3I6)
- **BC-3**: Create a CONFIRMED booking (UA-1). Backoffice `/stations` → delete the origin station (accept cascade if FK 400 offered). **Assert** either a referential block OR an orphaned booking (`GET /bookings/{id}` resolves but station lookups break). `stations.service.ts:110-137` ignores bookings.
- **BC-4**: `/classes` delete a seat-class referenced by a booking's `bookingSeat`. Assert orphan/FK behavior. matrix I4.
- **BC-5**: `/currencies` delete the USD↔ETB pair with active INTL fares. Next portal INTL search → fare collapses ~100× (1.0 fallback). **H-2**, matrix I6.
- **BC-3b** (new): `/routes` → delete a route referenced by a live schedule; assert orphaned schedule vs referential block. `routes.controller.ts`.
- **BC-4b** (new): `/schedules` → cancel a schedule with a CONFIRMED booking; assert whether the booking is stranded. *(Both new rows may be explicitly deferred if Phase 2 scope is tight.)*
### 4C. Staleness (matrix I1)
- **BC-6**: Backoffice edit ETB↔USD rate. Immediately do a portal USD search → **assert** portal may show the OLD rate (useCurrencies staleTime 5×60×1000). **Then force a reload / navigation / window-focus** to trigger the refetch (React-Query `staleTime` does NOT auto-refetch on its own), and assert the new rate. Distinguishes the 5-min window from live search pricing.
### 4D. Validation-via-UI vs direct-API (Suite H — direct-API bypass class; UI proves the client gaps)
- **BC-7** ✅ FIXED: `PATCH /seat-classes` with `basePrice:-500` is now rejected with **400** — `CreateSeatClassDto.basePrice` (and `insuranceFeeMinor`) carry `@Min(0)`, applied to updates via `PartialType`. **M-1**, matrix H1/H2.
- **BC-8** ✅ FIXED: `POST /promos` with `percentOff:200` is now rejected with **400** — `CreatePromotionDto.percentOff` carries `@Min(0) @Max(100)` (and `amountOffMinor` `@Min(0)`). A valid ≤100% promo still succeeds. **M-2**, matrix H4.
- **BC-9** ✅ FIXED: `PATCH /config {seat_hold_duration_minutes:"-1"}` is now rejected with **400** — a whitelisted `UpdateSystemConfigDto` coerces each known key to a positive integer (`seat_hold_duration_minutes` bounded 1..60). A sane value still stores. **M-3**, matrix H7.
- **BC-10**: `/schedules` → past `departureAt`. Client blocks only `arrival<=departure`, no past-date block. Assert schedule created in the past. **M-4**, matrix H3.
- **BC-11** ✅ FIXED: `PUT/PATCH /fare-engine/exchange-rates` now carry `@PassengerAdmin()` (as DELETE already did). Anon → 401, regular passenger → **403 forbidden**, staff admin → 200. **C-8**, matrix J1.
---
## 5. PHASE 2 harness plan
### 5.1 `playwright.config.ts` structure
```
e2e-ui/ # new; sibling to existing e2e/ (API harness)
playwright.config.ts
global-setup.ts # boot+await stack (VERIFAYDA_ENABLED=false), seed, mint storageStates
fixtures/
storage/passenger.json # generated by global-setup
storage/staff.json # generated by global-setup
seed-ui.ts # domain fixtures (see 5.4)
specs/
portal/*.spec.ts # Track A (UA-*), BC-1/2/6
backoffice/*.spec.ts # Track B config CRUD
propagation/*.spec.ts # BC-3..BC-11 cross-app
```
- **projects**: `portal` (baseURL `http://localhost:5174`, storageState `passenger.json`), `backoffice` (baseURL `http://localhost:5184`, storageState `staff.json`), plus a `guest` project (no storageState) for guest rows (UA-14). Pin `viewport` per project — portal desktop layout is `hidden md:block`; mobile diverges heavily. One shared **`globalSetup`**.
- `webServer`: optionally let Playwright start portal+backoffice (`pnpm --filter @edr/passenger-portal dev` etc.); reuseExistingServer in local dev.
### 5.2 global-setup
1. Ensure Postgres :5544 up and migrated (`.env.test`, `JWT_ACCESS_TOKEN_SECRET=test-access-secret-0000…`).
2. Boot passenger-api :4000 (**with `VERIFAYDA_ENABLED=false`** so the portal exposes the manual passenger form — otherwise Fayda defaults ON and every booking flow is blocked; the flag is `enabled = process.env.VERIFAYDA_ENABLED !== 'false'`, system-config.controller.ts:12,16) and payment-api :3003 (or assert reachable). Await `/health`-style ping. **Confirm which `fayda-status` prefix the portal hits** (`/config` vs `fare-engine.controller.ts:65`, which defaults `false`) so the right flag is set.
3. Run `seed-core.ts` + new `seed-ui.ts` (§5.4).
4. Mint the two storageStates (§5.3), write to `fixtures/storage/`.
### 5.3 The two storageState fixtures (grounded in auth map)
The passenger-API `JwtGuard` is **DB-backed against `iam.sessions`** — a fake JWT 401s. JWT payload is `{ id: <sessionId> }` (NOT userId); roles/permissions live in the session's `userInfo` jsonb.
**Passenger storageState (portal :5174)** — no server gate, but `/auth/profile` runs on load and self-ejects on 401:
1. Insert `iam.users` (individual, active).
2. Insert `iam.sessions` (`status='ACTIVE'`, future expiry, `userInfo.roles=[]`).
3. Insert Prisma `Passenger{iamUserId}` **+ `LoyaltyAccount` + `WalletAccount`(funded balanceMinor) + `UserPreferences`** — required or `getProfile` throws "Passenger not found" (passenger-auth.service.ts:262) and the portal logs out.
4. Mint JWT `{id: sessionId}` with `JWT_ACCESS_TOKEN_SECRET`.
5. Write storageState `localStorage` for origin :5174: `auth_token=<jwt>`, `auth_user=<profile JSON matching getProfile shape>`.
6. *Simplest alternative*: drive real `POST /auth/login` once with a seeded passenger, snapshot localStorage.
**Staff/admin storageState (backoffice :5184)** — server middleware requires the `auth_token` **cookie**; API staff calls require `userInfo.roles` carrying `super_admin`/`organization_admin` or the right permission keys:
- **Path A (robust)**: set `SEED_EDR_PASSENGER_ORG=true` + `SEED_PASSENGER_STAFF=true`, boot API → seeds org `edr`, roles, users (`passenger.admin@edr.local` / `Test@1234`). Then `POST /v1/auth/login` → `GET /v1/auth/me`, snapshot `localStorage` (`auth_token`,`auth_user`,`auth_refresh_token`) **and** set `auth_token` cookie.
- **Path B (fast)**: insert `iam.users`+`iam.sessions` with `userInfo.roles=[{key:'super_admin'}]`, mint JWT, write storageState localStorage + `auth_token` cookie for :5184, `auth_user` with `isSuperAdmin:true`. Config pages don't use `PermissionGuard` — only middleware cookie + API guards matter.
- **Note:** the `auth_token` cookie is **host-scoped (`localhost`), not port-scoped**, so it is also sent to the portal origin. Harmless (portal reads localStorage, not this cookie) but relevant if a single shared browser context is reused across projects.
### 5.4 Seed extensions (add to `seed-core.ts` or new `seed-ui.ts`)
`seed-core.ts` today has CoachType×1, SeatClass×2 (LOCAL 300 / INTL 500, both regular), Station×3 (A/B/C), Route×1 + 3 RouteStop (0/100/250km), 4 FX rows. **No Train/Schedule/Coach/Seat/Passenger/PaymentMethod.** Add:
- **PaymentMethod rows (BLOCKING — pay page is empty without them):** at minimum an **enabled `WALLET`** (currency ETB) and an **enabled `TELEBIRR`** (for UA-15). The method's `.currency` drives `booking-amount` and the displayed pay total (`payment/page.tsx:68`), so a DJF booking paid by an ETB wallet renders ETB on the pay page — relevant to UA-3/UA-3w.
- **Bookable trip** (all 9 searchability rules): `Train`×1 → `TrainSchedule`(A→C, `status:'SCHEDULED'`, `isPackageOnly:false`, `departureAt = now+2d`, whole-day in Addis TZ, **>30min ahead**) → 3 `TripStopTime`(A/B/C seq 1/2/3, future `plannedDepartureAt`) → `Coach`×1(`status:'ACTIVE'`) → `CoachAssignment`(`isOperational:true`) → `Seat`×N (AVAILABLE, non-empty `seatNumber`, `bedPosition:null`). Fares resolve via `SEAT_CLASS_BASE_FARE` distance formula with the existing USD→ETB row — no fare-rule rows needed for the green path.
- **Seat-class names (pin exactly):** the review flow builds a `seatClassName → seatClassId` map from `GET /seat-classes` (`review/page.tsx:277`) and fare-quote expects exact names `"Economy Regular"|"Economy Bed"` (search.dto.ts). Set `SeatClass.name` to the exact client strings, or those rows won't resolve. (The axis's "VIP Bed" has no seed/scenario — seed it or drop it from the axis; this matrix drops it.)
- **Berth combos** (UA-7): LOCAL+INTL SeatClasses with `bedPosition IN ('UPPER','MIDDLE','LOWER')` + a bed `Coach` + `Seat`s with lowercase `bedPosition:'upper'|'middle'|'lower'`.
- **Promotions** (UA-8/11): valid `percentOff:10`; expired (`validUntil` past, `active:true`). **Use schema field names `percentOff/amountOffMinor/active`** — NOT the backoffice UI field names. **Pin exact, unique `code` values** (lookup is `findUnique({where:{code}})`, search.service.ts:941); tests navigate with `?promoCode=<code>`. (Over-100% / over-subtotal promos belong to the API harness, not Track A.)
- **BaggageAllowance** ×1 per seat class (for excess-baggage rows).
- **Passenger satellite** for logged-in/WALLET: `Passenger{iamUserId}` + funded `WalletAccount(balanceMinor)` + `LoyaltyAccount`.
- **Blocked-seat negative case**: one `SeatBlock` row.
- **Segment override that actually bites** (PB-3): seed `SegmentFareRule` with `nationality:null` (engine matches `dto.nationality` string or null; 'LOCAL'/'INTERNATIONAL' rows won't match a real search).
- FX: existing 4 rows suffice for ETB/USD/DJF via ETB pivot; add `USD↔DJF` only if a direct-path currency test needs it.
### 5.5 Two smoke tests
- **Portal smoke** (`guest` project): home → search (seeded A→C, date = Addis date of `departureAt`) → results shows ≥1 card with a price → `formatFare` renders `ETB N.NN`. Asserts stack+seed+search+Fayda-flag wired.
- **Backoffice smoke** (`backoffice` project): staff storageState → `/currencies` loads list → open "Add Rate" modal. Asserts staff auth (cookie+localStorage+API token) all valid.
### 5.6 pnpm scripts + turbo task
- Root `package.json`: `"test:e2e:ui": "playwright test -c e2e-ui/playwright.config.ts"`.
- turbo `test:e2e:ui` task `"cache": false`; global-setup owns boot/seed. Single command: `pnpm test:e2e:ui`.
- Specs under `e2e-ui/specs/{portal,backoffice,propagation}`.
---
## 6. SELECTORS TO ADD — `data-testid` checklist (PREREQUISITE; both apps have 0 today)
Without these, every locator hangs off role/text/`name=`/placeholder, which is brittle across the portal's mobile/desktop breakpoint split. Recommend adding these before authoring (out of scope this phase; flag for user approval). **Promo has no selector — it enters via `?promoCode=` URL param.**
### Portal (`apps/edr-passenger-web/portal/src`)
- **Search**: `search-trip-type-oneway`/`-roundtrip` (page.tsx:777/789), `search-origin-input` (:1234), `search-dest-input` (:1274), `search-swap` (:1261), `search-depart-date` (:1305), `search-return-date` (:1486), `search-pax-trigger` (:1334), `pax-adult-plus`/`-minus`, `pax-child-plus`/`-minus` (PassengerModal:319/330), `nationality-eth`/`-dji`/`-other` (:352), `search-submit` (:1362).
- **Results**: `result-card` (per schedule), `result-card-price` (:821 — "starting from"), `result-select-btn` (:831), `coach-option` (:487), `coach-class-price` (:609), `continue-passenger-details` (:642), `modify-search` (:1282).
- **Passengers**: `pax-name-{i}`, `pax-dob-btn` (:327), `pax-gender`, `pax-nationality`, `pax-phone`, `pax-passport`, `verify-fayda-btn`, `enter-manually-toggle` (:1003), `create-account-checkbox`, `passengers-continue`.
- **DOB picker (`DobPickerModal` — required for UA-4/UA-5 free-child):** `dob-cal-etgc-toggle` (:346), `dob-manual-toggle` (:354), `dob-manual-day`/`-month`/`-year` inputs, `dob-day-cell-{n}`, `dob-confirm`.
- **Seats**: `seat-cell-{label}` (SeatButton:119), `berth-cell-{label}` (BedCard:38), `passenger-tab-{i}`, `auto-assign-seats` (~:2006), `seats-continue` (~:1989), `fare-change-confirm` (CustomModal).
- **Review**: `review-total` (:683 desktop / :1031 mobile), `review-pax-fare-{i}` (:662), `review-outbound-line`/`-return-line` (:670/674), `review-child-badge` (:657), `confirm-and-pay` (:694), `seat-hold-timer` (:719).
- **Payment**: `pay-method-{type}` (:597), `pay-total` (:390 / :651 mobile), `pay-submit` (:406), `cac-phone-input` (:488), `cac-otp-input` (:531).
- **Confirmation**: `confirmation-pnr` (:404), `confirmation-status` (:615), `confirmation-total-paid` (:631), `ticket-number-{i}` (:659), `download-voucher` (:794), `book-another` (:815).
### Backoffice (`apps/edr-passenger-web/backoffice/src`)
- **Login**: `login-email` (:165), `login-password` (:189), `login-submit` (:221).
- **DataTable / dialogs (shared)**: `add-entity-btn` (ActionButton), `row-edit-{id}`, `row-delete-{id}`, `confirm-dialog-confirm`, `confirm-cascade-checkbox`, `modal-submit`.
- **Tariff Rates** (`/tariff-rates`): `tab-seatclass`/`tab-route`/`tab-segment`/`tab-baggage`; RateModal fields already have `name=` (`name`, `baseFareMinor`, `insuranceFeeMinor`/`surchargeMinor`, `isActive`) — add `testid` on submit + modal.
- **Currencies** (`/currencies`): controlled form (no `name=`) — add `currency-from`, `currency-to`, `currency-rate`, `currency-save`, `currency-edit-rate`.
- **Classes** (`/classes`): FormData has `name=` (`coachTypeId,name,baseFareMinor,insuranceFeeMinor,isActive`) — add submit testid.
- **Schedules** (`/schedules`): controlled `addForm`/`DateTimePicker` — add `schedule-train`, `schedule-route`, `schedule-departure`, `schedule-arrival`, `schedule-status`, `schedule-save`, `schedule-cancel-btn`.
- **Stations** (`/stations`): FormData `name=` present — add submit testid.
- **Settings** (`/settings`): real `id=` (`hold-duration`, `hold-cutoff`, `boarding-window`, throttle-*) — usable, but add `config-save` testid.
- **Promos** (`/promos`, URL-only): FormData `name=` present — add submit + note field-name mismatch (PB-7).
---
## 7. OPEN QUESTIONS / RISKS (decide before Phase 2)
1. **Valid IAM token for storageState** — Path A (real `/v1/auth/login` after enabling `SEED_EDR_PASSENGER_ORG` + `SEED_PASSENGER_STAFF`) vs Path B (direct `iam.sessions` insert with `userInfo.roles=[{key:'super_admin'}]` + self-signed JWT). **Recommend Path A for staff, Path B acceptable for passenger.** Confirm.
2. **Seed `iam.sessions` vs dev bypass** — there is **no dev auth bypass** in the passenger-API `JwtGuard` (DB-backed, no env short-circuit). A session row is mandatory for any authenticated flow. Confirm we may write directly to `iam.sessions` in the test DB.
3. **Target DB / stack** — doc drift: CLAUDE.md says `postgres-passenger:5434/edr_passenger`; `.env.example` says `localhost:5432/edr_database?schema=passenger`; `.env.test` uses `5544`; no compose file provisions it. **Confirm the harness stands up its own Postgres :5544 + boots both APIs, or targets an existing dev stack.**
4. **Stack-startup reliability** — global-setup must boot passenger-api (:4000) + payment-api (:3003) + portal (:5174) + backoffice (:5184) + RabbitMQ (vhost `payment`), or route settlement through `/internal/payments/mark-paid` to avoid RabbitMQ. **Recommend the internal-endpoint path for green settlement determinism** — but note it will **not** reproduce a *late*-webhook race (C-5) nor the charge-currency conversion (DJF, UA-3), which both require a real forged-gateway webhook to :3003.
5. **Gateway webhook signing** — Telebirr/dmoney accept forged payloads (`signatureValid=true` hardcoded); Card/Waafi require valid HMAC. UA-3/UA-15/gateway rows must use Telebirr/dmoney or the internal endpoint. Confirm we won't need real Card/Waafi HMAC in Phase 2.
6. **Fayda flag & prefix** — global-setup must set `VERIFAYDA_ENABLED=false` (else the manual passenger form is hidden and every booking flow blocks). **Confirm which `fayda-status` route the portal reads** (`/config`, default-ON, vs `fare-engine.controller.ts:65`, default-OFF) so the correct flag is set.
7. **Promo money-flow — does the authed path share the guest override+clamp?** The guest booking service overrides its discounted total with `reviewedTotalMinor` and clamps (`guest-booking.service.ts:240-244,487,536-537,744`), making promos inert and negative totals unreachable via UI. **Verify whether `bookings.service.ts` (authed `POST /bookings`) has the same override+clamp** before finalizing UA-8's "promo silently dropped" assertion for logged-in users.
8. **`data-testid` addition** — Section 6 requires source edits to both web apps (including the `DobPickerModal` internals for child-fare rows). Approve adding testids (small, low-risk) vs authoring against fragile role/text selectors. **Strongly recommend adding testids first.**
9. **Two field-name mismatches to verify at runtime** (each may be its own finding): (a) Promos UI sends `discountType/discountValue/isActive` but DTO expects `percentOff/amountOffMinor/active` → possibly inert promos (PB-7). (b) Seat-class base written as `basePrice` (Tariff Rates, PB-2) vs `baseFareMinor` (Classes page, PB-6), across two endpoints (`/seat-classes` vs `/fleet/classes`) — confirm which the live `fare-engine` reads before asserting PB-2/PB-6.
10. **Portal station operational filter** (PB-5) — portal `GET /stations` passes no `operational` filter; whether a disabled station disappears depends on the server default. Verify before writing the disable-propagation assertion.
11. **Currency-controller collision** — two `@Controller('currencies')` register the same base path (`currencies.controller.ts` + `currency.controller.ts`) with different guards/bodies; confirm which one the backoffice `/currencies` page hits before asserting PB-1/PB-10 write semantics.
12. **Seat-hold TTL number** (PB-9) — the matrix draft said "20-min cron"; the seed map says `expiresAt = now + 15min` (`seats.service.ts:~272`). **Reconcile the actual fixed TTL** before asserting that the hold ignores the config value.
13. **On-select fare divergence** (UA-1b) — confirm that the value stored on select is `Math.min(baseFareMinor)` (results:320) and not the card's `displayAmountMinor` (results:821), and pin which one downstream fare-breakdown reconciles against for non-ETB currencies.
14. **Scope of Track A vs B** — Track A (UA-*) covers pricing integrity through the real browser (closes the Suite K gap); Track B/BC-* covers config propagation. Confirm both tracks are in Phase 2 scope, or prioritize Track A first (highest money-risk, most ✅ findings to surface in-browser).
15. **Explicitly out-of-scope money surfaces (deferral, not omission):** loyalty redemption (C-2, no browser path), refunds (no endpoint mapped), over-100%/over-subtotal promo negative totals (H-1, API-only), transit / `ROUND_TRIP_TRANSIT` (needs a 2nd seeded route), package booking (`/packages`, `isPackageOnly` schedules, `packageTierPriceMinor × 2`), `/pay-balance/[token]` partial-payment / `returnLegStatus`, and config surfaces `/fare-management` + `/pricing`. Confirm these stay deferred so the matrix is not read as exhaustive.