# EDR Passenger Platform — Issues Report Findings from the pricing/backoffice E2E bug-hunt. **No product code was changed** — this is a report. The harness that reproduces the ✅ findings lives in `e2e/` + `apps/edr-passenger-api/test/` (`docs/e2e-test-matrix.md` is the full test matrix; `e2e/README.md` explains how to run it). **Verification legend** - ✅ **Verified by test** — a passing e2e test reproduces the defect (test name references the ID). - 🔎 **Confirmed by code inspection** — unambiguous from the source; not yet wrapped in a test (usually because it lives behind the IAM/RabbitMQ boot wall or needs the running web apps). - ⚠️ **Suspected** — plausible from the source; needs runtime confirmation. **Severity**: how much money / trust is at risk, and how easily. Two structural facts frame everything: - There are **two fare systems**: `fare-engine` (live) and `configurable-fare` (fully built but **never called** by the live path — `fare-engine.calculate` never reads `fare_configurations`). All findings below concern the **live** `fare-engine` unless noted. - The domain seed (`prisma/seed.ts`) is **entirely disabled** (every step commented out). --- ## CRITICAL — money can be created, stolen, or set by the client ### C-1 ✅ Booking total is client-controlled (server fare computed, then discarded) - **Where**: `bookings.service.ts:863-899` (one-way), `:1065-1095` (round-trip), `guest-booking.service.ts:206-245,494-540`. Per-seat: `:840` `fareMinor = p.seatFareMinor ?? …`. - **Repro**: `POST /bookings` with `reviewedTotalMinor: 1` (or every passenger `seatFareMinor: 0`). - **Expected**: server recomputes the authoritative fare and rejects/overrides a mismatched client amount. **Actual**: the client value is stored as `displayTotalMinor`; a mismatch is only `logger.warn`-ed (`:873-874`), never rejected. A trip can be booked for 1 cent. - **Status**: ✅ verified — `critical-repro.e2e-spec.ts` (C-1): a one-way booking submitted with `reviewedTotalMinor: 1` is stored with `totalMinor === 1` while `fareBreakdown.totalMinor` is ≥ 30000. Matrix A1–A4. - **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. ### 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`. `loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10` subtracted from the total. - **Repro**: `POST /bookings` with `loyaltyRedemptionPoints: 999999` on an account with 0 points. - **Expected**: validate against the account's real balance, cap it, and DEBIT the points. **Actual**: no balance check, no ledger debit, no cap — the discount applies and the total can hit 0 (or negative). Points are only ever *awarded* (`payments.service.ts:1062`), never spent here. - **Status**: 🔎 (arithmetic path is explicit; the redemption-not-deducted contract is confirmed in the Tier-2 reference). Matrix A5/F6. - **Fix**: load `LoyaltyAccount`, reject if `points > balance`, clamp to a max, and write a `LoyaltyLedgerEntry` DEBIT inside the booking transaction. ### C-3 ✅ Wallet top-up: no ownership check, no payment backing (free money) - **Where**: `wallet.service.ts:50-56`; controller `wallet.controller.ts:34-39`. Also `GET /wallet/accounts` is `@IsPublic()` (`wallet.controller.ts:23-24`) → leaks all balances. - **Repro (verified)**: `money-integrity.e2e-spec.ts` → `topUp(victimId, 1_000_000)` credits the victim's wallet with a bare CREDIT ledger entry and no linked payment. - **Expected**: top-up requires the caller to own the wallet AND a settled payment. **Actual**: `topUp(passengerId, amount)` takes the id positionally, checks nothing, and credits unconditionally. - **Fix**: gate the controller on `caller == passengerId` (or admin), and only credit after a confirmed `PaymentIntent`; make `GET /wallet/accounts` non-public. ### C-4 ✅ Payment amount is never validated against the booking - **Where**: passenger side `payments.service.ts:809-848,910-939`; payment side `intents.service.ts:541-548` (mismatch only `logger.error`, intent still SUCCEEDED). Webhook handlers never set `confirmedAmountMinor` (e.g. `waafi-webhook.service.ts:63-69`). - **Repro**: ✅ verified — `critical-repro.e2e-spec.ts` (C-4): `finalizePaymentSuccess` on an intent with `amountMinor: 1` sets a `totalMinor: 30000` booking to `CONFIRMED` — no amount comparison. - **Expected**: reject/hold on amount mismatch. **Actual**: any provider "success" confirms the 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. ### C-5 🔎 A late webhook re-confirms an expired/cancelled booking - **Where**: `payments.service.ts:809-848` (`finalizePaymentSuccess` never reads `booking.status`); expiry cron `bookings.service.ts:2123-2128` (hardcoded 20 min). - **Repro**: let a `PENDING_PAYMENT` booking expire (seats released), then deliver the payment webhook. - **Expected**: reject payment for a cancelled/expired booking (and refund). **Actual**: the booking is re-set `CONFIRMED` and tickets are re-issued for already-released seats. Matrix G2. - **Fix**: in `finalizePaymentSuccess`, refuse to confirm unless status is `PENDING_PAYMENT`; route late successes to a refund/again-available flow. ### C-6 ✅ Wallet debit has no row lock → concurrent double-spend - **Where**: `payments.service.ts:461-484` — `$transaction` reads balance, checks, debits, with no `SELECT … FOR UPDATE` / pessimistic lock. - **Repro**: ✅ verified — `critical-repro.e2e-spec.ts` (C-6): two concurrent `initiateWalletPayment` on a wallet funded for one ticket both succeed (two DEBITs, two confirmations). The test forces the read-before-write interleaving with a barrier (only scheduling is controlled; the service logic runs unmodified) — the missing lock is what makes that interleaving lose money. - **Expected**: one succeeds, one fails; balance never over-drawn. **Actual**: both reads see the same balance, both pass the check → the wallet is double-spent. Matrix F4. - **Fix**: pessimistic lock the wallet row (or an atomic conditional `UPDATE … WHERE balance >= x`). ### C-7 ✅ Refund is computed (80%) but never disbursed - **Where**: `bookings.service.ts:2017-2027` — `refundAmount = floor(total*0.8)`, writes `BookingCancellation{ refundStatus:'PENDING' }`; the only `booking.cancelled` listener is a notification (`notifications.service.ts:750`). No `PaymentRefund`, no wallet credit, no provider refund anywhere. - **Repro (verified)**: `money-integrity.e2e-spec.ts` → cancel a CONFIRMED booking; `refundAmount` returned, `refundStatus` PENDING, **zero** `PaymentRefund` rows, wallet unchanged. - **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. - **Fix**: add `@PassengerAdmin()` (or `@PassengerStaff([currencies.manage])`) to `PUT`/`PATCH`. ### C-9 🔎 `@Roles('ADMIN')` is dead everywhere (RolesGuard never wired) - **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 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 J2–J4. - **Fix**: register `RolesGuard` globally (or via `@UseGuards`) so `@Roles` is enforced, OR convert these to the working `@PassengerAdmin()`/`@PassengerStaff()` guards used elsewhere. --- ## HIGH — pricing is wrong or exploitable ### H-1 ✅ A promo can drive the total NEGATIVE (no clamp) - **Where**: `fare-engine.service.ts:185-192` — `total = subtotal - discount`, no `Math.max(0,…)`. DTO gaps: `promos.dto.ts:20` (`percentOff` no `@Max(100)`), `:26` (`amountOffMinor` unbounded). - **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` → promo `percentOff:150` and a fixed `amountOffMinor > subtotal` both yield a **negative** `totalMinor`. - **Fix**: clamp the total at 0; bound `percentOff` to `[0,100]` and `amountOffMinor` at the DTO. ### H-2 ✅ Missing FX rate is silently substituted with 1.0 - **Where**: `currency.service.ts:142-147` (`getExchangeRate` returns `1.0` + a `warn`). - **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` (C1) — deleting the USD→ETB rate collapses 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. ### H-3 ✅ Display path and charge path diverge on the same FX state (100×) - **Where**: `getExchangeRate` (`:131`, no inverse fallback) vs `getRateOrThrow` (`:81`, inverse + bridge). The fare/display uses the former; the charge uses the latter. - **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C2) — with only the inverse rate present, `getExchangeRate(USD,ETB)=1.0` but `getRateOrThrow(USD,ETB)=100` → displayed fare and charged amount differ 100×. Matrix C2/C5. - **Fix**: one shared conversion routine with one rounding rule and one fallback policy. ### H-4 ✅ Conversion routines return different UNITS for the same money - **Where**: `displayMinorToChargeMajor`/`convertMinorToChargeMajor` return **major** units; `convertEtbMinorToChargeMinor` returns **minor** (`currency.service.ts:27,61,35`); `payments.service.ts:250-281` writes the major result into a field named `amountMinor`. - **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C5) — same amount comes out 100× apart. - **Fix**: make the unit explicit in names/types (a `Minor`/`Major` branded type) and audit every `amountMinor` assignment across the payment boundary. ### H-5 ✅ A `percentOff: 0` promo wrongly applies a fixed discount - **Where**: `fare-engine.service.ts:185` — `promo.percentOff ? percent : amountOffMinor`; `0` is falsy. - **Repro (verified)**: `pricing-fare-engine.e2e-spec.ts` (D4) — promo `{percentOff:0, amountOffMinor:5000}` deducts 5000 instead of 0. - **Fix**: test `percentOff != null` rather than truthiness. ### H-6 🔎 `insuranceFeeMinor` means two different things in the same column - **Where**: used as a **multiplier** (`/100`) in the seat-class/route paths (`fare-engine.service.ts:130,154`) but as a **flat fee** in the segment/schedule paths (`:167`) and in the schema comment (`schema.prisma:95`). - **Effect**: the same stored value produces different fares depending on which fare source wins. Matrix B1. - **Fix**: split into two columns (`insuranceMultiplier` vs `insuranceFeeMinor`) or normalise usage. ### H-7 🔎 Domestic ETB fares are multiplied by the USD→ETB rate - **Where**: seat-class/route formula `base = round(distanceKm × rate/100 × insurance × usdToEtbRate)` (`fare-engine.service.ts:157-160`). For a LOCAL (ETB) fare this multiplies by USD→ETB. - **Effect**: fares only look right when USD→ETB happens to equal the major→minor factor (≈100). Set a realistic rate (~132) and every domestic fare is ~30% off. Matrix B2. (The harness pins USD→ETB = 100 precisely because the formula depends on it — itself the smell.) - **Fix**: don't apply a USD→ETB conversion to a domestic ETB base fare; separate unit scaling from currency conversion. ### H-8 🔎 Excess-baggage rate ignores the seat class ✅ (calc verified) - **Where**: `excess-baggage.service.ts:53` — `baggageAllowance.findFirst({ orderBy:{createdAt:'asc'}})` (oldest global row, no `where`). - **Repro (verified)**: `money-integrity.e2e-spec.ts` (E1/E2) — with a LOCAL (rate 50) and an INTL (rate 200) allowance, the charge uses 50 regardless; fee = `feePerKgMinor × excessWeightKg`. - **Fix**: look up the allowance by the booking's `seatClassId`. ### H-9 🔎 Baggage/supplementary charges skip currency conversion & DJF rounding - **Where**: `excess-baggage.service.ts:166` and `supplementary-charges.service.ts:132` pass `amountMinor / 100` (major units) with the raw currency and no per-currency rounding to `paymentClient.initiate`. - **Effect**: wrong amount for DJF (0-decimal) and any non-ETB currency. Matrix E2/E-supp. - **Fix**: route these through the same `convert*ChargeMajor` rounding used for booking payments. ### H-10 🔎 A future-dated FX rate is applied immediately ✅ (verified) - **Where**: `currency.service.ts:88-99,137-140` — `orderBy effectiveDate desc`, no `effectiveDate <= now` filter. - **Repro (verified)**: `pricing-currency.e2e-spec.ts` (C3) — a rate dated one year out is used now. - **Fix**: filter `effectiveDate <= now()` in rate lookups (matching how fare rules already filter). ### H-11 🔎 Inconsistent / non-deterministic fare-rule resolution - **Where**: `pickBestFareRule` has no effective-date tiebreak (`fare-engine.service.ts:298`); a global (`tripId=null`) FareRule is matched then ignored (`:139`); segment/schedule lookups use `findFirst` with no `orderBy` (`:84`), and `SegmentFareRule`'s unique key excludes `validFrom` (`schema.prisma:1113`) so fares can't be versioned by date. Matrix B4/B5. - **Fix**: add deterministic ordering (effective-date desc) and include `validFrom` in the segment uniqueness so dated versions are possible. ### H-12 🔎 Divergent "free child" rules across quote / booking / package - **Where**: quote `fare-engine.service.ts:172` uses `min(child, adult)`; booking `bookings.service.ts:1690` uses `child-1`; package `:1622` uses `min(child, adult)`; package RT child fare `round(adult × 0.1)` float (`payments.service.ts:135,180`, `bookings.service.ts:34-40`). - **Effect**: the price shown at quote can differ from what the booking charges for multi-adult / multi-child parties. Matrix B6/B7. - **Fix**: one shared fare function used by quote, booking, and payment. --- ## MEDIUM — backoffice config accepts invalid data / unsafe deletes ### M-1 ✅ Negative fares accepted (missing `@Min`) - **Where**: `schedules.dto.ts:85,95` (`CreateFareRuleDto`/`CreateSegmentFareRuleDto.baseFareMinor`, `@IsInt` only); `seat-classes.dto.ts:29` (`basePrice`). Sibling `segments/segment-fare.dto.ts:24` *does* have `@Min(0)` — inconsistent. - **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. ### M-2 ✅ Promo bounds/date not validated - **Where**: `promos.dto.ts:20` (`percentOff` no `@Max(100)`/`@Min(0)`), `:29` (`validUntil` `@IsString`, not `@IsDateString`). - **Repro (verified)**: `config-validation.e2e-spec.ts` (H4/H5) — `percentOff:200` and `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`). ### 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`. Setting `seat_hold_duration_minutes = -1` or `"abc"` is persisted. Matrix H7. - **Fix**: a whitelisted, typed DTO with per-key numeric/range validation. ### 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); `:124-132` blocks only same-train+same-route+same-day, so the same train can run two routes at overlapping times. Matrix H3/H4. - **Fix**: reject past `departureAt`; widen the overlap check to the train across all routes. ### M-5 🔎 Deletes ignore referencing bookings; one cascade is non-transactional - **Where**: station delete ignores bookings (`stations.service.ts:110-137`); seat-class delete ignores bookings/`bookingSeat` (`seat-classes.service.ts:53-81`); `currencies.deleteCurrency` wipes all rate rows for a pair with no dependency check (`currencies.service.ts:119-134`) → future fares for that pair fall to the 1.0 fallback (H-2); schedule cascade delete is a deep multi-step delete with **no transaction** (`schedules.service.ts:438-485`) → partial-delete on failure. Matrix I3–I6. - **Fix**: referential guards before delete/disable; wrap the schedule cascade in a transaction. ### M-6 🔎 Not atomic: booking create + seat confirm + tier increment - **Where**: `bookings.service.ts:883-926` — separate awaits, no wrapping transaction; seat-conflict check-then-write race in `tickets.service.ts:357-372`. Matrix G8. - **Fix**: wrap the create/confirm/increment in a single transaction. --- ## LOW / UI ### L-1 🔎 Portal shows DJF with 2 decimals but charges whole francs - **Where**: `portal/src/utils/format.ts:22-28` (`Intl.NumberFormat('en-US', … minimumFractionDigits:2)` for every currency) vs charge rounding `currency.service.ts:9-13` (DJF = 0 decimals). Matrix C6/K3. - **Status**: needs the Playwright/UI suite (not yet run — see below). - **Fix**: format per `CHARGE_CURRENCY_DECIMALS`. ### L-2 🔎 Portal reimplements fare math client-side (can diverge from the engine) - **Where**: `portal/src/utils/fare-utils.ts:50,67,93`; `portal/src/app/booking/review/page.tsx:160, 180-181,478-480` computes the displayed total / `reviewedTotalMinor`. Matrix K1/K2 + ties to C-1. - **Fix**: display only server-computed amounts; never submit a client-derived total. ### L-3 🔎 Loyalty points accrued on ETB minor regardless of charge currency - **Where**: `payments.service.ts:1062,1067` — `floor(amountMinor/100)` on `booking.totalMinor` (always ETB minor). Matrix F5. - **Fix**: accrue from the actual charged amount/currency. --- ## Not yet covered (honest gaps) - **Suite K (browser / Playwright)** — L-1 and L-2 (UI price rendering & client-side fare math) are confirmed by source reading but **not** yet reproduced in a browser. Running them needs the portal + backoffice Next.js apps up with a seeded search result. Scaffolding is the remaining step of the "light Playwright" scope. - **C-1, C-4, C-6** are now reproduced (`critical-repro.e2e-spec.ts`). **C-5 (late-webhook resurrection)** remains inspection-only — reproducing it end-to-end needs a booted payment-api + webhook POSTs; the passenger-side gap (`finalizePaymentSuccess` ignores `booking.status`) is directly readable. - **`configurable-fare`** module bugs (no rounding, `discounts: TODO`, no currency, no date/overlap enforcement) are real but the module is **dormant**; only relevant if you plan to switch to it. --- ## Suggested priority order to fix 1. **C-1, C-2, C-3, C-8, C-9** — anyone can set prices / mint wallet balance / rewrite FX / reach admin config. These are actively exploitable. 2. **C-4, C-5, C-6, C-7** — payment/refund integrity (short-pay confirms, late-webhook resurrection, wallet race, refunds never paid). 3. **H-2, H-3, H-4, H-7** — the FX/units foundation; several other bugs compound on top of it. 4. **H-1, H-5, H-8..H-12, M-1, M-2** — pricing correctness + validation gaps. 5. **M-3..M-6, L-1..L-3** — config safety and UI consistency.