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

Payment Windows (minutes)

+

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

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

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

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

Time to pay a reschedule charge. Default: 120.

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

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

+
+

Seat Booking

{configLoading ? (

Loading...

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

Upgrade Policies

+

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

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

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

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

Every fare class already has a policy.

+ )} + {message &&

{message}

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

A policy stays attached to its fare class.

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

Higher beats lower. Must be unique among active policies.

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

{formError}

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

Missing booking reference.

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

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

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

Upgrade confirmed

+

New tickets have been issued for booking {ref}.

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

Upgrade awaiting payment

+

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

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

+ Upgrade summary +

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

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

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

Upgrade {ref}

+

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

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

Choose a class

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

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

+

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

+
+ {eligible.map((p) => { + const isActive = activeBookingSeatId === p.bookingSeatId; + const picked = picks[p.bookingSeatId]; + return ( + + ); + })} +
+ + {loadingSeats ? ( + + ) : ( + !!activeBookingSeatId && picks[activeBookingSeatId] === id} + isSeatAssignedToOther={(id) => { + const owner = seatOwner(id); + return !!owner && owner !== activeBookingSeatId; + }} + onSeatToggle={handleSeatToggle} + emptyLabel="No seats of that class on this train." + /> + )} + + )} +
+ +
+ +
+
+ +
+
+ +
+
+
+ )} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) { + return ( +
+
+ {wide ? ( +
{children}
+ ) : ( +
+ {children} +
+ )} +
+
+ ); +} + +export default function UpgradePage() { + return ( + }> + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx index 96ecd0d3e..cec48132c 100644 --- a/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx +++ b/apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx @@ -10,6 +10,7 @@ import { ChevronLeft, ChevronRight, Clock, + ArrowUpCircle, Eye, CreditCard, RefreshCw, @@ -63,33 +64,52 @@ function describeSeats(seats: MyBookingItem['seats'], leg: number) { interface RowActions { canReschedule: boolean; rescheduleBlocker: string | null; + canUpgrade: boolean; + upgradeBlocker: string | null; isPendingPayment: boolean; } /** - * The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules - * (fare-class policy, cutoff, seats still free) belong to the reschedule page, which - * names them as blockers — this only avoids sending the customer somewhere that is - * certain to reject them. The phone test matches the API's own ownership check - * (reschedule.service.ts loadOwnedBooking), which is phone-based, not account-based. + * The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and + * upgrade share it because the booking-shape rules and the ownership check are identical — only + * the wording differs, hence the verb. + * + * The per-leg rules (fare-class policy, cutoffs, whether a higher class even runs on this train, + * seats still free) belong to the reschedule and upgrade pages, which name them as blockers. This + * only avoids sending the customer somewhere certain to reject them. The phone test matches the + * API's own ownership check (loadOwnedBooking), which is phone-based, not account-based. */ function resolveActions(b: MyBookingItem, userPhone?: string): RowActions { const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT'; - let rescheduleBlocker: string | null = null; - if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled'; - else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online'; - else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType)) - rescheduleBlocker = 'Transit bookings cannot be rescheduled online'; - else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded'; - // The API applies policy.cutoffMinutes to the old leg's departure, so a departed trip - // is always rejected. Say so here instead of sending them to a page that refuses. - else if (new Date(b.schedule.departureAt).getTime() <= Date.now()) - rescheduleBlocker = 'This trip has already departed'; - else if (b.contactPhone && !samePhone(userPhone, b.contactPhone)) - rescheduleBlocker = 'Only the person who made this booking can reschedule it'; + // Both forms are needed: "can be rescheduled" but "can reschedule it". + type Verbs = { past: string; base: string }; + const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' }; + const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' }; - return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment }; + let reason: ((v: Verbs) => string) | null = null; + if (b.status !== 'CONFIRMED') reason = (v) => `Only a confirmed booking can be ${v.past}`; + else if (b.isPackageBooking) reason = (v) => `Package bookings cannot be ${v.past} online`; + else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType)) + reason = (v) => `Transit bookings cannot be ${v.past} online`; + else if (b.outboundBoardedAt) reason = () => 'This trip has already been boarded'; + // Both APIs apply a cutoff measured against departure, so a departed trip is always rejected. + // Say so here instead of sending them to a page that refuses. + else if (new Date(b.schedule.departureAt).getTime() <= Date.now()) + reason = () => 'This trip has already departed'; + else if (b.contactPhone && !samePhone(userPhone, b.contactPhone)) + reason = (v) => `Only the person who made this booking can ${v.base} it`; + + const rescheduleBlocker = reason ? reason(RESCHEDULE) : null; + const upgradeBlocker = reason ? reason(UPGRADE) : null; + + return { + canReschedule: rescheduleBlocker === null, + rescheduleBlocker, + canUpgrade: upgradeBlocker === null, + upgradeBlocker, + isPendingPayment, + }; } /** @@ -122,6 +142,7 @@ export default function MyBookingsTable() { const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`); const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`); + const openUpgrade = (b: MyBookingItem) => router.push(`/booking/upgrade?ref=${b.bookingRef}`); const cardClass = 'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'; @@ -268,6 +289,20 @@ export default function MyBookingsTable() { Reschedule )} + {!actions.isPendingPayment && ( + + )} @@ -344,6 +379,20 @@ export default function MyBookingsTable() { Reschedule )} + {!actions.isPendingPayment && ( + + )} ); From 9a9955cbaa015599d10b10532322ce05ded76710 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 3 Sep 2026 14:39:40 +0300 Subject: [PATCH 04/20] fix: (bookings) release the seat hold when a reschedule or upgrade expires and honour shortened payment windows --- .../modules/reschedule/reschedule.service.ts | 10 +++++- .../src/modules/seats/seats.service.ts | 34 ++++++++++++++----- .../src/modules/upgrade/upgrade.service.ts | 10 +++++- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index f296dac74..d45309ac1 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -433,7 +433,7 @@ export class RescheduleService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingReschedule.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true }, }); for (const r of stale) { await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } }); @@ -443,6 +443,14 @@ export class RescheduleService { data: { status: 'EXPIRED' }, }); } + // Release the seat the instant the request dies instead of leaving it to the hold's own + // TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at + // creation, and nothing keeps them in step afterwards — so without this the seat can sit + // unsellable long after the link that pays for it has expired. deleteMany: an already-swept + // hold must not throw. + if (r.holdId) { + await this.prisma.seatHold.deleteMany({ where: { id: r.holdId } }); + } } return stale.length; } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 5c3b74c53..e54c085a1 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -728,24 +728,42 @@ export class SeatsService { ? 0 // unused — the override wins below : await this.systemConfig.getNumber(CONFIG_KEYS.BOOKING_PAYMENT_WINDOW_MINUTES); - let extended = 0; + let aligned = 0; await Promise.all( holds.map(async (hold) => { const departureAt = departureById.get(hold.scheduleId); if (!departureAt) return; - const deadline = deadlineOverride ?? computePaymentDeadline(now, departureAt, undefined, windowMinutes); - // Only ever extend forward — never shorten a hold that's already valid longer - // than the payment deadline would give it (e.g. a second confirmSeats call on - // the same booking, or a hold that was already extended). + + if (deadlineOverride) { + // Authoritative in BOTH directions. The caller already issued a payment link with this + // exact deadline, so the hold must match it — including when it is EARLIER than the + // hold's own TTL. Extending only would leave the seat held after the link that pays for + // it has died (reachable whenever a flow's payment window is shorter than + // seat_hold_duration_minutes), so the seat sits unsellable in between. + if (hold.expiresAt.getTime() === deadlineOverride.getTime()) return; + await this.prisma.seatHold.update({ + where: { id: hold.id }, + data: { expiresAt: deadlineOverride }, + }); + aligned++; + return; + } + + const deadline = computePaymentDeadline(now, departureAt, undefined, windowMinutes); + // Normal booking path: only ever extend forward — never shorten a hold that's already + // valid longer than the payment deadline would give it. A round trip calls confirmSeats + // up to four times, and a later call must not pull in a hold an earlier one set. if (deadline <= hold.expiresAt) return; await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } }); - extended++; + aligned++; }), ); - if (extended > 0) { + if (aligned > 0) { this.logger.log( - `Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`, + deadlineOverride + ? `Aligned ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their charge's payment deadline` + : `Extended ${aligned} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`, ); } } diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts index f0248233c..95d3aee75 100644 --- a/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.service.ts @@ -603,7 +603,7 @@ export class UpgradeService { async expireStale(now = new Date()): Promise { const stale = await this.prisma.bookingUpgrade.findMany({ where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, - select: { id: true, supplementaryChargeId: true }, + select: { id: true, supplementaryChargeId: true, holdId: true }, }); for (const u of stale) { await this.prisma.bookingUpgrade.update({ where: { id: u.id }, data: { status: 'EXPIRED' } }); @@ -613,6 +613,14 @@ export class UpgradeService { data: { status: 'EXPIRED' }, }); } + // Release the seat the instant the request dies instead of leaving it to the hold's own + // TTL. The two are only ever equal by coincidence — confirmSeats copies the deadline once at + // creation, and nothing keeps them in step afterwards — so without this the seat can sit + // unsellable long after the link that pays for it has expired. deleteMany: an already-swept + // hold must not throw. + if (u.holdId) { + await this.prisma.seatHold.deleteMany({ where: { id: u.holdId } }); + } } return stale.length; } From 38df7f034ca79733fdbf550eb718fd85c3cd98f1 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 3 Sep 2026 16:09:03 +0300 Subject: [PATCH 05/20] 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 06/20] 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 17deb434ae973136d5edab7da00018496374cd3f Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 11:52:05 +0300 Subject: [PATCH 07/20] feat(exchange): support n-currency conversion, not just USD/ETB CbeExchangeProvider now parses every currency CBE quotes (USD, DJF, ...) from the single existing daily-rates fetch instead of hardcoding USD only, and ExchangeService.getRate gains a pivot step so a pair neither quoted directly nor as its inverse (e.g. USD->DJF) is derived by triangulating through the provider's base currency (ETB). Fallback rates and the load/save callbacks become per-currency instead of a single USD->ETB scalar. ExchangeService.getRateTable resolves a whole currency->target rate table in one call for pricing loops. No behavior change for existing USD/ETB callers. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- .../src/services/exchange/cbe.provider.ts | 157 +++++++++++------- .../src/services/exchange/exchange.options.ts | 35 ++-- .../src/services/exchange/exchange.service.ts | 35 +++- .../src/services/exchange/exchange.types.ts | 19 ++- .../api-common/src/services/exchange/index.ts | 1 + 5 files changed, 160 insertions(+), 87 deletions(-) diff --git a/packages/api-common/src/services/exchange/cbe.provider.ts b/packages/api-common/src/services/exchange/cbe.provider.ts index 3aa4fadba..74ba79025 100644 --- a/packages/api-common/src/services/exchange/cbe.provider.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -6,6 +6,7 @@ import { ResolvedExchangeOptions, } from "./exchange.options"; import { + CurrencyCode, CurrencyPair, ExchangeRateProvider, } from "./exchange.types"; @@ -41,62 +42,77 @@ export interface CbeProviderStatus { /** * Commercial Bank of Ethiopia (CBE) rate provider. * - * Sources a single canonical direction — **USD→ETB** (transactional selling - * rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the - * result and falling back to a configured rate when the fetch fails. The - * inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider - * only ever reports USD→ETB. + * Sources every quoted currency against **ETB** (transactional selling rate) + * from CBE's public `daily-exchange-rates` JSON endpoint in a single fetch — + * the payload carries every currency CBE quotes that day, not just one — caching + * the result and falling back to a configured rate per currency when the fetch + * fails. Every other pair (ETB→X, and cross-pairs like USD→DJF) is derived by + * {@link ExchangeService}, so this provider only ever reports X→ETB. */ export class CbeExchangeProvider implements ExchangeRateProvider { readonly name = "CBE"; + readonly baseCurrency: CurrencyCode = "ETB"; private readonly logger = new Logger(CbeExchangeProvider.name); private readonly options: ResolvedExchangeOptions; - private cachedRate: number | null = null; + private cachedRates: Map | null = null; private cacheExpiresAt = 0; private lastSuccessAt: number | null = null; private lastError: string | null = null; - private lastSource: CbeRateSource | null = null; + /** Source of the rate last served, per currency code. */ + private lastSource = new Map(); + /** Rate last served, per currency code — mirrors {@link lastSource}. */ + private lastServed = new Map(); constructor(options: ExchangeOptions) { this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; } async getBaseRate(pair: CurrencyPair): Promise { - // CBE only sources USD→ETB; everything else is derived upstream. - if (pair.from !== "USD" || pair.to !== "ETB") { + // CBE only sources X→ETB; everything else is derived upstream. + if (pair.to !== "ETB" || pair.from === "ETB") { return null; } - return this.getUsdToEtbRate(); + return this.getRateToEtb(pair.from); } - /** Health of the CBE feed — what was served last, and whether it is failing. */ - getStatus(): CbeProviderStatus { + /** + * Health of the CBE feed for one currency — what was served last, and + * whether it is currently failing. For operator-facing status displays. + */ + getStatus(code: CurrencyCode = "USD"): CbeProviderStatus { return { - rate: this.cachedRate, - source: this.lastSource, + rate: this.lastServed.get(code) ?? null, + source: this.lastSource.get(code) ?? null, lastSuccessAt: this.lastSuccessAt, lastError: this.lastError, }; } /** - * Returns the current CBE USD→ETB **transactional selling** rate. + * Returns the current CBE `code`→ETB **transactional selling** rate. * - * Cached for `cacheTtlMs`. On a successful fetch the rate is written back via + * Cached for `cacheTtlMs`, one fetch serving every currency. On a + * successful fetch each currency's rate is written back via * `saveFallbackRate`, so the stored fallback is never more than one good - * fetch stale. On failure the chain is: cached rate → `loadFallbackRate()` - * → static `fallbackRate`. + * fetch stale. On failure the chain is: cached rate → `loadFallbackRate(code)` + * → static `fallbackRates[code]`. */ - private async getUsdToEtbRate(): Promise { + private async getRateToEtb(code: CurrencyCode): Promise { const now = Date.now(); - if (this.cachedRate !== null && now < this.cacheExpiresAt) { - this.lastSource = "cache"; - return this.cachedRate; + if (this.cachedRates !== null && now < this.cacheExpiresAt) { + const cached = this.cachedRates.get(code); + if (cached !== undefined) { + this.lastSource.set(code, "cache"); + this.lastServed.set(code, cached); + return cached; + } + // Cache is fresh but never saw this currency quoted — fall through to + // stored/default rather than treating it as a live-fetch failure. } - const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = + const { scrapeUrl, fallbackRates, cacheTtlMs, requestTimeoutMs } = this.options; try { @@ -116,54 +132,68 @@ export class CbeExchangeProvider implements ExchangeRateProvider { throw new Error("CBE rates payload contained no daily record"); } - const rate = this.parseUsdRate(day); + const rates = this.parseRates(day); + const rate = rates.get(code) ?? null; if (rate === null) { throw new Error( - `USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`, + `${code} transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`, ); } - const previous = this.cachedRate; - this.cachedRate = rate; + const previous = this.cachedRates?.get(code) ?? null; + this.cachedRates = rates; this.cacheExpiresAt = now + cacheTtlMs; this.lastSuccessAt = now; this.lastError = null; - this.lastSource = "live"; + this.lastSource.set(code, "live"); + this.lastServed.set(code, rate); this.logger.log( - `CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`, + `CBE ${code}→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`, ); // Persist as the new fallback so a later outage reuses the last good // rate. Skipped when unchanged, to avoid pointless writes and audit noise. if (rate !== previous) { - await this.persistFallback(rate); + await this.persistFallback(code, rate); } return rate; } catch (err) { const message = (err as Error).message; this.lastError = message; - this.logger.error(`Failed to fetch CBE exchange rate. Error: ${message}`); + this.logger.error( + `Failed to fetch CBE exchange rate for ${code}. Error: ${message}`, + ); - if (this.cachedRate !== null) { - this.lastSource = "cache"; - this.logger.warn( - `Using previously cached CBE rate: ${this.cachedRate}`, - ); - return this.cachedRate; + const cached = this.cachedRates?.get(code); + if (cached !== undefined) { + this.lastSource.set(code, "cache"); + this.lastServed.set(code, cached); + this.logger.warn(`Using previously cached CBE rate for ${code}: ${cached}`); + return cached; } - const stored = await this.loadStoredFallback(); + const stored = await this.loadStoredFallback(code); if (stored !== null) { - this.lastSource = "stored"; - this.logger.warn(`Using stored fallback CBE rate: ${stored}`); + this.lastSource.set(code, "stored"); + this.lastServed.set(code, stored); + this.logger.warn(`Using stored fallback CBE rate for ${code}: ${stored}`); return stored; } - this.lastSource = "default"; - this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`); - return fallbackRate; + const fallback = fallbackRates[code]; + if (fallback === undefined) { + // No static default configured for this currency either — nothing + // left to fall back to. + throw new Error( + `No CBE rate available for ${code}→ETB (fetch failed and no fallback configured)`, + ); + } + this.lastSource.set(code, "default"); + this.lastServed.set(code, fallback); + this.logger.warn(`Using default fallback CBE rate for ${code}: ${fallback}`); + return fallback; } } @@ -172,34 +202,34 @@ export class CbeExchangeProvider implements ExchangeRateProvider { * logged and swallowed: persisting the fallback is housekeeping, and must * never fail the pricing call that triggered it. */ - private async persistFallback(rate: number): Promise { + private async persistFallback(code: CurrencyCode, rate: number): Promise { const { saveFallbackRate } = this.options; if (!saveFallbackRate) return; try { - await saveFallbackRate(rate); + await saveFallbackRate(code, rate); } catch (err) { this.logger.warn( - `Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`, + `Failed to persist CBE fallback rate ${rate} for ${code}: ${(err as Error).message}`, ); } } /** - * Reads the persisted fallback. Returns `null` — falling through to the - * static default — when unconfigured, unusable, or itself failing. + * Reads the persisted fallback for `code`. Returns `null` — falling through + * to the static default — when unconfigured, unusable, or itself failing. */ - private async loadStoredFallback(): Promise { + private async loadStoredFallback(code: CurrencyCode): Promise { const { loadFallbackRate } = this.options; if (!loadFallbackRate) return null; try { - const stored = await loadFallbackRate(); + const stored = await loadFallbackRate(code); const rate = Number(stored); return Number.isFinite(rate) && rate > 0 ? rate : null; } catch (err) { this.logger.warn( - `Failed to load stored CBE fallback rate: ${(err as Error).message}`, + `Failed to load stored CBE fallback rate for ${code}: ${(err as Error).message}`, ); return null; } @@ -217,18 +247,21 @@ export class CbeExchangeProvider implements ExchangeRateProvider { } /** - * Pulls USD `transactionalSelling` out of a daily record. Returns `null` when - * the entry is missing or the value isn't a usable positive number — CBE - * publishes `0`/`null` for currencies it isn't quoting that day. + * Pulls every currency's `transactionalSelling` out of a daily record in + * one pass. Skips entries missing or unusable — CBE publishes `0`/`null` + * for currencies it isn't quoting that day. */ - private parseUsdRate(day: CbeDailyRecord): number | null { - const usd = day.ExchangeRate?.find( - (entry) => entry?.currency?.CurrencyCode === "USD", - ); - if (!usd) return null; - - const rate = Number(usd.transactionalSelling); - return Number.isFinite(rate) && rate > 0 ? rate : null; + private parseRates(day: CbeDailyRecord): Map { + const rates = new Map(); + for (const entry of day.ExchangeRate ?? []) { + const code = entry?.currency?.CurrencyCode; + if (!code) continue; + const rate = Number(entry.transactionalSelling); + if (Number.isFinite(rate) && rate > 0) { + rates.set(code, rate); + } + } + return rates; } } diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts index 6ccf83489..1c00ee00f 100644 --- a/packages/api-common/src/services/exchange/exchange.options.ts +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -1,3 +1,5 @@ +import { CurrencyCode } from "./exchange.types"; + /** Injection token carrying the resolved {@link ExchangeOptions}. */ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); @@ -11,31 +13,34 @@ export interface ExchangeOptions { scrapeUrl?: string; /** - * Last-resort USD→ETB rate, used only when the fetch fails, no cached rate - * exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD - * direction is derived as its inverse. - * @default 162 + * Last-resort rate for each foreign currency, quoted against the provider's + * base currency (ETB for CBE) — used only when the fetch fails, no cached + * rate exists, and {@link loadFallbackRate} supplies nothing for that + * currency. Every other pair (including ETB→X and cross-pairs like + * USD→DJF) is derived from these. + * @default { USD: 162, DJF: 0.92 } */ - fallbackRate?: number; + fallbackRates?: Partial>; /** - * Reads the persisted fallback rate — the last known good CBE rate, or one - * set by an operator. Consulted only when the live fetch fails and no cached - * rate is available; a `null` result falls through to {@link fallbackRate}. + * Reads the persisted fallback rate for `code` — the last known good CBE + * rate, or one set by an operator. Consulted only when the live fetch fails + * and no cached rate is available; a `null` result falls through to + * {@link fallbackRates}. * - * Optional: omit it and the provider uses the static `fallbackRate` alone. + * Optional: omit it and the provider uses the static `fallbackRates` alone. */ - loadFallbackRate?: () => Promise; + loadFallbackRate?: (code: CurrencyCode) => Promise; /** - * Persists a freshly fetched live rate as the new fallback, so the stored - * value is never more than one successful fetch stale. Called after every - * successful fetch that produced a changed rate. + * Persists a freshly fetched live rate for `code` as the new fallback, so + * the stored value is never more than one successful fetch stale. Called + * after every successful fetch that produced a changed rate. * * Failures here are logged and swallowed — persisting the fallback must * never break the pricing call that triggered it. */ - saveFallbackRate?: (rate: number) => Promise; + saveFallbackRate?: (code: CurrencyCode, rate: number) => Promise; /** * How long a successfully fetched rate is cached, in milliseconds. @@ -60,7 +65,7 @@ export type ResolvedExchangeOptions = Required< export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = { scrapeUrl: "https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC", - fallbackRate: 162, + fallbackRates: { USD: 162, DJF: 0.92 }, cacheTtlMs: 3_600_000, requestTimeoutMs: 8_000, }; diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts index 135bafb6d..f86466db8 100644 --- a/packages/api-common/src/services/exchange/exchange.service.ts +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from "@nestjs/common"; import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider"; import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options"; -import { CurrencyCode } from "./exchange.types"; +import { CURRENCY_CODES, CurrencyCode } from "./exchange.types"; /** * Currency exchange service. Resolves the rate between any supported currency @@ -12,6 +12,8 @@ import { CurrencyCode } from "./exchange.types"; * 1. `from === to` → `1`. * 2. Provider supplies the pair directly (e.g. CBE → USD→ETB). * 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD). + * 4. Neither leg is quoted directly (e.g. USD→DJF) → pivot through the + * provider's base currency, which quotes both. * * Configure via {@link ExchangeModule.forRoot} / `forRootAsync`. */ @@ -42,17 +44,44 @@ export class ExchangeService { return 1 / inverse; } + // Neither leg is quoted directly (e.g. USD↔DJF): pivot through the + // provider's base currency, which quotes both. Mathematically identical + // to converting via that base currency by hand. + const base = this.provider.baseCurrency; + if (from !== base && to !== base) { + const fromToBase = await this.provider.getBaseRate({ from, to: base }); + const toToBase = await this.provider.getBaseRate({ from: to, to: base }); + if (fromToBase !== null && toToBase !== null && toToBase > 0) { + return fromToBase / toToBase; + } + } + throw new Error( `No exchange rate available for ${from}→${to} from provider ${this.provider.name}`, ); } + /** + * A conversion function for every source currency into `target`, resolved + * up front so a pricing loop never awaits per row. `fx(code)` is `1` for + * `code === target`, throws for a currency the provider cannot rate. + */ + async getRateTable( + target: CurrencyCode, + sources: readonly CurrencyCode[] = CURRENCY_CODES, + ): Promise> { + const entries = await Promise.all( + sources.map(async (code) => [code, await this.getRate(code, target)] as const), + ); + return Object.fromEntries(entries); + } + /** * Health of the underlying rate feed — what was served last and whether it * is currently failing. For operator-facing status displays. */ - getProviderStatus(): CbeProviderStatus { - return this.provider.getStatus(); + getProviderStatus(code: CurrencyCode = "USD"): CbeProviderStatus { + return this.provider.getStatus(code); } /** Converts `amount` from one currency to another using {@link getRate}. */ diff --git a/packages/api-common/src/services/exchange/exchange.types.ts b/packages/api-common/src/services/exchange/exchange.types.ts index 384ad93b3..0ac77603f 100644 --- a/packages/api-common/src/services/exchange/exchange.types.ts +++ b/packages/api-common/src/services/exchange/exchange.types.ts @@ -1,8 +1,10 @@ /** * ISO-4217 currency codes the exchange service can handle. - * Extend this union as new currencies are supported. + * Extend this list as new currencies are supported. */ -export type CurrencyCode = "USD" | "ETB"; +export const CURRENCY_CODES = ["ETB", "USD", "DJF"] as const; + +export type CurrencyCode = (typeof CURRENCY_CODES)[number]; /** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */ export interface CurrencyPair { @@ -11,18 +13,21 @@ export interface CurrencyPair { } /** - * A source of base exchange rates. Implementations fetch (scrape/API) the rate - * for a single canonical direction; the {@link ExchangeService} derives the - * inverse and same-currency (1:1) cases on top. + * A source of base exchange rates. Implementations fetch (scrape/API) rates + * quoted against a single canonical base currency; the {@link ExchangeService} + * derives every other pair — inverse, pivot, same-currency (1:1) — on top. * * Today the only implementation is the CBE (Central Bank of Ethiopia) provider, - * which sources USD→ETB. New providers (other banks, other base pairs) can be - * added without touching consumers. + * which quotes everything against ETB. New providers (other banks, other base + * currencies) can be added without touching consumers. */ export interface ExchangeRateProvider { /** Human-readable provider name, used in logs (e.g. `'CBE'`). */ readonly name: string; + /** The currency this provider quotes every other currency against. */ + readonly baseCurrency: CurrencyCode; + /** * Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`), * or `null` if this provider cannot supply that pair directly. diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts index f2f88891a..4e893098f 100644 --- a/packages/api-common/src/services/exchange/index.ts +++ b/packages/api-common/src/services/exchange/index.ts @@ -8,6 +8,7 @@ export type { ExchangeAsyncOptions, ResolvedExchangeOptions, } from "./exchange.options"; +export { CURRENCY_CODES } from "./exchange.types"; export type { CurrencyCode, CurrencyPair, From 21dc28a7084eb653980886662c366d5c0a114394 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 11:52:18 +0300 Subject: [PATCH 08/20] feat(exchange-settings): one fallback rate per currency exchange_settings was a single row holding the USD->ETB fallback only. Restructured to one row per foreign currency (adds a currency column, migration 3850000000000) so DJF gets its own fallback rate, source and sync timestamp instead of a parallel column. Service/controller/DTO follow: get/loadFallbackRate/saveFallbackRate/setManualRate all take a currency now, GET /exchange-settings returns the list, and PATCH /exchange-settings/:currency sets one. Per-currency manual-rate ceiling (USD ~10,000, DJF ~100) replaces the old fixed bound. Adds a spec exercising the multi-currency CBE parse and the USD<->DJF pivot against a fixture payload. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- ...50000000000-ExchangeSettingsPerCurrency.ts | 42 +++++++ .../dto/update-exchange-setting.dto.ts | 13 +- .../entities/exchange-setting.entity.ts | 14 ++- .../exchange-module-options.ts | 6 +- .../exchange-multi-currency.spec.ts | 102 +++++++++++++++ .../exchange-settings.controller.ts | 69 ++++++++--- .../exchange-settings.service.ts | 116 ++++++++++-------- 7 files changed, 285 insertions(+), 77 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3850000000000-ExchangeSettingsPerCurrency.ts create mode 100644 apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts diff --git a/apps/edr-freight-api/src/migrations/3850000000000-ExchangeSettingsPerCurrency.ts b/apps/edr-freight-api/src/migrations/3850000000000-ExchangeSettingsPerCurrency.ts new file mode 100644 index 000000000..07444c0a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-ExchangeSettingsPerCurrency.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `exchange_settings` was a single-row table holding the USD→ETB fallback + * only. Restructures it to one row per currency so DJF (and any future + * currency) gets its own fallback rate, source and sync timestamp instead of + * a parallel column per currency. + */ +export class ExchangeSettingsPerCurrency3850000000000 implements MigrationInterface { + name = 'ExchangeSettingsPerCurrency3850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5); + `); + // The single pre-existing row was always the USD→ETB fallback. + await queryRunner.query(` + UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency + ON freight.exchange_settings (currency) WHERE deleted_at IS NULL; + `); + // Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so + // pricing has a usable fallback before the first successful CBE fetch. + await queryRunner.query(` + INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at) + SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF'); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`); + } +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts index 98e87007c..4343732b7 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -1,13 +1,14 @@ -import { IsNumber, Max, Min } from "class-validator"; +import { IsNumber, Min } from "class-validator"; /** - * Operator-set USD→ETB fallback. Bounded well outside any plausible published - * rate but far short of a fat-fingered magnitude error — this value multiplies - * real invoice amounts whenever CBE is unreachable. + * Operator-set X→ETB fallback for one currency. The upper bound is enforced + * per currency in the controller (see `RATE_BOUNDS`) rather than here, since + * USD's plausible range (~100-300) and DJF's (~0.5-2) differ by two orders of + * magnitude — this value multiplies real invoice amounts whenever CBE is + * unreachable. */ export class UpdateExchangeSettingDto { @IsNumber({ maxDecimalPlaces: 6 }) - @Min(1) - @Max(10_000) + @Min(0.000001) fallbackRate!: number; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts index 1e1f4ad66..e1fc99780 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -8,14 +8,18 @@ import { Column, Entity } from "typeorm"; export type ExchangeFallbackSource = "AUTO" | "MANUAL"; /** - * Single-row table holding the USD→ETB fallback used when the CBE endpoint is - * unreachable. The live CBE rate always wins; this is only consulted on - * failure, and is overwritten by every successful fetch so it tracks the last - * known good rate. + * One row per foreign currency, holding the X→ETB fallback used when the CBE + * endpoint is unreachable for that currency. The live CBE rate always wins; + * this is only consulted on failure, and is overwritten by every successful + * fetch so it tracks the last known good rate. */ @Entity({ schema: "freight", name: "exchange_settings" }) export class ExchangeSetting extends BaseEntity { - /** USD→ETB rate served while the CBE endpoint is failing. */ + /** The foreign currency this row's fallback applies to, e.g. `USD`, `DJF`. */ + @Column({ name: "currency", type: "varchar", length: 5 }) + currency!: string; + + /** currency→ETB rate served while the CBE endpoint is failing for it. */ @Column({ name: "fallback_rate", type: "numeric", diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts index fb126f969..a52db2010 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -6,7 +6,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service"; /** * The app's single `ExchangeModule` registration shape: CBE endpoint config - * from `app.cbeExchange`, with the DB-backed fallback wired in. + * from `app.cbeExchange`, with the DB-backed per-currency fallback wired in. * * `ExchangeModule` is registered per-feature-module (bookings, contracts, * warehouses), so this keeps the three call sites identical rather than @@ -20,8 +20,8 @@ export function registerExchangeModule(): DynamicModule { settings: ExchangeSettingsService, ): ExchangeOptions => ({ ...(config.get("app.cbeExchange") ?? {}), - loadFallbackRate: () => settings.loadFallbackRate(), - saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + loadFallbackRate: (code) => settings.loadFallbackRate(code), + saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate), }), }); } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts new file mode 100644 index 000000000..e18bc830e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts @@ -0,0 +1,102 @@ +import { CbeExchangeProvider, ExchangeService } from '@edr/api-common'; + +/** + * The CBE feed quotes every currency it publishes against ETB in one fetch — + * this is a fixture of that shape (trimmed to USD + DJF, the two the app + * actually reads). Verified live against the real feed on 2026-09-04. + */ +const CBE_FIXTURE = [ + { + Date: '2026-09-04', + ExchangeRate: [ + { + transactionalSelling: 163.4365, + transactionalBuying: 160.2319, + currency: { CurrencyCode: 'USD' }, + }, + { + transactionalSelling: 0.9203, + transactionalBuying: 0.9022, + currency: { CurrencyCode: 'DJF' }, + }, + // CBE publishes 0 for a currency it isn't quoting cash-selling that + // day — must not be picked up as a usable rate. + { transactionalSelling: 0, currency: { CurrencyCode: 'ZZZ' } }, + ], + }, +]; + +function mockFetchOnce(payload: unknown): jest.Mock { + const fn = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(payload), + }); + (global as unknown as { fetch: typeof fetch }).fetch = fn as never; + return fn; +} + +describe('CbeExchangeProvider — multi-currency', () => { + it('parses every quoted currency out of one fetch, not just USD', async () => { + const fetchMock = mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + const usdToEtb = await provider.getBaseRate({ from: 'USD', to: 'ETB' }); + const djfToEtb = await provider.getBaseRate({ from: 'DJF', to: 'ETB' }); + + expect(usdToEtb).toBeCloseTo(163.4365); + expect(djfToEtb).toBeCloseTo(0.9203); + // Both rates came from the SAME cached fetch — one HTTP call serves + // every currency, not one per currency. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('skips a currency CBE reports as 0 (unquoted that day) — throws with no fallback configured', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ZZZ' as never, to: 'ETB' })).rejects.toThrow( + /No CBE rate available for ZZZ/, + ); + }); + + it('only ever answers for X→ETB — everything else is derived upstream', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ETB', to: 'USD' })).resolves.toBeNull(); + await expect(provider.getBaseRate({ from: 'USD', to: 'DJF' })).resolves.toBeNull(); + }); +}); + +describe('ExchangeService — USD↔DJF pivot', () => { + it('derives USD→DJF by pivoting through ETB, the provider’s base currency', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('USD', 'DJF'); + + // 163.4365 / 0.9203 — same arithmetic as converting via ETB by hand. + expect(rate).toBeCloseTo(163.4365 / 0.9203, 4); + expect(rate).toBeCloseTo(177.59, 1); + }); + + it('derives the inverse, DJF→USD, from the same pivot', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('DJF', 'USD'); + + expect(rate).toBeCloseTo(0.9203 / 163.4365, 6); + }); + + it('getRateTable resolves every supported currency into the target in one call', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const fx = await service.getRateTable('DJF'); + + expect(fx.DJF).toBe(1); + expect(fx.USD).toBeCloseTo(163.4365 / 0.9203, 4); + expect(fx.ETB).toBeCloseTo(1 / 0.9203, 4); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 001fc90d2..0e0736561 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { CurrentUser } from "@edr/api-common"; +import { CURRENCY_CODES, CurrencyCode, CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff } from "../../common/booking-guards"; @@ -8,6 +8,31 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; import { ExchangeSettingsService } from "./exchange-settings.service"; +/** + * Sane manual-rate ceiling per currency — bounded well outside any plausible + * published rate but far short of a fat-fingered magnitude error. USD trades + * in the hundreds (ETB per USD); DJF trades under 2 (ETB per DJF, since DJF + * itself is worth roughly 1/177th of a USD). + */ +const RATE_BOUNDS: Record = { + ETB: 1, + USD: 10_000, + DJF: 100, +}; + +const FOREIGN_CURRENCIES = CURRENCY_CODES.filter((c) => c !== "ETB"); + +function assertSupportedCurrency(currency: string): (typeof FOREIGN_CURRENCIES)[number] { + const code = currency?.toUpperCase(); + const match = FOREIGN_CURRENCIES.find((c) => c === code); + if (!match) { + throw new BadRequestException( + `Unsupported currency "${currency}" — must be one of ${FOREIGN_CURRENCIES.join(", ")}`, + ); + } + return match; +} + @ApiTags("exchange-settings") @ApiBearerAuth() @Controller("exchange-settings") @@ -17,37 +42,51 @@ export class ExchangeSettingsController { @Get() @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]) @ApiOperation({ - summary: "Current USD→ETB fallback rate and CBE feed health", + summary: "Current X→ETB fallback rates and CBE feed health, one entry per currency", }) - async get() { - const setting = await this.service.get(); - const status = this.service.getFeedStatus(); + async list() { + const settings = await this.service.list(); + const byCurrency = new Map(settings.map((s) => [s.currency, s])); - return { - fallbackRate: setting.fallbackRate, - fallbackSource: setting.fallbackSource, - lastSyncedAt: setting.lastSyncedAt, - updatedById: setting.updatedById, - feed: status, - }; + return FOREIGN_CURRENCIES.map((code) => { + const setting = byCurrency.get(code); + return { + currency: code, + fallbackRate: setting?.fallbackRate ?? null, + fallbackSource: setting?.fallbackSource ?? null, + lastSyncedAt: setting?.lastSyncedAt ?? null, + updatedById: setting?.updatedById ?? null, + feed: this.service.getFeedStatus(code), + }; + }); } - @Patch() + @Patch(":currency") @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: - "Set the USD→ETB fallback by hand (used only while CBE is unreachable)", + "Set a currency's X→ETB fallback by hand (used only while CBE is unreachable)", }) async update( + @Param("currency") currency: string, @Body() dto: UpdateExchangeSettingDto, @CurrentUser() user: TCurrentUser, ) { + const code = assertSupportedCurrency(currency); + if (dto.fallbackRate > RATE_BOUNDS[code]) { + throw new BadRequestException( + `Fallback rate ${dto.fallbackRate} is outside the accepted range for ${code} (max ${RATE_BOUNDS[code]})`, + ); + } + const updated = await this.service.setManualRate( + code, dto.fallbackRate, user?.id ?? null, ); return { + currency: updated.currency, fallbackRate: updated.fallbackRate, fallbackSource: updated.fallbackSource, lastSyncedAt: updated.lastSyncedAt, diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index e0b670292..df36821bc 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -1,16 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { CurrencyCode } from "@edr/api-common"; import { Repository } from "typeorm"; import { ExchangeSetting } from "./entities/exchange-setting.entity"; /** - * Rate used before the row exists and before the first successful CBE fetch — - * the CBE USD transactional selling rate on 2026-08-04. + * Rate used before a currency's row exists and before its first successful + * CBE fetch. USD is the CBE transactional selling rate on 2026-08-04; DJF is + * the CBE transactional selling rate on 2026-09-04 (CBE started being read + * for DJF then). */ -const SEED_FALLBACK_RATE = 162.4165; +const SEED_FALLBACK_RATES: Partial> = { + USD: 162.4165, + DJF: 0.9203, +}; -/** Health of the CBE feed, as surfaced to the backoffice. */ +/** Health of the CBE feed for one currency, as surfaced to the backoffice. */ export interface ExchangeFeedStatus { /** Rate most recently observed, whatever its source. */ rate: number | null; @@ -22,9 +28,17 @@ export interface ExchangeFeedStatus { lastError: string | null; } +const EMPTY_FEED_STATUS: ExchangeFeedStatus = { + rate: null, + source: null, + lastSuccessAt: null, + lastError: null, +}; + /** - * Owns the single `exchange_settings` row: the USD→ETB fallback used when the - * CBE endpoint is unreachable. + * Owns the `exchange_settings` rows — one per foreign currency (USD, DJF) — + * each holding the currency→ETB fallback used when the CBE endpoint is + * unreachable for it. * * The live CBE rate is always preferred. This value is only read on failure, * and every successful fetch overwrites it, so it tracks the last known good @@ -35,107 +49,113 @@ export class ExchangeSettingsService { private readonly logger = new Logger(ExchangeSettingsService.name); /** - * Feed health, recorded from the exchange provider's callbacks rather than - * read off an injected `ExchangeService`. The provider is registered several - * times (bookings, contracts, warehouses), so no single instance sees every - * fetch — and injecting one here would be circular, since those - * registrations inject *this* service. + * Feed health per currency, recorded from the exchange provider's + * callbacks rather than read off an injected `ExchangeService`. The + * provider is registered several times (bookings, contracts, warehouses), + * so no single instance sees every fetch — and injecting one here would be + * circular, since those registrations inject *this* service. */ - private feed: ExchangeFeedStatus = { - rate: null, - source: null, - lastSuccessAt: null, - lastError: null, - }; + private feed = new Map(); constructor( @InjectRepository(ExchangeSetting) private readonly repository: Repository, ) {} - /** Health of the CBE feed as last observed by any provider instance. */ - getFeedStatus(): ExchangeFeedStatus { - return { ...this.feed }; + /** Health of the CBE feed for `code` as last observed by any provider instance. */ + getFeedStatus(code: CurrencyCode): ExchangeFeedStatus { + return { ...(this.feed.get(code) ?? EMPTY_FEED_STATUS) }; } - /** The settings row, created at the seed rate on first access. */ - async get(): Promise { - const existing = await this.repository.findOne({ where: {} }); + /** The settings row for `code`, created at the seed rate on first access. */ + async get(code: CurrencyCode): Promise { + const existing = await this.repository.findOne({ where: { currency: code } }); if (existing) return existing; return this.repository.save( this.repository.create({ - fallbackRate: SEED_FALLBACK_RATE, + currency: code, + fallbackRate: SEED_FALLBACK_RATES[code] ?? 1, fallbackSource: "AUTO", lastSyncedAt: null, }), ); } + /** Every currency's settings row, for the backoffice settings list. */ + async list(): Promise { + return this.repository.find({ order: { currency: "ASC" } }); + } + /** - * Reads the stored fallback for the exchange provider. Returns `null` on any - * failure so the provider falls through to its own static default rather - * than propagating a database error into a pricing call. + * Reads the stored fallback for `code`, for the exchange provider. Returns + * `null` on any failure so the provider falls through to its own static + * default rather than propagating a database error into a pricing call. */ - async loadFallbackRate(): Promise { + async loadFallbackRate(code: CurrencyCode): Promise { // Only reached when the live fetch failed, so this call is itself the - // signal that the feed is down. + // signal that the feed is down for this currency. try { - const { fallbackRate } = await this.get(); + const { fallbackRate } = await this.get(code); const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; - this.feed = { - ...this.feed, - rate: usable ? fallbackRate : this.feed.rate, + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { + ...previous, + rate: usable ? fallbackRate : previous.rate, source: "stored", - lastError: this.feed.lastError ?? "CBE endpoint unreachable", - }; + lastError: previous.lastError ?? "CBE endpoint unreachable", + }); return usable ? fallbackRate : null; } catch (err) { const message = (err as Error).message; - this.feed = { ...this.feed, source: "stored", lastError: message }; - this.logger.warn(`Could not read stored exchange fallback: ${message}`); + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { ...previous, source: "stored", lastError: message }); + this.logger.warn( + `Could not read stored exchange fallback for ${code}: ${message}`, + ); return null; } } /** - * Records a freshly fetched live rate as the new fallback. Marked `AUTO`, - * overwriting a manual entry — a manual rate is a stopgap for while CBE is - * down, so a working CBE feed takes precedence again. + * Records a freshly fetched live rate as the new fallback for `code`. + * Marked `AUTO`, overwriting a manual entry — a manual rate is a stopgap + * for while CBE is down, so a working CBE feed takes precedence again. */ - async saveFallbackRate(rate: number): Promise { + async saveFallbackRate(code: CurrencyCode, rate: number): Promise { // Only called after a successful fetch, so the feed is confirmed healthy. - this.feed = { + this.feed.set(code, { rate, source: "live", lastSuccessAt: new Date().toISOString(), lastError: null, - }; + }); - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "AUTO", lastSyncedAt: new Date(), updatedById: null, }); - this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`); + this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/${code}`); } /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ async setManualRate( + code: CurrencyCode, rate: number, updatedById?: string | null, ): Promise { - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "MANUAL", updatedById: updatedById ?? null, }); this.logger.warn( - `Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`, + `Exchange fallback for ${code} set manually to ${rate} ETB/${code} by ${updatedById ?? "unknown user"}`, ); - return this.get(); + return this.get(code); } } From 3734ca3897210325f729624d220bcd263ea6442e Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 11:52:37 +0300 Subject: [PATCH 09/20] feat(freight-api): add DJF as a supported currency Adds DJF to freight.payments_currency_enum (migration 3860000000000, alone in its own migration per Postgres's ADD VALUE-in-a-transaction restriction) and to manual_payment_settings (djf_enabled column, migration 3870000000000, default true). Replaces the binary ETB/USD assumptions that would have silently mispriced or discarded a DJF booking: - booking-pricing / contract-pricing: usdToEtb scalar -> a rate table keyed by source currency (ExchangeService.getRateTable), so a contract-frozen rate converts into whatever currency the booking is paid in instead of being dropped when neither leg is ETB or USD. - warehouse-fee / booking-wagon-cancellation: normalizeCurrency no longer coerces anything non-ETB to USD. - additional-charge: convertAmount no longer bails out for a currency that isn't literally ETB or USD. - manual-payment-settings: isEnabled/enabledCurrencies cover DJF. Widens the three @IsIn(['ETB','USD']) DTO validators, and adds DJF to the export/report currency filter option lists. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- .../3860000000000-AddDjfPaymentsCurrency.ts | 26 ++++++++ ...870000000000-AddDjfManualPaymentSetting.ts | 23 +++++++ .../modules/billing/dto/filter-invoice.dto.ts | 2 +- .../bookings/additional-charge.service.ts | 16 +++-- .../bookings/booking-pricing.service.spec.ts | 15 +++-- .../bookings/booking-pricing.service.ts | 62 ++++++++++--------- .../booking-wagon-cancellation.service.ts | 19 ++++-- .../contracts/contract-pricing.service.ts | 8 +-- .../dto/create-booking-request.dto.ts | 2 +- .../contracts/shipment-currency.spec.ts | 27 +++++--- .../exports/datasets/contracts.dataset.ts | 1 + .../exports/datasets/invoices.dataset.ts | 1 + .../exports/datasets/payments.dataset.ts | 1 + .../dto/update-manual-payment-setting.dto.ts | 5 ++ .../entities/manual-payment-setting.entity.ts | 4 ++ .../manual-payment-settings.service.ts | 30 ++++++--- .../payment/entities/payment.entity.ts | 4 +- .../modules/reports/revenue-classification.ts | 1 + .../src/modules/warehouses/dto/invoice.dto.ts | 2 +- .../warehouses/warehouse-fee.service.ts | 9 ++- 20 files changed, 183 insertions(+), 75 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts create mode 100644 apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts diff --git a/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts new file mode 100644 index 000000000..8d0f52110 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds DJF to `freight.payments_currency_enum` — the only currency column in + * the schema backed by a real Postgres enum (every other currency column is + * a plain varchar and needed no migration). + * + * This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD + * VALUE` cannot be used within the same transaction that added it (Postgres + * restriction, still true on PG 12+), and migrations here run one-per- + * transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert + * that writes 'DJF' into `payments.currency` to this file. + */ +export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface { + name = 'AddDjfPaymentsCurrency3860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`); + } + + public async down(): Promise { + // Postgres cannot drop a single enum value. Reverting would require + // recreating the type and every dependent column/constraint — out of + // scope for a currency addition; leave it in place. + } +} diff --git a/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts new file mode 100644 index 000000000..6353e8664 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the DJF toggle to `manual_payment_settings`, alongside the existing + * `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF + * invoices are bank-transfer-settleable from day one. + */ +export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface { + name = 'AddDjfManualPaymentSetting3870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings + ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index fa00fb521..6d416a7e3 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -126,7 +126,7 @@ export class FilterInvoiceDto { @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) - @IsIn(["USD", "ETB"]) + @IsIn(["ETB", "USD", "DJF"]) currency?: "USD" | "ETB"; @ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." }) diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index 012d5f8db..86cad4277 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -1,7 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -291,20 +291,24 @@ export class AdditionalChargeService { } /** - * Amount converted to the other of ETB/USD, via the existing shared + * Amount converted to a second reference currency, via the existing shared * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` * rate) — same mechanism `booking-wagon-cancellation.service.ts` and - * warehouse fee pricing already use. Null on anything but ETB/USD, or if + * warehouse fee pricing already use. ETB converts to USD and vice versa + * (unchanged behaviour); any other supported currency (DJF) converts to + * USD, the system's pivot currency. Null on an unsupported currency, or if * the rate feed is down — this is a display convenience, not the payable * amount, so a failure here must never break the charge list. */ private async convertAmount( charge: AdditionalCharge, ): Promise<{ amount: number; currency: string } | null> { - if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; - const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; + const from = charge.currency?.toUpperCase(); + if (!(CURRENCY_CODES as readonly string[]).includes(from ?? '')) return null; + const source = from as CurrencyCode; + const target: CurrencyCode = source === 'ETB' ? 'USD' : source === 'USD' ? 'ETB' : 'USD'; try { - const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); + const amount = await this.exchangeService.convert(Number(charge.amount), source, target); return { amount: Math.round(amount * 100) / 100, currency: target }; } catch (err) { this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index ba1aaa875..742088da9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -38,7 +38,7 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let exchangeService: { getRate: jest.Mock }; + let exchangeService: { getRate: jest.Mock; getRateTable: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; @@ -47,6 +47,13 @@ describe('BookingPricingService — domestic corridor', () => { }; exchangeService = { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + // Delegates to `getRate` so a test that reassigns + // `exchangeService.getRate.mockResolvedValue(...)` gets a consistent + // rate table without also having to touch this mock. + getRateTable: jest.fn(async (target: string) => { + const rate = await exchangeService.getRate('USD', target); + return { ETB: rate, USD: rate, DJF: rate }; + }), }; service = new BookingPricingService( @@ -324,7 +331,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking })), } as never, { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -572,7 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => { } as never, { findById: jest.fn() } as never, { findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -707,7 +714,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { })), } as never, { findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 29104bbce..e654889eb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -8,7 +8,7 @@ import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { round2 } from '../billing/invoice-settlement.util'; -import { ExchangeService } from '@edr/api-common'; +import { CurrencyCode, ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -143,8 +143,9 @@ export class BookingPricingService { const ruleResult = await this.ruleEngineService.evaluate(evalInput); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; // H15: a booking created under a contract prices from that contract's FROZEN // rate snapshots (the agreed rates), not the live rate of the day. Loaded @@ -213,7 +214,7 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, fx); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -570,8 +571,9 @@ export class BookingPricingService { }> { const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; const isBulk = booking.freightType === 'BULK'; const rateType = @@ -608,7 +610,7 @@ export class BookingPricingService { frozenRates, container.containerTypeId, paymentCurrency, - usdToEtb, + fx, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -698,7 +700,7 @@ export class BookingPricingService { const unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk - ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb) + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, fx) : null; let amount: number; let unitAmount: number; @@ -771,8 +773,9 @@ export class BookingPricingService { const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; const containerCount = evalInput.containers.reduce( (sum, c) => sum + Number(c.quantity || 0), @@ -824,7 +827,7 @@ export class BookingPricingService { frozenRates, leg.rateType, paymentCurrency, - usdToEtb, + fx, ); let amount: number; let unitAmount: number; @@ -1021,13 +1024,18 @@ export class BookingPricingService { * drifted to.) Grandfathered ETB contracts convert the other way for the same * reason. * + * `fx` is a rate table converting FROM each source currency INTO the + * booking's currency (see `ExchangeService.getRateTable`) — a snapshot can + * be frozen in USD or (grandfathered) ETB, and the booking can be paid in + * any supported currency, so a scalar USD→ETB rate is no longer enough. + * * Returns null only when there is no snapshot or its price is unusable. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; @@ -1035,15 +1043,11 @@ export class BookingPricingService { if (!(unitPrice >= 0)) return null; if (snap.currency === bookingCurrency) return snap; - // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price. - if (!(usdToEtb > 0)) return null; - const converted = - snap.currency === 'USD' && bookingCurrency === 'ETB' - ? round2(unitPrice * usdToEtb) - : snap.currency === 'ETB' && bookingCurrency === 'USD' - ? unitPrice / usdToEtb - : null; - if (converted == null) return null; + // A rate of 0/NaN (an unpriced or unsupported source currency) would + // silently zero the price. + const rate = fx[snap.currency]; + if (!(rate > 0)) return null; + const converted = round2(unitPrice * rate); // A copy — the snapshot rows are shared across the pricing pass. return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { @@ -1061,7 +1065,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -1071,7 +1075,7 @@ export class BookingPricingService { return null; } if (!sizeFt) return null; - return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb); + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, fx); } /** @@ -1093,9 +1097,9 @@ export class BookingPricingService { const usedRates: Rate[] = []; const blocked: string[] = []; const currency = booking.paymentCurrency; - const isEtb = currency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + const fx = await this.exchangeService.getRateTable(currency as CurrencyCode); + const usdToEtb = fx['USD']; + const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToEtb)); // An Ethiopian-side-only customs service prices off its own rate; the // contract froze its snapshots under the matching code prefix. Resolved by @@ -1132,7 +1136,7 @@ export class BookingPricingService { const hasPerSizeSnapshot = frozenRates?.has(`${customsType}_20FT`) || frozenRates?.has(`${customsType}_40FT`); - const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); + const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, fx); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -1161,7 +1165,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb) + ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, fx) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1196,7 +1200,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); + const frozen = this.frozenRateByCode(frozenRates, customsType, currency, fx); const live = (booking.cargoTypeId ? onLeg.find( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 983564a51..a35e29663 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -8,7 +8,7 @@ import { NotFoundException, } from '@nestjs/common'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -114,6 +114,15 @@ interface PricedFee { * The cycle is repeatable by construction: the rebooked booking is a normal * PAID booking, so it can itself be partially cancelled again. */ + +/** Validates a stored currency string against the supported set, defaulting to USD. */ +function toCurrencyCode(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; +} + @Injectable() export class BookingWagonCancellationService { private readonly logger = new Logger(BookingWagonCancellationService.name); @@ -1697,10 +1706,10 @@ export class BookingWagonCancellationService { */ private async priceFee(booking: Booking, cut: RequestedCut): Promise { const raw = await this.priceFeeInRateCurrency(booking, cut); - // Bill in the booking's own currency (rates are configured in USD; ETB - // bookings pay ETB) — same USD→ETB conversion booking pricing applies. - const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; - const from = raw.currency === 'ETB' ? 'ETB' : 'USD'; + // Bill in the booking's own currency (rates are configured in USD; a + // non-USD booking converts) — same conversion booking pricing applies. + const target = toCurrencyCode(booking.paymentCurrency); + const from = toCurrencyCode(raw.currency); if (from === target) return raw; const fx = await this.exchangeService.getRate(from, target); return { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 04be6460f..1367e8c8a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -3,7 +3,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { round2 } from '../billing/invoice-settlement.util'; -import { ExchangeService } from '@edr/api-common'; +import { CurrencyCode, ExchangeService } from '@edr/api-common'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; @@ -95,9 +95,9 @@ export class ContractPricingService { (r) => !r.shippingLineCompanyId, ); const currency = contract.paymentCurrency; - const isEtb = currency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + const usdToTarget = + currency === 'USD' ? 1 : await this.exchangeService.getRate('USD', currency as CurrencyCode); + const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToTarget)); const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 9f596bbef..c0d4e65ee 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -98,7 +98,7 @@ export class CreateBookingRequestDto { 'Billing currency for the shipment GL will book. Intercity is always ETB.', }) @IsOptional() - @IsIn(['ETB', 'USD']) + @IsIn(['ETB', 'USD', 'DJF']) paymentCurrency?: string; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts index 7bd109430..c6edd436f 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => const frozenByCode = ( snap: ContractRateSnapshot | null, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): ContractRateSnapshot | null => ( BookingPricingService.prototype as unknown as { @@ -28,14 +28,14 @@ const frozenByCode = ( m: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ) => ContractRateSnapshot | null; } ).frozenRateByCode( snap ? new Map([['CONTAINER_20FT', snap]]) : null, 'CONTAINER_20FT', bookingCurrency, - usdToEtb, + fx, ); describe('per-shipment billing currency', () => { @@ -61,25 +61,34 @@ describe('frozen contract rate in the booking currency', () => { it('converts a USD snapshot for an ETB booking instead of dropping it', () => { // The old behaviour returned null here, which silently re-priced the // booking at live rates and lost the agreed contract price. - expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 150 })?.unitPrice).toBe(60_000); }); it('converts a grandfathered ETB snapshot back for a USD booking', () => { - expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400); + expect(frozenByCode(snapshot('ETB', 60_000), 'USD', { ETB: 1 / 150 })?.unitPrice).toBe(400); + }); + + it('converts a USD snapshot for a DJF booking via the USD->DJF rate', () => { + // 177.6 ETB/DJF pivot: USD->DJF = usdToEtb / djfToEtb = 150 / 0.845. + expect(frozenByCode(snapshot('USD', 400), 'DJF', { USD: 177.6 })?.unitPrice).toBe(71_040); }); it('passes a matching-currency snapshot through untouched', () => { const snap = snapshot('USD', 400); - expect(frozenByCode(snap, 'USD', 1)).toBe(snap); + expect(frozenByCode(snap, 'USD', { USD: 1 })).toBe(snap); }); it('refuses to price off an unusable exchange rate', () => { // Converting with 0 would zero the whole line. - expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull(); - expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: Number.NaN })).toBeNull(); + }); + + it('refuses to price off a currency the rate table has no entry for', () => { + expect(frozenByCode(snapshot('USD', 400), 'DJF', {})).toBeNull(); }); it('returns null when there is no snapshot', () => { - expect(frozenByCode(null, 'ETB', 150)).toBeNull(); + expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts index 51e99614a..d3b24dd43 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts @@ -112,6 +112,7 @@ export const contractsDataset: ExportDataset = { { key: 'paymentCurrency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'serviceTypeId', label: 'Service type', type: 'text' }, // Routes are one-to-many on contract_routes, so these filter via EXISTS diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index d4d635f6f..4afe28771 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -129,6 +129,7 @@ export const invoicesDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'minAmount', label: 'Min total', type: 'text' }, { key: 'maxAmount', label: 'Max total', type: 'text' }, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts index 59739823c..e7a9963f2 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts @@ -89,6 +89,7 @@ export const paymentsDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'search', label: 'Search order or transaction ID', type: 'text' }, ], diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts index 971de03cb..39d4757e7 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() usdEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" }) + @IsOptional() + @IsBoolean() + djfEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts index a18f97279..862456241 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "usd_enabled", type: "boolean", default: true }) usdEnabled!: boolean; + /** Manual settlement allowed for DJF invoices. */ + @Column({ name: "djf_enabled", type: "boolean", default: true }) + djfEnabled!: boolean; + /** IAM user id of the last operator to change either toggle. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) updatedById?: string | null; diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts index efb43fa91..dc397cf79 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -4,16 +4,23 @@ import { Repository } from "typeorm"; import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; -/** The two currencies an invoice can be settled by hand in. */ -export type ManualPaymentCurrency = "ETB" | "USD"; +/** The currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = "ETB" | "USD" | "DJF"; + +const FIELD_BY_CURRENCY: Record = { + ETB: "etbEnabled", + USD: "usdEnabled", + DJF: "djfEnabled", +}; /** * Owns the single `manual_payment_settings` row: whether Finance may settle * invoices by hand, per currency. * * Defaults mirror how the platform behaved before the toggles existed — USD - * has always been bank-transfer-only so it starts ON; ETB manual settlement is - * the new capability and starts OFF, so enabling it is a deliberate act. + * and DJF have always been bank-transfer-capable so they start ON; ETB manual + * settlement is the new capability and starts OFF, so enabling it is a + * deliberate act. */ @Injectable() export class ManualPaymentSettingsService { @@ -30,7 +37,7 @@ export class ManualPaymentSettingsService { if (existing) return existing; return this.repository.save( - this.repository.create({ etbEnabled: false, usdEnabled: true }), + this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }), ); } @@ -40,31 +47,34 @@ export class ManualPaymentSettingsService { const enabled: ManualPaymentCurrency[] = []; if (setting.etbEnabled) enabled.push("ETB"); if (setting.usdEnabled) enabled.push("USD"); + if (setting.djfEnabled) enabled.push("DJF"); return enabled; } /** Whether one currency may be settled by hand right now. */ async isEnabled(currency: string | null | undefined): Promise { const upper = currency?.toUpperCase(); - if (upper !== "ETB" && upper !== "USD") return false; + const field = FIELD_BY_CURRENCY[upper as ManualPaymentCurrency]; + if (!field) return false; const setting = await this.get(); - return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + return setting[field]; } - /** Flip either toggle; an omitted field leaves that currency unchanged. */ + /** Flip any toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, updatedById?: string | null, ): Promise { const current = await this.get(); await this.repository.update(current.id, { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); this.logger.warn( - `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`, ); return updated; } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 0cf3b886c..b5cdf582f 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -5,7 +5,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ type PaymentType = string type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill" -type Currency = "ETB" | "USD" +type Currency = "ETB" | "USD" | "DJF" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @Entity({ schema: 'freight', name: 'payments' }) @@ -25,7 +25,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] }) method!: PaymentMethod - @Column({ type: "enum", enum: ["ETB", "USD"] }) + @Column({ type: "enum", enum: ["ETB", "USD", "DJF"] }) currency!: Currency @Column({ type: "numeric" }) diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 550475599..d50b20e9c 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = { options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ], }; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 6d7084a96..6c22d37b5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -14,7 +14,7 @@ export class GenerateInvoiceDto { @ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' }) @IsOptional() - @IsIn(['ETB', 'USD']) + @IsIn(['ETB', 'USD', 'DJF']) billingCurrency?: 'ETB' | 'USD'; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index c59cd0617..80a81b13a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; @@ -430,8 +430,11 @@ export class WarehouseFeeService { }; } - private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { - return currency === 'ETB' ? 'ETB' : 'USD'; + private normalizeCurrency(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; } private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise { From d8a12939ae6604d6037956caafc4f4ea21cb4562 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 11:52:48 +0300 Subject: [PATCH 10/20] feat(overview): add DJF revenue and payment KPIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The billing KPI queries fan out per currency with a FILTER (WHERE payment.currency = '...') per column rather than a generic GROUP BY, so DJF needs an explicit third variant at each of the 7 query sites (revenueMtd*, amount*, revenue* pairs) plus the matching DTO fields. The already-generic queries (getBookingsByCurrency, getRevenueByCurrency, company-dashboard's sumPaidSpendByCurrency) needed no change — they GROUP BY currency and pick up DJF on their own. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- .../overview/dto/overview-response.dto.ts | 5 ++ .../modules/overview/overview.repository.ts | 56 ++++++++++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 1bd22c7c0..526dbafe6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto { export class OverviewBillingKpisDto { @ApiProperty() revenueMtdEtb!: number; @ApiProperty() revenueMtdUsd!: number; + @ApiProperty() revenueMtdDjf!: number; @ApiProperty() pendingPayments!: number; @ApiProperty() successfulPaymentsMtd!: number; } @@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto { @ApiProperty({ example: '2026-06-01' }) date!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewRecentBookingDto { @@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto { @ApiProperty() bookingsCreated!: number; @ApiProperty() revenueEtb!: number; @ApiProperty() revenueUsd!: number; + @ApiProperty() revenueDjf!: number; @ApiProperty() tons!: number; } @@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto { @ApiProperty() label!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewTonsTrendPointDto { @@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto { @ApiProperty() freightType!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewHeatmapCellDto { diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 6908498bb..d3100df9e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -265,6 +265,7 @@ export class OverviewRepository { async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; + revenueMtdDjf: number; pendingPayments: number; successfulPaymentsMtd: number; }> { @@ -279,6 +280,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueMtdUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueMtdDjf", + ) .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .where("payment.status = :status", { status: "success" }) .andWhere( @@ -298,6 +303,7 @@ export class OverviewRepository { return { revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0), pendingPayments, successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), }; @@ -370,7 +376,7 @@ export class OverviewRepository { days: number, dirs?: string[], offsetDays = 0, - ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -386,6 +392,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, @@ -394,12 +404,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") - .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ date: row.date, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -510,7 +521,7 @@ export class OverviewRepository { async getPaymentsByMethod( dirs?: string[], ): Promise< - { method: string; count: number; amountEtb: number; amountUsd: number }[] + { method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository @@ -525,6 +536,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`, + "amountDjf", + ) .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") @@ -533,6 +548,7 @@ export class OverviewRepository { count: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -540,6 +556,7 @@ export class OverviewRepository { count: Number(row.count), amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -580,6 +597,7 @@ export class OverviewRepository { bookingsCreated: number; revenueEtb: number; revenueUsd: number; + revenueDjf: number; tons: number; }> { const bookingScope = directionScopeSql("booking.trade_direction", dirs); @@ -605,13 +623,17 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( windowSql("COALESCE(payment.paid_at, payment.created_at)"), { days, offsetDays }, ) .andWhere(paymentScope.sql, paymentScope.params) - .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), + .getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(), this.cargoRepository .createQueryBuilder("cargo") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") @@ -626,6 +648,7 @@ export class OverviewRepository { bookingsCreated, revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0), + revenueDjf: Number(revenueRow?.revenueDjf ?? 0), tons: Number(tonsRow?.tons ?? 0), }; } @@ -634,7 +657,7 @@ export class OverviewRepository { async getRevenueByDirection( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -648,6 +671,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -656,12 +683,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.trade_direction IS NOT NULL") .groupBy("booking.trade_direction") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -669,7 +697,7 @@ export class OverviewRepository { async getRevenueByFreightType( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -683,6 +711,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -691,12 +723,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.freight_type IS NOT NULL") .groupBy("booking.freight_type") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -734,6 +767,7 @@ export class OverviewRepository { freightType: string; amountEtb: number; amountUsd: number; + amountDjf: number; }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); @@ -750,6 +784,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -765,6 +803,7 @@ export class OverviewRepository { freightType: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -772,6 +811,7 @@ export class OverviewRepository { freightType: row.freightType, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } From 5c59855b5be4e83fc1f58801dd73e940b300b1d5 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 11:53:01 +0300 Subject: [PATCH 11/20] feat(ui-common): shared currency formatting + DJF in CurrencySelector New lib/currency.ts is the single source of truth for currency display across both freight web apps: symbol, name and decimals per code (DJF is zero-decimal by convention, unlike ETB/USD). Portal's own currency lib and backoffice's ad-hoc formatters are rewired onto this in the next two commits instead of each guessing decimals. CurrencySelector gets an allowDjf prop mirroring the existing allowUsd gate, so import-shipment currency pickers can offer DJF the same way they offer USD. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- .../CurrencySelector/CurrencySelector.tsx | 24 ++++++++++- packages/ui-common/src/index.ts | 9 ++++ packages/ui-common/src/lib/currency.ts | 43 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 packages/ui-common/src/lib/currency.ts diff --git a/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx b/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx index f25a7b015..b24e18969 100644 --- a/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx +++ b/packages/ui-common/src/components/CurrencySelector/CurrencySelector.tsx @@ -1,10 +1,12 @@ import { Box, Text } from "@mantine/core"; import { Check } from "lucide-react"; +import type { SupportedCurrency } from "../../lib/currency"; + export interface CurrencySelectorProps { /** Selected currency code, or "" when none picked yet. */ value: string; - onChange: (currency: "USD" | "ETB") => void; + onChange: (currency: SupportedCurrency) => void; disabled?: boolean; /** Validation error shown under the cards. */ error?: string; @@ -14,6 +16,12 @@ export interface CurrencySelectorProps { * USD is settled by bank transfer, never through the online gateway. */ allowUsd?: boolean; + /** + * Offer DJF alongside ETB (and USD, if also allowed). Same import-only + * gating as `allowUsd` — DJF is settled both online (WAAFI / CAC Bank) and + * by bank transfer. + */ + allowDjf?: boolean; } const ETB_OPTION = { @@ -30,6 +38,13 @@ const USD_OPTION = { hint: "Paid by bank transfer — send the slip to Finance", } as const; +const DJF_OPTION = { + code: "DJF", + symbol: "Fdj", + name: "Djibouti Franc", + hint: "Pay online, or by bank transfer — send the slip to Finance", +} as const; + /** * Card-style USD/ETB billing-currency picker. Renders unselected when `value` * is "" so a required choice never looks pre-made. @@ -40,8 +55,13 @@ export function CurrencySelector({ disabled = false, error, allowUsd = false, + allowDjf = false, }: CurrencySelectorProps) { - const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION]; + const options = [ + ETB_OPTION, + ...(allowUsd ? [USD_OPTION] : []), + ...(allowDjf ? [DJF_OPTION] : []), + ]; return ( Date: Fri, 4 Sep 2026 11:53:19 +0300 Subject: [PATCH 12/20] feat(freight-portal): support DJF as a billing and payment currency lib/currency.ts re-exports the shared @edr/ui-common formatter (was a local implementation always forcing 2 decimals, wrong for DJF). Currency pickers (new-booking-form, new-contract-form, new-shipment Currency selector and schemas) offer DJF wherever USD is offered. The bigger piece: offline-payment.ts's isUsdCurrency/isUsdOfflineBooking assumed exactly two payment rails (ETB online, USD offline) and picked one. DJF supports BOTH, so it's replaced with independent canPayOnline/canPayOffline predicates, updated across the 5 call sites that gated the Pay button vs. the bank-transfer badge. PaymentMethodModal's WAAFI/CAC Bank entries (Djibouti gateways mislabeled USD-only) now list DJF, and a currency that matches no provider returns no providers instead of silently offering all of them (was returning every provider, including the ETB-only one, on any unmatched currency). Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- .../portal/src/lib/currency.ts | 27 +++--------- .../portal/src/pages/MyPortalPage/actions.ts | 7 ++- .../src/pages/billing/InvoiceDetailPage.tsx | 6 +-- .../portal/src/pages/billing/InvoicesList.tsx | 9 ++-- .../components/BookingPaymentPanel.tsx | 16 +++---- .../components/PaymentMethodModal.tsx | 43 +++++++++++-------- .../payment-currency-field.tsx | 6 ++- .../pages/bookings/new-booking-form/schema.ts | 8 +++- .../bookings/payments/offline-payment.ts | 35 +++++++++++---- .../bookings/payments/useBookingPayables.ts | 8 +++- .../src/pages/contracts/NewShipmentPage.tsx | 7 ++- .../contracts/NewShipmentRequestPage.tsx | 5 ++- .../contracts/new-contract-form/schema.ts | 7 ++- .../contracts/new-shipment-form/schema.ts | 2 +- 14 files changed, 111 insertions(+), 75 deletions(-) diff --git a/apps/edr-freight-web/portal/src/lib/currency.ts b/apps/edr-freight-web/portal/src/lib/currency.ts index d41b4edda..270882fc8 100644 --- a/apps/edr-freight-web/portal/src/lib/currency.ts +++ b/apps/edr-freight-web/portal/src/lib/currency.ts @@ -1,23 +1,8 @@ -/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */ -export type Currency = string; - -const SYMBOLS: Record = { - USD: "$", - ETB: "Br", - DJF: "DJF", -}; - /** - * Format a money amount with its currency symbol, e.g. `Br 12,500.00`. - * Unknown currency codes fall back to printing the raw code. + * Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). + * Re-exports the shared `@edr/ui-common` currency module so every currency + * gets the same symbol, decimals rule and formatting across both freight web + * apps — see that module for the source of truth. */ -export function formatCurrency( - amount: number, - currency: Currency = "ETB", -): string { - const symbol = SYMBOLS[currency] ?? currency; - return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; -} +export type { SupportedCurrency as Currency } from "@edr/ui-common"; +export { formatCurrency, currencySymbol, currencyDecimals, CURRENCY_CODES } from "@edr/ui-common"; diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts index b33017976..0360c579b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts @@ -1,6 +1,6 @@ import type { Freight } from "@edr/types"; -import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; +import { bookingCanPayOnline } from "@/pages/bookings/payments/offline-payment"; /** A pending customer action surfaced on the home "needs attention" card. */ export interface ActionItem { @@ -64,7 +64,10 @@ export function deriveActionItems( ? b.status === "FULLY_EXECUTED" : b.status === "SELECTED_FOR_BATCH"); if (canPay) { - const offlinePay = isUsdOfflineBooking(b); + // Online-only description unless the booking's currency has no online + // rail at all (USD) — DJF supports both, so it reads as a normal + // "payment due" like ETB rather than bank-transfer-only. + const offlinePay = !bookingCanPayOnline(b); items.push({ id: `pay-${b.id}`, kind: "pay", diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index b42a3cc8e..e3311d2f2 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -32,7 +32,7 @@ import { invoicesService } from "@/services/invoices.service"; import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; -import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment"; +import { canPayOffline, canPayOnline } from "@/pages/bookings/payments/offline-payment"; import { saveBlob } from "@/utils/download"; import { formatCurrency } from "@/lib/currency"; import { BORDER, INK, MUTED } from "../contracts/contract-ui"; @@ -232,7 +232,7 @@ export default function InvoiceDetailPage() { Receipt )} - {payable && !isUsdCurrency(invoice.currency) && ( + {payable && canPayOnline(invoice.currency) && ( + + {invalid && draft !== "" && ( +

Enter a rate greater than 0.

+ )} +

+ {setting.fallbackSource === "MANUAL" + ? "Set manually. The next successful CBE update will replace it." + : `Synced automatically from CBE (${formatTime(setting.lastSyncedAt)}).`} +

+ + + ); +} + +/** + * X→ETB fallback used when the CBE exchange-rate endpoint is unreachable for + * that currency — one row per foreign currency (USD, DJF). The live CBE rate + * always wins; every successful fetch overwrites the stored value, so it + * tracks the last known good rate on its own. Editing here is for a + * prolonged outage — the next successful CBE fetch replaces it. + */ +export default function ExchangeRateSettingsCard() { + const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery(); + return (
- Exchange rate (USD → ETB) + Exchange rates (→ ETB) - Rates come from the Commercial Bank of Ethiopia. The fallback - below is used only when CBE cannot be reached, and is refreshed - automatically after every successful update. + Rates come from the Commercial Bank of Ethiopia. Each fallback + below is used only when CBE cannot be reached for that currency, + and is refreshed automatically after every successful update.
-
- {invalid && draft !== "" && ( -

- Enter a rate between 1 and 10,000. -

- )} -

- {data?.fallbackSource === "MANUAL" - ? "Set manually. The next successful CBE update will replace it." - : `Synced automatically from CBE (${formatTime( - data?.lastSyncedAt ?? null, - )}).`} -

- + {(data ?? []).map((setting) => ( + + ))}
); diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx index 3fe5c1e1e..d7386259a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx @@ -17,11 +17,11 @@ import { useUpdateManualPaymentSettings, } from "@/hooks/useManualPaymentSettings"; -type Currency = "ETB" | "USD"; +type Currency = "ETB" | "USD" | "DJF"; const CURRENCIES: { code: Currency; - field: "etbEnabled" | "usdEnabled"; + field: "etbEnabled" | "usdEnabled" | "djfEnabled"; icon: typeof Banknote; title: string; description: string; @@ -42,6 +42,14 @@ const CURRENCIES: { description: "USD invoices are paid by bank transfer and have no online channel. Switching this off leaves USD customers with no way to be marked as paid.", }, + { + code: "DJF", + field: "djfEnabled", + icon: Landmark, + title: "Djibouti Franc (DJF) invoices", + description: + "DJF invoices can be paid online (Waafi / CAC Bank) or by bank transfer. Switch this off if Finance should stop accepting DJF payments by hand.", + }, ]; /** @@ -60,7 +68,9 @@ export default function ManualPaymentSettingsCard() { const { data, isLoading } = useManualPaymentSettingsQuery(); const update = useUpdateManualPaymentSettings(); - const noneEnabled = Boolean(data && !data.etbEnabled && !data.usdEnabled); + const noneEnabled = Boolean( + data && !data.etbEnabled && !data.usdEnabled && !data.djfEnabled, + ); return ( @@ -79,7 +89,7 @@ export default function ManualPaymentSettingsCard() {

- Both currencies are off — the Manual Payments list is empty and + Every currency is off — the Manual Payments list is empty and Finance cannot settle any invoice by hand.

diff --git a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts index 37b8dd254..d0591b775 100644 --- a/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/exchangeSettings.service.ts @@ -11,7 +11,7 @@ const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE; */ export type ExchangeRateSource = "live" | "stored"; -/** Health of the CBE exchange-rate feed. */ +/** Health of the CBE exchange-rate feed for one currency. */ export interface ExchangeFeedStatus { rate: number | null; source: ExchangeRateSource | null; @@ -19,25 +19,31 @@ export interface ExchangeFeedStatus { lastError: string | null; } -export interface ExchangeSettings { - fallbackRate: number; - /** `AUTO` when synced from CBE, `MANUAL` when set here. */ - fallbackSource: "AUTO" | "MANUAL"; +/** One currency's X→ETB fallback settings — the API returns one per foreign currency. */ +export interface ExchangeSetting { + currency: string; + fallbackRate: number | null; + /** `AUTO` when synced from CBE, `MANUAL` when set here. `null` before the row exists. */ + fallbackSource: "AUTO" | "MANUAL" | null; lastSyncedAt: string | null; updatedById: string | null; feed?: ExchangeFeedStatus; } export const exchangeSettingsService = { - get: async (): Promise => { - const response = await client.get>(BASE); + list: async (): Promise => { + const response = await client.get>(BASE); return unwrap(response.data); }, - setFallbackRate: async (fallbackRate: number): Promise => { - const response = await client.patch>(BASE, { - fallbackRate, - }); + setFallbackRate: async ( + currency: string, + fallbackRate: number, + ): Promise => { + const response = await client.patch>( + `${BASE}/${currency}`, + { fallbackRate }, + ); return unwrap(response.data); }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts index 6710730cb..ffe5281ec 100644 --- a/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts @@ -13,6 +13,7 @@ const BASE = URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.BASE; export interface ManualPaymentSettings { etbEnabled: boolean; usdEnabled: boolean; + djfEnabled: boolean; updatedById: string | null; updatedAt?: string; } @@ -25,7 +26,9 @@ export const manualPaymentSettingsService = { /** Partial: an omitted currency keeps its current setting. */ update: async ( - patch: Partial>, + patch: Partial< + Pick + >, ): Promise => { const response = await client.patch>( BASE, From 214f96dbae8f1edb75dcc944f5deca2480f1642f Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 11:53:56 +0300 Subject: [PATCH 14/20] feat(freight-backoffice): support DJF in booking, contract and warehouse screens Currency dropdowns/pickers (AdditionalPaymentsTab, ClearanceChargesTab, PhasedClearanceActionPanel, AdviseDutyCard, ContractRequestsPage, ruleEngine/resources, WarehouseRulesPage, VehicleDetailPage, FeePreviewModal) offer DJF alongside ETB/USD; GlCreateBookingForm's currency selector gets allowDjf next to allowUsd. Narrow 'ETB'|'USD' type unions widened to include 'DJF' across the warehouse billingCurrency plumbing (useWarehouses, warehouse.service, api.ts) and the customer/invoice types. Ad-hoc money() formatters (BookingTrucksPanel, AccrualDashboard, ImportTrucksPage, EmptyReturnRequestsPage) and formatMoney call sites that hardcoded 2 decimals (wagon-cancellation cards, BookingRequestDetailPage, WagonCancellationsPage, PaymentsPage, WarehouseInvoicesPage) now use currencyDecimals() from @edr/ui-common so DJF renders with 0 decimals instead of forced cents. The 3 duplicate overview formatCurrency/ formatAmount helpers (typed 'ETB'|'USD') widen to accept any currency. Two correctness fixes: OverviewRecentBookingsTable's currency==='USD' ? 'USD' : 'ETB' was mislabeling every non-USD currency as ETB; and WarehouseInvoicesPage's gateway-method default now routes any non-ETB currency (not just USD) to WAAFI, so DJF invoices get a working default instead of TELEBIRR (ETB-only). Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL --- .../src/components/bookings/AdditionalPaymentsTab.tsx | 2 +- .../src/components/bookings/detail/BookingTrucksPanel.tsx | 5 +++-- .../wagon-cancellation/RebookWagonCancellationModal.tsx | 4 ++-- .../wagon-cancellation/WagonCancellationCreditCard.tsx | 5 +++-- .../src/components/contracts/ClearanceChargesTab.tsx | 2 +- .../src/components/contracts/GlCreateBookingForm.tsx | 7 ++++--- .../components/contracts/PhasedClearanceActionPanel.tsx | 6 +++--- .../src/components/contracts/gl-actions/AdviseDutyCard.tsx | 2 +- .../src/components/overview/OverviewPaymentChart.tsx | 2 +- .../components/overview/OverviewRecentBookingsTable.tsx | 3 +-- .../src/components/overview/summary/OverviewHeroKpis.tsx | 4 ++-- .../components/overview/tabs/OverviewBillingTabPanel.tsx | 2 +- .../src/components/warehouses/AccrualDashboard.tsx | 4 +++- .../src/components/warehouses/FeePreviewModal.tsx | 5 +++-- apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts | 6 +++--- .../src/pages/bookings/BookingRequestDetailPage.tsx | 3 ++- .../src/pages/bookings/WagonCancellationsPage.tsx | 7 ++++--- .../src/pages/contracts/ContractRequestsPage.tsx | 1 + .../backoffice/src/pages/fleet/VehicleDetailPage.tsx | 1 + .../backoffice/src/pages/invoices/UsdPaymentsPage.tsx | 2 +- .../backoffice/src/pages/payments/PaymentsPage.tsx | 3 ++- .../backoffice/src/pages/ruleEngine/config/resources.ts | 1 + .../src/pages/warehouses/EmptyReturnRequestsPage.tsx | 4 ++-- .../backoffice/src/pages/warehouses/ImportTrucksPage.tsx | 3 ++- .../src/pages/warehouses/WarehouseInvoicesPage.tsx | 7 ++++--- .../backoffice/src/pages/warehouses/WarehouseRulesPage.tsx | 1 + apps/edr-freight-web/backoffice/src/services/api.ts | 4 ++-- .../backoffice/src/services/warehouse.service.ts | 6 +++--- apps/edr-freight-web/backoffice/src/types/customer.ts | 4 ++-- apps/edr-freight-web/backoffice/src/types/invoice.ts | 2 +- 30 files changed, 61 insertions(+), 47 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx index e6eb81a02..50ef7c4be 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -36,7 +36,7 @@ import { downloadBookingFile, fetchViewableFile } from "@/services/files.service import { formatDate, formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; -const CURRENCIES = ["ETB", "USD"]; +const CURRENCIES = ["ETB", "USD", "DJF"]; const STATUS_META: Record = { DRAFT: { label: "Draft", color: "gray" }, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx index 65c9719ed..678913f78 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Coins, Truck } from "lucide-react"; +import { currencyDecimals } from "@edr/ui-common"; import { api } from "@/services/api"; import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; @@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile"; const money = (amount: number, currency: string) => `${Number(amount).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: currencyDecimals(currency), + maximumFractionDigits: currencyDecimals(currency), })} ${currency === "ETB" ? "Birr (ETB)" : currency}`; /** diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx index 318ce3f9b..a03c29bec 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { OperationDatePicker } from "@edr/ui-common"; +import { OperationDatePicker, currencyDecimals } from "@edr/ui-common"; import { api } from "@/auth/http"; import { api as rpc } from "@/services/api"; @@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({ {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} {cancellation.wagonsCancelled} wagon(s) · credit{" "} - {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))} Shipment day diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx index 4bca7cced..4f50c5921 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx @@ -8,6 +8,7 @@ import { api } from "@/auth/http"; import { useAuth } from "@/auth/useAuth"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal"; import { canRebookWagonCancellations, @@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({ {Number(r.wagonsCancelled)} wagon(s) · credit{" "} - {formatMoney(Number(r.creditAmount), r.feeCurrency, 2)} + {formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))} {chip.label} @@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({ Cancelled {formatDate(r.createdAt)} {r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""} {Number(r.feeAmount) > 0 - ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${ + ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}${ r.feePaidAt ? " paid" : " unpaid" }` : ""} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx index 047d1361f..fb47ace60 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -39,7 +39,7 @@ import { import { formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; -const CURRENCIES = ["ETB", "USD"]; +const CURRENCIES = ["ETB", "USD", "DJF"]; const STATUS_META: Record< Freight.ClearanceChargeStatus, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 0d5a7046a..c607fcc3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -345,7 +345,7 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); // IMPORT bookings pick ETB or USD — starts empty so the choice is // deliberate (required before pricing). Everything else is forced to ETB. - const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "">(""); + const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">(""); // What the containers carry — captured per booking (moved off the contract). const [cargoDescription, setCargoDescription] = useState(""); const [containerLines, setContainerLines] = useState([]); @@ -1144,7 +1144,7 @@ export default function GlCreateBookingForm() { ]); // Only IMPORT actually chooses — the rest bill ETB regardless of the state. - const effectiveCurrency: "USD" | "ETB" = + const effectiveCurrency: "USD" | "ETB" | "DJF" = isImport && paymentCurrency ? paymentCurrency : "ETB"; const currencyError = isImport && !paymentCurrency @@ -2351,7 +2351,7 @@ export default function GlCreateBookingForm() { {requestCurrencyLocked ? "The customer chose the billing currency on the shipment request — it cannot be changed." : isImport - ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + ? "Import shipments may be invoiced in ETB, USD or DJF. USD is paid by bank transfer, not online." : "Shipments are invoiced in ETB."}
diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 313645db3..ea4ec0cb3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -1554,7 +1554,7 @@ function SecondDutyStep({ /> setCurrency(v ?? "ETB")} size="sm" @@ -2063,7 +2063,7 @@ function DutyStep({ /> setCurrency(v ?? "ETB")} size="sm" diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx index f1a74ac4e..aa5857840 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPaymentChart.tsx @@ -18,7 +18,7 @@ function formatDateLabel(date: string) { return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } -function formatAmount(value: number, currency: "ETB" | "USD") { +function formatAmount(value: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx index e1ceaec1c..3464141fe 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx @@ -9,10 +9,9 @@ import { SummaryCard } from "./summary/SummaryCard"; function formatAmount(amount: number | null, currency: string | null) { if (amount == null) return "—"; - const code = currency === "USD" ? "USD" : "ETB"; return new Intl.NumberFormat("en-US", { style: "currency", - currency: code, + currency: currency || "ETB", maximumFractionDigits: 0, }).format(amount); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx index e076b1f18..307d9fc22 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx @@ -4,7 +4,7 @@ import { KpiStrip, type KpiItem } from "@/components/page"; import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; import { CountUp } from "./CountUp"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, @@ -13,7 +13,7 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") { } /** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */ -function formatCompactCurrency(amount: number, currency: "ETB" | "USD") { +function formatCompactCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx index 52b8bf161..c7e2a1fe5 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBillingTabPanel.tsx @@ -18,7 +18,7 @@ import { OverviewKpiStrip } from "../OverviewKpiStrip"; import { OverviewPaymentChart } from "../OverviewPaymentChart"; import { overviewChartColors } from "../overview.styles"; -function formatCurrency(amount: number, currency: "ETB" | "USD") { +function formatCurrency(amount: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx index 5170b345d..635a78800 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/AccrualDashboard.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react'; +import { currencyDecimals } from '@edr/ui-common'; import { useAccrualDashboard } from '@/hooks/useWarehouses'; import { warehouseService } from '@/services/warehouse.service'; @@ -15,7 +16,8 @@ const ALERT_META: Record = { }; function money(amount: number, currency: string): string { - return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; + const decimals = currencyDecimals(currency); + return `${amount.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })} ${currency}`; } function freeDaysLabel(row: AccrualDashboardRow): string { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index cb2cbc658..058fc96bd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -113,7 +113,7 @@ function Row({ label, value }: { label: string; value: string }) { /** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { const { toast } = useToast(); - const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD'); + const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD' | 'DJF'>('USD'); const enabledId = opened ? inventoryId ?? undefined : undefined; const { data, isLoading } = useQuery( api.warehouses.feePreview.queryOptions({ @@ -211,10 +211,11 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa setBillingCurrency(value as 'ETB' | 'USD')} + onChange={(value) => setBillingCurrency(value as 'ETB' | 'USD' | 'DJF')} data={[ { value: 'USD', label: 'USD' }, { value: 'ETB', label: 'Birr' }, + { value: 'DJF', label: 'DJF' }, ]} disabled={Boolean(activeInvoice)} /> diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 6ed13636a..a6db563cd 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -221,7 +221,7 @@ export function useOnTimeDispatch() { } /** Live per-item fee accrual (storage/demurrage) with alerts. */ -export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') { +export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD' | 'DJF') { return useQuery({ queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'], queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data), @@ -598,7 +598,7 @@ export const useUpdateFeeRule = () => export const useDeleteFeeRule = () => useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); -export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' = 'USD') { +export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' | 'DJF' = 'USD') { return useQuery({ queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency], queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data), @@ -649,7 +649,7 @@ export function useGenerateInvoice() { }: { inventoryId: string; confirmZero?: boolean; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: 'ETB' | 'USD' | 'DJF'; }) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data), onSuccess, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index a75e242e1..579c8f290 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -72,6 +72,7 @@ import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsT import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { formatDateTime, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { BookingDetail } from "@/types/booking"; import { @@ -254,7 +255,7 @@ export default function BookingRequestDetailPage() { const kpis: KpiItem[] = [ { label: "Total value", - value: formatMoney(amount, booking.paymentCurrency, 2), + value: formatMoney(amount, booking.paymentCurrency, currencyDecimals(booking.paymentCurrency)), hint: booking.paymentStatus, icon: Wallet, color: "edr-green", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx index 1a18d4cf0..7d1685328 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -25,6 +25,7 @@ import { useAuth } from "@/auth/useAuth"; import { PageContainer, PageHeader } from "@/components/page"; import { toDayString } from "@/hooks/useListControls"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { DataTable, @@ -166,7 +167,7 @@ export default function WagonCancellationsPage() { header: () => Fee, cell: ({ row }) => ( - {formatMoney(row.original.feeAmount, row.original.feeCurrency, 2)} + {formatMoney(row.original.feeAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))} ), }, @@ -175,7 +176,7 @@ export default function WagonCancellationsPage() { header: () => Credit, cell: ({ row }) => ( - {formatMoney(row.original.creditAmount, row.original.feeCurrency, 2)} + {formatMoney(row.original.creditAmount, row.original.feeCurrency, currencyDecimals(row.original.feeCurrency))} ), }, @@ -347,7 +348,7 @@ export default function WagonCancellationsPage() { {voiding.booking?.reference ?? voiding.bookingId} ·{" "} {voiding.wagonsCancelled} wagon(s) · fee{" "} - {formatMoney(voiding.feeAmount, voiding.feeCurrency, 2)} + {formatMoney(voiding.feeAmount, voiding.feeCurrency, currencyDecimals(voiding.feeCurrency))} The pending fee is dropped and the wagons stay on the booking. diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index 455a6473e..b62d405fe 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -82,6 +82,7 @@ const CONTRACT_KIND_OPTIONS = [ const CURRENCY_OPTIONS = [ { value: "ETB", label: "ETB" }, { value: "USD", label: "USD" }, + { value: "DJF", label: "DJF" }, ]; /** value = `${sortBy}:${sortOrder}` for the sort Select. */ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx index bcfadb698..c0d577d89 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -279,6 +279,7 @@ const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => { + diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index 8b3e58a5f..eeceae91b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -274,7 +274,7 @@ function ConfirmCell({ export default function UsdPaymentsPanel({ currency, }: { - currency: "USD" | "ETB"; + currency: "USD" | "ETB" | "DJF"; }) { const navigate = useNavigate(); // Namespaced: the ETB and USD tabs share this panel and live on the same URL diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 2c30fb11f..868137ba3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -27,6 +27,7 @@ import { useQuery } from "@tanstack/react-query"; import { KpiStrip } from "@/components/page"; import { ExportButton } from "@/components/export/ExportButton"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { api } from "@/services/api"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { @@ -149,7 +150,7 @@ export default function PaymentsPanel() { header: () => Amount, cell: ({ row }) => ( - {formatMoney(row.original.amount, row.original.currency, 2)} + {formatMoney(row.original.amount, row.original.currency, currencyDecimals(row.original.currency))} ), }, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 62fecd600..0ec64f0bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -450,6 +450,7 @@ export const rateUnitOptions = ( const CURRENCIES = [ { label: "ETB (Birr)", value: "ETB" }, { label: "USD", value: "USD" }, + { label: "DJF", value: "DJF" }, ]; const PRIORITY_CONFIG_TYPES = [ diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx index 3c2b487df..41b4c8679 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx @@ -16,7 +16,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; +import { DataTable, type ColumnDef, currencyDecimals } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; @@ -45,7 +45,7 @@ const STATUS_META: Record amount == null ? "—" - : `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim(); + : `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: currencyDecimals(currency) })} ${currency ?? ""}`.trim(); /** * The queue for customer-initiated empty container returns: a booking sold diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx index a074006d8..05e89a1f9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx @@ -1,4 +1,5 @@ import { Fragment, useMemo, useState } from "react"; +import { currencyDecimals } from "@edr/ui-common"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { ActionIcon, @@ -78,7 +79,7 @@ const TRUCK_COLUMNS = [ ] as const; const money = (amount: number, currency: string) => - `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`; + `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: currencyDecimals(currency) })} ${currency === "ETB" ? "ETB" : currency}`; export interface BookingGroup { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 974755a71..1112778dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -17,7 +17,7 @@ import { } from '@mantine/core'; import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { DataTable, type ColumnDef, currencyDecimals } from '@edr/ui-common'; import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters'; import { PageContainer, PageHeader } from '@/components/page'; @@ -50,7 +50,7 @@ const STATUS_COLOR: Record = { CANCELLED: 'gray', }; -const fmt = (n: number, c: string) => formatMoney(n, c, 2); +const fmt = (n: number, c: string) => formatMoney(n, c, currencyDecimals(c)); const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—'); const INVOICE_FILTER_DEFS: FilterDef[] = [ @@ -204,7 +204,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); useEffect(() => { - setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'); + // WAAFI settles USD and DJF; TELEBIRR is ETB-only. + setGatewayMethod(inv?.currency !== 'ETB' ? 'WAAFI' : 'TELEBIRR'); setPayerAccount(''); }, [inv?.id, inv?.currency]); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 8c432f92a..02ced15fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -69,6 +69,7 @@ const TRADE = [ const CURRENCIES = [ { value: 'USD', label: 'USD - Dollar' }, { value: 'ETB', label: 'ETB - Birr' }, + { value: 'DJF', label: 'DJF - Djibouti Franc' }, ]; const clean = (s: string) => s.trim() || undefined; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index c130c12a5..8e48ead12 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1532,7 +1532,7 @@ export const api = { ), feePreview: endpoint< - { inventoryId: string; billingCurrency?: "ETB" | "USD" }, + { inventoryId: string; billingCurrency?: "ETB" | "USD" | "DJF" }, FeePreview[] >( "warehouse-inventory", @@ -1884,7 +1884,7 @@ export const api = { { inventoryId: string; confirmZero?: boolean; - billingCurrency?: "ETB" | "USD"; + billingCurrency?: "ETB" | "USD" | "DJF"; }, WarehouseFeeInvoice >( diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 98a8d59e4..18368110f 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -558,11 +558,11 @@ export const warehouseService = { updateFeeRule: (id: string, payload: Partial) => apiClient.patch(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload), deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)), - feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') => + feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD' | 'DJF') => apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), { params: cleanParams({ billingCurrency }), }), - accrualDashboard: (billingCurrency?: 'ETB' | 'USD') => + accrualDashboard: (billingCurrency?: 'ETB' | 'USD' | 'DJF') => apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, { params: cleanParams({ billingCurrency }), }), @@ -592,7 +592,7 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)), invoicesForBooking: (bookingId: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)), - generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD') => + generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD' | 'DJF') => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero, billingCurrency, diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 1d3eb7588..57be90f90 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -420,7 +420,7 @@ export interface CustomerBooking { originLabel: string; destinationLabel: string; totalAmount: number; - currency: "ETB" | "USD"; + currency: "ETB" | "USD" | "DJF"; scheduledDate?: string | null; createdAt: string; } @@ -469,7 +469,7 @@ export interface CustomerPayment { /** Booking reference the payment settles. */ bookingReference: string; amount: number; - currency: "ETB" | "USD"; + currency: "ETB" | "USD" | "DJF"; method: CustomerPaymentMethod; status: CustomerPaymentStatus; paidAt?: string | null; diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 218e478fa..0807894c1 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -77,7 +77,7 @@ export interface InvoiceListFilter { /** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */ paymentMethods?: string; search?: string; - currency?: "USD" | "ETB"; + currency?: "USD" | "ETB" | "DJF"; /** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */ issuedFrom?: string; issuedTo?: string; From 0dd0e8d992ae72d01916eb5e41a0a7897e1ff32d Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 4 Sep 2026 14:43:17 +0300 Subject: [PATCH 15/20] feat: add publication --- apps/edr-freight-api/src/app.module.ts | 2 + .../migrations/3850000000000-Publications.ts | 44 ++++ .../dto/create-publication.dto.ts | 33 +++ .../dto/update-publication.dto.ts | 5 + .../entities/publication.entity.ts | 51 +++++ .../public-publications.controller.ts | 51 +++++ .../publications/publications.controller.ts | 76 +++++++ .../publications/publications.module.ts | 17 ++ .../publications/publications.repository.ts | 29 +++ .../publications/publications.service.ts | 151 +++++++++++++ .../src/seed/freight-permissions.registry.ts | 5 + apps/edr-freight-web/backoffice/src/App.tsx | 14 ++ .../components/layout/sidebar-sections.tsx | 9 + .../backoffice/src/lib/permissions.ts | 4 + .../publications/DeletePublicationDialog.tsx | 54 +++++ .../publications/EditPublicationDialog.tsx | 211 ++++++++++++++++++ .../pages/publications/PublicationsPage.tsx | 173 ++++++++++++++ .../backoffice/src/services/api.ts | 49 +++- .../src/services/publications.service.ts | 74 ++++++ apps/edr-freight-web/portal/src/App.tsx | 2 + .../portal/src/constants/URLS.ts | 4 + .../portal/src/constants/apiConfig.ts | 10 + .../portal/src/hooks/usePublications.ts | 25 +++ .../src/pages/EDRFreightLandingPage.tsx | 32 ++- .../pages/publications/PublicationsPage.tsx | 163 ++++++++++++++ .../portal/src/pages/support/DocShell.tsx | 1 + packages/types/src/freight/index.ts | 1 + packages/types/src/freight/publications.ts | 43 ++++ 28 files changed, 1323 insertions(+), 10 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3850000000000-Publications.ts create mode 100644 apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts create mode 100644 apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts create mode 100644 apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts create mode 100644 apps/edr-freight-api/src/modules/publications/public-publications.controller.ts create mode 100644 apps/edr-freight-api/src/modules/publications/publications.controller.ts create mode 100644 apps/edr-freight-api/src/modules/publications/publications.module.ts create mode 100644 apps/edr-freight-api/src/modules/publications/publications.repository.ts create mode 100644 apps/edr-freight-api/src/modules/publications/publications.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/publications/PublicationsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/publications.service.ts create mode 100644 apps/edr-freight-web/portal/src/hooks/usePublications.ts create mode 100644 apps/edr-freight-web/portal/src/pages/publications/PublicationsPage.tsx create mode 100644 packages/types/src/freight/publications.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 84e3e4b7a..982728241 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; +import { PublicationsModule } from "./modules/publications/publications.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; @@ -231,6 +232,7 @@ if (!process.env.APPLICATION_NAME) { LogoSettingsModule, ContractTemplatesModule, SupportContentModule, + PublicationsModule, OtpModule, HealthModule, RuleEngineModule, diff --git a/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts new file mode 100644 index 000000000..d44f57931 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Public document library for the freight portal (PDFs, Markdown write-ups, + * PowerPoint decks about the platform), managed from the backoffice. Each row + * is one whole file stored in MinIO under `publications/` — a re-upload + * replaces the object and the row's file columns, there is no per-version + * history table like `support_documents` has. + */ +export class Publications3850000000000 implements MigrationInterface { + name = 'Publications3850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.publications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + title varchar(200) NOT NULL, + description text, + category varchar(60), + file_key varchar(512) NOT NULL, + file_name varchar(255) NOT NULL, + file_mime_type varchar(120) NOT NULL, + file_size_bytes bigint NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + published boolean NOT NULL DEFAULT true, + published_at timestamptz, + uploaded_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // Serves the public list: published rows in display order. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_publications_published_sort + ON freight.publications (published, sort_order) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts new file mode 100644 index 000000000..313ff182c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts @@ -0,0 +1,33 @@ +import { Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator"; + +/** + * Metadata fields for `POST /publications`, sent alongside the file as + * multipart/form-data — every field arrives as a string, so numeric/boolean + * fields need an explicit `@Transform` (global `enableImplicitConversion` is + * off, see main.ts). + */ +export class CreatePublicationDto { + @IsString() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(60) + category?: string; + + @IsOptional() + @IsInt() + @Transform(({ value }) => Number(value ?? 0)) + sortOrder?: number; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === undefined || value === "true" || value === true) + published?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts new file mode 100644 index 000000000..677b08c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreatePublicationDto } from "./create-publication.dto"; + +export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {} diff --git a/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts new file mode 100644 index 000000000..e627e638c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +/** + * One document in the freight portal's public library (/publications) — a + * PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and + * curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs + * edited in place, this is a real table of many rows and each upload is a + * whole new file — there is no version-history log here, a re-upload just + * replaces the file columns (see `PublicationsService.replaceFile`). + */ +@Entity({ schema: "freight", name: "publications" }) +@Index(["published", "sortOrder"]) +export class Publication extends BaseEntity { + @Column({ name: "title", type: "varchar", length: 200 }) + title!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + @Column({ name: "category", type: "varchar", length: 60, nullable: true }) + category?: string | null; + + /** MinIO object key. Never a signed URL — those expire; sign on read instead. */ + @Column({ name: "file_key", type: "varchar", length: 512 }) + fileKey!: string; + + /** Original filename, used for the download's Content-Disposition. */ + @Column({ name: "file_name", type: "varchar", length: 255 }) + fileName!: string; + + @Column({ name: "file_mime_type", type: "varchar", length: 120 }) + fileMimeType!: string; + + @Column({ name: "file_size_bytes", type: "bigint" }) + fileSizeBytes!: number; + + /** Manual ordering in the backoffice list and the public grid. */ + @Column({ name: "sort_order", type: "integer", default: 0 }) + sortOrder!: number; + + /** Unpublish without deleting — hides it from the public list only. */ + @Column({ name: "published", type: "boolean", default: true }) + published!: boolean; + + @Column({ name: "published_at", type: "timestamptz", nullable: true }) + publishedAt?: Date | null; + + @Column({ name: "uploaded_by_id", type: "uuid", nullable: true }) + uploadedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts new file mode 100644 index 000000000..8a95ffb39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts @@ -0,0 +1,51 @@ +import { Public } from "@edr/api-common"; +import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common"; +import { Response } from "express"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; + +import { PublicationsService } from "./publications.service"; + +/** + * The portal's /publications page — a public library of PDFs, Markdown + * write-ups and PowerPoint decks about the platform. No login required, same + * as /help, /faq and the legal pages: prospects reach it before any account + * exists. + */ +@ApiTags("publications") +@Public() +@Controller("publications") +export class PublicPublicationsController { + constructor(private readonly service: PublicationsService) {} + + @Get() + // Cheap to serve stale for a few minutes; every anonymous page view hits it. + @Header("Cache-Control", "public, max-age=300") + @ApiOperation({ summary: "List published publications for the public library" }) + list() { + return this.service.listPublic(); + } + + @Get(":id/file") + @ApiQuery({ + name: "download", + required: false, + description: "Set to 1/true to force a download instead of inline preview.", + }) + @ApiOperation({ summary: "Stream a published publication's file" }) + async getFile( + @Param("id", ParseUUIDPipe) id: string, + @Query("download") download: string | undefined, + @Res() res: Response, + ) { + const { stream, record } = await this.service.getPublishedFileStream(id); + const forceDownload = download === "1" || download === "true"; + + res.setHeader("Content-Type", record.fileMimeType); + res.setHeader( + "Content-Disposition", + `${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`, + ); + res.setHeader("Cache-Control", "public, max-age=300"); + stream.pipe(res); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.controller.ts b/apps/edr-freight-api/src/modules/publications/publications.controller.ts new file mode 100644 index 000000000..79f18e9e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.controller.ts @@ -0,0 +1,76 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { documentUploadMulterOptions } from "../../common/document-upload.options"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { CreatePublicationDto } from "./dto/create-publication.dto"; +import { UpdatePublicationDto } from "./dto/update-publication.dto"; +import { PublicationsService } from "./publications.service"; + +const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin]; +const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin]; + +@ApiTags("publications") +@ApiBearerAuth() +@Controller("publications") +export class PublicationsController { + constructor(private readonly service: PublicationsService) {} + + @Get("admin") + @BookingStaff(READ) + @ApiOperation({ summary: "List every publication, published or not" }) + list() { + return this.service.list(); + } + + @Post() + @BookingStaff(WRITE) + @UseInterceptors(FileInterceptor("file", documentUploadMulterOptions)) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload a new publication" }) + create( + @UploadedFile() file: Express.Multer.File, + @Body() dto: CreatePublicationDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.create(file, dto, user?.id ?? null); + } + + @Patch(":id") + @BookingStaff(WRITE) + @ApiOperation({ summary: "Update a publication's title, description, category, order or published state" }) + update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) { + return this.service.update(id, dto); + } + + @Post(":id/file") + @BookingStaff(WRITE) + @UseInterceptors(FileInterceptor("file", documentUploadMulterOptions)) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Replace a publication's file" }) + replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) { + return this.service.replaceFile(id, file); + } + + @Delete(":id") + @BookingStaff(WRITE) + @ApiOperation({ summary: "Remove a publication" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.module.ts b/apps/edr-freight-api/src/modules/publications/publications.module.ts new file mode 100644 index 000000000..46e612ebe --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { MinioModule } from "../minio/minio.module"; +import { Publication } from "./entities/publication.entity"; +import { PublicationsController } from "./publications.controller"; +import { PublicationsRepository } from "./publications.repository"; +import { PublicationsService } from "./publications.service"; +import { PublicPublicationsController } from "./public-publications.controller"; + +@Module({ + imports: [TypeOrmModule.forFeature([Publication]), MinioModule], + controllers: [PublicPublicationsController, PublicationsController], + providers: [PublicationsRepository, PublicationsService], + exports: [PublicationsService], +}) +export class PublicationsModule {} diff --git a/apps/edr-freight-api/src/modules/publications/publications.repository.ts b/apps/edr-freight-api/src/modules/publications/publications.repository.ts new file mode 100644 index 000000000..315f630bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.repository.ts @@ -0,0 +1,29 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Publication } from "./entities/publication.entity"; + +@Injectable() +export class PublicationsRepository extends BaseRepository { + constructor( + @InjectRepository(Publication) + repository: Repository, + ) { + super(repository); + } + + /** Public list: published rows only, in display order. */ + findPublished(): Promise { + return this.repository.find({ + where: { published: true }, + order: { sortOrder: "ASC", publishedAt: "DESC" }, + }); + } + + /** Admin list: every row, published or not. */ + override findAll(): Promise { + return this.repository.find({ order: { sortOrder: "ASC" } }); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.service.ts b/apps/edr-freight-api/src/modules/publications/publications.service.ts new file mode 100644 index 000000000..e7ff2b2a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.service.ts @@ -0,0 +1,151 @@ +import { + PublicationSummary, + PUBLICATION_ALLOWED_MIME_TYPES, + PUBLICATION_FILE_PREFIX, +} from "@edr/types"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { extname } from "path"; +import { Readable } from "stream"; +import { randomUUID } from "crypto"; + +import { MinioService } from "../minio/minio.service"; +import { CreatePublicationDto } from "./dto/create-publication.dto"; +import { UpdatePublicationDto } from "./dto/update-publication.dto"; +import { Publication } from "./entities/publication.entity"; +import { PublicationsRepository } from "./publications.repository"; + +@Injectable() +export class PublicationsService { + constructor( + private readonly repository: PublicationsRepository, + private readonly minio: MinioService, + ) {} + + private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File { + if (!file) throw new BadRequestException("No file uploaded"); + if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) { + throw new BadRequestException( + `Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`, + ); + } + } + + async create( + file: Express.Multer.File | undefined, + dto: CreatePublicationDto, + actorId: string | null, + ): Promise { + this.assertAllowedFile(file); + + const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`; + await this.minio.uploadFile(key, file.buffer, file.mimetype); + + const published = dto.published ?? true; + return this.repository.create({ + title: dto.title, + description: dto.description ?? null, + category: dto.category ?? null, + fileKey: key, + fileName: file.originalname, + fileMimeType: file.mimetype, + fileSizeBytes: file.size, + sortOrder: dto.sortOrder ?? 0, + published, + publishedAt: published ? new Date() : null, + uploadedById: actorId, + }); + } + + async update(id: string, dto: UpdatePublicationDto): Promise { + const existing = await this.getByIdOrThrow(id); + + const patch: Partial = { + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.category !== undefined && { category: dto.category }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }; + + if (dto.published !== undefined && dto.published !== existing.published) { + patch.published = dto.published; + patch.publishedAt = dto.published ? new Date() : null; + } + + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Publication ${id} not found`); + return updated; + } + + /** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */ + async replaceFile(id: string, file?: Express.Multer.File): Promise { + this.assertAllowedFile(file); + const existing = await this.getByIdOrThrow(id); + + const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`; + await this.minio.uploadFile(key, file.buffer, file.mimetype); + + const updated = await this.repository.update(id, { + fileKey: key, + fileName: file.originalname, + fileMimeType: file.mimetype, + fileSizeBytes: file.size, + }); + + await this.minio.deleteFile(existing.fileKey); + return updated!; + } + + async remove(id: string): Promise { + await this.getByIdOrThrow(id); + await this.repository.softDelete(id); + } + + /** Admin list — every row, published or not. */ + list(): Promise { + return this.repository.findAll(); + } + + /** + * Public list — published rows only. No file URL here: a presigned MinIO + * URL isn't reachable from the browser (see `fileViewUrl` in the portal's + * `apiConfig.ts`); the portal builds each file's URL itself from `id` via + * `GET /publications/:id/file`. + */ + async listPublic(): Promise { + const rows = await this.repository.findPublished(); + return rows.map((row) => this.toSummary(row)); + } + + private toSummary(row: Publication): PublicationSummary { + return { + id: row.id, + title: row.title, + description: row.description ?? null, + category: row.category ?? null, + fileName: row.fileName, + fileMimeType: row.fileMimeType, + fileSizeBytes: Number(row.fileSizeBytes), + sortOrder: row.sortOrder, + publishedAt: row.publishedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + /** For the public/staff file route: streams a published row's bytes. */ + async getPublishedFileStream( + id: string, + ): Promise<{ stream: Readable; record: Publication }> { + const record = await this.repository.findById(id); + if (!record || !record.published) { + throw new NotFoundException(`Publication ${id} not found`); + } + return { stream: await this.minio.getFileStream(record.fileKey), record }; + } + + private async getByIdOrThrow(id: string): Promise { + const record = await this.repository.findById(id); + if (!record) throw new NotFoundException(`Publication ${id} not found`); + return record; + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0a3d26d71..93e558dfe 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2498,6 +2498,11 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:support_content:view", manage: "edr_freight_app:settings:support_content:manage", }, + // Public /publications library (PDFs, Markdown, PowerPoint), edited from the backoffice. + publications: { + view: "edr_freight_app:settings:publications:view", + manage: "edr_freight_app:settings:publications:manage", + }, }, support: { agentView: "edr_freight_app:support:agent_view", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f95fe7cca..53cfabf63 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -60,6 +60,7 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage" import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; +import PublicationsPage from "./pages/publications/PublicationsPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; @@ -1173,6 +1174,19 @@ const App = () => { } /> + + + + } + /> , + permission: [ + FREIGHT_PERMS.settings.publications.view, + FREIGHT_PERMS.settings.publications.manage, + ], + }, { label: "Audit logs", href: "/dashboard/audit-logs", diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bc03bfcb5..fee1f42a2 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -426,6 +426,10 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:support_content:view", manage: "edr_freight_app:settings:support_content:manage", }, + publications: { + view: "edr_freight_app:settings:publications:view", + manage: "edr_freight_app:settings:publications:manage", + }, }, staff: { roles: { diff --git a/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx new file mode 100644 index 000000000..a4ca7e5a0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/publications/DeletePublicationDialog.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export interface DeletePublicationDialogProps { + title: string; + onConfirm?: () => void; + children: ReactNode; +} + +export default function DeletePublicationDialog({ + title, + onConfirm, + children, +}: DeletePublicationDialogProps) { + return ( + + {children} + + + + Delete publication? + + This will remove{" "} + {title} from the + public library. It stops being downloadable immediately. + + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx new file mode 100644 index 000000000..f7e6a8b2d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/publications/EditPublicationDialog.tsx @@ -0,0 +1,211 @@ +import type { Publication } from "@edr/types"; +import { useMutation } from "@tanstack/react-query"; +import { Loader2, UploadCloud } from "lucide-react"; +import { useRef, useState, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/services/api"; + +export interface EditPublicationDialogProps { + mode?: "create" | "edit"; + publication?: Publication; + children: ReactNode; +} + +const ACCEPT = + ".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +export default function EditPublicationDialog({ + mode = "create", + publication, + children, +}: EditPublicationDialogProps) { + const isEdit = mode === "edit"; + const fileInputRef = useRef(null); + + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(publication?.title ?? ""); + const [description, setDescription] = useState(publication?.description ?? ""); + const [category, setCategory] = useState(publication?.category ?? ""); + const [file, setFile] = useState(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + const createMutation = useMutation(api.publications.create.mutationOptions()); + const updateMutation = useMutation(api.publications.update.mutationOptions()); + const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions()); + const pending = + createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending; + + const reset = () => { + setTitle(publication?.title ?? ""); + setDescription(publication?.description ?? ""); + setCategory(publication?.category ?? ""); + setFile(null); + setProgress(null); + setError(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleSubmit = async () => { + setError(null); + if (!title.trim()) { + setError("Title is required."); + return; + } + if (!isEdit && !file) { + setError("Choose a file to upload."); + return; + } + + const meta = { + title: title.trim(), + description: description.trim() || undefined, + category: category.trim() || undefined, + }; + + try { + if (isEdit && publication) { + await updateMutation.mutateAsync({ id: publication.id, dto: meta }); + if (file) { + await replaceFileMutation.mutateAsync({ + id: publication.id, + file, + onProgress: setProgress, + }); + } + } else if (file) { + await createMutation.mutateAsync({ file, meta, onProgress: setProgress }); + } + setOpen(false); + if (!isEdit) reset(); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong. Try again."); + } finally { + setProgress(null); + } + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + {children} + + + + + {isEdit ? "Edit publication" : "New publication"} + + + {isEdit + ? "Update this document's title, description or category, or replace its file." + : "Upload a PDF, Markdown or PowerPoint file for the public library."} + + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g. EDR Freight Platform Guide" + /> +
+ +
+ + setCategory(e.target.value)} + placeholder="e.g. Guides, Reports" + /> +
+ +
+ +