|
|
|
|
@@ -0,0 +1,541 @@
|
|
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
ForbiddenException,
|
|
|
|
|
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 { hasPassengerPermission, MeLikeUser } from '../../common/passenger-permission.util';
|
|
|
|
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
|
|
|
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
|
|
|
|
import { BookingsService } from '../bookings/bookings.service';
|
|
|
|
|
import { SeatsService } from '../seats/seats.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 { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
|
|
|
|
|
|
|
|
|
|
export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE';
|
|
|
|
|
export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid';
|
|
|
|
|
|
|
|
|
|
type PolicyNumbers = {
|
|
|
|
|
feePercent: number;
|
|
|
|
|
feeMinMinor: number;
|
|
|
|
|
sameDayFeePercent: number;
|
|
|
|
|
sameDayFeeMinMinor: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Pure fee arithmetic — policy §3. Negative fare differences are recorded but NOT paid out
|
|
|
|
|
* (credit/refund handling is a later step), so amountDue never goes below the fee.
|
|
|
|
|
*/
|
|
|
|
|
export function computeRescheduleAmounts(
|
|
|
|
|
policy: PolicyNumbers,
|
|
|
|
|
oldFareMinor: number,
|
|
|
|
|
newFareMinor: number,
|
|
|
|
|
isSameDay: boolean,
|
|
|
|
|
): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } {
|
|
|
|
|
const pct = isSameDay ? policy.sameDayFeePercent : policy.feePercent;
|
|
|
|
|
const min = isSameDay ? policy.sameDayFeeMinMinor : policy.feeMinMinor;
|
|
|
|
|
const feeMinor = pct > 0 || min > 0 ? Math.max(Math.round((oldFareMinor * pct) / 100), min) : 0;
|
|
|
|
|
const fareDifferenceMinor = newFareMinor - oldFareMinor;
|
|
|
|
|
return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function addisDay(d: Date): string {
|
|
|
|
|
return d.toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ActingUser = MeLikeUser & { id?: string; sub?: string };
|
|
|
|
|
|
|
|
|
|
type LegView = {
|
|
|
|
|
leg: number;
|
|
|
|
|
scheduleId: string;
|
|
|
|
|
originStationId: string | null;
|
|
|
|
|
destinationStationId: string | null;
|
|
|
|
|
departureAt: Date;
|
|
|
|
|
seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>;
|
|
|
|
|
coachTypeId: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same
|
|
|
|
|
// sequence — the client submits newSeatIds in that order (BookingSeat has no creation order).
|
|
|
|
|
const bookingInclude: Prisma.BookingInclude = {
|
|
|
|
|
schedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } },
|
|
|
|
|
returnSchedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } },
|
|
|
|
|
seats: { include: { seat: { include: { coach: { select: { coachTypeId: true } } } } }, orderBy: [{ passengerName: 'asc' }, { id: 'asc' }] },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class RescheduleService {
|
|
|
|
|
private readonly logger = new Logger(RescheduleService.name);
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private prisma: PrismaService,
|
|
|
|
|
private bookingsService: BookingsService,
|
|
|
|
|
private seatsService: SeatsService,
|
|
|
|
|
private ticketsService: TicketsService,
|
|
|
|
|
private paymentsService: PaymentsService,
|
|
|
|
|
private supplementaryCharges: SupplementaryChargesService,
|
|
|
|
|
private currencyService: CurrencyService,
|
|
|
|
|
private auditService: AuditService,
|
|
|
|
|
private eventEmitter: EventEmitter2,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
// ── Policy admin ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async listPolicies() {
|
|
|
|
|
const coachTypes = await this.prisma.coachType.findMany({
|
|
|
|
|
where: { type: { notIn: ['dining', 'baggage'] } },
|
|
|
|
|
include: { reschedulePolicy: true },
|
|
|
|
|
orderBy: { code: 'asc' },
|
|
|
|
|
});
|
|
|
|
|
return coachTypes.map((ct) => ({
|
|
|
|
|
coachTypeId: ct.id,
|
|
|
|
|
code: ct.code,
|
|
|
|
|
name: ct.name,
|
|
|
|
|
policy: ct.reschedulePolicy,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, 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.reschedulePolicy.findUnique({ where: { coachTypeId } });
|
|
|
|
|
const policy = await this.prisma.reschedulePolicy.upsert({
|
|
|
|
|
where: { coachTypeId },
|
|
|
|
|
update: dto,
|
|
|
|
|
create: { coachTypeId, ...dto },
|
|
|
|
|
});
|
|
|
|
|
await this.auditService.log({
|
|
|
|
|
userId: actorId,
|
|
|
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
|
|
|
entityType: AUDIT_ENTITIES.ReschedulePolicy,
|
|
|
|
|
entityId: policy.id,
|
|
|
|
|
oldData: before ?? undefined,
|
|
|
|
|
newData: { coachTypeCode: coachType.code, ...dto },
|
|
|
|
|
});
|
|
|
|
|
return policy;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Reads ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** What the portal needs before picking a new schedule: per-leg eligibility + the rule set. */
|
|
|
|
|
async getOptions(bookingRef: string, user: ActingUser) {
|
|
|
|
|
const booking = await this.loadOwnedBooking(bookingRef, user);
|
|
|
|
|
const legs = this.legsOf(booking);
|
|
|
|
|
const out = [];
|
|
|
|
|
for (const leg of legs) {
|
|
|
|
|
const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } });
|
|
|
|
|
const blockers = this.legBlockers(booking, leg, policy);
|
|
|
|
|
out.push({
|
|
|
|
|
leg: leg.leg,
|
|
|
|
|
scheduleId: leg.scheduleId,
|
|
|
|
|
originStationId: leg.originStationId,
|
|
|
|
|
destinationStationId: leg.destinationStationId,
|
|
|
|
|
departureAt: leg.departureAt,
|
|
|
|
|
coachTypeId: leg.coachTypeId,
|
|
|
|
|
seatCount: leg.seats.length,
|
|
|
|
|
passengerNames: leg.seats.map((s) => s.passengerName),
|
|
|
|
|
oldFareMinor: this.legFare(booking, leg),
|
|
|
|
|
policy: policy && {
|
|
|
|
|
feePercent: policy.feePercent,
|
|
|
|
|
feeMinMinor: policy.feeMinMinor,
|
|
|
|
|
routeChangeAllowed: policy.routeChangeAllowed,
|
|
|
|
|
sameDayAllowed: policy.sameDayAllowed,
|
|
|
|
|
sameDayFeePercent: policy.sameDayFeePercent,
|
|
|
|
|
sameDayFeeMinMinor: policy.sameDayFeeMinMinor,
|
|
|
|
|
cutoffMinutes: policy.cutoffMinutes,
|
|
|
|
|
},
|
|
|
|
|
canReschedule: blockers.length === 0,
|
|
|
|
|
blockers,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const reschedules = await this.prisma.bookingReschedule.findMany({
|
|
|
|
|
where: { bookingId: booking.id },
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
});
|
|
|
|
|
const pending = reschedules.find((r) => r.status === 'PENDING_PAYMENT');
|
|
|
|
|
const charge = pending?.supplementaryChargeId
|
|
|
|
|
? await this.prisma.supplementaryCharge.findUnique({
|
|
|
|
|
where: { id: pending.supplementaryChargeId },
|
|
|
|
|
select: { paymentToken: true, status: true, expiresAt: true },
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
return {
|
|
|
|
|
bookingRef: booking.bookingRef,
|
|
|
|
|
bookingType: booking.bookingType,
|
|
|
|
|
legs: out,
|
|
|
|
|
pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null,
|
|
|
|
|
history: reschedules.filter((r) => r.status !== 'PENDING_PAYMENT'),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Quote / create / apply ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async quote(bookingRef: string, dto: RescheduleQuoteDto, user: ActingUser) {
|
|
|
|
|
const booking = await this.loadOwnedBooking(bookingRef, user);
|
|
|
|
|
return this.buildQuote(booking, dto);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async create(bookingRef: string, dto: CreateRescheduleDto, user: ActingUser) {
|
|
|
|
|
const booking = await this.loadOwnedBooking(bookingRef, user);
|
|
|
|
|
const q = await this.buildQuote(booking, dto);
|
|
|
|
|
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 !== dto.newScheduleId) throw new BadRequestException('Seat hold is for a different schedule');
|
|
|
|
|
const held = new Set(hold.seatIds);
|
|
|
|
|
if (!dto.newSeatIds.every((id) => held.has(id))) throw new BadRequestException('Selected seats are not covered by the hold');
|
|
|
|
|
|
|
|
|
|
// Availability was enforced when the hold was taken (holdSeats checks holds + booked
|
|
|
|
|
// segments for the leg); tickets.generate() re-checks at apply time.
|
|
|
|
|
|
|
|
|
|
const requestedBy = user.id ?? user.sub ?? booking.passengerId;
|
|
|
|
|
const newDeparture = q.newDepartureAt;
|
|
|
|
|
const expiresAt = computePaymentDeadline(new Date(), newDeparture);
|
|
|
|
|
|
|
|
|
|
const reschedule = await this.prisma.bookingReschedule.create({
|
|
|
|
|
data: {
|
|
|
|
|
bookingId: booking.id,
|
|
|
|
|
leg: q.leg,
|
|
|
|
|
status: 'PENDING_PAYMENT',
|
|
|
|
|
requestedBy,
|
|
|
|
|
oldScheduleId: q.oldScheduleId,
|
|
|
|
|
newScheduleId: dto.newScheduleId,
|
|
|
|
|
oldOriginStationId: q.oldOriginStationId,
|
|
|
|
|
oldDestinationStationId: q.oldDestinationStationId,
|
|
|
|
|
newOriginStationId: dto.newOriginStationId,
|
|
|
|
|
newDestinationStationId: dto.newDestinationStationId,
|
|
|
|
|
oldSeatIds: q.oldSeatIds,
|
|
|
|
|
newSeatIds: dto.newSeatIds,
|
|
|
|
|
holdId: dto.holdId,
|
|
|
|
|
oldFareMinor: q.oldFareMinor,
|
|
|
|
|
newFareMinor: q.newFareMinor,
|
|
|
|
|
fareDifferenceMinor: q.fareDifferenceMinor,
|
|
|
|
|
feeMinor: q.feeMinor,
|
|
|
|
|
amountDueMinor: q.amountDueMinor,
|
|
|
|
|
isSameDay: q.isSameDay,
|
|
|
|
|
isRouteChange: q.isRouteChange,
|
|
|
|
|
expiresAt: q.amountDueMinor > 0 ? expiresAt : null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (q.amountDueMinor === 0) {
|
|
|
|
|
await this.apply(reschedule.id);
|
|
|
|
|
return { rescheduleId: reschedule.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Money owed: raise a supplementary charge (pay page /pay-balance/:token, SMS+email link) and
|
|
|
|
|
// keep the new seats held until the same deadline the charge carries.
|
|
|
|
|
const charge = await this.supplementaryCharges.create({
|
|
|
|
|
bookingRef: booking.bookingRef,
|
|
|
|
|
amountMinor: q.amountDueMinor,
|
|
|
|
|
reason: RESCHEDULE_CHARGE_REASON,
|
|
|
|
|
notes: `Reschedule leg ${q.leg} → schedule ${dto.newScheduleId}`,
|
|
|
|
|
createdBy: requestedBy,
|
|
|
|
|
expiresAt,
|
|
|
|
|
});
|
|
|
|
|
await this.prisma.bookingReschedule.update({
|
|
|
|
|
where: { id: reschedule.id },
|
|
|
|
|
data: { supplementaryChargeId: charge.id },
|
|
|
|
|
});
|
|
|
|
|
await this.seatsService.confirmSeats(dto.newSeatIds);
|
|
|
|
|
|
|
|
|
|
await this.auditService.log({
|
|
|
|
|
userId: requestedBy,
|
|
|
|
|
action: AUDIT_ACTIONS.CREATE,
|
|
|
|
|
entityType: AUDIT_ENTITIES.BookingReschedule,
|
|
|
|
|
entityId: reschedule.id,
|
|
|
|
|
newData: { bookingRef: booking.bookingRef, leg: q.leg, amountDueMinor: q.amountDueMinor, chargeId: charge.id },
|
|
|
|
|
});
|
|
|
|
|
return { rescheduleId: reschedule.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 r = await this.prisma.bookingReschedule.findUnique({ where: { supplementaryChargeId } });
|
|
|
|
|
if (!r || r.status !== 'PENDING_PAYMENT') return;
|
|
|
|
|
await this.apply(r.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Moves the booking leg: booking fields, seats, journey segments, tickets. */
|
|
|
|
|
async apply(rescheduleId: string) {
|
|
|
|
|
const r = await this.prisma.bookingReschedule.findUnique({ where: { id: rescheduleId } });
|
|
|
|
|
if (!r) throw new NotFoundException('Reschedule not found');
|
|
|
|
|
if (r.status !== 'PENDING_PAYMENT') return r;
|
|
|
|
|
|
|
|
|
|
const booking = await this.prisma.booking.findUnique({ where: { id: r.bookingId }, include: bookingInclude });
|
|
|
|
|
if (!booking) throw new NotFoundException('Booking not found');
|
|
|
|
|
const leg = this.legsOf(booking).find((l) => l.leg === r.leg);
|
|
|
|
|
if (!leg) throw new BadRequestException('Leg no longer exists on booking');
|
|
|
|
|
if (leg.seats.length !== r.newSeatIds.length) throw new BadRequestException('Seat count changed since quote');
|
|
|
|
|
|
|
|
|
|
const newTotal = Math.max(0, booking.totalMinor + r.fareDifferenceMinor);
|
|
|
|
|
const displayTotal =
|
|
|
|
|
booking.displayCurrency && booking.displayCurrency !== 'ETB'
|
|
|
|
|
? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any)
|
|
|
|
|
: newTotal;
|
|
|
|
|
const perSeatNew = this.splitFare(r.newFareMinor, leg.seats);
|
|
|
|
|
|
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
|
|
|
await tx.booking.update({
|
|
|
|
|
where: { id: booking.id },
|
|
|
|
|
data: {
|
|
|
|
|
...(r.leg === 1
|
|
|
|
|
? { scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId }
|
|
|
|
|
: { returnScheduleId: r.newScheduleId, returnOriginStationId: r.newOriginStationId, returnDestinationStationId: r.newDestinationStationId }),
|
|
|
|
|
totalMinor: newTotal,
|
|
|
|
|
displayTotalMinor: displayTotal,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
// Two passes so the (scheduleId, seatId) unique key never collides mid-update when a
|
|
|
|
|
// passenger takes a seat another passenger of the same booking is leaving.
|
|
|
|
|
for (const s of leg.seats) {
|
|
|
|
|
await tx.bookingSeat.update({ where: { id: s.id }, data: { scheduleId: `moving-${s.id}` } });
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < leg.seats.length; i++) {
|
|
|
|
|
await tx.bookingSeat.update({
|
|
|
|
|
where: { id: leg.seats[i].id },
|
|
|
|
|
data: { seatId: r.newSeatIds[i], scheduleId: r.newScheduleId, fareMinor: perSeatNew[i], seatLabelSnapshot: null },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await tx.bookingModification.create({
|
|
|
|
|
data: {
|
|
|
|
|
bookingId: booking.id,
|
|
|
|
|
modifiedBy: r.requestedBy,
|
|
|
|
|
modificationType: 'RESCHEDULE',
|
|
|
|
|
oldData: { leg: r.leg, scheduleId: r.oldScheduleId, originStationId: r.oldOriginStationId, destinationStationId: r.oldDestinationStationId, seatIds: r.oldSeatIds, fareMinor: r.oldFareMinor },
|
|
|
|
|
newData: { leg: r.leg, scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId, seatIds: r.newSeatIds, fareMinor: r.newFareMinor, feeMinor: r.feeMinor },
|
|
|
|
|
fareAdjustment: r.fareDifferenceMinor,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await tx.bookingReschedule.update({ where: { id: r.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(`Reschedule ${r.id}: journey segments failed: ${err instanceof Error ? err.message : err}`);
|
|
|
|
|
}
|
|
|
|
|
// Old tickets' SYSTEM seat blocks reference ticket ids that generate() is about to delete.
|
|
|
|
|
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(`Reschedule ${r.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// The new seats are owned by the journey now; the old seats' own booking-time hold (holds
|
|
|
|
|
// outlive confirmation until the payment deadline) would otherwise keep them HELD on the old
|
|
|
|
|
// schedule. Nobody else can hold a booked seat, so any hold there is this booking's.
|
|
|
|
|
await this.prisma.seatHold.deleteMany({
|
|
|
|
|
where: { OR: [{ id: r.holdId ?? '' }, { scheduleId: r.oldScheduleId, seatIds: { hasSome: r.oldSeatIds } }] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await this.auditService.log({
|
|
|
|
|
userId: r.requestedBy,
|
|
|
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
|
|
|
entityType: AUDIT_ENTITIES.Booking,
|
|
|
|
|
entityId: booking.id,
|
|
|
|
|
oldData: { leg: r.leg, scheduleId: r.oldScheduleId, seatIds: r.oldSeatIds },
|
|
|
|
|
newData: { leg: r.leg, scheduleId: r.newScheduleId, seatIds: r.newSeatIds, feeMinor: r.feeMinor, fareDifferenceMinor: r.fareDifferenceMinor, rescheduleId: r.id },
|
|
|
|
|
});
|
|
|
|
|
this.eventEmitter.emit('booking.rescheduled', { booking: fresh ?? booking, reschedule: r });
|
|
|
|
|
return { ...r, status: 'APPLIED' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Cron hook: unpaid reschedules past their payment deadline. The seat hold lapses by itself. */
|
|
|
|
|
async expireStale(now = new Date()): Promise<number> {
|
|
|
|
|
const stale = await this.prisma.bookingReschedule.findMany({
|
|
|
|
|
where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } },
|
|
|
|
|
select: { id: true, supplementaryChargeId: true },
|
|
|
|
|
});
|
|
|
|
|
for (const r of stale) {
|
|
|
|
|
await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } });
|
|
|
|
|
if (r.supplementaryChargeId) {
|
|
|
|
|
await this.prisma.supplementaryCharge.updateMany({
|
|
|
|
|
where: { id: r.supplementaryChargeId, status: 'PENDING' },
|
|
|
|
|
data: { status: 'EXPIRED' },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return stale.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Internals ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
private async loadOwnedBooking(bookingRef: string, user: ActingUser) {
|
|
|
|
|
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude });
|
|
|
|
|
if (!booking) throw new NotFoundException('Booking not found');
|
|
|
|
|
const iamUserId = user.id ?? user.sub;
|
|
|
|
|
if (!iamUserId) throw new ForbiddenException();
|
|
|
|
|
if (hasPassengerPermission(user, PASSENGER_PERMS.bookings.reschedule)) return booking;
|
|
|
|
|
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
|
|
|
|
|
if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking');
|
|
|
|
|
return booking;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private legsOf(booking: any): LegView[] {
|
|
|
|
|
const legs: LegView[] = [];
|
|
|
|
|
const seatsOf = (n: number) =>
|
|
|
|
|
(booking.seats as any[])
|
|
|
|
|
.filter((s) => (s.leg ?? 1) === n)
|
|
|
|
|
.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 });
|
|
|
|
|
}
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
return legs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The leg's original fare: per-seat amounts when recorded, else the whole booking (one-way). */
|
|
|
|
|
private legFare(booking: any, leg: LegView): number {
|
|
|
|
|
const recorded = leg.seats.reduce((sum, s) => sum + (s.fareMinor ?? 0), 0);
|
|
|
|
|
if (recorded > 0) return recorded;
|
|
|
|
|
return booking.bookingType === 'ONE_WAY' ? booking.totalMinor : Math.round(booking.totalMinor / 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private legBlockers(booking: any, leg: LegView, policy: any, now = new Date()): string[] {
|
|
|
|
|
const blockers: string[] = [];
|
|
|
|
|
if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be rescheduled.');
|
|
|
|
|
if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be rescheduled.');
|
|
|
|
|
// ponytail: a boarded leg can't be moved and tickets.generate() rebuilds every leg, so a
|
|
|
|
|
// 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.');
|
|
|
|
|
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.`);
|
|
|
|
|
}
|
|
|
|
|
return blockers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async buildQuote(booking: any, dto: RescheduleQuoteDto) {
|
|
|
|
|
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 policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } });
|
|
|
|
|
const blockers = this.legBlockers(booking, leg, policy);
|
|
|
|
|
|
|
|
|
|
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.');
|
|
|
|
|
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.');
|
|
|
|
|
|
|
|
|
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
|
|
|
|
where: { id: dto.newScheduleId },
|
|
|
|
|
include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
|
|
|
|
|
});
|
|
|
|
|
if (!schedule) throw new NotFoundException('New schedule not found');
|
|
|
|
|
const now = new Date();
|
|
|
|
|
if (schedule.departureAt <= now || schedule.status !== 'SCHEDULED') blockers.push('The selected departure is no longer bookable.');
|
|
|
|
|
if (schedule.id === leg.scheduleId && dto.newOriginStationId === leg.originStationId && dto.newDestinationStationId === leg.destinationStationId) {
|
|
|
|
|
blockers.push('Pick a different departure, route or date.');
|
|
|
|
|
}
|
|
|
|
|
const originStop = schedule.stopTimes.find((s) => s.stationId === dto.newOriginStationId);
|
|
|
|
|
const destStop = schedule.stopTimes.find((s) => s.stationId === dto.newDestinationStationId);
|
|
|
|
|
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) blockers.push('Origin/destination are not valid for this schedule.');
|
|
|
|
|
|
|
|
|
|
const isRouteChange = dto.newOriginStationId !== leg.originStationId || dto.newDestinationStationId !== leg.destinationStationId;
|
|
|
|
|
if (isRouteChange && policy && !policy.routeChangeAllowed) blockers.push('Route changes are not permitted for this fare class.');
|
|
|
|
|
const isSameDay = addisDay(schedule.departureAt) === addisDay(leg.departureAt);
|
|
|
|
|
if (isSameDay && policy && !policy.sameDayAllowed) blockers.push('Same-day changes are not permitted for this fare class.');
|
|
|
|
|
|
|
|
|
|
// Keep the round trip chronologically sane.
|
|
|
|
|
if (booking.bookingType === 'ROUND_TRIP') {
|
|
|
|
|
if (legNo === 1 && booking.returnSchedule && schedule.arrivalAt >= booking.returnSchedule.departureAt) blockers.push('New outbound must arrive before the return departs.');
|
|
|
|
|
if (legNo === 2 && booking.schedule && schedule.departureAt <= booking.schedule.arrivalAt) blockers.push('New return must depart after the outbound arrives.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New seats: same coach type as booked (no class change in this step), priced per seat.
|
|
|
|
|
const seats = await this.prisma.seat.findMany({
|
|
|
|
|
where: { id: { in: dto.newSeatIds } },
|
|
|
|
|
include: { coach: { include: { coachType: { include: { seatClasses: { where: { isActive: true } } } } } } },
|
|
|
|
|
});
|
|
|
|
|
const seatById = new Map(seats.map((s) => [s.id, s]));
|
|
|
|
|
let newFareMinor = 0;
|
|
|
|
|
if (originStop && destStop && seats.length === dto.newSeatIds.length) {
|
|
|
|
|
const nationalityType = booking.displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL';
|
|
|
|
|
// ponytail: passenger nationality isn't stored on the booking; currency is the proxy the
|
|
|
|
|
// search/fare code already uses (ETB/DJF = local, USD = international).
|
|
|
|
|
const nationality = booking.displayCurrency === 'DJF' ? 'Djiboutian' : booking.displayCurrency === 'ETB' ? 'Ethiopian' : undefined;
|
|
|
|
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
|
|
|
|
for (let i = 0; i < dto.newSeatIds.length; i++) {
|
|
|
|
|
const seat = seatById.get(dto.newSeatIds[i])!;
|
|
|
|
|
if (seat.coach.coachTypeId !== leg.coachTypeId) { blockers.push('New seats must be in the same class as the original booking.'); break; }
|
|
|
|
|
const oldSeat = leg.seats[i];
|
|
|
|
|
if (oldSeat.fareMinor === 0) continue; // free child keeps riding free
|
|
|
|
|
const seatClass = this.pickSeatClass(seat.coach.coachType.seatClasses, seat.bedPosition, nationalityType);
|
|
|
|
|
if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; }
|
|
|
|
|
newFareMinor += await this.bookingsService.getBaseFare(
|
|
|
|
|
schedule.id, seatClass.id, segmentRoute, undefined, nationality,
|
|
|
|
|
originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} else if (seats.length !== dto.newSeatIds.length) {
|
|
|
|
|
blockers.push('One or more selected seats do not exist.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const oldFareMinor = this.legFare(booking, leg);
|
|
|
|
|
const amounts = policy
|
|
|
|
|
? computeRescheduleAmounts(policy, oldFareMinor, newFareMinor, isSameDay)
|
|
|
|
|
: { feeMinor: 0, fareDifferenceMinor: newFareMinor - oldFareMinor, amountDueMinor: 0 };
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
allowed: blockers.length === 0,
|
|
|
|
|
blockers: Array.from(new Set(blockers)),
|
|
|
|
|
leg: legNo,
|
|
|
|
|
oldScheduleId: leg.scheduleId,
|
|
|
|
|
oldOriginStationId: leg.originStationId,
|
|
|
|
|
oldDestinationStationId: leg.destinationStationId,
|
|
|
|
|
oldSeatIds: leg.seats.map((s) => s.seatId),
|
|
|
|
|
newScheduleId: schedule.id,
|
|
|
|
|
newDepartureAt: schedule.departureAt,
|
|
|
|
|
isSameDay,
|
|
|
|
|
isRouteChange,
|
|
|
|
|
oldFareMinor,
|
|
|
|
|
newFareMinor,
|
|
|
|
|
...amounts,
|
|
|
|
|
currency: 'ETB',
|
|
|
|
|
cutoffAt: policy ? new Date(leg.departureAt.getTime() - policy.cutoffMinutes * 60_000) : null,
|
|
|
|
|
policy: policy && { feePercent: policy.feePercent, feeMinMinor: policy.feeMinMinor, sameDayFeePercent: policy.sameDayFeePercent, sameDayFeeMinMinor: policy.sameDayFeeMinMinor, routeChangeAllowed: policy.routeChangeAllowed, sameDayAllowed: policy.sameDayAllowed, cutoffMinutes: policy.cutoffMinutes },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Mirrors SearchService's class matching: nationality filter, then bed position. */
|
|
|
|
|
private 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 the leg fare over seats, free children (fare 0) stay 0; rounding lands on the last paid seat. */
|
|
|
|
|
private splitFare(total: number, seats: LegView['seats']): 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;
|
|
|
|
|
}
|
|
|
|
|
}
|