mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
feat: (upgrade) implement per-passenger fare class upgrade with configurable policies
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user