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

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