From a60bcf81630d428e1de566b5fc1884ed109bed1e Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 31 Aug 2026 13:34:10 +0300 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 076884623e3a84ab4402375d43080748fc92a479 Mon Sep 17 00:00:00 2001 From: hager Date: Thu, 3 Sep 2026 04:36:55 +0000 Subject: [PATCH 04/13] feat(warehouses): load trains from the warehouse, mirroring the schedule Ready to Load listed inventory and offered an auto-load picker, but the warehouse floor works train by train: a train stands at the yard, its bookings board wagon by wagon, it rolls. That view existed only on the train schedule workspace, which the warehouse staff do not run. Ready to Load now has an Items / By train switch. By train is a mirror of the schedule's own column, placed where the loading actually happens. Nothing in it has its own rules. Train position, the per-yard loading and unloading windows, and the per-booking Load / Wagons / Unload actions all come from the train-scheduling endpoints the schedule workspace already calls, so a Load that would be refused there is disabled here with the same reason and the two surfaces cannot disagree. No train-scheduling code is touched. What the warehouse adds is what the schedule cannot see: which of the train's bookings are physically in the shed, with their GRN and inspection state, laid out along the flow the staff follow -- receive and GRN, inspect, ready, open the loading window, train at yard, load per wagon, dispatch, unload at port. Wagons - The wagon modal calls the same per-wagon journey endpoints, so every server gate (train at the yard, window started, PAID, GRN) is the schedule's own. - Wagons go one at a time in order: the server flips the booking to IN_TRANSIT or ARRIVED on whichever call clears the last wagon, so sequential is required, not merely tidy. A failure stops the run, the wagons already sent stay done, and the toast says how many, so a retry only resends the rest. - Deliberately not mirrored: cancelling wagons that will not ride, and the direct truck-to-train handover. Both are commercial decisions (fees, credits, GRN waiver) that belong to the schedule workspace. loadable-trains takes includeDispatched. Loading follows the train after it rolls, since a mid-corridor warehouse boards its cargo when the train stands at its yard, and the train-centric view needs the same set the schedule offers Load on. The default stays pre-dispatch only, so the existing auto-load picker is unchanged. Also fixes the backoffice build: ReceiveInventoryModal used MultiSelect without importing it, left behind by the self-haul assignment work. --- .../last-mile-requests.service.ts | 7 + .../last-mile/last-mile.advance-gate.spec.ts | 214 ++++ .../modules/last-mile/last-mile.service.ts | 155 ++- .../warehouse-inventory.controller.ts | 11 +- .../warehouses/warehouse-inventory.service.ts | 14 +- .../warehouses/ReceiveInventoryModal.tsx | 56 +- .../warehouses/TrainLoadingWorkspace.tsx | 927 ++++++++++++++++++ .../warehouses/TrainWagonLoadModal.tsx | 320 ++++++ .../src/components/warehouses/index.ts | 2 + .../src/pages/operations/LastMilePage.tsx | 25 +- .../src/services/last-mile.service.ts | 6 + .../src/services/warehouse.service.ts | 12 +- 12 files changed, 1719 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/TrainLoadingWorkspace.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/TrainWagonLoadModal.tsx diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 37612d318..8d56bd0d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -378,6 +378,13 @@ export class LastMileRequestsService { contractGeneratedAt: new Date(), } as Partial); + // Only now, with the request APPROVED, is an advance actually owed on the + // leg. The warehouse auto-accept (IMPORT inspection PASSED) may have already + // opened that leg at READY_TO_TRANSIT, so pull it back to PAYMENT_PENDING — + // otherwise this booking would be dispatchable before the customer has + // signed the contract or paid a birr. No-op for a leg this call just created. + await this.lastMileService.holdForAdvance(lastMile.id); + if (booking.companyId) { void this.notifications.notify({ recipients: { companyId: booking.companyId }, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts new file mode 100644 index 000000000..8e0b8ba55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts @@ -0,0 +1,214 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { ADVANCE_UNPAID_MESSAGE, LastMileService } from './last-mile.service'; +import type { LastMileStatus } from './entities/last-mile.entity'; +import type { UpdateLastMileDto } from './dto/update-last-mile.dto'; + +/** + * The advance gate: a delivery becomes dispatchable (READY_TO_TRANSIT) or moves + * (IN_TRANSIT) only once the customer has paid the advance the Truck & Machinery + * chief approved. + * + * It used to leak both ways. The warehouse auto-accept (IMPORT inspection + * PASSED) opens the leg at READY_TO_TRANSIT and runs independently of the + * review, so whichever side acted second found the other already done: accept + * first and the leg was dispatchable before an advance was ever asked for; + * approve first and create() handed the existing dispatchable leg straight back + * untouched. + */ +function makeService( + opts: { + /** APPROVED requests on the booking carrying a positive advance. */ + advancesDue?: number; + /** PAID LAST_MILE_ADVANCE invoices on the leg. */ + advancesPaid?: number; + status?: LastMileStatus; + } = {}, +) { + const leg = { + id: 'lm-1', + bookingId: 'b-1', + status: opts.status ?? 'READY_TO_TRANSIT', + vehicleId: 'v-1', + booking: { reference: 'BK-001' }, + }; + + const query = jest.fn((sql: string) => { + if (sql.includes('customer_truck_assignments')) return Promise.resolve([]); + // The batched list enrichment, not the gate's own lookup. + if (sql.includes('FROM freight.last_mile lm')) return Promise.resolve([]); + if (sql.includes('freight.last_mile_requests')) { + return Promise.resolve([{ count: opts.advancesDue ?? 0 }]); + } + // Discriminated on the charge type, not the table: attachMileFinancials + // also queries freight.invoices (for the booking-invoice advance line). + if (sql.includes('LAST_MILE_ADVANCE')) { + return Promise.resolve([{ count: opts.advancesPaid ?? 0 }]); + } + if (sql.includes('FROM freight.bookings')) { + return Promise.resolve([ + { tradeDirection: 'IMPORT', firstMile: null, lastMile: 'Bole, Addis Ababa' }, + ]); + } + return Promise.resolve([]); + }); + + const lastMileRepository = { + findAll: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(leg), + create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })), + update: jest.fn((_id: string, patch: object) => Promise.resolve({ ...leg, ...patch })), + }; + + const service = new LastMileService( + lastMileRepository as never, + {} as never, // bookingsRepository + { + findById: jest.fn().mockResolvedValue({ + id: 'v-1', + plateNumber: 'AA-123', + assignedDriverId: 'd-1', + assignedDriverName: 'Driver', + }), + setAvailability: jest.fn(), + releaseIfUnused: jest.fn(), + } as never, // vehiclesService + {} as never, // driversService + {} as never, // smsClient + { + query, + // DELIVERED frees the trucks this leg was holding. + manager: { + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + } as unknown as DataSource, + { record: jest.fn() } as never, // history + { findBySourceIds: jest.fn().mockResolvedValue([]) } as never, // billing + { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService + {} as never, // filesService + ); + + return { service, lastMileRepository, leg }; +} + +const createdStatus = (repo: { create: jest.Mock }) => + (repo.create.mock.calls[0]?.[0] as { status?: string } | undefined)?.status; + +describe('LastMileService - advance gate on creation', () => { + it('opens an auto-accepted leg at PAYMENT_PENDING when an advance is owed', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 1 }); + + // The warehouse path asks for no status at all - it used to get + // READY_TO_TRANSIT and hand the customer a dispatchable unpaid delivery. + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('PAYMENT_PENDING'); + }); + + it('still opens at READY_TO_TRANSIT when no approved request owes an advance', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 0 }); + + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('READY_TO_TRANSIT'); + }); +}); + +describe('LastMileService - advance gate on transitions', () => { + it('refuses IN_TRANSIT while the advance is unpaid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toThrow(ADVANCE_UNPAID_MESSAGE); + }); + + it('refuses a leg being made dispatchable while the advance is unpaid', async () => { + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + await expect( + service.update('lm-1', { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows IN_TRANSIT once the advance invoice is paid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 1 }); + + const updated = await service.update('lm-1', { + status: 'IN_TRANSIT', + } as UpdateLastMileDto); + + expect(updated.status).toBe('IN_TRANSIT'); + }); + + it('requires one paid advance per approved departure', async () => { + // Containers arriving across two departures get a request - and an advance + // - each. One paid advance does not release the second. + const { service } = makeService({ advancesDue: 2, advancesPaid: 1 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('lets the paid listener through before the invoice row is visible', async () => { + // Billing emits inline, pre-commit, when the transition joins a caller's + // transaction - so the invoice still reads unpaid here. The event is the + // proof of payment; re-reading the row would refuse the transition the + // payment just earned. + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + const updated = await service.update( + 'lm-1', + { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto, + { advanceSettled: true }, + ); + + expect(updated.status).toBe('READY_TO_TRANSIT'); + }); + + it('leaves states that are not transit alone', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'DELIVERED' } as UpdateLastMileDto), + ).resolves.toBeDefined(); + }); +}); + +describe('LastMileService.holdForAdvance', () => { + it('pulls an already-dispatchable leg back when approval imposes an advance', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'READY_TO_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).toHaveBeenCalledWith( + 'lm-1', + expect.objectContaining({ status: 'PAYMENT_PENDING' }), + ); + }); + + it('never rewrites a leg that is already on the road', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'IN_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 8ed5ae8aa..4f8acd03a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -59,6 +59,13 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ 'createdAt', ]; +/** The states that mean the delivery is dispatchable or already on the road. */ +const TRANSIT_STATUSES: LastMileStatus[] = ['READY_TO_TRANSIT', 'IN_TRANSIT']; + +export const ADVANCE_UNPAID_MESSAGE = + 'The last-mile advance has not been paid yet — this delivery cannot become ' + + 'dispatchable or move until the advance invoice is settled.'; + @Injectable() export class LastMileService { private readonly logger = new Logger(LastMileService.name); @@ -93,9 +100,47 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await this.attachAdvanceState(records); await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } + /** + * Flag the legs whose advance is still owed, so the UI can disable the actions + * the API would refuse instead of firing them into a 400. Same rule as + * {@link advanceOutstanding}, batched over the whole page. + */ + private async attachAdvanceState(records: LastMile[]): Promise { + const ids = records.map((r) => r.id).filter(Boolean); + if (!ids.length) return; + const rows: Array<{ lastMileId: string; due: number; paid: number }> = + await this.dataSource.query( + `SELECT lm.id AS "lastMileId", + (SELECT COUNT(*) + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = lm.booking_id + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0)::int AS "due", + (SELECT COUNT(*) + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = lm.id::text + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL)::int AS "paid" + FROM freight.last_mile lm + WHERE lm.id = ANY($1::uuid[]) AND lm.deleted_at IS NULL`, + [ids], + ); + const outstanding = new Map( + rows.map((r) => [r.lastMileId, Number(r.due) > Number(r.paid)]), + ); + for (const r of records) { + (r as LastMile & { advanceOutstanding?: boolean }).advanceOutstanding = + outstanding.get(r.id) ?? false; + } + } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( @@ -185,6 +230,67 @@ export class LastMileService { } } + /** + * Whether this booking still owes an advance on its delivery. + * + * An advance is owed for every APPROVED last-mile request carrying a positive + * approved amount — a booking whose containers arrive across several + * departures gets a request, and therefore an advance, per departure. Each is + * settled by a PAID `LAST_MILE_ADVANCE` invoice raised on the leg when the + * customer signs that request's contract, so the leg is clear only once it has + * as many paid advance invoices as the booking has approved requests. + * + * A booking with no approved request owes nothing and is unaffected: legs that + * never went through the confirmation flow keep behaving exactly as before. + * `lastMileId` is null while the leg is still being created — no invoice can + * point at a row that does not exist yet, so nothing can have been settled. + */ + private async advanceOutstanding( + bookingId: string, + lastMileId: string | null, + ): Promise { + const [due] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = $1 + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0`, + [bookingId], + ); + const owed = Number(due?.count ?? 0); + if (!owed) return false; + if (!lastMileId) return true; + + const [paid] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = $1 + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL`, + [lastMileId], + ); + return Number(paid?.count ?? 0) < owed; + } + + /** + * Hold a leg at PAYMENT_PENDING because an advance has just been imposed on it. + * + * The warehouse auto-accept (IMPORT inspection PASSED) opens the leg + * independently of the chief's review, and opens it at READY_TO_TRANSIT. When + * that happens first, approval has to pull the leg back — otherwise the advance + * gate never holds on that ordering and the delivery is dispatchable unpaid. + * A leg already IN_TRANSIT or DELIVERED is left alone: that is a record of what + * happened, not a plan that can still be changed. + */ + async holdForAdvance(id: string): Promise { + const record = await this.findById(id); + if (record.status !== 'READY_TO_TRANSIT') return; + await this.update(id, { status: 'PAYMENT_PENDING' } as UpdateLastMileDto); + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -425,9 +531,16 @@ export class LastMileService { await this.assertEdrHaulsThisBooking(dto.bookingId); + // A leg that owes an advance is not dispatchable, whatever the caller asked + // for. The warehouse auto-accept path asks for no status at all and used to + // land straight in READY_TO_TRANSIT, which let an unpaid delivery go. + const status: LastMileStatus = (await this.advanceOutstanding(dto.bookingId, null)) + ? 'PAYMENT_PENDING' + : (dto.status ?? 'READY_TO_TRANSIT'); + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status, advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')), @@ -461,11 +574,16 @@ export class LastMileService { async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { if (payload.type === 'LAST_MILE_ADVANCE') { - // Advance paid → the leg becomes dispatchable, not delivered. - await this.update(payload.sourceId, { - status: 'READY_TO_TRANSIT', - advancedPayment: payload.totalAmount, - } as unknown as UpdateLastMileDto); + // Advance paid → the leg becomes dispatchable, not delivered. This event + // IS the settlement, so it carries its own way past the advance gate. + await this.update( + payload.sourceId, + { + status: 'READY_TO_TRANSIT', + advancedPayment: payload.totalAmount, + } as unknown as UpdateLastMileDto, + { advanceSettled: true }, + ); this.logger.log( `Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`, ); @@ -484,9 +602,32 @@ export class LastMileService { } } - async update(id: string, dto: UpdateLastMileDto): Promise { + /** + * `opts.advanceSettled` is the paid listener's own bypass, and nothing else + * should pass it: the invoice event is itself the proof of payment, and it can + * reach us inline before the invoice row commits (billing emits before commit + * when the transition is enlisted in a caller-supplied manager), so re-reading + * the invoice here would still see it unpaid and refuse the very transition the + * payment just earned. + */ + async update( + id: string, + dto: UpdateLastMileDto, + opts: { advanceSettled?: boolean } = {}, + ): Promise { const existing = await this.findById(id); + // Nothing becomes dispatchable, and nothing moves, until the advance is paid. + if ( + !opts.advanceSettled && + dto.status !== undefined && + dto.status !== existing.status && + TRANSIT_STATUSES.includes(dto.status) && + (await this.advanceOutstanding(existing.bookingId, id)) + ) { + throw new BadRequestException(ADVANCE_UNPAID_MESSAGE); + } + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle // assigned in this same request). if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 15a3f6c4a..77755fa2f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -199,9 +199,14 @@ export class WarehouseInventoryController { @Get('loadable-trains') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) - @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) - loadableTrains() { - return this.inventoryService.loadableTrains(); + @ApiOperation({ + summary: + 'EXPORT trains with inventory waiting to be loaded — pre-dispatch by default; `includeDispatched=true` adds rolling trains still picking cargo up along the corridor', + }) + loadableTrains(@Query('includeDispatched') includeDispatched?: string) { + return this.inventoryService.loadableTrains({ + includeDispatched: includeDispatched === 'true' || includeDispatched === '1', + }); } @Get('train/:scheduleId/loadable-items') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 82231bacc..ef68f3673 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2091,7 +2091,17 @@ export class WarehouseInventoryService { */ private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; - async loadableTrains(): Promise { + /** + * @param includeDispatched also list DISPATCHED trains. Loading follows the + * train after it rolls — a mid-corridor warehouse boards its cargo when the + * train stands at its yard — so the warehouse's train-centric loading view + * needs the same set the schedule workspace offers Load on. The default + * (pre-dispatch only) keeps the existing auto-load picker unchanged. + */ + async loadableTrains(opts: { includeDispatched?: boolean } = {}): Promise { + const statuses = opts.includeDispatched + ? ['DRAFT', 'SCHEDULED', 'DISPATCHED'] + : ['DRAFT', 'SCHEDULED']; const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( @@ -2129,7 +2139,7 @@ export class WarehouseInventoryService { AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, - [['DRAFT', 'SCHEDULED']], + [statuses], ); return rows diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index b10e937c3..1f3a1cf81 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -11,8 +11,10 @@ import { Loader, Menu, Modal, + MultiSelect, NumberInput, ScrollArea, + SegmentedControl, Select, SimpleGrid, Stack, @@ -91,6 +93,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { TrainLoadingWorkspace } from './TrainLoadingWorkspace'; import { YardLoadingWindows } from './YardLoadingWindows'; import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; @@ -1861,6 +1864,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); const qc = useQueryClient(); + // "Items" is the inventory list with the auto-load picker; "By train" mirrors + // the train schedule's per-booking Load / Wagons / Unload workspace here. + const [view, setView] = useState<'items' | 'train'>('items'); const [trainPickerOpen, setTrainPickerOpen] = useState(false); const [expandedRow, setExpandedRow] = useState(null); const [targetScheduleId, setTargetScheduleId] = useState(null); @@ -1952,16 +1958,50 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: } }; + if (view === 'train') { + return ( + + + setView(v as 'items' | 'train')} + data={[ + { value: 'items', label: 'Items' }, + { value: 'train', label: 'By train' }, + ]} + /> + + Per-booking Load, wagon-by-wagon loading and unloading — the train schedule's own + actions, run from the warehouse. + + + + + ); + } + return ( - - - {selected.size > 0 ? ( - <>{selected.size} of {controls.filteredRows.length} selected - ) : ( - <>{controls.filteredRows.length} item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load - )} - + + + setView(v as 'items' | 'train')} + data={[ + { value: 'items', label: 'Items' }, + { value: 'train', label: 'By train' }, + ]} + /> + + {selected.size > 0 ? ( + <>{selected.size} of {controls.filteredRows.length} selected + ) : ( + <>{controls.filteredRows.length} item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load + )} + + + ) : null} ) : null} + {creditRebookable ? ( + } + onClick={() => onRebookCredit(r)} + > + Rebook cancellation credit + + ) : null} {r.contractId ? ( } diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 4f2feeea3..f6f97db3f 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -810,6 +810,14 @@ export const bookingsService = { return ensurePdfBlob(response.data as Blob); }, + /** The Wagons tab's Excel export — customer name plus one row per wagon. */ + downloadWagonsWorkbook: async (id: string): Promise => { + const response = await client.get(B.WAGONS_EXPORT(id), { + responseType: "blob", + }); + return response.data as Blob; + }, + getDjClearanceQueue: async (): Promise => { const response = await client.get(B.CLEARANCE_DJ_QUEUE); return (unwrap(response.data) ?? []) as BookingDetail[]; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 2756d3155..d536734ef 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -216,6 +216,14 @@ export interface BookingDetail { wagonsRequired?: number | null; scheduledAt?: string | null; trainScheduleId?: string | null; + /** + * The allocated train's departure date, attached by the LIST endpoint (the + * detail endpoint carries the fuller `trainScheduleSummary` instead). This is + * the operational date, as opposed to the customer-requested `scheduledDate`. + */ + trainScheduleDepartureDate?: string | null; + /** The allocated train's reference (S-YYYY-NNNNN) or train number. */ + trainScheduleReference?: string | null; /** Operational status of the allocated train (null until scheduled). */ trainScheduleStatus?: string | null; /** The allocated train's identity + clock, attached by the detail endpoint. */ @@ -244,6 +252,12 @@ export interface BookingDetail { allDocsApproved?: boolean; /** ET clearance queue: a customer document is PENDING or QUERIED. */ hasDocumentsAwaitingReview?: boolean; + /** + * ET clearance queue: id of an unspent wagon-cancellation credit on this + * booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is + * none — GL rebooks the credit straight from the queue row. + */ + rebookableCancellationId?: string | null; contractKind?: "ONE_TIME" | "GENERAL" | null; contractId?: string | null; /** Reference of the contract this booking was created under (list column + search). */ @@ -304,6 +318,10 @@ export interface BookingListRow { schedulingStatus?: string; serviceTypeLabel?: string; trainScheduleId?: string | null; + /** Departure date of the train this booking is allocated to; null until scheduled. */ + trainScheduleDepartureDate?: string | null; + /** Reference of the train this booking is allocated to. */ + trainScheduleReference?: string | null; isGovernment?: boolean; governmentInstitution?: string | null; consolidationPartnerId?: string | null; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 037f3ddcc..3f5a0d0fe 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -1258,10 +1258,24 @@ export interface BookingWagonRow { allocatedWeightTons: number | string | null; loadType: string | null; status: string; + /** Numeric columns arrive as strings from pg — parse before arithmetic. */ + tareWeightTons?: number | string | null; + capacityTons?: number | string | null; + lengthMeters?: number | string | null; + /** The train this wagon rides on, and where it runs. */ + trainNumber?: string | null; + departureAt?: string | null; + originStation?: string | null; + destinationStation?: string | null; + /** Set only when the wagon carries bulk rather than containers. */ + bulkCargoDescription?: string | null; + bulkQuantity?: number | string | null; containers: Array<{ containerNumber: string | null; sizeFt: number | null; grossWeightTons: number | string | null; + sealNumber?: string | null; + positionOnWagon?: number | null; }>; } From 38df7f034ca79733fdbf550eb718fd85c3cd98f1 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 3 Sep 2026 16:09:03 +0300 Subject: [PATCH 10/13] 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 11/13] 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 12/13] 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. */}