From a60bcf81630d428e1de566b5fc1884ed109bed1e Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 31 Aug 2026 13:34:10 +0300 Subject: [PATCH 1/8] feat: ( bookings ) add My Bookings history covering account and same-phone guest bookings --- .../src/modules/bookings/bookings.service.ts | 236 +++++++++++------- .../portal/src/components/MyBookingsTable.tsx | 4 + 2 files changed, 152 insertions(+), 88 deletions(-) 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 b627d64ba..a3931ef1f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -93,22 +93,139 @@ export class BookingsService { private readonly paymentsService: PaymentsService, ) {} - async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { - // An IAM user with no Passenger row is normal, not an error: a freshly registered - // account that has never booked, or a staff account. findUniqueOrThrow raised P2025 - // here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead. - const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); - if (!passenger) { - const page = filters.page ?? 1; - const pageSize = filters.pageSize ?? 20; - return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; - } - return this.findByPassengerId(passenger.id, filters); + /** + * Every Booking-level condition that means "this booking belongs to the person who + * owns `variants`". Shared by findByPhone (public guest retrieval) and + * findByIamUserId (the portal's own history) so the two can never disagree about + * what a phone number owns. + * + * Each sub-lookup is independently catch-and-warn: a phone match is a best-effort + * widening, and one unavailable source must not fail the whole listing. + */ + private async buildPhoneOwnershipClauses( + variants: string[], + ): Promise<{ clauses: any[]; passengerIds: string[] }> { + if (variants.length === 0) return { clauses: [], passengerIds: [] }; + + // Authenticated-user bookings don't store contactPhone — their phone lives in + // iam.users.phone_number, linked through passenger.iamUserId. + const iamRows = await this.dataSource + .query<{ id: string }[]>( + `SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`, + [variants], + ) + .catch((err: unknown) => { + this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); + return [] as { id: string }[]; + }); + + const iamPassengerIds = iamRows.length > 0 + ? (await this.prisma.passenger.findMany({ + where: { iamUserId: { in: iamRows.map(r => r.id) } }, + select: { id: true }, + })).map(p => p.id) + : []; + + // Guest bookings store phone in TravelerProfile.notes JSON (created for every guest + // booking). Catches cases where contactPhone was null but the profile recorded it. + const travelerRows = await this.dataSource + .query<{ passengerId: string }[]>( + `SELECT DISTINCT passenger_id AS "passengerId" + FROM passenger.traveler_profiles + WHERE notes IS NOT NULL + AND (notes::jsonb->>'phone') = ANY($1::text[])`, + [variants], + ) + .catch((err: unknown) => { + this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); + return [] as { passengerId: string }[]; + }); + + // Guests who saved their profile (savePassengerDetails:true) have a + // SavedPassengerProfile row with phone + deviceId; guest bookings stash that + // deviceId in Booking.userAgent. + const savedProfileRows = await this.dataSource + .query<{ deviceId: string }[]>( + `SELECT DISTINCT device_id AS "deviceId" + FROM passenger.saved_passenger_profiles + WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`, + [variants], + ) + .catch((err: unknown) => { + this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); + return [] as { deviceId: string }[]; + }); + + const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerRows.map(r => r.passengerId)])]; + const guestDeviceIds = savedProfileRows.map(r => r.deviceId); + + return { + clauses: [ + { contactPhone: { in: variants } }, + { passenger: { user: { phone: { in: variants } } } }, + ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), + ...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []), + ], + passengerIds: allPassengerIds, + }; } /** * The portal's authenticated "My bookings" history (GET /bookings/my). * + * Returns bookings made **while signed in** (they hang off the Passenger row linked + * to this IAM user) *and* bookings made as a **guest with the same phone number**. + * The second half matters: resolveGuestPassenger (guest-booking.service.ts) creates a + * fresh, unlinked `Passenger` for every guest booking and never looks the phone up, so + * a customer's guest history is scattered across orphan rows that a passengerId-only + * filter cannot see. On the dev database one account had 4 visible bookings out of 30 + * carrying its own phone number. + * + * Privacy note: the widened set is exactly what `GET /bookings/by-phone` already + * returns to *anonymous* callers, so showing it to the verified owner of that number + * exposes nothing that was not already public. The phone comes from iam.users, not + * from the request. + */ + async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { + // An IAM user with no Passenger row is normal, not an error: a freshly registered + // account that has never booked, or a staff account. findUniqueOrThrow raised P2025 + // here, which surfaced as a 500 on the portal's "My bookings" page. + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); + + const iamRows = await this.dataSource + .query<{ phone_number: string | null }[]>( + `SELECT phone_number FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ) + .catch((err: unknown) => { + this.logger.warn(`IAM self phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); + return [] as { phone_number: string | null }[]; + }); + + const variants = normalizePhoneVariants(iamRows[0]?.phone_number ?? ''); + const ownership: any[] = [ + ...(passenger ? [{ passengerId: passenger.id }] : []), + ...(await this.buildPhoneOwnershipClauses(variants)).clauses, + ]; + + if (ownership.length === 0) { + const page = filters.page ?? 1; + const pageSize = filters.pageSize ?? 20; + return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; + } + + return this.findBookingsForOwner({ OR: ownership }, filters); + } + + async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { + return this.findBookingsForOwner({ passengerId }, filters); + } + + /** + * One page of a customer's own bookings. `ownerClause` says whose they are (a single + * passengerId, or the OR of every phone-ownership clause) and is ANDed with the + * search / status / scope filters, so none of them can clobber another's `OR`. + * * `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates * correctly, rather than the client filtering one page at a time. Note it filters on * `schedule.departureAt` — the schedule's own origin departure — while each item's @@ -116,40 +233,40 @@ export class BookingsService { * boarding stop. They differ by the run time to that stop; that is close enough for a * tab filter and avoids a correlated stopTimes query per row. */ - async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { + private async findBookingsForOwner(ownerClause: any, filters: BookingFilters = {}) { const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - const where: any = { passengerId }; + const and: any[] = [ownerClause]; if (search) { - where.OR = [ - { bookingRef: { contains: search, mode: 'insensitive' } }, - { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, - { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, - ]; + and.push({ + OR: [ + { bookingRef: { contains: search, mode: 'insensitive' } }, + { schedule: { originStation: { name: { contains: search, mode: 'insensitive' } } } }, + { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, + ], + }); } // `status` used to be forwarded raw, so an unrecognised value threw a Prisma // validation error (a 500) rather than being ignored. Only accept real enum members. if (status && (Object.values(BookingStatus) as string[]).includes(status)) { - where.status = status; + and.push({ status }); } const now = new Date(); let orderBy: any = { createdAt: 'desc' }; if (scope === 'cancelled') { - where.status = { in: CLOSED_BOOKING_STATUSES }; + and.push({ status: { in: CLOSED_BOOKING_STATUSES } }); } else if (scope === 'upcoming' || scope === 'past') { - // Don't clobber an explicit `status` filter — intersect with it. - if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES }; - where.schedule = { - ...(where.schedule ?? {}), - departureAt: scope === 'upcoming' ? { gte: now } : { lt: now }, - }; + and.push({ status: { notIn: CLOSED_BOOKING_STATUSES } }); + and.push({ schedule: { departureAt: scope === 'upcoming' ? { gte: now } : { lt: now } } }); orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } }; } + const where: any = { AND: and }; + const [items, total] = await Promise.all([ this.prisma.booking.findMany({ where, @@ -227,68 +344,11 @@ export class BookingsService { const { status, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - // Authenticated-user bookings don't store contactPhone — their phone lives in - // iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup - // that findAll uses for the search field. - const iamRows = await this.dataSource - .query<{ id: string }[]>( - `SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`, - [variants], - ) - .catch((err: unknown) => { - this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); - return [] as { id: string }[]; - }); + // Same ownership resolution the authenticated history uses, so a customer sees the + // same set here and on "My bookings". + const ownership = await this.buildPhoneOwnershipClauses(variants); - const iamPassengerIds = iamRows.length > 0 - ? (await this.prisma.passenger.findMany({ - where: { iamUserId: { in: iamRows.map(r => r.id) } }, - select: { id: true }, - })).map(p => p.id) - : []; - - // Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking). - // This catches cases where contactPhone was null but the phone was still recorded in the profile. - const travelerRows = await this.dataSource - .query<{ passengerId: string }[]>( - `SELECT DISTINCT passenger_id AS "passengerId" - FROM passenger.traveler_profiles - WHERE notes IS NOT NULL - AND (notes::jsonb->>'phone') = ANY($1::text[])`, - [variants], - ) - .catch((err: unknown) => { - this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); - return [] as { passengerId: string }[]; - }); - const travelerPassengerIds = travelerRows.map(r => r.passengerId); - - // Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile - // row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent. - const savedProfileRows = await this.dataSource - .query<{ deviceId: string }[]>( - `SELECT DISTINCT device_id AS "deviceId" - FROM passenger.saved_passenger_profiles - WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`, - [variants], - ) - .catch((err: unknown) => { - this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); - return [] as { deviceId: string }[]; - }); - const guestDeviceIds = savedProfileRows.map(r => r.deviceId); - - // Merge all passenger IDs from every source - const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])]; - - const where: any = { - OR: [ - { contactPhone: { in: variants } }, - { passenger: { user: { phone: { in: variants } } } }, - ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), - ...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []), - ], - }; + const where: any = { OR: ownership.clauses }; if (status) where.status = status; // PackageBooking is a separate table with its own contactPhone field — @@ -296,7 +356,7 @@ export class BookingsService { const pkgWhere: any = { OR: [ { contactPhone: { in: variants } }, - ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), + ...(ownership.passengerIds.length > 0 ? [{ passengerId: { in: ownership.passengerIds } }] : []), ], }; if (status) pkgWhere.status = status; diff --git a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx index 2d3cf7d64..96ecd0d3e 100644 --- a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx +++ b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx @@ -82,6 +82,10 @@ function resolveActions(b: MyBookingItem, userPhone?: string): RowActions { else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType)) rescheduleBlocker = 'Transit bookings cannot be rescheduled online'; else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded'; + // The API applies policy.cutoffMinutes to the old leg's departure, so a departed trip + // is always rejected. Say so here instead of sending them to a page that refuses. + else if (new Date(b.schedule.departureAt).getTime() <= Date.now()) + rescheduleBlocker = 'This trip has already departed'; else if (b.contactPhone && !samePhone(userPhone, b.contactPhone)) rescheduleBlocker = 'Only the person who made this booking can reschedule it'; From 31121db07b7337d17c28f8e0469ac9b7c25ad23a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 31 Aug 2026 14:24:59 +0300 Subject: [PATCH 2/8] Update bookings.service.ts --- .../src/modules/bookings/bookings.service.ts | 42 ++----------------- 1 file changed, 4 insertions(+), 38 deletions(-) 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 a3931ef1f..004aca024 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -99,8 +99,8 @@ export class BookingsService { * findByIamUserId (the portal's own history) so the two can never disagree about * what a phone number owns. * - * Each sub-lookup is independently catch-and-warn: a phone match is a best-effort - * widening, and one unavailable source must not fail the whole listing. + * The IAM lookup is catch-and-warn: a phone match is a best-effort widening, and an + * unavailable IAM must not fail the whole listing. */ private async buildPhoneOwnershipClauses( variants: string[], @@ -126,47 +126,13 @@ export class BookingsService { })).map(p => p.id) : []; - // Guest bookings store phone in TravelerProfile.notes JSON (created for every guest - // booking). Catches cases where contactPhone was null but the profile recorded it. - const travelerRows = await this.dataSource - .query<{ passengerId: string }[]>( - `SELECT DISTINCT passenger_id AS "passengerId" - FROM passenger.traveler_profiles - WHERE notes IS NOT NULL - AND (notes::jsonb->>'phone') = ANY($1::text[])`, - [variants], - ) - .catch((err: unknown) => { - this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); - return [] as { passengerId: string }[]; - }); - - // Guests who saved their profile (savePassengerDetails:true) have a - // SavedPassengerProfile row with phone + deviceId; guest bookings stash that - // deviceId in Booking.userAgent. - const savedProfileRows = await this.dataSource - .query<{ deviceId: string }[]>( - `SELECT DISTINCT device_id AS "deviceId" - FROM passenger.saved_passenger_profiles - WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`, - [variants], - ) - .catch((err: unknown) => { - this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`); - return [] as { deviceId: string }[]; - }); - - const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerRows.map(r => r.passengerId)])]; - const guestDeviceIds = savedProfileRows.map(r => r.deviceId); - return { clauses: [ { contactPhone: { in: variants } }, { passenger: { user: { phone: { in: variants } } } }, - ...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []), - ...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []), + ...(iamPassengerIds.length > 0 ? [{ passengerId: { in: iamPassengerIds } }] : []), ], - passengerIds: allPassengerIds, + passengerIds: iamPassengerIds, }; } From e914aaeb531c1a4160919cd75c98bf029ec2017a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 2 Sep 2026 16:18:36 +0300 Subject: [PATCH 3/8] feat: (upgrade) implement per-passenger fare class upgrade with configurable policies --- .../migration.sql | 65 ++ apps/edr-passenger-api/prisma/schema.prisma | 60 ++ apps/edr-passenger-api/prisma/seed.ts | 1 + apps/edr-passenger-api/src/app.module.ts | 2 + .../src/common/audit.actions.ts | 2 + .../src/common/utils/booking-change.utils.ts | 144 +++ .../common/utils/payment-deadline.utils.ts | 15 +- .../src/modules/bookings/bookings.module.ts | 3 +- .../modules/bookings/guest-booking.service.ts | 7 +- .../notifications/notifications.service.ts | 20 + .../src/modules/payments/payments.module.ts | 2 + .../modules/payments/payments.service.spec.ts | 3 + .../src/modules/payments/payments.service.ts | 7 +- .../modules/reschedule/reschedule.module.ts | 3 +- .../modules/reschedule/reschedule.service.ts | 25 +- .../src/modules/seats/seats.service.ts | 15 +- .../system-config/system-config.dto.ts | 18 + .../system-config/system-config.service.ts | 10 + .../src/modules/tasks/tasks.module.ts | 3 +- .../src/modules/tasks/tasks.service.ts | 32 +- .../src/modules/upgrade/upgrade.controller.ts | 91 ++ .../src/modules/upgrade/upgrade.dto.ts | 94 ++ .../src/modules/upgrade/upgrade.module.ts | 46 + .../modules/upgrade/upgrade.service.spec.ts | 52 ++ .../src/modules/upgrade/upgrade.service.ts | 859 ++++++++++++++++++ .../backoffice/src/app/settings/page.tsx | 47 + .../src/app/upgrade-policies/layout.tsx | 5 + .../src/app/upgrade-policies/page.tsx | 26 + .../src/components/layout/Sidebar.tsx | 2 + .../upgrade/UpgradePolicyManager.tsx | 363 ++++++++ .../backoffice/src/lib/api/index.ts | 34 + .../portal/src/app/booking/detail/page.tsx | 27 + .../portal/src/app/booking/upgrade/page.tsx | 571 ++++++++++++ .../portal/src/components/MyBookingsTable.tsx | 85 +- 34 files changed, 2705 insertions(+), 34 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260901090000_add_fare_class_upgrade/migration.sql create mode 100644 apps/edr-passenger-api/src/common/utils/booking-change.utils.ts create mode 100644 apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts create mode 100644 apps/edr-passenger-api/src/modules/upgrade/upgrade.service.spec.ts create mode 100644 apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/upgrade-policies/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260901090000_add_fare_class_upgrade/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260901090000_add_fare_class_upgrade/migration.sql new file mode 100644 index 000000000..db3b9d832 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260901090000_add_fare_class_upgrade/migration.sql @@ -0,0 +1,65 @@ +-- Fare-class upgrade (policy US-17). One policy row per fare class — fare classes map 1:1 onto +-- coach types — plus a per-request table recording the frozen quote. +-- +-- Structure only, and additive/idempotent. Policy data lives in prisma/seed.ts. + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."UpgradePolicy" ( + "id" TEXT NOT NULL, + "coachTypeId" TEXT NOT NULL, + "rank" INTEGER NOT NULL DEFAULT 0, + "feePercent" INTEGER NOT NULL DEFAULT 0, + "feeMinMinor" INTEGER NOT NULL DEFAULT 0, + "feeWaived" BOOLEAN NOT NULL DEFAULT false, + "isUpgradable" BOOLEAN NOT NULL DEFAULT true, + "isTargetable" BOOLEAN NOT NULL DEFAULT true, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UpgradePolicy_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "UpgradePolicy_coachTypeId_key" ON "passenger"."UpgradePolicy"("coachTypeId"); + +DO $$ BEGIN + ALTER TABLE "passenger"."UpgradePolicy" + ADD CONSTRAINT "UpgradePolicy_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") + REFERENCES "passenger"."CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."BookingUpgrade" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', + "requestedBy" TEXT NOT NULL, + "scheduleId" TEXT NOT NULL, + "items" JSONB NOT NULL, + "holdId" TEXT, + "oldFareMinor" INTEGER NOT NULL, + "newFareMinor" INTEGER NOT NULL, + "fareDifferenceMinor" INTEGER NOT NULL, + "feeMinor" INTEGER NOT NULL, + "amountDueMinor" INTEGER NOT NULL, + "supplementaryChargeId" TEXT, + "expiresAt" TIMESTAMP(3), + "appliedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BookingUpgrade_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "BookingUpgrade_supplementaryChargeId_key" ON "passenger"."BookingUpgrade"("supplementaryChargeId"); +CREATE INDEX IF NOT EXISTS "BookingUpgrade_bookingId_status_idx" ON "passenger"."BookingUpgrade"("bookingId", "status"); + +DO $$ BEGIN + ALTER TABLE "passenger"."BookingUpgrade" + ADD CONSTRAINT "BookingUpgrade_bookingId_fkey" FOREIGN KEY ("bookingId") + REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- No data seeding here on purpose. This migration creates structure only; the ladder itself is +-- business policy and is seeded separately by `seedUpgradePolicies` in prisma/seed.ts +-- (`pnpm prisma:seed`), so a production deploy never silently writes fare rules nobody approved. diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 693bf1fb2..d5d2b891a 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -81,6 +81,7 @@ model CoachType { coaches Coach[] seatClasses SeatClass[] reschedulePolicy ReschedulePolicy? + upgradePolicy UpgradePolicy? @@schema("passenger") } @@ -570,6 +571,7 @@ model Booking { agentBooking AgentBooking? modifications BookingModification[] reschedules BookingReschedule[] + upgrades BookingUpgrade[] cancellation BookingCancellation? baggage BaggageBooking[] excessBaggageCharges ExcessBaggageCharge[] @@ -1207,6 +1209,64 @@ model AgentCommission { @@schema("passenger") } +/// Fare-class upgrade rule, one row per coach type (policy US-17). A coach type with no row here +/// can be neither upgraded from nor to — the same "no policy = not allowed" semantics +/// ReschedulePolicy uses. Edited in backoffice Master Data → Upgrade Policies. +model UpgradePolicy { + id String @id @default(uuid()) + coachTypeId String @unique + /// Position on the ladder — an upgrade requires target.rank > source.rank. An explicit column + /// rather than a price comparison: SeatClass.baseFareMinor is a per-km tariff, while the fare + /// actually charged resolves through SegmentFareRule/FareRule first, so on some segments the + /// price order differs from the class order. Which class is "higher" is a business decision + /// and must not flip because someone edited a tariff. + rank Int @default(0) + feePercent Int @default(0) // % of the passenger's original fare + feeMinMinor Int @default(0) // fee floor, ETB minor units + feeWaived Boolean @default(false) + isUpgradable Boolean @default(true) // passengers may leave this class + isTargetable Boolean @default(true) // passengers may arrive in this class + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + + @@schema("passenger") +} + +/// One fare-class upgrade request for one leg. Same lifecycle as BookingReschedule +/// (PENDING_PAYMENT → APPLIED | EXPIRED) but the schedule never changes — only the seats, and +/// only for the passengers named in `items`. +model BookingUpgrade { + id String @id @default(uuid()) + bookingId String + leg Int @default(1) + status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED + requestedBy String + scheduleId String // unchanged by the upgrade; recorded so the audit row reads standalone + /// Frozen per-passenger quote, keyed on bookingSeatId — NOT array position. Only some + /// passengers move, so a positional pairing (as BookingReschedule uses) would be fragile. + /// Each element: { bookingSeatId, passengerName, passengerCategory, + /// oldSeatId, oldSeatLabel, oldCoachTypeId, oldSeatClassId, oldFareMinor, + /// newSeatId, newSeatLabel, newCoachTypeId, newSeatClassId, newFareMinor, + /// feeMinor, fareDifferenceMinor } + items Json + holdId String? + oldFareMinor Int + newFareMinor Int + fareDifferenceMinor Int + feeMinor Int + amountDueMinor Int + supplementaryChargeId String? @unique + expiresAt DateTime? + appliedAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId, status]) + @@schema("passenger") +} + /// Rescheduling rule per fare class. Fare families from the passenger policy map 1:1 onto /// coach types (HSC = Standard, HBC = Flex, SBC = Premium). Seeded by migration from the policy /// doc; edited in backoffice Settings → Reschedule Policy. diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 8768ba899..fb1261daf 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -658,6 +658,7 @@ async function seedNotificationTemplates() { { id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' }, { id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' }, { id: uuidv4(), code: 'booking.rescheduled', channel: 'EMAIL', subject: 'Booking Rescheduled', bodyTemplate: 'Your {{leg}} journey on booking {{bookingRef}} has been rescheduled. New tickets have been issued. Change fee: {{feeAmount}} {{currency}}.' }, + { id: uuidv4(), code: 'booking.upgraded', channel: 'EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: 'Booking {{bookingRef}}: {{passengerSummary}} upgraded on your {{leg}} journey. New tickets have been issued. Paid: {{amountPaid}} {{currency}}.' }, // Templates below are not wired to handlers yet (Phase 2 — full event coverage). { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index ff77b8b07..4cd1c767a 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -65,6 +65,7 @@ import { SegmentFareSeeder } from "./seed/segment-fare.seeder"; import { EOtpType } from "@tria-plc/iamapi-common"; import { RescheduleModule } from './modules/reschedule/reschedule.module'; +import { UpgradeModule } from './modules/upgrade/upgrade.module'; @Module({ imports: [ @@ -166,6 +167,7 @@ import { RescheduleModule } from './modules/reschedule/reschedule.module'; AppReleasesModule, ConfigurableFareModule, RescheduleModule, + UpgradeModule, ], providers: [ { provide: APP_FILTER, useClass: DeleteExceptionFilter }, diff --git a/apps/edr-passenger-api/src/common/audit.actions.ts b/apps/edr-passenger-api/src/common/audit.actions.ts index 8ac818e48..310191704 100644 --- a/apps/edr-passenger-api/src/common/audit.actions.ts +++ b/apps/edr-passenger-api/src/common/audit.actions.ts @@ -88,6 +88,8 @@ export const AUDIT_ENTITIES = { Booking: 'Booking', BookingReschedule: 'BookingReschedule', ReschedulePolicy: 'ReschedulePolicy', + BookingUpgrade: 'BookingUpgrade', + UpgradePolicy: 'UpgradePolicy', } as const; export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES]; diff --git a/apps/edr-passenger-api/src/common/utils/booking-change.utils.ts b/apps/edr-passenger-api/src/common/utils/booking-change.utils.ts new file mode 100644 index 000000000..bba635311 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-change.utils.ts @@ -0,0 +1,144 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma.service'; +import { MeLikeUser } from '../passenger-permission.util'; +import { normalizePhone, samePhone } from './phone.utils'; + +/** + * Shared by every flow that lets a passenger change a confirmed booking — reschedule today, + * fare-class upgrade next. These were private to RescheduleService; they live here so the two + * features cannot drift apart on who is allowed to act or how a seat is priced. + * + * Plain functions rather than a provider on purpose: AuditService injects REQUEST, so anything + * made injectable here would drag request scope into whatever consumes it. + */ + +export type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string }; + +/** + * The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an + * empty string, so `iam.users` is the source of truth — and reading it live also means a user + * who changed their number does not have to sign out before the new one counts. + */ +export async function resolveUserPhone( + prisma: PrismaService, + iamUserId: string, + user: ActingUser, +): Promise { + const fromSession = normalizePhone(user.phoneNumber); + if (fromSession) return fromSession; + const rows = await prisma.$queryRaw<{ phone_number: string | null }[]>` + SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1 + `; + return normalizePhone(rows[0]?.phone_number); +} + +/** + * Loads a booking only for the person who made it, proven by their account's phone number + * matching the booking's `contactPhone`. Being merely *named* on the booking is not enough — a + * passenger travelling on someone else's booking cannot change it. + * + * There is deliberately no staff override. `bookings:reschedule` exists in the registry (and on + * the stationMaster preset) but is not honoured, so a station master cannot act on a customer's + * behalf yet. + * + * `action` only shapes the error message ("reschedule it" / "upgrade it"). + */ +export async function loadOwnedBooking( + prisma: PrismaService, + bookingRef: string, + user: ActingUser, + include: T, + action = 'change it', +) { + const booking = await prisma.booking.findUnique({ where: { bookingRef }, include }); + if (!booking) throw new NotFoundException('Booking not found'); + const iamUserId = user.id ?? user.sub; + if (!iamUserId) throw new ForbiddenException(); + + const b = booking as any; + if (b.contactPhone) { + const callerPhone = await resolveUserPhone(prisma, iamUserId, user); + if (samePhone(callerPhone, b.contactPhone)) return booking; + throw new ForbiddenException( + `Only the person who made this booking can ${action}. Sign in with the phone number used to book.`, + ); + } + + // A small tail of bookings carry no contactPhone at all, so there is nothing to match against. + // Fall back to the account link rather than locking their owner out entirely. + const passenger = await prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); + if (!passenger || passenger.id !== b.passengerId) throw new ForbiddenException('Not your booking'); + return booking; +} + +/** + * Coaches nobody buys a seat in, so they can never carry a fare-class policy. + * + * Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' | + * 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space + * included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the + * dining coach as a fare class. Mirrors the portal's own test (`/dining|dpc/i`). + */ +export const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage']; + +export const NOT_A_FARE_CLASS = { + NOT: NON_FARE_COACH_TERMS.flatMap((term) => [ + { type: { contains: term, mode: 'insensitive' as const } }, + { code: { contains: term, mode: 'insensitive' as const } }, + ]), +}; + +/** True when this coach type is a dining/baggage coach rather than a sellable fare class. */ +export function isNonFareCoachType(coachType: { type?: string | null; code?: string | null }): boolean { + const haystack = `${coachType.type ?? ''} ${coachType.code ?? ''}`.toLowerCase(); + return NON_FARE_COACH_TERMS.some((t) => haystack.includes(t)); +} + +/** + * Nationality is not stored on the booking, so the display currency is the proxy the search and + * fare code already use: ETB/DJF are local tariffs, USD is the international one. Both flows must + * use the same proxy or an upgrade would be priced on a different tariff than the original sale. + */ +export function resolveNationalityProxy(displayCurrency?: string | null): { + nationalityType: 'LOCAL' | 'INTERNATIONAL'; + nationality: string | undefined; +} { + return { + nationalityType: displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL', + nationality: + displayCurrency === 'DJF' ? 'Djiboutian' : displayCurrency === 'ETB' ? 'Ethiopian' : undefined, + }; +} + +/** + * Mirrors SearchService's class matching: nationality filter, then bed position. + * `Seat.bedPosition` is lowercase and `SeatClass.bedPosition` uppercase, hence the folding. + */ +export function pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) { + const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType); + const pool = byNat.length ? byNat : classes; + const bed = bedPosition?.toLowerCase() ?? null; + const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed); + return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null; +} + +/** + * Distributes a leg fare over seats; free children (fare 0) stay 0 and rounding lands on the last + * paid seat. + */ +export function splitFare(total: number, seats: Array<{ fareMinor: number | null }>): number[] { + const paid = seats.map((s) => s.fareMinor !== 0); + const n = paid.filter(Boolean).length || 1; + const each = Math.floor(total / n); + let remaining = total; + let lastPaid = -1; + const out = seats.map((_, i) => { + if (!paid[i]) return 0; + lastPaid = i; + remaining -= each; + return each; + }); + if (lastPaid >= 0) out[lastPaid] += remaining; + return out; +} diff --git a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts index 8c391aef1..43a2c65e8 100644 --- a/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/payment-deadline.utils.ts @@ -24,12 +24,25 @@ export const MIN_PAYMENT_WINDOW_MINUTES = 7; export const PAYMENT_SETTLE_MARGIN_SECONDS = 60; +/** + * `windowMinutes` is how long the payer is given, and is configurable per flow + * (`booking_payment_window_minutes`, `reschedule_…`, `upgrade_…` in SystemConfig). It defaults to + * MAX_PAYMENT_HOURS so any caller that does not pass it behaves exactly as before. + * + * The check-in cutoff is still the hard ceiling: a longer window can never let someone pay after + * boarding has closed on their train. + * + * EVERY site that decides whether a booking is still payable — the payment link, the seat hold, + * and the crons that auto-cancel unpaid bookings — must pass the SAME window for a given booking, + * or a cron will cancel a booking whose link still says it is valid. + */ export function computePaymentDeadline( createdAt: Date, departureAt: Date, checkinMinutes: number = CUTOFF_MINUTES, + windowMinutes: number = MAX_PAYMENT_HOURS * 60, ): Date { - const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000); + const maxDeadline = new Date(createdAt.getTime() + windowMinutes * 60 * 1000); const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000); return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline; } diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index 78b7d4bc7..048be2b82 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -1,3 +1,4 @@ +import { SystemConfigModule } from '../system-config/system-config.module'; import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { AuditModule } from '../../common/audit.module'; @@ -14,7 +15,7 @@ import { PaymentsModule } from '../payments/payments.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule], + imports: [SystemConfigModule, AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] 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 133d289e3..cf5d92b82 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 @@ -16,6 +16,7 @@ import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking- import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; import { JourneyDirection } from '../seats/seats.dto'; import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { randomUUID } from 'crypto'; @@ -90,6 +91,7 @@ export class GuestBookingService { private readonly logger = new Logger(GuestBookingService.name); constructor( + private systemConfig: SystemConfigService, private prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private seatsService: SeatsService, @@ -582,7 +584,10 @@ export class GuestBookingService { const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData); const payToken = isStaff ? undefined : randomUUID(); - const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt); + const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + const payTokenExpiresAt = isStaff + ? undefined + : computePaymentDeadline(new Date(), schedule.departureAt, undefined, bookingWindowMinutes); const booking = await this.prisma.booking.create({ data: { diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 44ba03dea..4dc168d00 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -749,6 +749,26 @@ export class NotificationsService { ); } + @OnEvent('booking.upgraded') + async onBookingUpgraded(payload: any) { + const { booking, upgrade } = payload; + const items = Array.isArray(upgrade?.items) ? upgrade.items : []; + await this.send( + 'booking.upgraded', + booking.passengerId, + { + bookingRef: booking.bookingRef, + leg: upgrade?.leg === 2 ? 'return' : 'outbound', + passengerSummary: items.map((i: any) => i.passengerName).join(', '), + amountPaid: (((upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0)) / 100).toFixed(2), + currency: 'ETB', + category: 'BOOKING', + deepLink: `edr://bookings/${booking.bookingRef}`, + }, + ['IN_APP', 'EMAIL', 'SMS'], + ); + } + @OnEvent('booking.cancelled') async onBookingCancelled(payload: any) { const booking = payload.booking; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 970983cb4..ec492505d 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -1,3 +1,4 @@ +import { SystemConfigModule } from '../system-config/system-config.module'; import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; @@ -55,6 +56,7 @@ function rabbitMQImport(): DynamicModule[] { @Module({ imports: [ + SystemConfigModule, SeatsModule, TicketsModule, CurrencyModule, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts index 6c8f4b58d..2ee97aa11 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PaymentsService } from "./payments.service"; +import { SystemConfigService } from "../system-config/system-config.service"; import { PaymentClientService } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { PrismaService } from "../../common/prisma.service"; @@ -133,6 +134,8 @@ describe("PaymentsService", () => { { provide: PaymentClientService, useValue: mockPaymentClient }, { provide: CurrencyService, useValue: mockCurrencyService }, { provide: AuditService, useValue: { log: jest.fn() } }, + // 120 = the default booking payment window; the deadline maths under test is unchanged by it. + { provide: SystemConfigService, useValue: { getNumber: jest.fn().mockResolvedValue(120) } }, ], }).compile(); diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index ced758d98..e8e17b608 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -36,6 +36,7 @@ import { MIN_PAYMENT_WINDOW_MINUTES, PAYMENT_SETTLE_MARGIN_SECONDS, } from "../../common/utils/payment-deadline.utils"; +import { CONFIG_KEYS, SystemConfigService } from "../system-config/system-config.service"; import { PaymentClientService, PaymentDiagnostic, @@ -88,6 +89,7 @@ export class PaymentsService { private readonly waafiDemoTrustReturn = true; constructor( + private systemConfig: SystemConfigService, private prisma: PrismaService, private seatsService: SeatsService, private ticketsService: TicketsService, @@ -748,7 +750,10 @@ export class PaymentsService { originRouteStop?.checkinMinutesBefore ?? booking.schedule.route?.checkinMinutesBefore ?? undefined; - return computePaymentDeadline(booking.createdAt, dep, checkinMinutes); + // Same window the auto-cancel cron uses, or the payer would be shown a deadline the cron + // does not honour. + const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + return computePaymentDeadline(booking.createdAt, dep, checkinMinutes, windowMinutes); } private resolveReturnUrls( diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts index ffb2c23bc..03c277647 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts @@ -7,6 +7,7 @@ import { SeatsModule } from '../seats/seats.module'; import { TicketsModule } from '../tickets/tickets.module'; import { PaymentsModule } from '../payments/payments.module'; import { CurrencyModule } from '../currency/currency.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; import { RescheduleController } from './reschedule.controller'; import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service'; @@ -32,7 +33,7 @@ export class RescheduleEventsListener { } @Module({ - imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule], + imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule], controllers: [RescheduleController], providers: [RescheduleService, RescheduleEventsListener], exports: [RescheduleService], diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index fdf10ca0a..f296dac74 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -13,6 +13,7 @@ import { AuditService } from '../../common/audit.service'; import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; import { MeLikeUser } from '../../common/passenger-permission.util'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; import { normalizePhone, samePhone } from '../../common/utils/phone.utils'; import { BookingsService } from '../bookings/bookings.service'; import { SeatsService } from '../seats/seats.service'; @@ -85,6 +86,8 @@ type LegView = { departureAt: Date; seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>; coachTypeId: string; + /** Every distinct coach type on the leg. More than one means a partial upgrade happened. */ + coachTypeIds: string[]; }; // Seats are ordered by passenger name so getOptions(), quote() and create() all see the same @@ -109,6 +112,7 @@ export class RescheduleService { private currencyService: CurrencyService, private auditService: AuditService, private eventEmitter: EventEmitter2, + private systemConfig: SystemConfigService, ) {} // ── Policy admin ───────────────────────────────────────────────────────── @@ -267,7 +271,8 @@ export class RescheduleService { const requestedBy = user.id ?? user.sub ?? booking.passengerId; const newDeparture = q.newDepartureAt; - const expiresAt = computePaymentDeadline(new Date(), newDeparture); + const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES); + const expiresAt = computePaymentDeadline(new Date(), newDeparture, undefined, windowMinutes); const reschedule = await this.prisma.bookingReschedule.create({ data: { @@ -314,7 +319,8 @@ export class RescheduleService { where: { id: reschedule.id }, data: { supplementaryChargeId: charge.id }, }); - await this.seatsService.confirmSeats(dto.newSeatIds); + // Same instant the charge carries, so the hold and the payment link die together. + await this.seatsService.confirmSeats(dto.newSeatIds, new Date(), expiresAt); await this.auditService.log({ userId: requestedBy, @@ -497,11 +503,11 @@ export class RescheduleService { .map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId })); const l1 = seatsOf(1); if (l1.length && booking.schedule) { - legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId }); + legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId, coachTypeIds: [...new Set(l1.map((s) => s.coachTypeId))] }); } const l2 = seatsOf(2); if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) { - legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId }); + legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId, coachTypeIds: [...new Set(l2.map((s) => s.coachTypeId))] }); } return legs; } @@ -521,6 +527,13 @@ export class RescheduleService { // round trip whose outbound was already used can't change its return yet — needs leg-scoped // ticket regeneration. if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.'); + // A partial fare-class upgrade can leave one leg spanning two coach types. Everything below + // — the policy lookup, the fee, the seat map — keys off a single leg-wide class taken from + // the first seat, so a mixed leg would silently reschedule at the wrong class and price. + // Refuse it outright until reschedule is made class-aware per passenger. + if (leg.coachTypeIds.length > 1) { + blockers.push('This booking has passengers in different fare classes. Please contact support to change it.'); + } if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.'); else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) { blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`); @@ -537,6 +550,10 @@ export class RescheduleService { const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } }); if (pending) blockers.push('A reschedule is already awaiting payment for this booking.'); + // One change at a time. Two live supplementary charges would both drive ticket regeneration + // on this booking and interleave unpredictably once each is paid. + const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } }); + if (pendingUpgrade) blockers.push('An upgrade is awaiting payment for this booking — finish or cancel it first.'); if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`); if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.'); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index c678dcf44..5c3b74c53 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -701,7 +701,14 @@ export class SeatsService { // short seat-selection hold (5 min by default). Without this, the hold could expire // while the customer was still on the payment page, and a second customer could // hold/book the exact same seat out from under them. - async confirmSeats(seatIds: string[], now: Date = new Date()): Promise { + /** + * `deadlineOverride` pins the hold to a deadline the caller has already computed. The + * reschedule and upgrade flows pass the exact value their supplementary charge carries — if + * this recomputed it instead, a per-flow payment window would give the hold and the payment + * link different lifetimes and the seat could lapse while the link still worked. + * Without it, the booking payment window is used, as before. + */ + async confirmSeats(seatIds: string[], now: Date = new Date(), deadlineOverride?: Date): Promise { if (seatIds.length === 0) return; const holds = await this.prisma.seatHold.findMany({ @@ -717,12 +724,16 @@ export class SeatsService { }); const departureById = new Map(schedules.map(s => [s.id, s.departureAt])); + const windowMinutes = deadlineOverride + ? 0 // unused — the override wins below + : await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + let extended = 0; await Promise.all( holds.map(async (hold) => { const departureAt = departureById.get(hold.scheduleId); if (!departureAt) return; - const deadline = computePaymentDeadline(now, departureAt); + const deadline = deadlineOverride ?? computePaymentDeadline(now, departureAt, undefined, windowMinutes); // Only ever extend forward — never shorten a hold that's already valid longer // than the payment deadline would give it (e.g. a second confirmSeats call on // the same booking, or a hold that was already extended). diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts index 5c1cd9679..32d09f41d 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.dto.ts @@ -1,6 +1,7 @@ import { IsInt, IsOptional, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; +import { MIN_PAYMENT_WINDOW_MINUTES } from '../../common/utils/payment-deadline.utils'; /** * Whitelisted, typed body for `PATCH /config`. Config is persisted as string key/values, but every @@ -22,6 +23,23 @@ export class UpdateSystemConfigDto { @IsOptional() @Type(() => Number) @IsInt() @Min(0) boarding_window_hours_before_departure?: number; + // Payment windows, in minutes. Floored at MIN_PAYMENT_WINDOW_MINUTES (7) because canOpenPaymentSession + // refuses to open a provider session with less than that left — a window below it makes every + // card/HPP payment impossible to start. Capped at 1440 (24h) — the check-in cutoff already bounds the + // effective deadline, but a stray 100000 would make the auto-cancel pre-filter scan pointlessly + // far back. + @ApiPropertyOptional({ example: 120, description: 'Minutes a new booking has to be paid (7..1440)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440) + booking_payment_window_minutes?: number; + + @ApiPropertyOptional({ example: 120, description: 'Minutes a reschedule charge has to be paid (7..1440)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440) + reschedule_payment_window_minutes?: number; + + @ApiPropertyOptional({ example: 120, description: 'Minutes a fare upgrade has to be paid (7..1440)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(1440) + upgrade_payment_window_minutes?: number; + @ApiPropertyOptional({ example: 5 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) throttle_auth_limit?: number; diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts index 3cfa1beb8..437fcd22d 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -5,6 +5,12 @@ export const CONFIG_KEYS = { SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes', HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure', BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE: 'boarding_window_hours_before_departure', + // How long a passenger has to pay, per flow. The effective deadline is always + // MIN(now + window, departure - check-in cutoff) — a longer window can never let someone pay + // after boarding closes. + BOOKING_PAYMENT_WINDOW_MINUTES: 'booking_payment_window_minutes', + RESCHEDULE_PAYMENT_WINDOW_MINUTES: 'reschedule_payment_window_minutes', + UPGRADE_PAYMENT_WINDOW_MINUTES: 'upgrade_payment_window_minutes', THROTTLE_AUTH_LIMIT: 'throttle_auth_limit', THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms', THROTTLE_STRICT_LIMIT: 'throttle_strict_limit', @@ -17,6 +23,10 @@ const DEFAULTS: Record = { [CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5', [CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2', [CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE]: '4', + // 120 = the 2 hours these flows used before the window became configurable. + [CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES]: '120', + [CONFIG_KEYS.RESCHEDULE_PAYMENT_WINDOW_MINUTES]: '120', + [CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES]: '120', [CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5', [CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000', [CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20', diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts index 335d11f81..2cec27cfe 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.module.ts @@ -3,10 +3,11 @@ import { PrismaModule } from '../../common/prisma.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { CurrencyModule } from '../currency/currency.module'; import { PaymentsModule } from '../payments/payments.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; import { TasksService } from './tasks.service'; @Module({ - imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule], + imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule, SystemConfigModule], providers: [TasksService], }) export class TasksModule {} diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 27a94f4e9..29dde3d53 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -6,6 +6,8 @@ import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; import { PaymentsService } from '../payments/payments.service'; import { RescheduleService } from '../reschedule/reschedule.service'; +import { UpgradeService } from '../upgrade/upgrade.service'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -44,6 +46,8 @@ export class TasksService { // REQUEST), and injecting a request-scoped provider here would make TasksService request-scoped // too — which silently stops all its @Cron methods from firing. Resolve it per-tick instead. private readonly moduleRef: ModuleRef, + // Singleton (only injects Prisma), so it does not drag request scope in and silence the crons. + private readonly systemConfig: SystemConfigService, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -177,6 +181,7 @@ export class TasksService { // ── Send reminder at the midpoint of each booking's payment window ──────── private async sendPaymentReminders(now: Date) { + const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); // Only look at bookings created within the last 3 h with a future departure. const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000); @@ -216,7 +221,7 @@ export class TasksService { ); const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30; if (dep <= now) continue; // segment has already departed; cancel job handles clean-up - const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes); const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); // Skip degenerate windows (< 2 min) — the cancel job will handle these immediately @@ -260,7 +265,10 @@ export class TasksService { // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); + // Must be the SAME window the payment link was issued with, or a shortened window would + // leave older bookings unselected by the pre-filter and never auto-cancelled. + const bookingWindowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); + const windowAgo = new Date(now.getTime() - bookingWindowMinutes * 60 * 1000); // The departure pre-filter below is a query-scoping optimization only — the real // deadline check happens per-row further down. It must be widened to the largest @@ -286,7 +294,7 @@ export class TasksService { where: { status: 'PENDING_PAYMENT', OR: [ - { createdAt: { lte: twoHoursAgo } }, + { createdAt: { lte: windowAgo } }, { schedule: { departureAt: { lte: departureCutoff } } }, ], }, @@ -329,7 +337,7 @@ export class TasksService { (s: any) => s.stationId === (booking as any).originStationId, ); const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES; - const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes, bookingWindowMinutes); if (now < paymentDeadline) continue; // Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded @@ -526,6 +534,22 @@ export class TasksService { } } + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: fare-class upgrades whose payment deadline passed → EXPIRED. + // Separate from the reschedule sweep on purpose — a failure in one must not + // skip the other. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async expireStaleUpgrades() { + try { + const upgrade = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false }); + const n = await upgrade.expireStale(); + if (n > 0) this.logger.log(`Expired ${n} unpaid upgrade request(s)`); + } catch (err) { + this.logger.error(`expireStaleUpgrades failed: ${err instanceof Error ? err.message : err}`); + } + } + @Cron('0 2 * * *') async purgeExpiredData() { const now = new Date(); diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts new file mode 100644 index 000000000..f52abcbc4 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts @@ -0,0 +1,91 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { UpgradeService } from './upgrade.service'; +import { + CreateUpgradeDto, + CreateUpgradePolicyDto, + UpgradeHoldDto, + UpgradeQuoteDto, + UpdateUpgradePolicyDto, +} from './upgrade.dto'; + +@ApiTags('Fare upgrade') +@Controller() +export class UpgradeController { + constructor(private service: UpgradeService) {} + + @Get('upgrade/policies') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' }) + listPolicies() { + return this.service.listPolicies(); + } + + @Get('upgrade/policies/available-coach-types') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' }) + listUnconfiguredCoachTypes() { + return this.service.listUnconfiguredCoachTypes(); + } + + @Post('upgrade/policies') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' }) + createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) { + return this.service.createPolicy(dto, req.user?.id); + } + + @Patch('upgrade/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' }) + updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) { + return this.service.updatePolicy(coachTypeId, dto, req.user?.id); + } + + @Delete('upgrade/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' }) + deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { + return this.service.deletePolicy(coachTypeId, req.user?.id); + } + + @Get('bookings/:bookingRef/upgrade') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Per-leg upgrade eligibility, per-passenger targets, pending request and history' }) + options(@Req() req: any, @Param('bookingRef') bookingRef: string) { + return this.service.getOptions(bookingRef, req.user); + } + + @Post('bookings/:bookingRef/upgrade/quote') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Price an upgrade without committing to it' }) + quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeQuoteDto) { + return this.service.quote(bookingRef, dto, req.user); + } + + @Post('bookings/:bookingRef/upgrade/hold') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Hold the chosen seats, clearing abandoned attempts on this booking first' }) + hold(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: UpgradeHoldDto) { + return this.service.holdForUpgrade(bookingRef, dto, req.user); + } + + @Post('bookings/:bookingRef/upgrade') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Request an upgrade; returns a payment token when money is owed' }) + create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateUpgradeDto) { + return this.service.create(bookingRef, dto, req.user); + } +} diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts new file mode 100644 index 000000000..d587172d5 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.dto.ts @@ -0,0 +1,94 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +export class UpdateUpgradePolicyDto { + @ApiPropertyOptional({ example: 2, description: 'Ladder position — an upgrade needs a strictly higher rank' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + rank?: number; + + @ApiPropertyOptional({ example: 0, description: '% of the passenger\'s original fare charged as a change fee' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) + feePercent?: number; + + @ApiPropertyOptional({ example: 0, description: 'Fee floor in ETB minor units (500 ETB = 50000)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + feeMinMinor?: number; + + @ApiPropertyOptional({ example: true, description: 'Waive the change fee entirely (policy US-17 §5)' }) + @IsOptional() @IsBoolean() + feeWaived?: boolean; + + @ApiPropertyOptional({ example: true, description: 'Passengers may upgrade OUT of this class' }) + @IsOptional() @IsBoolean() + isUpgradable?: boolean; + + @ApiPropertyOptional({ example: true, description: 'Passengers may upgrade INTO this class' }) + @IsOptional() @IsBoolean() + isTargetable?: boolean; + + @ApiPropertyOptional({ example: true }) + @IsOptional() @IsBoolean() + isActive?: boolean; +} + +export class CreateUpgradePolicyDto extends UpdateUpgradePolicyDto { + @ApiProperty({ example: 'coach-type-uuid', description: 'CoachType this policy applies to (one per fare class)' }) + @IsString() + coachTypeId: string; +} + +export class UpgradeItemDto { + @ApiProperty({ example: 'booking-seat-uuid', description: 'The BookingSeat row being upgraded' }) + @IsString() + bookingSeatId: string; + + @ApiProperty({ example: 'seat-uuid', description: 'Seat this passenger moves to, in the target coach type' }) + @IsString() + newSeatId: string; +} + +export class UpgradeQuoteDto { + @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) + leg?: number; + + @ApiProperty({ example: 'coach-type-uuid', description: 'Fare class every listed passenger is moving to' }) + @IsString() + newCoachTypeId: string; + + @ApiProperty({ + type: [UpgradeItemDto], + description: + 'One entry per upgrading passenger. Keyed on bookingSeatId, not array position — only some ' + + 'passengers move, so a positional pairing would be ambiguous.', + }) + @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => UpgradeItemDto) + items: UpgradeItemDto[]; +} + +export class UpgradeHoldDto { + @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) + leg?: number; + + @ApiProperty({ type: [String], description: 'Seats to hold, in the target coach type' }) + @IsArray() @ArrayMinSize(1) @IsString({ each: true }) + seatIds: string[]; +} + +export class CreateUpgradeDto extends UpgradeQuoteDto { + @ApiProperty({ example: 'seat-hold-uuid', description: 'Hold covering every newSeatId' }) + @IsString() + holdId: string; +} diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts new file mode 100644 index 000000000..ad9b7fa64 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.module.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger, Module } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AuditModule } from '../../common/audit.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { SeatsModule } from '../seats/seats.module'; +import { SegmentsModule } from '../segments/segments.module'; +import { TicketsModule } from '../tickets/tickets.module'; +import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; +import { SystemConfigModule } from '../system-config/system-config.module'; +import { SUPPLEMENTARY_CHARGE_PAID_EVENT } from '../reschedule/reschedule.service'; +import { UpgradeController } from './upgrade.controller'; +import { UpgradeService } from './upgrade.service'; + +/** + * Same shape and same reason as RescheduleEventsListener: UpgradeService is request-scoped by + * transitivity (AuditService injects REQUEST), and Nest never fires @OnEvent on request-scoped + * providers — so the listener is a singleton that resolves the service per event. + * + * Two listeners on one event is fine: each looks its charge up by its own unique + * `supplementaryChargeId` and returns silently when the charge is not theirs. + */ +@Injectable() +export class UpgradeEventsListener { + private readonly logger = new Logger(UpgradeEventsListener.name); + constructor(private readonly moduleRef: ModuleRef) {} + + @OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true }) + async onChargePaid(payload: { chargeId: string }) { + try { + const service = await this.moduleRef.resolve(UpgradeService, undefined, { strict: false }); + await service.applyForCharge(payload.chargeId); + } catch (err) { + this.logger.error(`Failed to apply upgrade for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`); + } + } +} + +@Module({ + imports: [AuditModule, BookingsModule, SeatsModule, SegmentsModule, TicketsModule, PaymentsModule, CurrencyModule, SystemConfigModule], + controllers: [UpgradeController], + providers: [UpgradeService, UpgradeEventsListener], + exports: [UpgradeService], +}) +export class UpgradeModule {} diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.spec.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.spec.ts new file mode 100644 index 000000000..e0740e41b --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.spec.ts @@ -0,0 +1,52 @@ +import { computeUpgradeAmounts } from './upgrade.service'; + +// Pure arithmetic only, mirroring reschedule.service.spec.ts — no Nest test module, no mocks. +describe('computeUpgradeAmounts', () => { + const free = { feePercent: 0, feeMinMinor: 0, feeWaived: false }; + const waived = { feePercent: 30, feeMinMinor: 50000, feeWaived: true }; + const percentOnly = { feePercent: 10, feeMinMinor: 0, feeWaived: false }; + const flooredFee = { feePercent: 10, feeMinMinor: 50000, feeWaived: false }; + + it('charges only the fare difference when the class has no fee', () => { + // RS 1752.34 → EBC 2336.46, as seeded on dev + expect(computeUpgradeAmounts(free, 175234, 233646)).toEqual({ + feeMinor: 0, + fareDifferenceMinor: 58412, + amountDueMinor: 58412, + }); + }); + + it('ignores a configured fee when the policy waives it (US-17 §5)', () => { + expect(computeUpgradeAmounts(waived, 175234, 233646)).toEqual({ + feeMinor: 0, + fareDifferenceMinor: 58412, + amountDueMinor: 58412, + }); + }); + + it('takes the fee as a percentage of the ORIGINAL fare, not of the difference', () => { + const r = computeUpgradeAmounts(percentOnly, 175234, 233646); + expect(r.feeMinor).toBe(17523); // 10% of 175234, not of 58412 + expect(r.amountDueMinor).toBe(17523 + 58412); + }); + + it('applies the fee floor when the percentage falls below it', () => { + const r = computeUpgradeAmounts(flooredFee, 100000, 150000); + expect(r.feeMinor).toBe(50000); // max(10% of 100000 = 10000, floor 50000) + expect(r.amountDueMinor).toBe(100000); + }); + + it('never lets a negative difference reduce the amount due', () => { + // Refused upstream, but the arithmetic must not produce a credit if it ever gets here. + const r = computeUpgradeAmounts(percentOnly, 200000, 150000); + expect(r.fareDifferenceMinor).toBe(-50000); + expect(r.amountDueMinor).toBe(r.feeMinor); + expect(r.amountDueMinor).toBeGreaterThanOrEqual(0); + }); + + it('charges the full target fare for a free child', () => { + const r = computeUpgradeAmounts(free, 0, 233646); + expect(r.feeMinor).toBe(0); // a percentage of zero is zero + expect(r.amountDueMinor).toBe(233646); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts new file mode 100644 index 000000000..f0248233c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts @@ -0,0 +1,859 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { CONFIG_KEYS, SystemConfigService } from '../system-config/system-config.service'; +import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; +import { + ActingUser, + isNonFareCoachType, + loadOwnedBooking, + NOT_A_FARE_CLASS, + pickSeatClass, + resolveNationalityProxy, +} from '../../common/utils/booking-change.utils'; +import { BookingsService } from '../bookings/bookings.service'; +import { SeatsService } from '../seats/seats.service'; +import { SegmentsService } from '../segments/segments.service'; +import { TicketsService } from '../tickets/tickets.service'; +import { PaymentsService } from '../payments/payments.service'; +import { SupplementaryChargesService } from '../payments/supplementary-charges.service'; +import { CurrencyService } from '../currency/currency.service'; +import { JourneyDirection } from '../seats/seats.dto'; +import { + CreateUpgradeDto, + CreateUpgradePolicyDto, + UpgradeHoldDto, + UpgradeQuoteDto, + UpdateUpgradePolicyDto, +} from './upgrade.dto'; + +export const UPGRADE_CHARGE_REASON = 'UPGRADE'; + +type PolicyFee = { feePercent: number; feeMinMinor: number; feeWaived: boolean }; + +/** + * Pure fee arithmetic for one upgrading passenger — policy US-17. The fee is read from the class + * being upgraded TO (§5 waives it for the premium classes), and is a percentage of that + * passenger's ORIGINAL fare, not of the difference. + * + * A non-positive difference never produces a credit: an upgrade that prices below the current + * seat is refused upstream rather than refunded here (see `buildQuote`). + */ +export function computeUpgradeAmounts( + policy: PolicyFee, + oldFareMinor: number, + newFareMinor: number, +): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } { + const feeMinor = policy.feeWaived + ? 0 + : policy.feePercent > 0 || policy.feeMinMinor > 0 + ? Math.max(Math.round((oldFareMinor * policy.feePercent) / 100), policy.feeMinMinor) + : 0; + const fareDifferenceMinor = newFareMinor - oldFareMinor; + return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) }; +} + +type UpgradeItem = { + bookingSeatId: string; + passengerName: string; + passengerCategory: string; + oldSeatId: string; + oldSeatLabel: string | null; + oldCoachTypeId: string; + oldSeatClassId: string | null; + oldFareMinor: number; + newSeatId: string; + newSeatLabel: string | null; + newCoachTypeId: string; + newSeatClassId: string | null; + newFareMinor: number; + feeMinor: number; + fareDifferenceMinor: number; +}; + +// Seats ordered the same way the reschedule flow orders them, so both features present a leg's +// passengers in one stable sequence. Upgrade itself keys on bookingSeatId, not position. +const bookingInclude = { + schedule: { + select: { + id: true, departureAt: true, arrivalAt: true, status: true, + originStationId: true, destinationStationId: true, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, + }, + }, + returnSchedule: { + select: { + id: true, departureAt: true, arrivalAt: true, status: true, + originStationId: true, destinationStationId: true, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, + }, + }, + seats: { + include: { seat: { include: { coach: { select: { id: true, coachTypeId: true } } } } }, + orderBy: [{ passengerName: 'asc' as const }, { id: 'asc' as const }], + }, +} satisfies Prisma.BookingInclude; + +@Injectable() +export class UpgradeService { + private readonly logger = new Logger(UpgradeService.name); + + constructor( + private prisma: PrismaService, + private bookingsService: BookingsService, + private seatsService: SeatsService, + private segmentsService: SegmentsService, + private ticketsService: TicketsService, + private paymentsService: PaymentsService, + private supplementaryCharges: SupplementaryChargesService, + private currencyService: CurrencyService, + private auditService: AuditService, + private eventEmitter: EventEmitter2, + private systemConfig: SystemConfigService, + ) {} + + // ── Policy admin ───────────────────────────────────────────────────────── + + async listPolicies() { + return this.prisma.upgradePolicy.findMany({ + include: { coachType: { select: { id: true, code: true, name: true, type: true } } }, + orderBy: { rank: 'asc' }, + }); + } + + /** Fare classes with no upgrade policy yet — the add dialog's dropdown. */ + async listUnconfiguredCoachTypes() { + return this.prisma.coachType.findMany({ + where: { ...NOT_A_FARE_CLASS, upgradePolicy: { is: null } }, + select: { id: true, code: true, name: true, type: true }, + orderBy: { code: 'asc' }, + }); + } + + async createPolicy(dto: CreateUpgradePolicyDto, actorId?: string) { + const { coachTypeId, ...values } = dto; + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + if (isNonFareCoachType(coachType)) { + throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`); + } + const existing = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } }); + if (existing) throw new ConflictException(`${coachType.code} already has an upgrade policy — edit it instead.`); + await this.assertRankIsFree(values.rank ?? 0, null); + + const policy = await this.prisma.upgradePolicy.create({ data: { coachTypeId, ...values } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.UpgradePolicy, + entityId: policy.id, + newData: { coachTypeCode: coachType.code, ...values }, + }); + return policy; + } + + async updatePolicy(coachTypeId: string, dto: UpdateUpgradePolicyDto, actorId?: string) { + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + const before = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId } }); + if (dto.rank !== undefined) await this.assertRankIsFree(dto.rank, coachTypeId); + + const policy = await this.prisma.upgradePolicy.upsert({ + where: { coachTypeId }, + update: dto, + create: { coachTypeId, ...dto }, + }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.UpgradePolicy, + entityId: policy.id, + oldData: before ?? undefined, + newData: { coachTypeCode: coachType.code, ...dto }, + }); + return policy; + } + + async deletePolicy(coachTypeId: string, actorId?: string) { + const policy = await this.prisma.upgradePolicy.findUnique({ + where: { coachTypeId }, + include: { coachType: { select: { code: true } } }, + }); + if (!policy) throw new NotFoundException('Upgrade policy not found'); + + await this.prisma.upgradePolicy.delete({ where: { coachTypeId } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.UpgradePolicy, + entityId: policy.id, + oldData: policy, + }); + // With no policy the class can be neither left nor entered — the intended effect of deleting. + return { deleted: true, coachTypeId }; + } + + /** + * Two active policies sharing a rank make "strictly higher" undefined, so the ladder must stay + * a total order. + */ + private async assertRankIsFree(rank: number, exceptCoachTypeId: string | null) { + const clash = await this.prisma.upgradePolicy.findFirst({ + where: { rank, isActive: true, ...(exceptCoachTypeId ? { coachTypeId: { not: exceptCoachTypeId } } : {}) }, + include: { coachType: { select: { code: true } } }, + }); + if (clash) { + throw new ConflictException(`Rank ${rank} is already used by ${clash.coachType.code}. Ranks must be unique.`); + } + } + + // ── Reads ──────────────────────────────────────────────────────────────── + + /** Per leg: who can upgrade, to which classes, and roughly what it costs. */ + async getOptions(bookingRef: string, user: ActingUser) { + const booking = await this.load(bookingRef, user); + const legs = this.legsOf(booking); + const pending = await this.prisma.bookingUpgrade.findFirst({ + where: { bookingId: booking.id, status: 'PENDING_PAYMENT' }, + }); + const charge = pending?.supplementaryChargeId + ? await this.prisma.supplementaryCharge.findUnique({ + where: { id: pending.supplementaryChargeId }, + select: { paymentToken: true, status: true, expiresAt: true }, + }) + : null; + + const out = []; + for (const leg of legs) { + const blockers = await this.legBlockers(booking, leg); + const targets = await this.targetsFor(leg); + // Each passenger's own class decides what counts as "up" for them, so the source policy + // has to be resolved per seat — on a mixed-class booking they differ. + const sourcePolicies = await this.prisma.upgradePolicy.findMany({ + where: { coachTypeId: { in: [...new Set(leg.seats.map((s: any) => s.coachTypeId as string).filter(Boolean))] as string[] } }, + }); + const sourceByCoachType = new Map(sourcePolicies.map((p) => [p.coachTypeId, p])); + + const passengers = leg.seats.map((s: any) => { + const source = sourceByCoachType.get(s.coachTypeId); + const canLeave = !!source && source.isActive && source.isUpgradable; + return { + bookingSeatId: s.id, + passengerName: s.passengerName, + passengerCategory: s.passengerCategory, + seatId: s.seatId, + seatLabel: s.seatLabel, + coachTypeId: s.coachTypeId, + currentRank: source?.rank ?? null, + currentFareMinor: s.fareMinor ?? 0, + // A passenger can only move up from where they actually sit, which on a mixed-class + // booking differs per passenger. No policy on their current class means they cannot + // leave it at all. + targets: canLeave + ? targets.filter((t) => t.rank > source!.rank && t.coachTypeId !== s.coachTypeId) + : [], + }; + }); + + out.push({ + leg: leg.leg, + scheduleId: leg.scheduleId, + originStationId: leg.originStationId, + destinationStationId: leg.destinationStationId, + departureAt: leg.departureAt, + checkinCutoffAt: leg.checkin?.cutoffAt ?? null, + checkinMinutes: leg.checkin?.checkinMinutes ?? null, + canUpgrade: blockers.length === 0 && passengers.some((p: any) => p.targets.length > 0), + blockers, + passengers, + }); + } + + const history = await this.prisma.bookingUpgrade.findMany({ + where: { bookingId: booking.id, status: { not: 'PENDING_PAYMENT' } }, + orderBy: { createdAt: 'desc' }, + }); + + return { + bookingRef: booking.bookingRef, + bookingType: booking.bookingType, + legs: out, + pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null, + history, + }; + } + + // ── Quote / create / apply ─────────────────────────────────────────────── + + /** + * Takes the seat hold for an upgrade attempt. + * + * Server-side rather than letting the portal call `/seats/hold` directly, because an upgrade + * holds on the SAME schedule the booking already occupies — so a retry collides with the + * caller's own abandoned attempt: first on the synthetic passenger id, and if they re-pick the + * same seat, on the seat itself. Clearing this booking's own stale upgrade holds first is the + * only way a passenger can change their mind inside the hold TTL. Deriving the schedule and + * stations from the booking instead of trusting the client is a bonus. + */ + async holdForUpgrade(bookingRef: string, dto: UpgradeHoldDto, user: ActingUser) { + const booking = await this.load(bookingRef, user); + const legNo = dto.leg ?? 1; + const leg = this.legsOf(booking).find((l) => l.leg === legNo); + if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`); + + await this.releaseAbandonedHolds(booking.bookingRef, leg.scheduleId); + + return this.seatsService.holdSeats({ + scheduleId: leg.scheduleId, + originStationId: leg.originStationId, + destinationStationId: leg.destinationStationId, + journeyDirection: legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY, + // Synthetic ids: there is no real passenger id to hand, and holdSeats only uses them to + // stop one passenger holding two seats on a leg. Tagged with the booking ref so this + // booking's own abandoned attempts can be told apart from anyone else's hold. + passengers: dto.seatIds.map((seatId, i) => ({ + passengerId: `${this.upgradeHoldPrefix(bookingRef)}${i}`, + seatId, + })), + } as any); + } + + private upgradeHoldPrefix(bookingRef: string) { + return `upgrade-${bookingRef}-`; + } + + /** + * Deletes holds this booking's own earlier upgrade attempts left behind, except one already + * committed to a PENDING_PAYMENT upgrade (that one is paid-for and must survive). + */ + private async releaseAbandonedHolds(bookingRef: string, scheduleId: string) { + const prefix = this.upgradeHoldPrefix(bookingRef); + const live = await this.prisma.bookingUpgrade.findMany({ + where: { status: 'PENDING_PAYMENT', holdId: { not: null } }, + select: { holdId: true }, + }); + const committed = new Set(live.map((u) => u.holdId!)); + + const holds = await this.prisma.seatHold.findMany({ where: { scheduleId } }); + const mine = holds.filter((h) => { + if (committed.has(h.id)) return false; + if (!h.createdBy?.trimStart().startsWith('{')) return false; + try { + const meta = JSON.parse(h.createdBy); + return (meta.passengers ?? []).some((p: any) => String(p.passengerId ?? '').startsWith(prefix)); + } catch { + return false; + } + }); + if (mine.length) { + await this.prisma.seatHold.deleteMany({ where: { id: { in: mine.map((h) => h.id) } } }); + this.logger.log(`Released ${mine.length} abandoned upgrade hold(s) for ${bookingRef}`); + } + } + + async quote(bookingRef: string, dto: UpgradeQuoteDto, user: ActingUser) { + const booking = await this.load(bookingRef, user); + return this.buildQuote(booking, dto); + } + + async create(bookingRef: string, dto: CreateUpgradeDto, user: ActingUser) { + const booking = await this.load(bookingRef, user); + const q = await this.buildQuote(booking, dto, { skipAvailability: true }); + if (!q.allowed) throw new BadRequestException(q.blockers.join(' ')); + + const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); + if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); + if (hold.scheduleId !== q.scheduleId) throw new BadRequestException('Seat hold is for a different schedule'); + const held = new Set(hold.seatIds); + if (!q.items.every((it) => held.has(it.newSeatId))) { + throw new BadRequestException('Selected seats are not covered by the hold'); + } + + const requestedBy = user.id ?? user.sub ?? booking.passengerId; + // Deadline is the earlier of the usual 2h payment window and the check-in cutoff, so a + // passenger can never pay for an upgrade after boarding has closed on it. + const windowMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.UPGRADE_PAYMENT_WINDOW_MINUTES); + const expiresAt = computePaymentDeadline( + new Date(), + q.checkin.segmentTime, + q.checkin.checkinMinutes, + windowMinutes, + ); + + const upgrade = await this.prisma.bookingUpgrade.create({ + data: { + bookingId: booking.id, + leg: q.leg, + status: 'PENDING_PAYMENT', + requestedBy, + scheduleId: q.scheduleId, + items: q.items as unknown as Prisma.InputJsonValue, + holdId: dto.holdId, + oldFareMinor: q.oldFareMinor, + newFareMinor: q.newFareMinor, + fareDifferenceMinor: q.fareDifferenceMinor, + feeMinor: q.feeMinor, + amountDueMinor: q.amountDueMinor, + expiresAt: q.amountDueMinor > 0 ? expiresAt : null, + }, + }); + + if (q.amountDueMinor === 0) { + await this.apply(upgrade.id); + return { upgradeId: upgrade.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q }; + } + + const charge = await this.supplementaryCharges.create({ + bookingRef: booking.bookingRef, + amountMinor: q.amountDueMinor, + reason: UPGRADE_CHARGE_REASON, + notes: `Upgrade leg ${q.leg} → ${q.newCoachTypeCode} (${q.items.length} passenger(s))`, + createdBy: requestedBy, + expiresAt, + }); + await this.prisma.bookingUpgrade.update({ + where: { id: upgrade.id }, + data: { supplementaryChargeId: charge.id }, + }); + // Same instant the charge carries, so the hold and the payment link die together. + await this.seatsService.confirmSeats(q.items.map((it) => it.newSeatId), new Date(), expiresAt); + + await this.auditService.log({ + userId: requestedBy, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.BookingUpgrade, + entityId: upgrade.id, + newData: { + bookingRef: booking.bookingRef, + leg: q.leg, + newCoachTypeId: dto.newCoachTypeId, + amountDueMinor: q.amountDueMinor, + chargeId: charge.id, + }, + }); + return { + upgradeId: upgrade.id, + status: 'PENDING_PAYMENT', + amountDueMinor: q.amountDueMinor, + paymentToken: charge.paymentToken, + expiresAt, + quote: q, + }; + } + + /** Entry point for the paid-charge event. Idempotent: only a PENDING_PAYMENT row is applied. */ + async applyForCharge(supplementaryChargeId: string) { + const u = await this.prisma.bookingUpgrade.findUnique({ where: { supplementaryChargeId } }); + if (!u || u.status !== 'PENDING_PAYMENT') return; + await this.apply(u.id); + } + + /** Moves the named passengers into their new seats. The schedule never changes. */ + async apply(upgradeId: string) { + const u = await this.prisma.bookingUpgrade.findUnique({ where: { id: upgradeId } }); + if (!u) throw new NotFoundException('Upgrade not found'); + if (u.status !== 'PENDING_PAYMENT') return u; + + const booking = await this.prisma.booking.findUnique({ where: { id: u.bookingId }, include: bookingInclude }); + if (!booking) throw new NotFoundException('Booking not found'); + const items = u.items as unknown as UpgradeItem[]; + + const seatById = new Map(booking.seats.map((s) => [s.id, s])); + for (const it of items) { + if (!seatById.has(it.bookingSeatId)) { + throw new BadRequestException('A passenger on this upgrade is no longer on the booking'); + } + } + + const newTotal = Math.max(0, booking.totalMinor + u.fareDifferenceMinor); + const displayTotal = + booking.displayCurrency && booking.displayCurrency !== 'ETB' + ? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any) + : newTotal; + + await this.prisma.$transaction(async (tx) => { + await tx.booking.update({ + where: { id: booking.id }, + data: { totalMinor: newTotal, displayTotalMinor: displayTotal }, + }); + + // Two passes, as the reschedule flow does. Here it is defensive rather than required: the + // schedule is unchanged, so `@@unique([scheduleId, seatId])` can only collide when one + // request upgrades two passengers and the second lands on a seat the first is vacating + // (B: EBC→VIP frees EBC-7 while A: RS→EBC takes it). Parking every row on a per-row-unique + // sentinel first makes the write order irrelevant. + for (const it of items) { + await tx.bookingSeat.update({ + where: { id: it.bookingSeatId }, + data: { scheduleId: `moving-${it.bookingSeatId}` }, + }); + } + for (const it of items) { + await tx.bookingSeat.update({ + where: { id: it.bookingSeatId }, + data: { + seatId: it.newSeatId, + scheduleId: u.scheduleId, + fareMinor: it.newFareMinor, + seatLabelSnapshot: null, + }, + }); + } + + await tx.bookingModification.create({ + data: { + bookingId: booking.id, + modifiedBy: u.requestedBy, + modificationType: 'UPGRADE', + oldData: { + leg: u.leg, + scheduleId: u.scheduleId, + items: items.map((i) => ({ + bookingSeatId: i.bookingSeatId, passengerName: i.passengerName, + seatId: i.oldSeatId, seatLabel: i.oldSeatLabel, + coachTypeId: i.oldCoachTypeId, fareMinor: i.oldFareMinor, + })), + }, + newData: { + leg: u.leg, + scheduleId: u.scheduleId, + feeMinor: u.feeMinor, + items: items.map((i) => ({ + bookingSeatId: i.bookingSeatId, passengerName: i.passengerName, + seatId: i.newSeatId, seatLabel: i.newSeatLabel, + coachTypeId: i.newCoachTypeId, fareMinor: i.newFareMinor, + })), + }, + fareAdjustment: u.fareDifferenceMinor, + }, + }); + + await tx.bookingUpgrade.update({ + where: { id: u.id }, + data: { status: 'APPLIED', appliedAt: new Date() }, + }); + }); + + // Occupancy + tickets are rebuilt from the (now updated) booking, outside the transaction. + const fresh = await this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { seats: true, tickets: { select: { id: true } } }, + }); + if (fresh) { + try { + await this.seatsService.releaseSeats(fresh.id); + await this.paymentsService.createJourneySegments(fresh as any); + } catch (err) { + this.logger.error(`Upgrade ${u.id}: journey segments failed: ${err instanceof Error ? err.message : err}`); + } + // Old tickets' SYSTEM seat blocks reference ticket ids generate() is about to delete, and + // generate() only clears blocks for the booking's CURRENT seats — the vacated seat is no + // longer among them, so its block would survive. + for (const t of fresh.tickets) { + await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: t.id }, blockedBy: 'SYSTEM' } }); + } + try { + await this.ticketsService.generate(fresh.id); + } catch (err) { + this.logger.error(`Upgrade ${u.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`); + } + } + + // Unlike reschedule, this upgrade stayed on the SAME schedule — so the booking's original + // seat hold is still in scope and would keep the vacated seat reading HELD on the very train + // still being sold. Clearing it is what puts that seat back on sale. + await this.prisma.seatHold.deleteMany({ + where: { + OR: [ + { id: u.holdId ?? '' }, + { scheduleId: u.scheduleId, seatIds: { hasSome: items.map((i) => i.oldSeatId) } }, + ], + }, + }); + + await this.auditService.log({ + userId: u.requestedBy, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Booking, + entityId: booking.id, + oldData: { leg: u.leg, items: items.map((i) => ({ seatId: i.oldSeatId, coachTypeId: i.oldCoachTypeId })) }, + newData: { + leg: u.leg, + upgradeId: u.id, + feeMinor: u.feeMinor, + fareDifferenceMinor: u.fareDifferenceMinor, + items: items.map((i) => ({ seatId: i.newSeatId, coachTypeId: i.newCoachTypeId })), + }, + }); + this.eventEmitter.emit('booking.upgraded', { booking: fresh ?? booking, upgrade: u }); + return { ...u, status: 'APPLIED' }; + } + + /** Cron hook: unpaid upgrades past their payment deadline. The seat hold lapses by itself. */ + async expireStale(now = new Date()): Promise { + const stale = await this.prisma.bookingUpgrade.findMany({ + where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, + select: { id: true, supplementaryChargeId: true }, + }); + for (const u of stale) { + await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } }); + if (u.supplementaryChargeId) { + await this.prisma.supplementaryCharge.updateMany({ + where: { id: u.supplementaryChargeId, status: 'PENDING' }, + data: { status: 'EXPIRED' }, + }); + } + } + return stale.length; + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private load(bookingRef: string, user: ActingUser) { + return loadOwnedBooking(this.prisma, bookingRef, user, bookingInclude, 'upgrade it') as Promise< + Prisma.BookingGetPayload<{ include: typeof bookingInclude }> + >; + } + + private legsOf(booking: any) { + const legs: any[] = []; + const build = (n: number, scheduleId: string, schedule: any, originStationId: string, destinationStationId: string) => { + const seats = (booking.seats as any[]) + .filter((s) => (s.leg ?? 1) === n) + .map((s) => ({ + id: s.id, + seatId: s.seatId, + seatLabel: s.seatLabelSnapshot ?? s.seat?.seatNumber ?? null, + passengerName: s.passengerName, + passengerCategory: s.passengerCategory, + fareMinor: s.fareMinor, + coachTypeId: s.seat?.coach?.coachTypeId, + })); + if (!seats.length || !schedule) return; + legs.push({ leg: n, scheduleId, schedule, originStationId, destinationStationId, departureAt: schedule.departureAt, seats }); + }; + build(1, booking.scheduleId, booking.schedule, booking.originStationId, booking.destinationStationId); + if (booking.bookingType === 'ROUND_TRIP') { + build(2, booking.returnScheduleId, booking.returnSchedule, booking.returnOriginStationId, booking.returnDestinationStationId); + } + return legs; + } + + /** Resolves the boarding stop's check-in cutoff — the deadline US-17 §1 means by "before check-in". */ + private async resolveLegCheckin(leg: any) { + const stopTime = await this.prisma.tripStopTime.findFirst({ + where: { scheduleId: leg.scheduleId, stationId: leg.originStationId ?? undefined }, + select: { plannedArrivalAt: true, plannedDepartureAt: true }, + }); + return resolveCheckinCutoff(leg.schedule, stopTime, leg.originStationId); + } + + private async legBlockers(booking: any, leg: any, now = new Date()): Promise { + const blockers: string[] = []; + if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be upgraded.'); + if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be upgraded.'); + if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.'); + if (leg.schedule?.status !== 'SCHEDULED' || leg.departureAt <= now) blockers.push('This departure is no longer upgradable.'); + + leg.checkin = await this.resolveLegCheckin(leg); + if (leg.checkin.cutoffAt <= now) { + blockers.push(`Upgrades close ${leg.checkin.checkinMinutes} minutes before departure.`); + } + + // One change at a time. Two live supplementary charges could both drive ticket regeneration + // on this booking and interleave unpredictably. + const pendingUpgrade = await this.prisma.bookingUpgrade.findFirst({ + where: { bookingId: booking.id, status: 'PENDING_PAYMENT' }, + }); + if (pendingUpgrade) blockers.push('An upgrade is already awaiting payment for this booking.'); + const pendingReschedule = await this.prisma.bookingReschedule.findFirst({ + where: { bookingId: booking.id, status: 'PENDING_PAYMENT' }, + }); + if (pendingReschedule) blockers.push('A reschedule is awaiting payment for this booking — finish or cancel it first.'); + + return blockers; + } + + /** Fare classes on this schedule that anyone could upgrade into. */ + private async targetsFor(leg: any) { + const assignments = await this.prisma.coachAssignment.findMany({ + where: { scheduleId: leg.scheduleId, isOperational: true }, + select: { coach: { select: { coachTypeId: true } } }, + }); + const onBoard = [...new Set(assignments.map((a) => a.coach.coachTypeId))]; + if (!onBoard.length) return []; + + const policies = await this.prisma.upgradePolicy.findMany({ + where: { coachTypeId: { in: onBoard }, isActive: true, isTargetable: true, coachType: NOT_A_FARE_CLASS }, + include: { coachType: { select: { id: true, code: true, name: true } } }, + orderBy: { rank: 'asc' }, + }); + return policies.map((p) => ({ + coachTypeId: p.coachTypeId, + code: p.coachType.code, + name: p.coachType.name, + rank: p.rank, + feePercent: p.feePercent, + feeMinMinor: p.feeMinMinor, + feeWaived: p.feeWaived, + })); + } + + private async buildQuote(booking: any, dto: UpgradeQuoteDto, opts: { skipAvailability?: boolean } = {}) { + const legNo = dto.leg ?? 1; + const leg = this.legsOf(booking).find((l) => l.leg === legNo); + if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`); + + const blockers = await this.legBlockers(booking, leg); + + const target = await this.prisma.upgradePolicy.findUnique({ + where: { coachTypeId: dto.newCoachTypeId }, + include: { coachType: { select: { id: true, code: true, name: true, type: true, seatClasses: { where: { isActive: true } } } } }, + }); + if (!target) throw new NotFoundException('That fare class has no upgrade policy'); + if (!target.isActive || !target.isTargetable) blockers.push(`${target.coachType.code} cannot be upgraded to.`); + + const onSchedule = await this.prisma.coachAssignment.count({ + where: { scheduleId: leg.scheduleId, isOperational: true, coach: { coachTypeId: dto.newCoachTypeId } }, + }); + if (!onSchedule) blockers.push(`${target.coachType.code} is not available on this train.`); + + const seatRows = await this.prisma.seat.findMany({ + where: { id: { in: dto.items.map((i) => i.newSeatId) } }, + include: { coach: { select: { id: true, coachTypeId: true } } }, + }); + const seatById = new Map(seatRows.map((s) => [s.id, s])); + if (seatRows.length !== dto.items.length) blockers.push('One or more selected seats do not exist.'); + if (new Set(dto.items.map((i) => i.newSeatId)).size !== dto.items.length) blockers.push('Duplicate seats selected.'); + + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: leg.scheduleId }, + include: { station: { select: { code: true } } }, + orderBy: { sequence: 'asc' }, + }); + const originStop = stopTimes.find((s) => s.stationId === leg.originStationId); + const destStop = stopTimes.find((s) => s.stationId === leg.destinationStationId); + if (!originStop || !destStop) blockers.push('This leg\'s route could not be resolved.'); + + const { nationalityType, nationality } = resolveNationalityProxy(booking.displayCurrency); + const segmentRoute = originStop && destStop ? `${originStop.station.code}-${destStop.station.code}` : undefined; + + const bookingSeats = new Map(leg.seats.map((s: any) => [s.id, s])); + const items: UpgradeItem[] = []; + let oldFareMinor = 0; + let newFareMinor = 0; + let feeMinor = 0; + + for (const req of dto.items) { + const current: any = bookingSeats.get(req.bookingSeatId); + if (!current) { blockers.push('A selected passenger is not on this leg.'); break; } + + const source = await this.prisma.upgradePolicy.findUnique({ where: { coachTypeId: current.coachTypeId } }); + if (!source || !source.isActive || !source.isUpgradable) { + blockers.push(`${current.passengerName} is in a class that cannot be upgraded.`); + break; + } + if (target.rank <= source.rank) { + blockers.push(`${target.coachType.code} is not an upgrade from ${current.passengerName}'s current class.`); + break; + } + + const seat = seatById.get(req.newSeatId); + if (!seat) break; // already reported above + if (seat.coach.coachTypeId !== dto.newCoachTypeId) { + blockers.push('Every selected seat must be in the fare class being upgraded to.'); + break; + } + + const seatClass = originStop && destStop + ? pickSeatClass(target.coachType.seatClasses, seat.bedPosition, nationalityType) + : null; + if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; } + + const seatFare = await this.bookingsService.getBaseFare( + leg.scheduleId, seatClass.id, segmentRoute, undefined, nationality, + originStop!.sequence, destStop!.sequence, originStop!.stationId, destStop!.stationId, + ); + const currentFare = current.fareMinor ?? 0; + const amounts = computeUpgradeAmounts(target, currentFare, seatFare); + + // Refuse rather than credit. A "higher" class pricing below the current seat means the fare + // configuration disagrees with the ladder; handing out a free upgrade would hide that. + if (amounts.fareDifferenceMinor <= 0) { + blockers.push(`${target.coachType.code} is not priced above ${current.passengerName}'s current seat on this route.`); + break; + } + + oldFareMinor += currentFare; + newFareMinor += seatFare; + feeMinor += amounts.feeMinor; + items.push({ + bookingSeatId: current.id, + passengerName: current.passengerName, + passengerCategory: current.passengerCategory, + oldSeatId: current.seatId, + oldSeatLabel: current.seatLabel, + oldCoachTypeId: current.coachTypeId, + oldSeatClassId: null, + oldFareMinor: currentFare, + newSeatId: seat.id, + newSeatLabel: seat.seatNumber, + newCoachTypeId: seat.coach.coachTypeId, + newSeatClassId: seatClass.id, + newFareMinor: seatFare, + feeMinor: amounts.feeMinor, + fareDifferenceMinor: amounts.fareDifferenceMinor, + }); + } + + // Availability last, so a bad selection reports the clearer error first. + // + // Skipped when re-quoting inside create(): by then the caller is holding these very seats, + // so this check would see their own hold and refuse the upgrade they just paid to make. The + // hold itself is the stronger guarantee — holdSeats ran assertNoRouteSeatConflict plus the + // hold and journey-segment collision checks, and create() verifies the hold is unexpired, + // for this schedule, and covers exactly these seats. + if (!opts.skipAvailability && !blockers.length && originStop && destStop) { + const free = await this.segmentsService.getFreeSeatIds( + leg.scheduleId, + items.map((i) => i.newSeatId), + stopTimes as any, + originStop.sequence, + destStop.sequence, + legNo === 2 ? JourneyDirection.RETURN : JourneyDirection.ONE_WAY, + ); + const taken = items.filter((i) => !free.has(i.newSeatId)); + if (taken.length) blockers.push('One or more selected seats have just been taken.'); + } + + const fareDifferenceMinor = newFareMinor - oldFareMinor; + return { + allowed: blockers.length === 0 && items.length === dto.items.length, + blockers: Array.from(new Set(blockers)), + leg: legNo, + scheduleId: leg.scheduleId, + newCoachTypeId: dto.newCoachTypeId, + newCoachTypeCode: target.coachType.code, + newCoachTypeName: target.coachType.name, + checkin: leg.checkin, + items, + oldFareMinor, + newFareMinor, + fareDifferenceMinor, + feeMinor, + amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor), + currency: 'ETB', + policy: { feePercent: target.feePercent, feeMinMinor: target.feeMinMinor, feeWaived: target.feeWaived }, + }; + } +} diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 90725b683..f0a0d17f5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -9,6 +9,9 @@ type Tab = 'general' | 'payment' | 'integrations' | 'configurations'; export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); const [seatHoldMinutes, setSeatHoldMinutes] = useState('5'); + const [bookingPayWindow, setBookingPayWindow] = useState('120'); + const [reschedulePayWindow, setReschedulePayWindow] = useState('120'); + const [upgradePayWindow, setUpgradePayWindow] = useState('120'); const [holdCutoffHours, setHoldCutoffHours] = useState('2'); const [boardingWindowHours, setBoardingWindowHours] = useState('4'); const [throttleAuthLimit, setThrottleAuthLimit] = useState('5'); @@ -24,6 +27,9 @@ export default function SettingsPage() { systemConfigApi.getAll() .then((data) => { if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes); + if (data?.booking_payment_window_minutes) setBookingPayWindow(data.booking_payment_window_minutes); + if (data?.reschedule_payment_window_minutes) setReschedulePayWindow(data.reschedule_payment_window_minutes); + if (data?.upgrade_payment_window_minutes) setUpgradePayWindow(data.upgrade_payment_window_minutes); if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure); if (data?.boarding_window_hours_before_departure) setBoardingWindowHours(data.boarding_window_hours_before_departure); if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit); @@ -40,6 +46,9 @@ export default function SettingsPage() { try { await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes, + booking_payment_window_minutes: bookingPayWindow, + reschedule_payment_window_minutes: reschedulePayWindow, + upgrade_payment_window_minutes: upgradePayWindow, hold_cutoff_hours_before_departure: holdCutoffHours, boarding_window_hours_before_departure: boardingWindowHours, throttle_auth_limit: throttleAuthLimit, @@ -149,6 +158,44 @@ export default function SettingsPage() { )} +

Payment Windows (minutes)

+

+ How long a payer has before the request expires and the held seat is released. The + check-in cutoff is still the hard limit — a longer window can never allow payment after + boarding closes. +

+
+
+ + setBookingPayWindow(e.target.value)} + /> +

Time to pay for a new booking before it is auto-cancelled. Default: 120.

+
+
+ + setReschedulePayWindow(e.target.value)} + /> +

Time to pay a reschedule charge. Default: 120.

+
+
+ + setUpgradePayWindow(e.target.value)} + /> +

Time to pay a fare-class upgrade. Default: 120.

+
+

Seat Booking

{configLoading ? (

Loading...

diff --git a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/layout.tsx new file mode 100644 index 000000000..47bf1a723 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function UpgradePoliciesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx new file mode 100644 index 000000000..5be9108b9 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import UpgradePolicyManager from '@/components/upgrade/UpgradePolicyManager'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; + +/** + * Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy + * can be neither upgraded from nor to. Gated on bookings:view because that is what + * `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side. + */ +export default function UpgradePoliciesPage() { + return ( + +
+
+

Upgrade Policies

+

+ Which fare classes a passenger may move up to before check-in, and what the change costs +

+
+ +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 99c24be9a..58f20b17c 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -29,6 +29,7 @@ import { Briefcase, Calendar, CalendarClock, + ArrowUpNarrowWide, Utensils, Package, Moon, @@ -92,6 +93,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view }, + { name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx new file mode 100644 index 000000000..49e66f607 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx @@ -0,0 +1,363 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Edit, Plus, Save, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { + upgradePolicyApi, + type UpgradePolicyCoachType, + type UpgradePolicyRow, + type UpgradePolicyValues, +} from '@/lib/api'; + +const EMPTY_POLICY: UpgradePolicyValues = { + rank: 0, + feePercent: 0, + feeMinMinor: 0, + feeWaived: false, + isUpgradable: true, + isTargetable: true, + isActive: true, +}; + +// Money is entered in ETB and stored in minor units. +const etb = (minor: number) => String(minor / 100); +const toMinor = (value: string) => Math.round(Number(value || 0) * 100); +const feeLabel = (p: UpgradePolicyRow) => + p.feeWaived + ? 'Waived' + : p.feePercent > 0 || p.feeMinMinor > 0 + ? `${p.feePercent}% · min ETB ${etb(p.feeMinMinor)}` + : 'Free'; + +/** + * Policy US-17 — one upgrade policy per fare class (coach type), listed as a table and edited in + * a dialog, the same shape as Reschedule Policies and Coach Management. + */ +export default function UpgradePolicyManager() { + const [rows, setRows] = useState([]); + const [available, setAvailable] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(''); + + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [coachTypeId, setCoachTypeId] = useState(''); + const [form, setForm] = useState(EMPTY_POLICY); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(''); + + const [deleting, setDeleting] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + + const load = async () => { + setLoading(true); + try { + const [policies, coachTypes] = await Promise.all([ + upgradePolicyApi.list(), + upgradePolicyApi.availableCoachTypes(), + ]); + setRows(Array.isArray(policies) ? policies : []); + setAvailable(Array.isArray(coachTypes) ? coachTypes : []); + } catch { + setMessage('Failed to load upgrade policies.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openCreate = () => { + setEditing(null); + setCoachTypeId(''); + // Suggest the next free rung rather than 0, which would clash with an existing policy. + setForm({ ...EMPTY_POLICY, rank: Math.max(0, ...rows.map((r) => r.rank)) + 1 }); + setFormError(''); + setShowModal(true); + }; + + const openEdit = (row: UpgradePolicyRow) => { + setEditing(row); + setCoachTypeId(row.coachTypeId); + setForm({ + rank: row.rank, + feePercent: row.feePercent, + feeMinMinor: row.feeMinMinor, + feeWaived: row.feeWaived, + isUpgradable: row.isUpgradable, + isTargetable: row.isTargetable, + isActive: row.isActive, + }); + setFormError(''); + setShowModal(true); + }; + + const setField = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + const submit = async () => { + if (!editing && !coachTypeId) { + setFormError('Pick a fare class.'); + return; + } + setSaving(true); + setFormError(''); + try { + if (editing) await upgradePolicyApi.update(editing.coachTypeId, form); + else await upgradePolicyApi.create({ coachTypeId, ...form }); + setShowModal(false); + setMessage(editing ? 'Policy updated.' : 'Policy created.'); + await load(); + } catch (err: any) { + setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.'); + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleting) return; + setDeleteBusy(true); + try { + await upgradePolicyApi.remove(deleting.coachTypeId); + setDeleting(null); + setMessage('Policy deleted.'); + await load(); + } catch { + setMessage('Failed to delete the policy.'); + } finally { + setDeleteBusy(false); + } + }; + + const columns = [ + { + key: 'coachType', + label: 'Fare class', + render: (row: UpgradePolicyRow) => ( +
+ {row.coachType?.code} + — {row.coachType?.name} +
+ ), + }, + { + key: 'rank', + label: 'Rank', + render: (row: UpgradePolicyRow) => {row.rank}, + }, + { + key: 'fee', + label: 'Change fee', + render: (row: UpgradePolicyRow) => {feeLabel(row)}, + }, + { + key: 'isUpgradable', + label: 'Upgrade from', + render: (row: UpgradePolicyRow) => ( + + {row.isUpgradable ? 'Allowed' : 'No'} + + ), + }, + { + key: 'isTargetable', + label: 'Upgrade to', + render: (row: UpgradePolicyRow) => ( + + {row.isTargetable ? 'Allowed' : 'No'} + + ), + }, + { + key: 'isActive', + label: 'Status', + render: (row: UpgradePolicyRow) => ( + + {row.isActive ? 'Active' : 'Disabled'} + + ), + }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, + { + label: 'Delete', + onClick: (row: UpgradePolicyRow) => setDeleting(row), + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+

+ Rank orders the ladder — a passenger may only move to a class with a higher rank, on the same train. + Fee = max(fee % × the passenger's original fare, minimum), read from the class being upgraded + to and charged per upgraded passenger. A fare class with no policy here can be neither + upgraded from nor to. +

+ + Add Upgrade Policy + +
+ + {!loading && available.length === 0 && ( +

Every fare class already has a policy.

+ )} + {message &&

{message}

} + + + + setShowModal(false)} + title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade Policy'} + size="lg" + > +
+
+ + {editing ? ( + <> + +

A policy stays attached to its fare class.

+ + ) : ( + + )} +
+ +
+
+ + setField({ rank: Number(e.target.value) })} + /> +

Higher beats lower. Must be unique among active policies.

+
+
+ + +
+
+ + setField({ feePercent: Number(e.target.value) })} + /> +
+
+ + setField({ feeMinMinor: toMinor(e.target.value) })} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + {formError &&

{formError}

} + +
+ setShowModal(false)}> + Cancel + + + {editing ? 'Update Policy' : 'Create Policy'} + +
+
+
+ + setDeleting(null)} + onConfirm={confirmDelete} + title="Delete upgrade policy" + message={`Delete the upgrade policy for ${deleting?.coachType?.code ?? ''}?`} + warning="Passengers will no longer be able to upgrade out of or into this fare class. Upgrades already applied are unaffected." + confirmText="Delete" + isDanger + isLoading={deleteBusy} + /> +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index f444adda8..56de6b1a5 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -565,6 +565,40 @@ export interface ReschedulePolicyRow extends ReschedulePolicyValues { coachTypeId: string; coachType: ReschedulePolicyCoachType; } +// Fare-class upgrade policy API — one policy per coach type. `rank` orders the ladder; an +// upgrade requires a strictly higher rank. A coach type with no policy can be neither left nor +// entered. +export interface UpgradePolicyValues { + rank: number; + feePercent: number; + feeMinMinor: number; + feeWaived: boolean; + isUpgradable: boolean; + isTargetable: boolean; + isActive: boolean; +} +export interface UpgradePolicyCoachType { + id: string; + code: string; + name: string; + type: string; +} +export interface UpgradePolicyRow extends UpgradePolicyValues { + id: string; + coachTypeId: string; + coachType: UpgradePolicyCoachType; +} +export const upgradePolicyApi = { + list: () => apiClient.get('/upgrade/policies'), + availableCoachTypes: () => + apiClient.get('/upgrade/policies/available-coach-types'), + create: (data: UpgradePolicyValues & { coachTypeId: string }) => + apiClient.post('/upgrade/policies', data), + update: (coachTypeId: string, data: Partial) => + apiClient.patch(`/upgrade/policies/${coachTypeId}`, data), + remove: (coachTypeId: string) => apiClient.delete(`/upgrade/policies/${coachTypeId}`), +}; + export const reschedulePolicyApi = { list: () => apiClient.get('/reschedule/policies'), availableCoachTypes: () => diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index ab3a6376f..3c98e64da 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -10,6 +10,7 @@ import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { useEffect, useState } from "react"; import { Clock, + ArrowUpCircle, Users, CheckCircle2, AlertCircle, @@ -351,6 +352,7 @@ function BookingDetailContent() { const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone); const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`; + const upgradePath = `/booking/upgrade?ref=${booking.bookingRef}`; const StatusBadge = () => { const statusConfig = { @@ -1070,6 +1072,31 @@ function BookingDetailContent() { {isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"} )} + {/* Same gating as Reschedule: hidden from a signed-in viewer who did not book + the trip, because the API refuses them; a guest still gets the sign-in + prompt, since signing in as the booker is what unblocks them. Whether any + higher class actually exists on this train is the upgrade page's call. */} + {bookingSupportsReschedule && (!isAuthInitialized || !isAuthenticated || canReschedule) && ( + + )} )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx new file mode 100644 index 000000000..94ef6a114 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/upgrade/page.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { AlertCircle, ArrowUpCircle, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; +import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap"; + +type Target = { + coachTypeId: string; + code: string; + name: string; + rank: number; + feePercent: number; + feeMinMinor: number; + feeWaived: boolean; +}; + +type PassengerOption = { + bookingSeatId: string; + passengerName: string; + passengerCategory: string; + seatId: string; + seatLabel: string | null; + coachTypeId: string; + currentFareMinor: number; + targets: Target[]; +}; + +type LegOption = { + leg: number; + scheduleId: string; + originStationId: string | null; + destinationStationId: string | null; + departureAt: string; + checkinCutoffAt: string | null; + checkinMinutes: number | null; + canUpgrade: boolean; + blockers: string[]; + passengers: PassengerOption[]; +}; + +type Options = { + bookingRef: string; + bookingType: string; + legs: LegOption[]; + pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null; +}; + +type QuoteItem = { + bookingSeatId: string; + passengerName: string; + oldSeatLabel: string | null; + newSeatLabel: string | null; + oldFareMinor: number; + newFareMinor: number; + feeMinor: number; + fareDifferenceMinor: number; +}; + +type Quote = { + allowed: boolean; + blockers: string[]; + newCoachTypeCode: string; + items: QuoteItem[]; + oldFareMinor: number; + newFareMinor: number; + fareDifferenceMinor: number; + feeMinor: number; + amountDueMinor: number; +}; + +const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`; + +function UpgradePageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const ref = searchParams.get("ref") || ""; + + // Every endpoint here is behind JwtGuard, so a guest deep-linking would otherwise watch the + // options request 401 and land on a message blaming the booking. Send them to sign in and + // bring them back. Waits for isInitialized: the store starts logged-out. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + const needsLogin = isAuthInitialized && !isAuthenticated; + + useEffect(() => { + if (!needsLogin) return; + const back = ref ? `/booking/upgrade?ref=${ref}` : "/booking/lookup"; + router.replace(`/login?redirect=${encodeURIComponent(back)}`); + }, [needsLogin, ref, router]); + + const [legNo, setLegNo] = useState(1); + const [targetCoachTypeId, setTargetCoachTypeId] = useState(""); + /** bookingSeatId → chosen seat. Only the passengers in here are upgrading. */ + const [picks, setPicks] = useState>({}); + const [activeBookingSeatId, setActiveBookingSeatId] = useState(null); + const [selectedCoach, setSelectedCoach] = useState(null); + const [done, setDone] = useState<{ status: string } | null>(null); + const [error, setError] = useState(null); + + const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ + queryKey: ["upgrade-options", ref], + queryFn: () => apiClient.get(`/bookings/${ref}/upgrade`), + enabled: !!ref && isAuthInitialized && isAuthenticated, + retry: false, + }); + + const leg = useMemo( + () => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0], + [options, legNo], + ); + + // Every class anyone on this leg could move up to, de-duplicated for the chooser. + const targets = useMemo(() => { + const byId = new Map(); + for (const p of leg?.passengers ?? []) for (const t of p.targets) byId.set(t.coachTypeId, t); + return [...byId.values()].sort((a, b) => a.rank - b.rank); + }, [leg]); + + const target = targets.find((t) => t.coachTypeId === targetCoachTypeId) ?? null; + + const resetSelection = () => { + setPicks({}); + setActiveBookingSeatId(null); + setSelectedCoach(null); + setError(null); + }; + + // Switching leg or target invalidates every seat already picked — they belong to a coach that + // is no longer being shown. + useEffect(() => { + resetSelection(); + }, [legNo, targetCoachTypeId]); + + const { data: seatMap, isLoading: loadingSeats } = useQuery({ + queryKey: ["upgrade-seatmap", leg?.scheduleId, targetCoachTypeId, leg?.originStationId, leg?.destinationStationId], + queryFn: async () => { + const res: any = await apiClient.get( + `/seats/seatmap/${leg!.scheduleId}?coachTypeId=${targetCoachTypeId}` + + `&journeyDirection=${legNo === 2 ? "RETURN" : "ONE_WAY"}` + + `&originStationId=${leg!.originStationId}&destinationStationId=${leg!.destinationStationId}`, + ); + return res?.data || res; + }, + enabled: !!leg?.scheduleId && !!targetCoachTypeId, + }); + + const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]); + + const autoExpandedFor = useRef(null); + useEffect(() => { + if (!targetCoachTypeId || coaches.length === 0) return; + if (autoExpandedFor.current === targetCoachTypeId) return; + autoExpandedFor.current = targetCoachTypeId; + setSelectedCoach(coaches[0].id); + }, [targetCoachTypeId, coaches]); + + /** Passengers eligible for the chosen target, in the API's own order. */ + const eligible = useMemo( + () => (leg?.passengers ?? []).filter((p) => p.targets.some((t) => t.coachTypeId === targetCoachTypeId)), + [leg, targetCoachTypeId], + ); + + // Someone must be "active" for a seat click to mean anything. Without this the seat map looks + // fully interactive but every click is a silent no-op until a passenger row is clicked first — + // and on a single-passenger booking there is nothing obvious to click. + useEffect(() => { + if (!targetCoachTypeId || eligible.length === 0) return; + setActiveBookingSeatId((current) => { + if (current && eligible.some((p) => p.bookingSeatId === current)) return current; + return eligible[0].bookingSeatId; + }); + }, [targetCoachTypeId, eligible]); + + const items = useMemo( + () => + eligible + .filter((p) => picks[p.bookingSeatId]) + .map((p) => ({ bookingSeatId: p.bookingSeatId, newSeatId: picks[p.bookingSeatId] })), + [eligible, picks], + ); + + const quoteBody = leg && targetCoachTypeId && items.length > 0 + ? { leg: leg.leg, newCoachTypeId: targetCoachTypeId, items } + : null; + + const { data: quote, isFetching: quoting } = useQuery({ + queryKey: ["upgrade-quote", ref, quoteBody], + queryFn: () => apiClient.post(`/bookings/${ref}/upgrade/quote`, quoteBody), + enabled: !!quoteBody, + }); + + const confirm = useMutation({ + mutationFn: async () => { + // Goes through the upgrade module rather than /seats/hold directly: an upgrade holds on + // the SAME schedule the booking already occupies, so a retry collides with the caller's + // own abandoned attempt. The server clears those first, and derives the schedule and + // stations from the booking instead of trusting us. + const hold: any = await apiClient.post(`/bookings/${ref}/upgrade/hold`, { + leg: leg!.leg, + seatIds: items.map((it) => it.newSeatId), + }); + return apiClient.post(`/bookings/${ref}/upgrade`, { ...quoteBody, holdId: hold.holdId || hold.id }); + }, + onSuccess: (res) => { + if (res.paymentToken) router.push(`/pay-balance/${res.paymentToken}`); + else setDone({ status: res.status }); + }, + onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not upgrade"), + }); + + const seatOwner = (seatId: string) => + Object.entries(picks).find(([, sid]) => sid === seatId)?.[0] ?? null; + + const handleSeatToggle = (seatId: string) => { + const owner = seatOwner(seatId); + if (owner && owner !== activeBookingSeatId) return; // already another passenger's pick + // Fall back to the first passenger still without a seat, so a click is never swallowed. + const forPassenger = + activeBookingSeatId ?? eligible.find((p) => !picks[p.bookingSeatId])?.bookingSeatId; + if (!forPassenger) return; + + setPicks((prev) => { + const next = { ...prev }; + if (next[forPassenger] === seatId) { + delete next[forPassenger]; + return next; + } + next[forPassenger] = seatId; + // Move to the next passenger still without a seat, so a multi-passenger upgrade can be + // filled by clicking straight down the coach — same behaviour as /booking/seats. + const nextUnassigned = eligible.find((p) => p.bookingSeatId !== forPassenger && !next[p.bookingSeatId]); + if (nextUnassigned) setActiveBookingSeatId(nextUnassigned.bookingSeatId); + return next; + }); + }; + + const labelForSeat = (seatId: string) => { + const seat = coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId); + return seat ? buildSeatLabel(seat) : ""; + }; + + if (!ref) return

Missing booking reference.

; + if (!isAuthInitialized || needsLogin) { + return ; + } + if (loadingOptions) return ; + if (optionsError || !options || !leg) { + return ( + +

+ {(optionsError as any)?.response?.data?.message || "This booking cannot be upgraded."} +

+
+ ); + } + + if (done) { + return ( + +
+ +

Upgrade confirmed

+

New tickets have been issued for booking {ref}.

+ +
+
+ ); + } + + if (options.pending) { + return ( + +
+

Upgrade awaiting payment

+

+ An upgrade of {etb(options.pending.amountDueMinor)} is waiting to be paid + {options.pending.expiresAt ? ` before ${format(new Date(options.pending.expiresAt), "dd MMM HH:mm")}` : ""}. + Your new seats are held until then. +

+ {options.pending.paymentToken && ( + + )} +
+
+ ); + } + + const Summary = () => ( +
+

+ Upgrade summary +

+
+
Journey
+
+ {format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")} +
+ {leg.checkinCutoffAt && ( +
+ Upgrades close {format(new Date(leg.checkinCutoffAt), "dd MMM HH:mm")} +
+ )} +
+ + {target && ( +
+
Upgrading to
+
+ {target.code} — {target.name} +
+
+ Change fee: {target.feeWaived || (target.feePercent === 0 && target.feeMinMinor === 0) + ? "none" + : `${target.feePercent}% (min ${etb(target.feeMinMinor)})`} +
+
+ )} + +
+ {eligible.map((p) => ( +
+ {p.passengerName} + + {picks[p.bookingSeatId] + ? `${p.seatLabel ?? "seat"} → ${labelForSeat(picks[p.bookingSeatId])}` + : "Not upgrading"} + +
+ ))} +
+ + {!quoteBody ? ( +

+ Choose a class and a seat for each passenger you want to upgrade. +

+ ) : quoting ? ( +
+ +
+ ) : quote ? ( +
+ + + + +
+ Total due now + {etb(quote.amountDueMinor)} +
+ {quote.blockers.length > 0 && ( +
+ +
{quote.blockers.map((b) =>
{b}
)}
+
+ )} + {error &&
{error}
} + +
+ ) : null} +
+ ); + + return ( + + +

Upgrade {ref}

+

+ Move to a higher fare class on the same train. Each passenger can be upgraded on their own. +

+ + {options.legs.length > 1 && ( +
+ {options.legs.map((l) => ( + + ))} +
+ )} + + {!leg.canUpgrade && ( +
+ +
+ {leg.blockers.length + ? leg.blockers.map((b) =>
{b}
) + :
No higher fare class is available on this train.
} +
+
+ )} + + {leg.canUpgrade && ( +
+
+
+ {/* Step 1 — class */} +

Choose a class

+
+ {targets.map((t) => ( + + ))} +
+ + {/* Step 2 — who, and which seat */} + {targetCoachTypeId && ( + <> +

+ Who is upgrading? ({items.length}/{eligible.length}) +

+

+ Pick a passenger, then choose their new seat below. Leave a passenger unselected to keep + their current seat. +

+
+ {eligible.map((p) => { + const isActive = activeBookingSeatId === p.bookingSeatId; + const picked = picks[p.bookingSeatId]; + return ( + + ); + })} +
+ + {loadingSeats ? ( + + ) : ( + !!activeBookingSeatId && picks[activeBookingSeatId] === id} + isSeatAssignedToOther={(id) => { + const owner = seatOwner(id); + return !!owner && owner !== activeBookingSeatId; + }} + onSeatToggle={handleSeatToggle} + emptyLabel="No seats of that class on this train." + /> + )} + + )} +
+ +
+ +
+
+ +
+
+ +
+
+
+ )} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) { + return ( +
+
+ {wide ? ( +
{children}
+ ) : ( +
+ {children} +
+ )} +
+
+ ); +} + +export default function UpgradePage() { + return ( + }> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx index 96ecd0d3e..cec48132c 100644 --- a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx +++ b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx @@ -10,6 +10,7 @@ import { ChevronLeft, ChevronRight, Clock, + ArrowUpCircle, Eye, CreditCard, RefreshCw, @@ -63,33 +64,52 @@ function describeSeats(seats: MyBookingItem['seats'], leg: number) { interface RowActions { canReschedule: boolean; rescheduleBlocker: string | null; + canUpgrade: boolean; + upgradeBlocker: string | null; isPendingPayment: boolean; } /** - * The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules - * (fare-class policy, cutoff, seats still free) belong to the reschedule page, which - * names them as blockers — this only avoids sending the customer somewhere that is - * certain to reject them. The phone test matches the API's own ownership check - * (reschedule.service.ts loadOwnedBooking), which is phone-based, not account-based. + * The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and + * upgrade share it because the booking-shape rules and the ownership check are identical — only + * the wording differs, hence the verb. + * + * The per-leg rules (fare-class policy, cutoffs, whether a higher class even runs on this train, + * seats still free) belong to the reschedule and upgrade pages, which name them as blockers. This + * only avoids sending the customer somewhere certain to reject them. The phone test matches the + * API's own ownership check (loadOwnedBooking), which is phone-based, not account-based. */ function resolveActions(b: MyBookingItem, userPhone?: string): RowActions { const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT'; - let rescheduleBlocker: string | null = null; - if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled'; - else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online'; - else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType)) - rescheduleBlocker = 'Transit bookings cannot be rescheduled online'; - else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded'; - // The API applies policy.cutoffMinutes to the old leg's departure, so a departed trip - // is always rejected. Say so here instead of sending them to a page that refuses. - else if (new Date(b.schedule.departureAt).getTime() <= Date.now()) - rescheduleBlocker = 'This trip has already departed'; - else if (b.contactPhone && !samePhone(userPhone, b.contactPhone)) - rescheduleBlocker = 'Only the person who made this booking can reschedule it'; + // Both forms are needed: "can be rescheduled" but "can reschedule it". + type Verbs = { past: string; base: string }; + const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' }; + const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' }; - return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment }; + let reason: ((v: Verbs) => string) | null = null; + if (b.status !== 'CONFIRMED') reason = (v) => `Only a confirmed booking can be ${v.past}`; + else if (b.isPackageBooking) reason = (v) => `Package bookings cannot be ${v.past} online`; + else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType)) + reason = (v) => `Transit bookings cannot be ${v.past} online`; + else if (b.outboundBoardedAt) reason = () => 'This trip has already been boarded'; + // Both APIs apply a cutoff measured against departure, so a departed trip is always rejected. + // Say so here instead of sending them to a page that refuses. + else if (new Date(b.schedule.departureAt).getTime() <= Date.now()) + reason = () => 'This trip has already departed'; + else if (b.contactPhone && !samePhone(userPhone, b.contactPhone)) + reason = (v) => `Only the person who made this booking can ${v.base} it`; + + const rescheduleBlocker = reason ? reason(RESCHEDULE) : null; + const upgradeBlocker = reason ? reason(UPGRADE) : null; + + return { + canReschedule: rescheduleBlocker === null, + rescheduleBlocker, + canUpgrade: upgradeBlocker === null, + upgradeBlocker, + isPendingPayment, + }; } /** @@ -122,6 +142,7 @@ export default function MyBookingsTable() { const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`); const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`); + const openUpgrade = (b: MyBookingItem) => router.push(`/booking/upgrade?ref=${b.bookingRef}`); const cardClass = 'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'; @@ -268,6 +289,20 @@ export default function MyBookingsTable() { Reschedule )} + {!actions.isPendingPayment && ( + + )} @@ -344,6 +379,20 @@ export default function MyBookingsTable() { Reschedule )} + {!actions.isPendingPayment && ( + + )} ); From 9a9955cbaa015599d10b10532322ce05ded76710 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 3 Sep 2026 14:39:40 +0300 Subject: [PATCH 4/8] fix: (bookings) release the seat hold when a reschedule or upgrade expires and honour shortened payment windows --- .../modules/reschedule/reschedule.service.ts | 10 +++++- .../src/modules/seats/seats.service.ts | 34 ++++++++++++++----- .../src/modules/upgrade/upgrade.service.ts | 10 +++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index f296dac74..d45309ac1 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -433,7 +433,7 @@ export class RescheduleService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingReschedule.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true }, }); for (const r of stale) { await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } }); @@ -443,6 +443,14 @@ export class RescheduleService { data: { status: 'EXPIRED' }, }); } + // Release the seat the instant the request dies instead of leaving it to the hold's own + // TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at + // creation, and nothing keeps them in step afterwards — so without this the seat can sit + // unsellable long after the link that pays for it has expired. deleteMany: an already-swept + // hold must not throw. + if (r.holdId) { + await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } }); + } } return stale.length; } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 5c3b74c53..e54c085a1 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -728,24 +728,42 @@ export class SeatsService { ? 0 // unused — the override wins below : await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); - let extended = 0; + let aligned = 0; await Promise.all( holds.map(async (hold) => { const departureAt = departureById.get(hold.scheduleId); if (!departureAt) return; - const deadline = deadlineOverride ?? computePaymentDeadline(now, departureAt, undefined, windowMinutes); - // Only ever extend forward — never shorten a hold that's already valid longer - // than the payment deadline would give it (e.g. a second confirmSeats call on - // the same booking, or a hold that was already extended). + + if (deadlineOverride) { + // Authoritative in BOTH directions. The caller already issued a payment link with this + // exact deadline, so the hold must match it — including when it is EARLIER than the + // hold's own TTL. Extending only would leave the seat held after the link that pays for + // it has died (reachable whenever a flow's payment window is shorter than + // seat_hold_duration_minutes), so the seat sits unsellable in between. + if (hold.expiresAt.getTime() === deadlineOverride.getTime()) return; + await this.prisma.seatHold.update({ + where: { id: hold.id }, + data: { expiresAt: deadlineOverride }, + }); + aligned++; + return; + } + + const deadline = computePaymentDeadline(now, departureAt, undefined, windowMinutes); + // Normal booking path: only ever extend forward — never shorten a hold that's already + // valid longer than the payment deadline would give it. A round trip calls confirmSeats + // up to four times, and a later call must not pull in a hold an earlier one set. if (deadline <= hold.expiresAt) return; await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } }); - extended++; + aligned++; }), ); - if (extended > 0) { + if (aligned > 0) { this.logger.log( - `Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`, + deadlineOverride + ? `Aligned ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their charge's payment deadline` + : `Extended ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`, ); } } diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts index f0248233c..95d3aee75 100644 --- a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts @@ -603,7 +603,7 @@ export class UpgradeService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingUpgrade.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true }, }); for (const u of stale) { await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } }); @@ -613,6 +613,14 @@ export class UpgradeService { data: { status: 'EXPIRED' }, }); } + // Release the seat the instant the request dies instead of leaving it to the hold's own + // TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at + // creation, and nothing keeps them in step afterwards — so without this the seat can sit + // unsellable long after the link that pays for it has expired. deleteMany: an already-swept + // hold must not throw. + if (u.holdId) { + await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } }); + } } return stale.length; } From 38df7f034ca79733fdbf550eb718fd85c3cd98f1 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 3 Sep 2026 16:09:03 +0300 Subject: [PATCH 5/8] feat: ( notifications ) send SMS and email for applied and expired reschedules and upgrades --- apps/edr-passenger-api/prisma/seed.ts | 66 ++++- .../notifications-booking-change.spec.ts | 141 ++++++++++ .../notifications/notifications.service.ts | 254 ++++++++++++++++-- .../modules/reschedule/reschedule.service.ts | 9 +- .../src/modules/upgrade/upgrade.service.ts | 9 +- 5 files changed, 448 insertions(+), 31 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/notifications/notifications-booking-change.spec.ts diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index fb1261daf..3ef232148 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -657,8 +657,70 @@ async function seedNotificationTemplates() { { id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' }, { id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' }, { id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' }, - { id: uuidv4(), code: 'booking.rescheduled', channel: 'EMAIL', subject: 'Booking Rescheduled', bodyTemplate: 'Your {{leg}} journey on booking {{bookingRef}} has been rescheduled. New tickets have been issued. Change fee: {{feeAmount}} {{currency}}.' }, - { id: uuidv4(), code: 'booking.upgraded', channel: 'EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: 'Booking {{bookingRef}}: {{passengerSummary}} upgraded on your {{leg}} journey. New tickets have been issued. Paid: {{amountPaid}} {{currency}}.' }, + // Rich bodies mirroring booking.created: these are delivered by SMS *and* email, so the + // wording has to stand alone in a text message. Channel is 'SMS,EMAIL' — the applied/expired + // handlers deliver directly, but the field keeps the row honest about where it goes. + { id: uuidv4(), code: 'booking.rescheduled', channel: 'SMS,EMAIL', subject: 'Booking Rescheduled', bodyTemplate: `Dear {{passengerName}}, + +Your {{leg}} journey on booking ({{bookingRef}}) has been rescheduled. + +Route: {{origin}} → {{destination}} +{{trainSeatLines}} +{{previousLine}}Travel Date: {{travelDate}} +Departure: {{departureTime}} +Arrival: {{arrivalTime}} + +Paid: {{amountPaid}} {{currency}} (change fee: {{feeAmount}} {{currency}}) +New tickets have been issued. + +View your booking: {{detailLink}} + +Thank you for choosing EDR.` }, + { id: uuidv4(), code: 'booking.upgraded', channel: 'SMS,EMAIL', subject: 'Fare Class Upgraded', bodyTemplate: `Dear {{passengerName}}, + +Your booking ({{bookingRef}}) has been upgraded on the {{leg}} journey. + +Route: {{origin}} → {{destination}} +{{changeLines}} +Travel Date: {{travelDate}} +Departure: {{departureTime}} + +Paid: {{amountPaid}} {{currency}} +New tickets have been issued. + +View your booking: {{detailLink}} + +Thank you for choosing EDR.` }, + { id: uuidv4(), code: 'booking.reschedule.expired', channel: 'SMS,EMAIL', subject: 'Reschedule Request Expired', bodyTemplate: `Dear {{passengerName}}, + +Your reschedule request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied. + +The seat that was being held for it has been released. Your original booking, seats and travel date are unchanged: + +Route: {{origin}} → {{destination}} +{{trainSeatLines}} +Travel Date: {{travelDate}} +Departure: {{departureTime}} + +You can start a new reschedule any time before check-in closes: +{{detailLink}} + +Thank you for choosing EDR.` }, + { id: uuidv4(), code: 'booking.upgrade.expired', channel: 'SMS,EMAIL', subject: 'Upgrade Request Expired', bodyTemplate: `Dear {{passengerName}}, + +Your upgrade request for booking ({{bookingRef}}) expired before it was paid, so it has not been applied. + +The seat that was being held for it has been released. Your original booking and seats are unchanged: + +Route: {{origin}} → {{destination}} +{{trainSeatLines}} +Travel Date: {{travelDate}} +Departure: {{departureTime}} + +You can start a new upgrade any time before check-in closes: +{{detailLink}} + +Thank you for choosing EDR.` }, // Templates below are not wired to handlers yet (Phase 2 — full event coverage). { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications-booking-change.spec.ts b/apps/edr-passenger-api/src/modules/notifications/notifications-booking-change.spec.ts new file mode 100644 index 000000000..204afa6b9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/notifications/notifications-booking-change.spec.ts @@ -0,0 +1,141 @@ +import { NotificationsService } from './notifications.service'; + +/** + * Regression cover for the bug these handlers were written to fix: reschedule/upgrade + * notifications resolved an address only through `iam.users`, where `Passenger.iamUserId` is set + * on under 2% of rows, so EMAIL and SMS were silently skipped on virtually every real booking. + * The handlers must fall back to the contact details the booking itself carries. + */ +describe('booking-change notifications', () => { + const BOOKING = { + id: 'bk-1', + bookingRef: 'NFMRR0', + passengerId: 'pax-1', + bookingType: 'ONE_WAY', + contactPhone: '+251923594242', + contactEmail: 'work.abubeker@gmail.com', + originStationId: 'st-a', + destinationStationId: 'st-b', + schedule: { + departureAt: new Date('2026-09-22T09:00:00Z'), + arrivalAt: new Date('2026-09-22T18:00:00Z'), + originStation: { id: 'st-a', name: 'Sebeta' }, + destinationStation: { id: 'st-b', name: 'Dire Dawa' }, + stopTimes: [], + }, + seats: [ + { + leg: 1, + passengerName: 'Abubeker Yasin', + seat: { seatNumber: '3', coach: { number: 'VIP-0001', coachType: { name: 'VIP Seat' } } }, + }, + ], + }; + + const TEMPLATE = { + code: 'booking.upgraded', + subject: 'Fare Class Upgraded', + bodyTemplate: + 'Dear {{passengerName}},\n{{bookingRef}} {{origin}} → {{destination}}\n{{changeLines}}\nPaid: {{amountPaid}} {{currency}}\n{{detailLink}}', + active: true, + }; + + function build(opts: { iamAddress?: string | null; template?: any; booking?: any } = {}) { + const sms = jest.fn().mockResolvedValue({ queued: true }); + const email = jest.fn().mockResolvedValue({ queued: true }); + const svc: any = Object.create(NotificationsService.prototype); + svc.prisma = { + booking: { findUnique: jest.fn().mockResolvedValue(opts.booking === undefined ? BOOKING : opts.booking) }, + notificationTemplate: { + findUnique: jest.fn().mockResolvedValue(opts.template === undefined ? TEMPLATE : opts.template), + }, + }; + svc.smsClient = { sendSms: sms }; + svc.emailClient = { sendEmail: email }; + svc.logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + // The live condition: IAM knows nothing about this passenger. + svc.getRecipientAddress = jest.fn().mockResolvedValue(opts.iamAddress ?? null); + svc.createInAppNotification = jest.fn().mockResolvedValue(undefined); + return { svc, sms, email }; + } + + const upgradePayload = { + booking: { id: 'bk-1' }, + upgrade: { + leg: 1, + feeMinor: 0, + fareDifferenceMinor: 70000, + items: [ + { passengerName: 'Abubeker Yasin', oldSeatLabel: 'RS-0002 seat 5', newSeatLabel: 'VIP-0001 seat 3' }, + ], + }, + }; + + it('sends SMS and email via the booking contacts when IAM resolves nothing', async () => { + const { svc, sms, email } = build(); + await svc.onBookingUpgraded(upgradePayload); + + expect(sms).toHaveBeenCalledTimes(1); + expect(sms.mock.calls[0][0].to).toBe('+251923594242'); + expect(email).toHaveBeenCalledTimes(1); + expect(email.mock.calls[0][0].to).toBe('work.abubeker@gmail.com'); + expect(email.mock.calls[0][0].subject).toBe('Fare Class Upgraded'); + }); + + it('renders the old → new seat line and the amount paid', async () => { + const { svc, sms } = build(); + await svc.onBookingUpgraded(upgradePayload); + + const body = sms.mock.calls[0][0].message; + expect(body).toContain('Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3'); + expect(body).toContain('Paid: 700.00 ETB'); + expect(body).toContain('Sebeta → Dire Dawa'); + expect(body).not.toContain('{{'); // every placeholder interpolated + }); + + it('prefers the IAM address when there is one', async () => { + const { svc, sms } = build({ iamAddress: '+251900000000' }); + await svc.onBookingUpgraded(upgradePayload); + expect(sms.mock.calls[0][0].to).toBe('+251900000000'); + }); + + it('a failing SMS gateway does not suppress the email', async () => { + const { svc, sms, email } = build(); + sms.mockRejectedValue(new Error('gateway down')); + await svc.onBookingUpgraded(upgradePayload); + expect(email).toHaveBeenCalledTimes(1); + expect(svc.logger.warn).toHaveBeenCalled(); + }); + + it('sends nothing and does not throw when the booking has no contacts', async () => { + const { svc, sms, email } = build({ booking: { ...BOOKING, contactPhone: null, contactEmail: null } }); + await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined(); + expect(sms).not.toHaveBeenCalled(); + expect(email).not.toHaveBeenCalled(); + }); + + it('a missing template is logged, not thrown', async () => { + const { svc, sms } = build({ template: null }); + await expect(svc.onBookingUpgraded(upgradePayload)).resolves.toBeUndefined(); + expect(sms).not.toHaveBeenCalled(); + expect(svc.logger.warn).toHaveBeenCalledWith(expect.stringContaining('not found or inactive')); + }); + + it('expiry notification sends and never throws', async () => { + const { svc, sms, email } = build({ + template: { ...TEMPLATE, code: 'booking.upgrade.expired', subject: 'Upgrade Request Expired' }, + }); + await svc.onUpgradeExpired({ + bookingId: 'bk-1', + request: { leg: 1, amountDueMinor: 70000, items: upgradePayload.upgrade.items }, + }); + expect(sms).toHaveBeenCalledTimes(1); + expect(email.mock.calls[0][0].subject).toBe('Upgrade Request Expired'); + }); + + it('a booking that vanished is logged, not thrown', async () => { + const { svc, sms } = build({ booking: null }); + await expect(svc.onUpgradeExpired({ bookingId: 'gone', request: {} })).resolves.toBeUndefined(); + expect(sms).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 4dc168d00..ded721c8a 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -148,6 +148,180 @@ export class NotificationsService { }); } + + // ── Booking-change notification helpers ────────────────────────────────── + + /** Relations the change templates render: station names, coach number and coach-type name. */ + private static readonly CHANGE_INCLUDE = { + schedule: { + include: { + originStation: true, + destinationStation: true, + train: true, + stopTimes: { include: { station: true } }, + }, + }, + seats: { + include: { seat: { include: { coach: { include: { coachType: true } } } } }, + orderBy: { leg: 'asc' as const }, + }, + }; + + private fmtDate(d: any): string { + return d + ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) + : 'TBD'; + } + + private fmtTime(d: any): string { + return d + ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) + : 'TBD'; + } + + /** Minor units → major, 2dp. Charges are raised in ETB, so no conversion applies. */ + private fmtMinor(minor: number): string { + return ((minor ?? 0) / 100).toFixed(2); + } + + /** + * "Abubeker Yasin: RS-0002 seat 5 → VIP-0001 seat 3", one line per upgraded passenger. + * Labels come off `BookingUpgrade.items`, which snapshots them at quote time — so the message + * still reads correctly even after the seats have moved. + */ + private buildUpgradeChangeLines(items: any[]): string { + return (items ?? []) + .map((i) => { + const who = String(i?.passengerName ?? '').trim(); + const from = String(i?.oldSeatLabel ?? '').trim() || 'previous seat'; + const to = String(i?.newSeatLabel ?? '').trim() || 'new seat'; + return `${who ? `${who}: ` : ''}${from} → ${to}`; + }) + .join('\n'); + } + + /** + * Delivery addresses for a booking-change message. IAM first so a registered passenger's + * current details win, then the contact the booking itself carries — which is the only address + * a guest booking ever has. Mirrors the `iamPhone ?? contactPhone` fallback that + * `onBookingCreated` and `onPaymentSucceeded` already use. + */ + private async resolveDeliveryContacts( + booking: any, + passengerId: string | null, + ): Promise<{ phone: string | null; email: string | null }> { + const iamPhone = passengerId + ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) + : null; + const iamEmail = passengerId + ? await this.getRecipientAddress(passengerId, 'EMAIL').catch(() => null) + : null; + return { + phone: iamPhone ?? booking?.contactPhone ?? null, + email: iamEmail ?? booking?.contactEmail ?? null, + }; + } + + /** Shared context for every change template: who, where, when, which seats. */ + private buildBookingChangeContext(booking: any, ref: string): Record { + const { passengerName, trainSeatLines } = buildSeatSummary( + booking?.seats ?? [], + booking?.bookingType, + ); + const segment = resolveBookingSegment( + booking?.schedule ?? {}, + booking?.originStationId, + booking?.destinationStationId, + ); + return { + passengerName, + bookingRef: ref, + origin: segment.origin?.name ?? '', + destination: segment.destination?.name ?? '', + trainSeatLines, + travelDate: this.fmtDate(segment.departureAt), + departureTime: this.fmtTime(segment.departureAt), + arrivalTime: this.fmtTime(segment.arrivalAt), + currency: 'ETB', + detailLink: `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`, + }; + } + + /** + * Re-fetch → interpolate → in-app + direct SMS/email. + * + * The event payload is not enough on its own: the reschedule/upgrade services' own + * `bookingInclude` selects `coach: { select: { id, coachTypeId } }` and no station names, so + * buildSeatSummary would render "-, seat no. N". Always read the booking back with + * CHANGE_INCLUDE. + * + * Every failure here is logged and swallowed — a notification must never take down the cron or + * the event emitter that invoked it, and the reschedule/upgrade itself is already committed. + */ + private async notifyBookingChange( + templateCode: string, + bookingId: string, + extra: Record, + ): Promise { + try { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + include: NotificationsService.CHANGE_INCLUDE as any, + }); + if (!booking) { + this.logger.warn(`${templateCode}: booking ${bookingId} not found — nothing sent`); + return; + } + + const template = await this.prisma.notificationTemplate.findUnique({ + where: { code: templateCode }, + }); + if (!template || !template.active) { + this.logger.warn(`Template ${templateCode} not found or inactive`); + return; + } + + const ref = (booking as any).bookingRef; + const context = { ...this.buildBookingChangeContext(booking, ref), ...extra }; + const { subject, body } = this.interpolate(template, context); + + const passengerId = (booking as any).passengerId ?? null; + if (passengerId) { + await this.createInAppNotification(passengerId, subject, body, { + category: 'BOOKING', + deepLink: `edr://bookings/${ref}`, + }).catch((err) => + this.logger.warn(`${templateCode}: in-app notification failed for ${ref}: ${err}`), + ); + } + + const { phone, email } = await this.resolveDeliveryContacts(booking, passengerId); + if (!phone && !email) { + this.logger.warn(`${templateCode}: no contact details for booking ${ref} — nothing sent`); + return; + } + + // Independent try/catch per channel: a dead SMS gateway must not cost the passenger + // their email too. + if (phone) { + try { + await this.smsClient.sendSms({ to: phone, message: body }); + } catch (err) { + this.logger.warn(`${templateCode}: SMS failed for booking ${ref}: ${err}`); + } + } + if (email) { + try { + await this.emailClient.sendEmail({ to: email, subject, text: body }); + } catch (err) { + this.logger.warn(`${templateCode}: email failed for booking ${ref}: ${err}`); + } + } + } catch (err) { + this.logger.error(`${templateCode}: notification failed for booking ${bookingId}: ${err}`); + } + } + private interpolate( template: { subject?: string | null; bodyTemplate: string }, context: Record, @@ -731,42 +905,68 @@ export class NotificationsService { ); } + /** + * Booking-change notifications (reschedule / upgrade, applied or expired). + * + * These deliberately do NOT go through `send()`. That path resolves an address only via + * `iam.users`, and `Passenger.iamUserId` is set on well under 2% of rows (and can dangle even + * when set), so EMAIL and SMS were silently skipped for almost every real booking while only the + * in-app row was written. They follow `onBookingCreated` instead: re-fetch, interpolate the + * template, then deliver straight to the booking's own contact details. + */ @OnEvent('booking.rescheduled') async onBookingRescheduled(payload: any) { const { booking, reschedule } = payload; - await this.send( - 'booking.rescheduled', - booking.passengerId, - { - bookingRef: booking.bookingRef, - leg: reschedule?.leg === 2 ? 'return' : 'outbound', - feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2), - currency: 'ETB', - category: 'BOOKING', - deepLink: `edr://bookings/${booking.bookingRef}`, - }, - ['IN_APP', 'EMAIL', 'SMS'], - ); + let previousTravelDate = ''; + if (reschedule?.oldScheduleId) { + const old = await this.prisma.trainSchedule + .findUnique({ where: { id: reschedule.oldScheduleId }, select: { departureAt: true } }) + .catch(() => null); + // Pre-formatted so the template never renders a dangling 'Previously:' label. + previousTravelDate = old?.departureAt ? `Previously: ${this.fmtDate(old.departureAt)} +` : ''; + } + await this.notifyBookingChange('booking.rescheduled', booking.id, { + leg: reschedule?.leg === 2 ? 'return' : 'outbound', + previousLine: previousTravelDate, + feeAmount: this.fmtMinor(reschedule?.feeMinor ?? 0), + amountPaid: this.fmtMinor( + (reschedule?.feeMinor ?? 0) + Math.max(0, reschedule?.fareDifferenceMinor ?? 0), + ), + }); } @OnEvent('booking.upgraded') async onBookingUpgraded(payload: any) { const { booking, upgrade } = payload; const items = Array.isArray(upgrade?.items) ? upgrade.items : []; - await this.send( - 'booking.upgraded', - booking.passengerId, - { - bookingRef: booking.bookingRef, - leg: upgrade?.leg === 2 ? 'return' : 'outbound', - passengerSummary: items.map((i: any) => i.passengerName).join(', '), - amountPaid: (((upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0)) / 100).toFixed(2), - currency: 'ETB', - category: 'BOOKING', - deepLink: `edr://bookings/${booking.bookingRef}`, - }, - ['IN_APP', 'EMAIL', 'SMS'], - ); + await this.notifyBookingChange('booking.upgraded', booking.id, { + leg: upgrade?.leg === 2 ? 'return' : 'outbound', + passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '), + changeLines: this.buildUpgradeChangeLines(items), + amountPaid: this.fmtMinor( + (upgrade?.feeMinor ?? 0) + Math.max(0, upgrade?.fareDifferenceMinor ?? 0), + ), + }); + } + + @OnEvent('booking.reschedule.expired') + async onRescheduleExpired(payload: any) { + await this.notifyBookingChange('booking.reschedule.expired', payload.bookingId, { + leg: payload.request?.leg === 2 ? 'return' : 'outbound', + amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0), + }); + } + + @OnEvent('booking.upgrade.expired') + async onUpgradeExpired(payload: any) { + const items = Array.isArray(payload.request?.items) ? payload.request.items : []; + await this.notifyBookingChange('booking.upgrade.expired', payload.bookingId, { + leg: payload.request?.leg === 2 ? 'return' : 'outbound', + passengerSummary: items.map((i: any) => i.passengerName).filter(Boolean).join(', '), + changeLines: this.buildUpgradeChangeLines(items), + amountDue: this.fmtMinor(payload.request?.amountDueMinor ?? 0), + }); } @OnEvent('booking.cancelled') diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index d45309ac1..e7e9f5f30 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -433,7 +433,7 @@ export class RescheduleService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingReschedule.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true, holdId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true }, }); for (const r of stale) { await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } }); @@ -452,6 +452,13 @@ export class RescheduleService { await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } }); } } + + // After the loop on purpose: the rows are already committed, so a notification failure + // cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors. + for (const s of stale) { + this.eventEmitter.emit('booking.reschedule.expired', { bookingId: s.bookingId, request: s }); + } + return stale.length; } diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts index 95d3aee75..df3c1b5db 100644 --- a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts @@ -603,7 +603,7 @@ export class UpgradeService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingUpgrade.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true, holdId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true, bookingId: true, leg: true, amountDueMinor: true, items: true }, }); for (const u of stale) { await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } }); @@ -622,6 +622,13 @@ export class UpgradeService { await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } }); } } + + // After the loop on purpose: the rows are already committed, so a notification failure + // cannot leave a request half-expired. Fire-and-forget — the listener swallows its own errors. + for (const s of stale) { + this.eventEmitter.emit('booking.upgrade.expired', { bookingId: s.bookingId, request: s }); + } + return stale.length; } From 6eb5f2ee1e15519cc3be218533ef92d2b244c76b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 3 Sep 2026 16:16:10 +0300 Subject: [PATCH 6/8] Update page.tsx --- apps/edr-passenger-web/portal/src/app/login/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/portal/src/app/login/page.tsx b/apps/edr-passenger-web/portal/src/app/login/page.tsx index 5f7325072..3da6bb723 100644 --- a/apps/edr-passenger-web/portal/src/app/login/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/login/page.tsx @@ -277,7 +277,10 @@ function LoginContent() { ); const heading = { - identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' }, + identifier: { + title: 'Sign in or create account', + subtitle: "Enter your phone number or email — we'll sign you in, or set up a new account", + }, password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' }, setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' }, signup: { title: 'Create your account', subtitle: 'We just need a couple of details' }, From 380ba4c4c98368d936dd0d619cc4ef79f9020325 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 3 Sep 2026 22:27:00 +0300 Subject: [PATCH 7/8] Refactor code structure for improved readability and maintainability --- .../rate-change-requests.service.spec.ts | 51 +++++- .../services/rate-change-requests.service.ts | 35 ++++- .../rule-engine/services/rates.service.ts | 13 ++ .../train-scheduling.controller.ts | 21 +++ .../services/train-scheduling.service.ts | 148 ++++++++++++++++++ .../train-scheduling.module.ts | 2 + .../backoffice/src/constants/URLS.ts | 2 + .../pages/ruleEngine/RateApprovalsSection.tsx | 133 +++++++++------- .../ruleEngine/RuleEngineResourcePage.tsx | 19 ++- .../TrainScheduleV2DetailPage.tsx | 40 +++++ .../src/services/trainScheduling.service.ts | 9 ++ 11 files changed, 407 insertions(+), 66 deletions(-) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index f8617cd65..60121c2fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => { rate?: Rate; pending?: RateChangeRequest | null; applyThrows?: Error; + /** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */ + derived?: Partial; + previewThrows?: Error; } = {}) => { const rate = opts.rate ?? liveRate(); const saved: RateChangeRequest[] = []; @@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => { const rates = { findById: jest.fn(async () => rate), assertUpdateValid: jest.fn(async () => undefined), + // Stands in for buildUpdate: it resolves a patch into the full column + // set, including columns the form never posts (rateType and friends). + previewUpdate: jest.fn(async (_id: string, dto: Record) => { + if (opts.previewThrows) throw opts.previewThrows; + return { ...dto, ...(opts.derived ?? {}) } as Partial; + }), applyApprovedUpdate: jest.fn(async () => { if (opts.applyThrows) throw opts.applyThrows; return rate; @@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => { }); it('validates up front so the requester hears about a bad patch, not the approver', async () => { - const { service, rates } = build(); - rates.assertUpdateValid.mockRejectedValueOnce( - new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), - ); + // Resolving the patch IS the validation — buildUpdate throws on a bad + // unit, so previewUpdate surfaces it at submit time. + const { service } = build({ + previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + }); await expect( service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), ).rejects.toThrow(/not valid for this rate/); }); + + it('shows the approver a bulk switch, which only exists as a derived column', async () => { + // The form posts intercityKind: BULK — never stored. The real edit lands + // on rateType (+ the cargo/container swap), so that is what the approver + // must see. Diffing the raw patch showed an empty change list. + const { service } = build({ + rate: liveRate({ + rateType: 'INTERCITY_CONTAINER', + appliesTo: 'INTERCITY', + containerTypeId: 'ct-1', + }), + derived: { + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + } as Partial, + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never, + }); + + expect(request.payload).toMatchObject({ + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + }); + expect(request.previousValues).toMatchObject({ + rateType: 'INTERCITY_CONTAINER', + containerTypeId: 'ct-1', + }); + }); }); describe('approve', () => { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 8913c9ef9..68d2ccb2e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; -/** Fields a change request may carry — anything else in the patch is ignored. */ +/** + * Persisted columns an approver is shown a before→after for. + * + * These are RESOLVED entity columns, not raw form fields: the diff runs + * against `RatesService.previewUpdate`, so a change the form expresses through + * a non-stored selector still shows up here as the column it actually moves + * (a flip to bulk lands on `rateType` + the container/cargo swap). + */ const DIFFABLE_FIELDS = [ 'rateValue', 'currency', @@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [ 'tradeDirection', 'containerTypeId', 'cargoTypeId', + // The container-vs-bulk shape of the rate. Missing here, switching a LIVE + // rate to bulk showed the approver an empty change list — the only column + // that records the kind is rateType, and the form never posts it directly. + 'rateType', + // Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping + // line diffed to nothing. + 'shippingLineCompanyId', // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate // diffed to nothing and the submit was refused as "nothing changed". 'originYardId', @@ -82,7 +96,11 @@ export class RateChangeRequestsService { ); } - const payload = this.changedFieldsOnly(rate, dto.update); + // Diff the RESOLVED columns, not the raw patch: the form's cargoKind / + // intercityKind selectors are never stored, so a bulk switch only shows up + // once the patch is resolved into the columns it moves. + const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto); + const payload = this.changedFieldsOnly(rate, resolved); if (Object.keys(payload).length === 0) { throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); } @@ -98,7 +116,9 @@ export class RateChangeRequestsService { ); } - await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + // previewUpdate above already ran the full validation (it IS buildUpdate), + // so re-validating here would only repeat it — and the trimmed payload is + // resolved columns, not a form patch, so it is not the right input for it. const request = await this.repo.save( this.repo.create({ @@ -186,13 +206,14 @@ export class RateChangeRequestsService { } /** - * Keep only fields the requester actually changed. A form posts every field - * back, so without this the diff would list untouched values as changes. + * Keep only columns the edit actually moves. `buildUpdate` returns a full + * resolved column set (it re-derives scope on every patch), so without this + * the diff would list every untouched column as a change. */ - private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + private changedFieldsOnly(rate: Rate, resolved: Partial): Record { const patch: Record = {}; for (const field of DIFFABLE_FIELDS) { - const proposed = (update as Record)[field]; + const proposed = (resolved as Record)[field]; if (proposed === undefined) continue; if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; patch[field] = proposed; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 6677fcce5..a3361e526 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -753,6 +753,19 @@ export class RatesService { await this.buildUpdate(await this.findById(id), dto); } + /** + * The exact column changes applying this patch would make, without writing. + * + * A change request diffs against THIS rather than the raw patch: the form + * posts selectors that are never stored (`cargoKind`, `intercityKind`), and + * the real edit they encode lands on derived columns — flipping a rate to + * bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing + * the raw patch missed all of it, so the approver saw an empty change list. + */ + async previewUpdate(id: string, dto: UpdateRateDto): Promise> { + return this.buildUpdate(await this.findById(id), dto); + } + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { const updates = await this.buildUpdate(existing, dto); const updated = await this.repository.update(existing.id, updates); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 4fc921ccb..1c7cd11ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -867,6 +867,27 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/wagons/export") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)", + }) + async scheduleWagonListExport( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = + await this.trainSchedulingService.scheduleWagonListWorkbook(id); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + @Get("schedules/:id/export/load-list/document") @TrainSchedulingView() @ApiOperation({ summary: "Download printable export marshalling / load list PDF" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 4f416cdcf..03d75da18 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; +import { TabularExportService } from '../../exports/tabular-export.service'; + +/** One line of the schedule wagon-list export (raw SQL projection). */ +interface ScheduleWagonListRow { + sequenceNo: number | null; + wagonNumber: string | null; + wagonType: string | null; + containerNumber: string | null; + containerSizeFt: number | null; + loadType: string | null; + status: string | null; + bulkCargoDescription: string | null; + /** numeric columns arrive as strings from pg. */ + vgmTons: string | null; + originLabel: string | null; + destinationLabel: string | null; + bookingReference: string | null; + customerName: string | null; +} import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -427,6 +446,9 @@ export class TrainSchedulingService { // Per-wagon history ledger (global module). @Optional keeps the positional // spec constructors working; production always has it. @Optional() private readonly wagonHistory?: WagonHistoryService, + // Trailing + @Optional so the positional constructors in the existing specs + // keep working; production always resolves it from ExportsModule. + @Optional() private readonly tabularExport?: TabularExportService, ) {} /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ @@ -3747,6 +3769,132 @@ export class TrainSchedulingService { }; } + /** + * The schedule detail page's wagon-list Excel export. + * + * One row per container (a wagon carrying two boxes yields two rows, repeating + * the wagon number) so each container's own VGM is present and totals footable. + * Bulk wagons, having no containers, yield a single row carrying the bulk + * description and the allocated tonnage as the VGM figure. + * + * Only wagon slots that actually carry an allocation are listed — empty slots + * on the consist are omitted. + */ + async scheduleWagonListWorkbook( + scheduleId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.tabularExport) { + throw new BadRequestException('Tabular export service is unavailable'); + } + + // Row grain is the container item; the LEFT JOIN keeps bulk (and any + // container-less) allocation as one row. `booking_container_units` is joined + // on BOTH container number and its booking_container line — container + // numbers repeat across bookings, so number alone would multiply rows. + const rows: ScheduleWagonListRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + w.wagon_number AS "wagonNumber", + COALESCE(wt.name, wt.code) AS "wagonType", + ci.container_number AS "containerNumber", + cit.size_ft AS "containerSizeFt", + a.load_type AS "loadType", + a.status AS "status", + bl.cargo_description AS "bulkCargoDescription", + COALESCE( + ci.gross_weight_tons, + bcu.vgm_tons, + bc.vgm_per_unit_tons, + a.allocated_weight_tons + ) AS "vgmTons", + COALESCE(by_.label, so.label) AS "originLabel", + COALESCE(ay.label, sd.label) AS "destinationLabel", + b.reference AS "bookingReference", + COALESCE( + slc.name, + CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END, + c.name + ) AS "customerName" + FROM freight.train_schedules s + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations a + ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.bookings b ON b.id = a.booking_id + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id + LEFT JOIN freight.booking_container bc + ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu + ON bcu.container_number = ci.container_number + AND bcu.booking_container_id = bc.id + AND bcu.deleted_at IS NULL + LEFT JOIN freight.wagon_allocation_bulk_loads bl + ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id + WHERE s.id = $1 AND s.deleted_at IS NULL + ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`, + [scheduleId], + ); + + // "number" is the printed line number of the sheet, not the wagon sequence — + // a two-container wagon occupies two lines, and the reader counts lines. + const sheetRows = rows.map((row, index) => ({ + number: index + 1, + wagonNumber: row.wagonNumber ?? '—', + containerNumber: + row.containerNumber ?? + (row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'), + vgmTons: row.vgmTons === null ? null : Number(row.vgmTons), + originLabel: row.originLabel ?? '—', + destinationLabel: row.destinationLabel ?? '—', + customerName: row.customerName ?? '—', + })); + + const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0); + const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; + + const buffer = await this.tabularExport.toXlsx({ + title: `Wagons ${reference}`.slice(0, 31), + description: `Wagon list for train ${reference}`, + label: 'train-schedule:wagon-list', + kpis: [ + { label: 'Lines', value: sheetRows.length }, + { + label: 'Wagons', + value: new Set(rows.map((r) => r.sequenceNo)).size, + }, + { label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' }, + ], + columns: [ + { key: 'number', label: 'No.', type: 'number' }, + { key: 'wagonNumber', label: 'Wagon', type: 'string' }, + { key: 'containerNumber', label: 'Container number', type: 'string' }, + { key: 'vgmTons', label: 'VGM', type: 'tons' }, + { key: 'originLabel', label: 'Origin', type: 'string' }, + { key: 'destinationLabel', label: 'Destination', type: 'string' }, + { key: 'customerName', label: 'Customer', type: 'string' }, + ], + rows: sheetRows, + }); + + return { + filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`, + buffer, + }; + } + async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index dd5cc74bc..fdbc504b5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; +import { ExportsModule } from '../exports/exports.module'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FacilityHandlingService } from './facility-handling.service'; @@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module'; UserTradeAccessModule, NotificationsModule, NotificationInboxModule, + ExportsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 0dafb926f..ae972022e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -537,6 +537,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/import-djibouti/load-list/document`, EXPORT_LOAD_LIST_DOCUMENT: (id: string) => `/train-scheduling/schedules/${id}/export/load-list/document`, + SCHEDULE_WAGONS_EXPORT: (id: string) => + `/train-scheduling/schedules/${id}/wagons/export`, INTERCITY_MARSHALLING_DOCUMENT: (id: string) => `/train-scheduling/schedules/${id}/intercity/marshalling/document`, MARSHALLING_STOPS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx index a61376158..ce77041eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -27,8 +27,24 @@ const FIELD_LABELS: Record = { cargoTypeId: "Cargo type", originYardId: "Origin yard", destinationYardId: "Destination yard", + minKm: "From km", + maxKm: "To km", + baseLiters: "Base liters", + rateType: "Rate type", }; +/** + * A key the backend diffed but the UI has no label for still names a real + * change, so turn "baseLiters" into "Base liters" rather than hiding it. + */ +const labelFor = (field: string): string => + FIELD_LABELS[field] ?? + field + .replace(/([A-Z])/g, " $1") + .replace(/^./, (c) => c.toUpperCase()) + .replace(/\bId\b/, "") + .trim(); + const fmtDateTime = (iso: string) => new Date(iso).toLocaleString("en-GB", { day: "numeric", @@ -43,13 +59,16 @@ const fmtValue = ( value: unknown, labels?: Record, ): string => { - if (value === null || value === undefined || value === "") return "—"; + // "Not set" reads as a real before-state; a bare em dash on both sides of the + // arrow made a newly-set field look like no change at all. + if (value === null || value === undefined || value === "") return "Not set"; if (field === "rateValue") { const num = Number(value); return Number.isNaN(num) ? String(value) : num.toLocaleString(); } - // Yard ids are unreadable — an approver decides on the route, not a UUID. - if (field === "originYardId" || field === "destinationYardId") { + // Any id is unreadable — an approver decides on "Perishable → Truck", not on + // a pair of uuids. Covers yards, cargo types, container types and lines. + if (field.endsWith("Id")) { return labels?.[String(value)] ?? String(value); } return String(value).replace(/_/g, " "); @@ -66,13 +85,33 @@ const rateSummary = (r: RateChangeRequest): string => { return parts.join(" · ") || "Rate"; }; -/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */ -const headline = (r: RateChangeRequest): string | null => { - if (!("rateValue" in r.payload)) return null; - const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | undefined)?.currency ?? ""); - const before = fmtValue("rateValue", r.previousValues.rateValue); - const after = fmtValue("rateValue", r.payload.rateValue); - return `${before} → ${after}${currency ? ` ${currency}` : ""}`; +/** + * Every change in the request, as readable before→after pairs. The queue must + * be scannable without expanding: a cargo or direction change is just as much + * the point as a repricing, so it gets the same one-line treatment as the rate. + */ +const summaryRows = ( + r: RateChangeRequest, + labels?: Record, +): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => { + const currency = String( + r.payload.currency ?? + r.previousValues.currency ?? + (r.rate as Record | undefined)?.currency ?? + "", + ); + // Rate first — it is what most changes are about — then the rest in a stable + // order so the same edit always reads the same way. + const fields = Object.keys(r.payload).sort((a, b) => + a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b), + ); + return fields.map((field) => ({ + field, + label: labelFor(field), + before: fmtValue(field, r.previousValues[field], labels), + after: fmtValue(field, r.payload[field], labels), + suffix: field === "rateValue" && currency ? ` ${currency}` : "", + })); }; type Decide = UseMutationResult< @@ -87,8 +126,9 @@ interface RateApprovalsSectionProps { canDecide: boolean; approve: Decide; reject: Decide; - /** yardId → label, so a re-routed rate reads as yards, not UUIDs. */ - yardLabels?: Record; + /** id → label for every reference a diff can name (yards, cargo/container + * types, shipping lines), so a change reads as names, not UUIDs. */ + refLabels?: Record; } /** @@ -101,7 +141,7 @@ const RateApprovalsSection = ({ canDecide, approve, reject, - yardLabels, + refLabels, }: RateApprovalsSectionProps) => { const [openId, setOpenId] = useState(null); const [notes, setNotes] = useState>({}); @@ -127,7 +167,7 @@ const RateApprovalsSection = ({ {requests.map((r) => { const isOpen = openId === r.id; const fields = Object.keys(r.payload); - const summaryLine = headline(r); + const rows = summaryRows(r, refLabels); // Only the row being decided shows a spinner — the mutation's // isPending is shared across every row. const busy = decidingId === r.id; @@ -145,38 +185,36 @@ const RateApprovalsSection = ({ - {summaryLine ? ( - + {rows.map((row) => ( + + + {row.label} + - {fmtValue("rateValue", r.previousValues.rateValue)} + {row.before} - + - {fmtValue("rateValue", r.payload.rateValue)} - - - {String( - r.payload.currency ?? - r.previousValues.currency ?? - (r.rate as Record | undefined)?.currency ?? - "", - )} + {row.after} + {row.suffix} - ) : null} + ))} Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} {fields.length === 1 ? "field" : "fields"} changed - + {canDecide ? ( + + ) : null} @@ -218,24 +256,11 @@ const RateApprovalsSection = ({ - - {fields.map((field) => ( - - - {FIELD_LABELS[field] ?? field} - - - {fmtValue(field, r.previousValues[field], yardLabels)} - - - - {fmtValue(field, r.payload[field], yardLabels)} - - - ))} - {canDecide ? ( + {canDecide ? ( + + {/* The change itself is always visible above, so this panel + carries only what the approver adds. */}