feat: (upgrade) implement per-passenger fare class upgrade with configurable policies

This commit is contained in:
Abubeker Yasin
2026-09-02 16:18:36 +03:00
parent 31121db07b
commit e914aaeb53
34 changed files with 2705 additions and 34 deletions

View File

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

View File

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

View File

@@ -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' },