diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index d8343df7a..dbbeaf7c6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -935,7 +935,9 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor, - currency: displayCurrency, + // Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in + // displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units. + currency: Currency.ETB, adultCount, childCount, displayCurrency, @@ -1135,7 +1137,7 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -1328,7 +1330,7 @@ export class BookingsService { status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -1538,7 +1540,7 @@ export class BookingsService { destinationStationId: dto.leg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', - totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, + totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor // Outbound transit leg-2 leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index cb3760796..d40824c36 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -304,7 +304,9 @@ export class GuestBookingService { destinationStationId: dto.destinationStationId, status: 'PENDING_PAYMENT', totalMinor: resolvedTotalMinor, - currency: displayCurrency, + // Charge basis is ETB (resolvedTotalMinor). The passenger's currency and amount live in + // displayCurrency/displayTotalMinor — keep currency coherent with totalMinor's units. + currency: Currency.ETB, adultCount, childCount, displayCurrency, @@ -583,7 +585,7 @@ export class GuestBookingService { status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -787,7 +789,7 @@ export class GuestBookingService { status: 'PENDING_PAYMENT', bookingType: 'TRANSIT', totalMinor, - currency: displayCurrency, + currency: Currency.ETB, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor adultCount, childCount, displayCurrency, @@ -1003,7 +1005,7 @@ export class GuestBookingService { destinationStationId: dto.returnLeg2DestinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ROUND_TRIP_TRANSIT', - totalMinor, currency: displayCurrency, adultCount, childCount, displayCurrency, displayTotalMinor, + totalMinor, currency: Currency.ETB, adultCount, childCount, displayCurrency, displayTotalMinor, // ETB charge basis; passenger currency in displayCurrency/displayTotalMinor leg2ScheduleId: dto.leg2ScheduleId, leg2OriginStationId: dto.transitStationId, leg2DestinationStationId: dto.leg2DestinationStationId, diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index fd3d75401..69b0266ed 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -23,8 +23,11 @@ export class FareEngineService { if (!originStop) throw new BadRequestException('Origin station not found on this route'); if (!destStop) throw new BadRequestException('Destination station not found on this route'); - if (originStop.sequence >= destStop.sequence) - throw new BadRequestException('Origin must come before destination in the route sequence'); + // Origin and destination must be distinct stops, but EITHER direction is valid: a round-trip + // return leg traverses the same route high→low (e.g. C→A), so we price the segment by its + // absolute distance rather than rejecting the reverse order. + if (originStop.sequence === destStop.sequence) + throw new BadRequestException('Origin and destination must be different stops on this route'); const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }); if (!seatClass) throw new NotFoundException('Seat class not found'); @@ -43,8 +46,8 @@ export class FareEngineService { }, }) ?? seatClass; - const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; - if (totalDistanceKm < 0 || isNaN(totalDistanceKm)) + const totalDistanceKm = Math.abs(destStop.distanceKm! - originStop.distanceKm!); + if (totalDistanceKm <= 0 || isNaN(totalDistanceKm)) throw new BadRequestException('Invalid distance calculation - check route stop distances'); const now = new Date(); diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index ba609fa98..2c7d37f0f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -318,7 +318,7 @@ export default function ResultsPage() { ); // Use displayAmountMinor (passenger's currency) so stored fare matches what the card showed. const minFare = coachType?.classes.length - ? Math.min(...coachType.classes.map((c) => c.baseFareMinor)) + ? Math.min(...coachType.classes.map((c) => c.displayAmountMinor ?? c.baseFareMinor)) : 0; const fareCurrency = displayCurrencyCode; diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 99316c042..1fe3fb964 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -226,6 +226,20 @@ Two structural facts frame everything: `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. +- **Resolution (booking-record coherence, UA-1b/UA-2/UA-3w)** ✅ — the stored booking record no longer + mislabels its amount. Every `booking.create` path (`bookings.service.ts` one-way/round-trip/ + transit/round-trip-transit + the guest equivalents) now stores `currency: Currency.ETB` (the actual + currency of `totalMinor`/the ETB charge basis) instead of the display currency. The passenger-facing + amount stays in `displayCurrency`/`displayTotalMinor` (Birr for Ethiopian, DJF for Djiboutian, USD + for Other), and every read endpoint already prefers those. The portal `results/page.tsx` on-select + now carries the passenger-currency fare (`displayAmountMinor`) forward, aligning with the seats + page's already-`displayAmountMinor` fare logic. Net effect (agreed model **A**): the passenger sees + and is charged in their own currency; the internal charge basis stays ETB (the unit every downstream + calc — wallet debit, loyalty, refund, gateway conversion — already assumes), now honestly labeled. + Proven by `e2e-ui/specs/portal/ua2-usd-booking.spec.ts` and `ua3-djf.spec.ts` (UA-3w): `currency` + is `ETB` while `displayCurrency`/`displayTotalMinor` carry USD/DJF — red before the fix + (`currency` was `USD`/`DJF`), green after; UA-1 (ETB) unchanged. The deeper H-3 (unify + `getExchangeRate`/`getRateOrThrow`) and H-4 (branded Minor/Major units) refactors remain open. ### H-4 ✅ Conversion routines return different UNITS for the same money - **Where**: `displayMinorToChargeMajor`/`convertMinorToChargeMajor` return **major** units; @@ -379,6 +393,13 @@ Two structural facts frame everything: `: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. +- **Resolution (past departure)** ✅ — `schedules.service.ts` `createSchedule` now rejects a + `departureAt` in the past (`dep.getTime() < Date.now()` → 400) alongside the existing + `arrivalAt > departureAt` check. Proven by `e2e-ui/specs/backoffice/config-validation.spec.ts` + (BC-10): a 2020 departure → 400 while a future schedule still creates (red before the guard, green + after). Scoped to creation (an admin may still need to edit metadata on an already-departed + schedule via `updateSchedule`). The cross-route train double-booking overlap widening remains a + follow-up (not exercised by BC-10). ### M-5 🔎 Deletes ignore referencing bookings; one cascade is non-transactional - **Where**: station delete ignores bookings (`stations.service.ts:110-137`); seat-class delete diff --git a/docs/ui-e2e-test-matrix.md b/docs/ui-e2e-test-matrix.md index fa5877648..7a2f2c816 100644 --- a/docs/ui-e2e-test-matrix.md +++ b/docs/ui-e2e-test-matrix.md @@ -78,13 +78,13 @@ Axes: booking type {one-way, round-trip} × pax mix {1A, 2A, 1A+1C-free, 1A+2C ( | 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-1b** | One-way, 1 adult, **Other/USD** — assert card `displayAmountMinor` vs internal `baseFareMinor` | nationality OTHER → USD; INTL class | ✅ FIXED — the card shows the USD fare (`displayAmountMinor`), the internal `baseFareMinor` is the ETB source it was converted from (rate apart, coherent). The portal now carries the USD value forward (`results/page.tsx` on-select uses `displayAmountMinor`, aligning with the already-USD seats-page logic). | div #1; H-3/H-4 | +| **UA-2** | One-way, 1 adult, Other/USD, INTERNATIONAL Regular, WALLET | OTHER → displayCurrency USD | ✅ FIXED — money chain COHERENT: `displayCurrency=USD`/`displayTotalMinor` = what the passenger saw (=`reviewedTotalMinor`); `currency=ETB`/`totalMinor` = the ETB charge basis (=`displayTotalMinor × rate`). The prior `currency:USD`-on-an-ETB-amount mislabel is gone. | 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-6** | Round-trip, 1 adult, Economy Regular, ETB, WALLET | ROUND_TRIP, outbound+inbound holds | ✅ FIXED — the fare engine now prices the reverse (C→A) leg by absolute distance (was: threw "origin must come before destination", leaving the inbound leg with seats but no priced coach → unbookable). Full two-leg flow completes: 2 seats (one per leg), total = 2× the one-way fare. | 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 | @@ -142,7 +142,7 @@ Each row: change made in backoffice UI (:5184) → API write → portal read (:5 - **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-10** ✅ FIXED: `POST /schedules` with a past `departureAt` is now rejected with **400** — `schedules.service.createSchedule` guards `departureAt >= now` alongside the existing `arrival > departure` check. A future schedule still creates. **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. --- diff --git a/e2e-ui-report/index.html b/e2e-ui-report/index.html index b507eeb71..ba053244d 100644 --- a/e2e-ui-report/index.html +++ b/e2e-ui-report/index.html @@ -87,4 +87,4 @@ Error generating stack: `+l.message+`