Files
edr-platform/docs/ISSUES.md
Muluhabt c4f54a666b test: add EDR passenger pricing/config E2E bug-hunt harness
Hermetic E2E harness targeting pricing integrity and backoffice config:
- e2e/ docker Postgres (5544) + prepare.sh/run.sh one-command runner + HTML report
- 6 suites / 23 tests reproducing pricing, FX, wallet, refund, config and auth
  defects (see docs/ISSUES.md); docs/e2e-test-matrix.md documents the matrix
- two-tier harness (slim module boot + direct service instantiation) to work
  around the IAM/RabbitMQ/file-type boot wall
- .env.test.example tracked; loader falls back to it for fresh checkouts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:22:40 +03:00

20 KiB
Raw Blame History

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 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.

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.tstopUp(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-2027refundAmount = 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.tsupsert/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 J2J4.
  • 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-192total = 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:185promo.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:53baggageAllowance.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-140orderBy 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<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.

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 I3I6.
  • 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,1067floor(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.