mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
feat: ( reschedule ) implement booking reschedule
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
-- Rescheduling (passenger policy §3). One policy row per fare class — fare classes map 1:1 onto
|
||||
-- coach types (HSC = Standard, HBC = Flex, SBC = Premium) — plus a per-leg request table.
|
||||
-- Additive and idempotent; the seed below only inserts for coach types that exist.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "passenger"."ReschedulePolicy" (
|
||||
"id" TEXT NOT NULL,
|
||||
"coachTypeId" TEXT NOT NULL,
|
||||
"feePercent" INTEGER NOT NULL DEFAULT 0,
|
||||
"feeMinMinor" INTEGER NOT NULL DEFAULT 0,
|
||||
"routeChangeAllowed" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sameDayAllowed" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sameDayFeePercent" INTEGER NOT NULL DEFAULT 0,
|
||||
"sameDayFeeMinMinor" INTEGER NOT NULL DEFAULT 0,
|
||||
"cutoffMinutes" INTEGER NOT NULL DEFAULT 60,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ReschedulePolicy_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ReschedulePolicy_coachTypeId_key" ON "passenger"."ReschedulePolicy"("coachTypeId");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "passenger"."ReschedulePolicy"
|
||||
ADD CONSTRAINT "ReschedulePolicy_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"."BookingReschedule" (
|
||||
"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,
|
||||
"oldScheduleId" TEXT NOT NULL,
|
||||
"newScheduleId" TEXT NOT NULL,
|
||||
"oldOriginStationId" TEXT,
|
||||
"oldDestinationStationId" TEXT,
|
||||
"newOriginStationId" TEXT NOT NULL,
|
||||
"newDestinationStationId" TEXT NOT NULL,
|
||||
"oldSeatIds" TEXT[],
|
||||
"newSeatIds" TEXT[],
|
||||
"holdId" TEXT,
|
||||
"oldFareMinor" INTEGER NOT NULL,
|
||||
"newFareMinor" INTEGER NOT NULL,
|
||||
"fareDifferenceMinor" INTEGER NOT NULL,
|
||||
"feeMinor" INTEGER NOT NULL,
|
||||
"amountDueMinor" INTEGER NOT NULL,
|
||||
"isSameDay" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isRouteChange" BOOLEAN NOT NULL DEFAULT false,
|
||||
"supplementaryChargeId" TEXT,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"appliedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "BookingReschedule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "BookingReschedule_supplementaryChargeId_key" ON "passenger"."BookingReschedule"("supplementaryChargeId");
|
||||
CREATE INDEX IF NOT EXISTS "BookingReschedule_bookingId_status_idx" ON "passenger"."BookingReschedule"("bookingId", "status");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "passenger"."BookingReschedule"
|
||||
ADD CONSTRAINT "BookingReschedule_bookingId_fkey" FOREIGN KEY ("bookingId")
|
||||
REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Seed the policy doc's §3 values. Same-day fee is stored as an absolute rule per class:
|
||||
-- Flex's "50% of the Standard fee" (30% / min 500 ETB) is 15% / min 250 ETB.
|
||||
INSERT INTO "passenger"."ReschedulePolicy"
|
||||
("id", "coachTypeId", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes", "updatedAt")
|
||||
SELECT gen_random_uuid()::text, ct."id", v."feePercent", v."feeMinMinor", v."routeChangeAllowed", v."sameDayAllowed", v."sameDayFeePercent", v."sameDayFeeMinMinor", v."cutoffMinutes", CURRENT_TIMESTAMP
|
||||
FROM (VALUES
|
||||
('HSC', 30, 50000, false, false, 0, 0, 120),
|
||||
('HBC', 0, 0, true, true, 15, 25000, 60),
|
||||
('SBC', 0, 0, true, true, 0, 0, 60)
|
||||
) AS v("code", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes")
|
||||
JOIN "passenger"."CoachType" ct ON ct."code" = v."code"
|
||||
ON CONFLICT ("coachTypeId") DO NOTHING;
|
||||
@@ -80,6 +80,7 @@ model CoachType {
|
||||
updatedAt DateTime @updatedAt
|
||||
coaches Coach[]
|
||||
seatClasses SeatClass[]
|
||||
reschedulePolicy ReschedulePolicy?
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -567,6 +568,7 @@ model Booking {
|
||||
foodOrders FoodOrder[]
|
||||
agentBooking AgentBooking?
|
||||
modifications BookingModification[]
|
||||
reschedules BookingReschedule[]
|
||||
cancellation BookingCancellation?
|
||||
baggage BaggageBooking[]
|
||||
excessBaggageCharges ExcessBaggageCharge[]
|
||||
@@ -1204,6 +1206,61 @@ model AgentCommission {
|
||||
@@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.
|
||||
model ReschedulePolicy {
|
||||
id String @id @default(uuid())
|
||||
coachTypeId String @unique
|
||||
feePercent Int @default(0) // % of the leg's original fare
|
||||
feeMinMinor Int @default(0) // fee floor, ETB minor units
|
||||
routeChangeAllowed Boolean @default(true)
|
||||
sameDayAllowed Boolean @default(true)
|
||||
sameDayFeePercent Int @default(0) // same-day change: % of the leg's original fare (replaces feePercent)
|
||||
sameDayFeeMinMinor Int @default(0) // same-day change: fee floor, ETB minor units
|
||||
cutoffMinutes Int @default(60) // reject when departure - now < this
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
/// One reschedule request for one leg of a booking. PENDING_PAYMENT while the supplementary
|
||||
/// charge is unpaid; APPLIED once the booking was moved; EXPIRED when the payment deadline passed.
|
||||
model BookingReschedule {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
leg Int @default(1) // 1 = outbound, 2 = return
|
||||
status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED
|
||||
requestedBy String // passengerId or IAM user id
|
||||
oldScheduleId String
|
||||
newScheduleId String
|
||||
oldOriginStationId String?
|
||||
oldDestinationStationId String?
|
||||
newOriginStationId String
|
||||
newDestinationStationId String
|
||||
oldSeatIds String[]
|
||||
newSeatIds String[]
|
||||
holdId String?
|
||||
oldFareMinor Int
|
||||
newFareMinor Int
|
||||
fareDifferenceMinor Int // new - old; negative = forfeited for now
|
||||
feeMinor Int
|
||||
amountDueMinor Int // fee + max(0, difference)
|
||||
isSameDay Boolean @default(false)
|
||||
isRouteChange Boolean @default(false)
|
||||
supplementaryChargeId String? @unique
|
||||
expiresAt DateTime?
|
||||
appliedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@index([bookingId, status])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model BookingModification {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
|
||||
@@ -657,6 +657,7 @@ 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}}.' },
|
||||
// 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' },
|
||||
|
||||
@@ -63,6 +63,7 @@ import { ConfigurableFareModule } from "./modules/configurable-fare/configurable
|
||||
import { SegmentFareSeeder } from "./seed/segment-fare.seeder";
|
||||
|
||||
import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
import { RescheduleModule } from './modules/reschedule/reschedule.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -149,6 +150,7 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
TasksModule,
|
||||
AppReleasesModule,
|
||||
ConfigurableFareModule,
|
||||
RescheduleModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },
|
||||
|
||||
@@ -86,6 +86,8 @@ export const AUDIT_ENTITIES = {
|
||||
// Operations
|
||||
Ticket: 'Ticket',
|
||||
Booking: 'Booking',
|
||||
BookingReschedule: 'BookingReschedule',
|
||||
ReschedulePolicy: 'ReschedulePolicy',
|
||||
} as const;
|
||||
|
||||
export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES];
|
||||
|
||||
@@ -28,7 +28,7 @@ type EmployeeLike = {
|
||||
delegatedPositions?: PositionLike[];
|
||||
};
|
||||
|
||||
type MeLikeUser = {
|
||||
export type MeLikeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?: EmployeeLike | EmployeeLike[] | null;
|
||||
|
||||
@@ -26,7 +26,6 @@ import { BookingsService } from "./bookings.service";
|
||||
import { GuestBookingService } from "./guest-booking.service";
|
||||
import {
|
||||
CreateBookingDto,
|
||||
ModifyBookingDto,
|
||||
CancelBookingDto,
|
||||
} from "./bookings.dto";
|
||||
import {
|
||||
@@ -663,22 +662,6 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Patch(":bookingRef/modify")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "Modify booking seats or trip",
|
||||
description: "Allows modification of confirmed bookings before departure",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Booking modified successfully" })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Cannot modify cancelled or past bookings",
|
||||
})
|
||||
modify(@Req() req: any, @Body() dto: ModifyBookingDto) {
|
||||
return this.service.modify(dto, req.user?.id);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
|
||||
@@ -214,13 +214,6 @@ export class CreateBookingDto {
|
||||
@IsOptional() @IsString() returnLeg2SeatClassId?: string;
|
||||
}
|
||||
|
||||
export class ModifyBookingDto {
|
||||
@ApiProperty() @IsString() bookingRef: string;
|
||||
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
|
||||
@ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[];
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
|
||||
}
|
||||
|
||||
export class CancelBookingDto {
|
||||
@ApiProperty() @IsString() bookingRef: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { CreateBookingDto } from './bookings.dto';
|
||||
import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
@@ -1905,7 +1905,7 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getBaseFare(
|
||||
async getBaseFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
segmentRoute?: string,
|
||||
@@ -2231,22 +2231,6 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
async modify(dto: ModifyBookingDto, iamUserId?: string) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
|
||||
if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
|
||||
|
||||
const oldSeats = booking.seats.map(s => s.seatId);
|
||||
await this.prisma.bookingModification.create({
|
||||
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
|
||||
});
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
await this.seatsService.confirmSeats(dto.newSeatIds);
|
||||
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } });
|
||||
return { modified: true, bookingRef: dto.bookingRef };
|
||||
}
|
||||
|
||||
async cancel(bookingRef: string, reason?: string, iamUserId?: string) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
@@ -731,6 +731,24 @@ export class NotificationsService {
|
||||
);
|
||||
}
|
||||
|
||||
@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'],
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('booking.cancelled')
|
||||
async onBookingCancelled(payload: any) {
|
||||
const booking = payload.booking;
|
||||
|
||||
@@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
PaymentSyncService,
|
||||
ServiceAuthGuard,
|
||||
],
|
||||
exports: [PaymentClientService, PaymentsService],
|
||||
exports: [PaymentClientService, PaymentsService, SupplementaryChargesService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -1619,6 +1619,7 @@ export class PaymentsService {
|
||||
settledCurrency: event.currency,
|
||||
},
|
||||
});
|
||||
this.eventEmitter.emit("supplementary-charge.paid", { chargeId: charge.id });
|
||||
}
|
||||
return { processed: true, alreadyFinalized: count === 0 };
|
||||
}
|
||||
@@ -1996,7 +1997,7 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
private async createJourneySegments(
|
||||
async createJourneySegments(
|
||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||
) {
|
||||
const b = booking as any;
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('SupplementaryChargesService — audit', () => {
|
||||
};
|
||||
audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
// Constructor order: prisma, audit, sms, email, paymentClient, currency.
|
||||
// Constructor order: prisma, audit, sms, email, paymentClient, currency, eventEmitter.
|
||||
service = new SupplementaryChargesService(
|
||||
prisma as any,
|
||||
audit as any,
|
||||
@@ -60,6 +60,7 @@ describe('SupplementaryChargesService — audit', () => {
|
||||
{ sendEmail: jest.fn() } as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
return row;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@edr/types';
|
||||
import { PaymentPlatformDto } from './payments.dto';
|
||||
import { PaymentMethodType } from '@prisma/client';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
|
||||
|
||||
@@ -45,6 +46,7 @@ export class SupplementaryChargesService {
|
||||
private emailClient: EmailClientService,
|
||||
private paymentClient: PaymentClientService,
|
||||
private currencyService: CurrencyService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async create(dto: {
|
||||
@@ -53,6 +55,8 @@ export class SupplementaryChargesService {
|
||||
reason: string;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
/** Overrides the default 72h link lifetime (a reschedule charge must die with its seat hold). */
|
||||
expiresAt?: Date;
|
||||
}) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: dto.bookingRef },
|
||||
@@ -64,7 +68,7 @@ export class SupplementaryChargesService {
|
||||
}
|
||||
if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
|
||||
const expiresAt = dto.expiresAt ?? new Date(Date.now() + CHARGE_TTL_MS);
|
||||
const charge = await this.prisma.supplementaryCharge.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
@@ -170,6 +174,7 @@ export class SupplementaryChargesService {
|
||||
providerTxnId: providerTxnId ?? null,
|
||||
},
|
||||
});
|
||||
this.eventEmitter.emit('supplementary-charge.paid', { chargeId: id });
|
||||
}
|
||||
|
||||
return updated!;
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => {
|
||||
{} as any,
|
||||
paymentClient as any,
|
||||
new CurrencyService(prisma as any),
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Body, Controller, 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 { RescheduleService } from './reschedule.service';
|
||||
import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
|
||||
|
||||
@ApiTags('Reschedule')
|
||||
@Controller()
|
||||
export class RescheduleController {
|
||||
constructor(private service: RescheduleService) {}
|
||||
|
||||
@Get('reschedule/policies')
|
||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reschedule policy per coach type (fare class)' })
|
||||
listPolicies() {
|
||||
return this.service.listPolicies();
|
||||
}
|
||||
|
||||
@Patch('reschedule/policies/:coachTypeId')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
|
||||
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
|
||||
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingRef/reschedule')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reschedule eligibility per leg, pending request, history' })
|
||||
options(@Req() req: any, @Param('bookingRef') bookingRef: string) {
|
||||
return this.service.getOptions(bookingRef, req.user);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingRef/reschedule/quote')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Itemised quote (fee, fare difference, amount due) for a proposed change' })
|
||||
quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: RescheduleQuoteDto) {
|
||||
return this.service.quote(bookingRef, dto, req.user);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingRef/reschedule')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reschedule a leg. Applies immediately when nothing is due, otherwise returns a payment token' })
|
||||
create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateRescheduleDto) {
|
||||
return this.service.create(bookingRef, dto, req.user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateReschedulePolicyDto {
|
||||
@ApiPropertyOptional({ example: 30, description: '% of the leg fare charged as a change fee' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
|
||||
feePercent?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 50000, description: 'Fee floor in ETB minor units (500 ETB = 50000)' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
feeMinMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: false })
|
||||
@IsOptional() @IsBoolean()
|
||||
routeChangeAllowed?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false })
|
||||
@IsOptional() @IsBoolean()
|
||||
sameDayAllowed?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: 15, description: 'Same-day change: % of the leg fare (replaces feePercent)' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100)
|
||||
sameDayFeePercent?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 25000, description: 'Same-day change: fee floor in ETB minor units' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
sameDayFeeMinMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120, description: 'Changes are refused this many minutes before departure' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
|
||||
cutoffMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional() @IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class RescheduleQuoteDto {
|
||||
@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: 'schedule-uuid' })
|
||||
@IsString() newScheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() newOriginStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() newDestinationStationId: string;
|
||||
|
||||
@ApiProperty({ type: [String], description: 'One seat per seated passenger of the leg, in BookingSeat order' })
|
||||
@IsArray() @ArrayMinSize(1) @IsString({ each: true })
|
||||
newSeatIds: string[];
|
||||
}
|
||||
|
||||
export class CreateRescheduleDto extends RescheduleQuoteDto {
|
||||
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold on the new schedule covering newSeatIds (POST /seats/hold)' })
|
||||
@IsString() holdId: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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 { TicketsModule } from '../tickets/tickets.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { RescheduleController } from './reschedule.controller';
|
||||
import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service';
|
||||
|
||||
/**
|
||||
* RescheduleService is request-scoped by transitivity (AuditService injects REQUEST), and Nest
|
||||
* never fires @OnEvent on request-scoped providers — so the listener lives on this singleton and
|
||||
* resolves the service per event, the same way TasksService reaches PaymentsService.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RescheduleEventsListener {
|
||||
private readonly logger = new Logger(RescheduleEventsListener.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(RescheduleService, undefined, { strict: false });
|
||||
await service.applyForCharge(payload.chargeId);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to apply reschedule for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule],
|
||||
controllers: [RescheduleController],
|
||||
providers: [RescheduleService, RescheduleEventsListener],
|
||||
exports: [RescheduleService],
|
||||
})
|
||||
export class RescheduleModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { addisDay, computeRescheduleAmounts } from './reschedule.service';
|
||||
|
||||
const standard = { feePercent: 30, feeMinMinor: 50000, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 };
|
||||
const flex = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 15, sameDayFeeMinMinor: 25000 };
|
||||
const premium = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 };
|
||||
|
||||
describe('computeRescheduleAmounts (policy §3)', () => {
|
||||
it('Standard: 30% of fare, floored at 500 ETB, plus positive fare difference', () => {
|
||||
// 1000 ETB fare → 30% = 300 < 500 floor
|
||||
expect(computeRescheduleAmounts(standard, 100000, 120000, false)).toEqual({ feeMinor: 50000, fareDifferenceMinor: 20000, amountDueMinor: 70000 });
|
||||
// 3000 ETB fare → 30% = 900 > floor
|
||||
expect(computeRescheduleAmounts(standard, 300000, 300000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: 0, amountDueMinor: 90000 });
|
||||
});
|
||||
|
||||
it('negative fare difference is recorded but never paid out', () => {
|
||||
expect(computeRescheduleAmounts(standard, 300000, 200000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: -100000, amountDueMinor: 90000 });
|
||||
expect(computeRescheduleAmounts(flex, 300000, 200000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: -100000, amountDueMinor: 0 });
|
||||
});
|
||||
|
||||
it('Flex: free, fare difference only; same-day is 15% min 250 ETB', () => {
|
||||
expect(computeRescheduleAmounts(flex, 100000, 150000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: 50000, amountDueMinor: 50000 });
|
||||
expect(computeRescheduleAmounts(flex, 100000, 100000, true)).toEqual({ feeMinor: 25000, fareDifferenceMinor: 0, amountDueMinor: 25000 });
|
||||
expect(computeRescheduleAmounts(flex, 400000, 400000, true)).toEqual({ feeMinor: 60000, fareDifferenceMinor: 0, amountDueMinor: 60000 });
|
||||
});
|
||||
|
||||
it('Premium: always free, even same-day', () => {
|
||||
expect(computeRescheduleAmounts(premium, 500000, 500000, true).amountDueMinor).toBe(0);
|
||||
expect(computeRescheduleAmounts(premium, 500000, 560000, true).amountDueMinor).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addisDay', () => {
|
||||
it('compares calendar days in Africa/Addis_Ababa (UTC+3), not UTC', () => {
|
||||
expect(addisDay(new Date('2026-09-01T21:30:00Z'))).toBe('2026-09-02');
|
||||
expect(addisDay(new Date('2026-09-01T20:30:00Z'))).toBe('2026-09-01');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -738,9 +738,11 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking. The segment FK has no
|
||||
// ON DELETE CASCADE, so segments go first or the journey delete fails on a ticketed booking.
|
||||
async releaseSeats(bookingId: string) {
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
||||
await this.prisma.journeySegment.deleteMany({ where: { journey: { bookingId } } });
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } });
|
||||
}
|
||||
|
||||
async getBlockedSeats() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
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 { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -510,6 +511,21 @@ export class TasksService {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: reschedule requests whose payment deadline passed → EXPIRED
|
||||
// (their supplementary charge too). The new-seat hold lapses on its own.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async expireStaleReschedules() {
|
||||
try {
|
||||
const reschedule = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false });
|
||||
const n = await reschedule.expireStale();
|
||||
if (n > 0) this.logger.log(`Expired ${n} unpaid reschedule request(s)`);
|
||||
} catch (err) {
|
||||
this.logger.error(`expireStaleReschedules failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron('0 2 * * *')
|
||||
async purgeExpiredData() {
|
||||
const now = new Date();
|
||||
|
||||
@@ -18,6 +18,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
|
||||
perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'),
|
||||
perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'),
|
||||
perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'),
|
||||
perm('0c5e7a2b-9d41-4f7e-8b36-2a1c6d9e4f50', 'edr_passenger_app:bookings:reschedule', 'Reschedule bookings'),
|
||||
perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'),
|
||||
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
|
||||
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
|
||||
@@ -77,6 +78,7 @@ export const PASSENGER_PERMS = {
|
||||
view: 'edr_passenger_app:bookings:view',
|
||||
manage: 'edr_passenger_app:bookings:manage',
|
||||
cancel: 'edr_passenger_app:bookings:cancel',
|
||||
reschedule: 'edr_passenger_app:bookings:reschedule',
|
||||
},
|
||||
passengers: {
|
||||
view: 'edr_passenger_app:passengers:view',
|
||||
@@ -181,6 +183,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
stationMaster: [
|
||||
PASSENGER_PERMS.bookings.view,
|
||||
PASSENGER_PERMS.bookings.manage,
|
||||
PASSENGER_PERMS.bookings.reschedule,
|
||||
PASSENGER_PERMS.tickets.view,
|
||||
PASSENGER_PERMS.tickets.manage,
|
||||
PASSENGER_PERMS.tickets.generate,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { systemConfigApi } from '@/lib/api';
|
||||
import { systemConfigApi, reschedulePolicyApi, type ReschedulePolicyRow } from '@/lib/api';
|
||||
|
||||
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
|
||||
type Tab = 'general' | 'payment' | 'integrations' | 'configurations' | 'reschedule';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('general');
|
||||
@@ -57,6 +57,7 @@ export default function SettingsPage() {
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'configurations', label: 'Configurations' },
|
||||
{ id: 'reschedule', label: 'Reschedule Policy' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -111,6 +112,8 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'reschedule' && <ReschedulePolicyTab />}
|
||||
|
||||
{activeTab === 'configurations' && (
|
||||
<div className="card space-y-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>
|
||||
@@ -224,3 +227,125 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PolicyForm = NonNullable<ReschedulePolicyRow['policy']>;
|
||||
|
||||
const EMPTY_POLICY: PolicyForm = {
|
||||
feePercent: 0, feeMinMinor: 0, routeChangeAllowed: true, sameDayAllowed: true,
|
||||
sameDayFeePercent: 0, sameDayFeeMinMinor: 0, cutoffMinutes: 60, isActive: true,
|
||||
};
|
||||
|
||||
/** Policy §3 — one editable row per fare class (HSC = Standard, HBC = Flex, SBC = Premium). Money is entered in ETB, stored in minor units. */
|
||||
function ReschedulePolicyTab() {
|
||||
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
|
||||
const [forms, setForms] = useState<Record<string, PolicyForm>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
reschedulePolicyApi.list()
|
||||
.then((data) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setRows(list);
|
||||
setForms(Object.fromEntries(list.map((r) => [r.coachTypeId, { ...EMPTY_POLICY, ...(r.policy ?? {}) }])));
|
||||
})
|
||||
.catch(() => setMessage('Failed to load policies.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const setField = (id: string, patch: Partial<PolicyForm>) =>
|
||||
setForms((f) => ({ ...f, [id]: { ...f[id], ...patch } }));
|
||||
|
||||
const save = async (id: string) => {
|
||||
setSaving(id);
|
||||
setMessage('');
|
||||
try {
|
||||
await reschedulePolicyApi.update(id, forms[id]);
|
||||
setMessage('Saved.');
|
||||
} catch {
|
||||
setMessage('Failed to save.');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const etb = (minor: number) => String(minor / 100);
|
||||
const minor = (etbValue: string) => Math.round(Number(etbValue || 0) * 100);
|
||||
|
||||
if (loading) return <div className="card"><p className="text-sm text-muted-foreground">Loading...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="card space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Rescheduling rules per fare class</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is not refunded.
|
||||
Same-day = new departure on the same calendar day as the original.
|
||||
</p>
|
||||
</div>
|
||||
{rows.map((r) => {
|
||||
const f = forms[r.coachTypeId];
|
||||
return (
|
||||
<div key={r.coachTypeId} className="border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">{r.code}</span>
|
||||
<span className="text-muted-foreground"> — {r.name}</span>
|
||||
{!r.policy && <span className="ml-2 text-xs text-amber-600">no policy yet (rescheduling disabled)</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={f.isActive} onChange={(e) => setField(r.coachTypeId, { isActive: e.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="label">Fee (% of fare)</label>
|
||||
<input type="number" min="0" max="100" className="input" value={f.feePercent} onChange={(e) => setField(r.coachTypeId, { feePercent: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">Minimum fee (ETB)</label>
|
||||
<input type="number" min="0" step="0.01" className="input" value={etb(f.feeMinMinor)} onChange={(e) => setField(r.coachTypeId, { feeMinMinor: minor(e.target.value) })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">Cutoff before departure (min)</label>
|
||||
<input type="number" min="0" className="input" value={f.cutoffMinutes} onChange={(e) => setField(r.coachTypeId, { cutoffMinutes: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">Route change</label>
|
||||
<label className="flex items-center gap-2 text-sm h-10">
|
||||
<input type="checkbox" checked={f.routeChangeAllowed} onChange={(e) => setField(r.coachTypeId, { routeChangeAllowed: e.target.checked })} />
|
||||
Allowed
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">Same-day change</label>
|
||||
<label className="flex items-center gap-2 text-sm h-10">
|
||||
<input type="checkbox" checked={f.sameDayAllowed} onChange={(e) => setField(r.coachTypeId, { sameDayAllowed: e.target.checked })} />
|
||||
Allowed
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">Same-day fee (% of fare)</label>
|
||||
<input type="number" min="0" max="100" className="input" disabled={!f.sameDayAllowed} value={f.sameDayFeePercent} onChange={(e) => setField(r.coachTypeId, { sameDayFeePercent: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="label">Same-day minimum fee (ETB)</label>
|
||||
<input type="number" min="0" step="0.01" className="input" disabled={!f.sameDayAllowed} value={etb(f.sameDayFeeMinMinor)} onChange={(e) => setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button className="btn btn-primary flex items-center gap-2" disabled={saving === r.coachTypeId} onClick={() => save(r.coachTypeId)}>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving === r.coachTypeId ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && <p className="text-sm text-muted-foreground">No passenger coach types found.</p>}
|
||||
{message && <span className="text-sm text-muted-foreground">{message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -527,6 +527,28 @@ export const systemConfigApi = {
|
||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
|
||||
};
|
||||
|
||||
// Reschedule Policy API (one row per coach type = fare class)
|
||||
export interface ReschedulePolicyRow {
|
||||
coachTypeId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
policy: {
|
||||
feePercent: number;
|
||||
feeMinMinor: number;
|
||||
routeChangeAllowed: boolean;
|
||||
sameDayAllowed: boolean;
|
||||
sameDayFeePercent: number;
|
||||
sameDayFeeMinMinor: number;
|
||||
cutoffMinutes: number;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
}
|
||||
export const reschedulePolicyApi = {
|
||||
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
|
||||
update: (coachTypeId: string, data: Partial<NonNullable<ReschedulePolicyRow['policy']>>) =>
|
||||
apiClient.patch<any>(`/reschedule/policies/${coachTypeId}`, data),
|
||||
};
|
||||
|
||||
// App Releases API
|
||||
export const appReleasesApi = {
|
||||
getAll: async () => {
|
||||
|
||||
@@ -1022,6 +1022,15 @@ function BookingDetailContent() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!booking.isPackageBooking && ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && !booking.outboundBoardedAt && (
|
||||
<button
|
||||
onClick={() => router.push(`/booking/reschedule?ref=${booking.bookingRef}`)}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
Reschedule
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import ModernDatePicker from "@/components/ModernDatePicker";
|
||||
import { formatTime } from "@/utils/format";
|
||||
|
||||
type Station = { id: string; name: string; code?: string };
|
||||
|
||||
type LegOption = {
|
||||
leg: number;
|
||||
scheduleId: string;
|
||||
originStationId: string | null;
|
||||
destinationStationId: string | null;
|
||||
departureAt: string;
|
||||
coachTypeId: string;
|
||||
seatCount: number;
|
||||
passengerNames: string[];
|
||||
oldFareMinor: number;
|
||||
policy: {
|
||||
feePercent: number;
|
||||
feeMinMinor: number;
|
||||
routeChangeAllowed: boolean;
|
||||
sameDayAllowed: boolean;
|
||||
sameDayFeePercent: number;
|
||||
sameDayFeeMinMinor: number;
|
||||
cutoffMinutes: number;
|
||||
} | null;
|
||||
canReschedule: boolean;
|
||||
blockers: string[];
|
||||
};
|
||||
|
||||
type Options = {
|
||||
bookingRef: string;
|
||||
bookingType: string;
|
||||
legs: LegOption[];
|
||||
pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null;
|
||||
};
|
||||
|
||||
type Quote = {
|
||||
allowed: boolean;
|
||||
blockers: string[];
|
||||
oldFareMinor: number;
|
||||
newFareMinor: number;
|
||||
feeMinor: number;
|
||||
fareDifferenceMinor: number;
|
||||
amountDueMinor: number;
|
||||
isSameDay: boolean;
|
||||
isRouteChange: boolean;
|
||||
};
|
||||
|
||||
const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`;
|
||||
|
||||
function ReschedulePageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const ref = searchParams.get("ref") || "";
|
||||
|
||||
const [legNo, setLegNo] = useState(1);
|
||||
const [date, setDate] = useState<Date | undefined>(undefined);
|
||||
const [originId, setOriginId] = useState("");
|
||||
const [destinationId, setDestinationId] = useState("");
|
||||
const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null);
|
||||
const [schedule, setSchedule] = useState<any | null>(null);
|
||||
const [seatIds, setSeatIds] = useState<string[]>([]);
|
||||
const [done, setDone] = useState<{ status: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
|
||||
queryKey: ["reschedule-options", ref],
|
||||
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/reschedule`),
|
||||
enabled: !!ref,
|
||||
retry: false,
|
||||
});
|
||||
const { data: stations = [] } = useQuery<Station[]>({
|
||||
queryKey: ["stations"],
|
||||
queryFn: () => apiClient.get<Station[]>("/stations"),
|
||||
});
|
||||
|
||||
const leg = useMemo(() => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0], [options, legNo]);
|
||||
|
||||
// Prefill route from the leg being changed.
|
||||
useEffect(() => {
|
||||
if (!leg) return;
|
||||
setOriginId(leg.originStationId ?? "");
|
||||
setDestinationId(leg.destinationStationId ?? "");
|
||||
setSchedule(null);
|
||||
setSeatIds([]);
|
||||
setSearched(null);
|
||||
}, [leg?.leg, leg?.scheduleId]);
|
||||
|
||||
const journeyDirection = leg?.leg === 2 ? "RETURN" : options?.bookingType === "ROUND_TRIP" ? "OUTBOUND" : "ONE_WAY";
|
||||
|
||||
const { data: schedules = [], isFetching: searching } = useQuery<any[]>({
|
||||
queryKey: ["reschedule-search", searched],
|
||||
queryFn: async () => {
|
||||
const res: any = await apiClient.post("/search", {
|
||||
originStationId: searched!.originId,
|
||||
destinationStationId: searched!.destinationId,
|
||||
date: searched!.date,
|
||||
adultCount: leg?.seatCount ?? 1,
|
||||
childCount: 0,
|
||||
journeyType: "ONE_WAY",
|
||||
});
|
||||
const list = Array.isArray(res) ? res : res?.outbound ?? res?.data ?? [];
|
||||
return list.filter((s: any) => (s.scheduleId || s.id) !== leg?.scheduleId || searched!.originId !== leg?.originStationId || searched!.destinationId !== leg?.destinationStationId);
|
||||
},
|
||||
enabled: !!searched && !!leg,
|
||||
});
|
||||
|
||||
const scheduleId = schedule ? schedule.scheduleId || schedule.id : null;
|
||||
const { data: seatMap, isLoading: loadingSeats } = useQuery<any>({
|
||||
queryKey: ["reschedule-seatmap", scheduleId, leg?.coachTypeId, originId, destinationId],
|
||||
queryFn: async () => {
|
||||
const res: any = await apiClient.get(
|
||||
`/seats/seatmap/${scheduleId}?coachTypeId=${leg!.coachTypeId}&journeyDirection=${journeyDirection}&originStationId=${originId}&destinationStationId=${destinationId}`,
|
||||
);
|
||||
return res?.data || res;
|
||||
},
|
||||
enabled: !!scheduleId && !!leg,
|
||||
});
|
||||
|
||||
const quoteBody = leg && scheduleId && seatIds.length === leg.seatCount
|
||||
? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: seatIds }
|
||||
: null;
|
||||
const { data: quote, isFetching: quoting } = useQuery<Quote>({
|
||||
queryKey: ["reschedule-quote", ref, quoteBody],
|
||||
queryFn: () => apiClient.post<Quote>(`/bookings/${ref}/reschedule/quote`, quoteBody),
|
||||
enabled: !!quoteBody,
|
||||
});
|
||||
|
||||
const confirm = useMutation({
|
||||
mutationFn: async () => {
|
||||
const hold: any = await apiClient.post("/seats/hold", {
|
||||
scheduleId,
|
||||
originStationId: originId,
|
||||
destinationStationId: destinationId,
|
||||
journeyDirection,
|
||||
passengers: seatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })),
|
||||
});
|
||||
return apiClient.post<any>(`/bookings/${ref}/reschedule`, { ...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 reschedule"),
|
||||
});
|
||||
|
||||
const toggleSeat = (id: string) => {
|
||||
setSeatIds((prev) => {
|
||||
if (prev.includes(id)) return prev.filter((s) => s !== id);
|
||||
if (prev.length >= (leg?.seatCount ?? 1)) return [...prev.slice(1), id];
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—";
|
||||
|
||||
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
|
||||
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
|
||||
if (optionsError || !options || !leg) {
|
||||
return <Shell><p className="text-red-600">{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}</p></Shell>;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="text-center space-y-4">
|
||||
<CheckCircle2 className="w-14 h-14 text-green-600 mx-auto" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Booking rescheduled</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">New tickets have been issued for booking {ref}.</p>
|
||||
<button className="btn-primary" onClick={() => router.push(`/booking/detail?ref=${ref}`)}>View booking</button>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.pending) {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Reschedule awaiting payment</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
A change 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.
|
||||
</p>
|
||||
{options.pending.paymentToken && (
|
||||
<button className="btn-primary" onClick={() => router.push(`/pay-balance/${options.pending!.paymentToken}`)}>Pay now</button>
|
||||
)}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const routeLocked = !leg.policy?.routeChangeAllowed;
|
||||
const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId;
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<button onClick={() => router.push(`/booking/detail?ref=${ref}`)} className="flex items-center gap-1 text-sm text-gray-500 mb-4">
|
||||
<ChevronLeft className="w-4 h-4" /> Back to booking
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">Reschedule {ref}</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
{leg.passengerNames.join(", ")} · currently {format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")} · {stationName(leg.originStationId)} → {stationName(leg.destinationStationId)}
|
||||
</p>
|
||||
|
||||
{options.legs.length > 1 && (
|
||||
<div className="flex gap-2 mb-6">
|
||||
{options.legs.map((l) => (
|
||||
<button key={l.leg} onClick={() => setLegNo(l.leg)} className={`px-4 py-2 rounded-lg border text-sm ${legNo === l.leg ? "border-primary text-primary" : "border-gray-200 text-gray-600"}`}>
|
||||
{l.leg === 1 ? "Outbound" : "Return"} · {format(new Date(l.departureAt), "dd MMM")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leg.policy && (
|
||||
<div className="rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 text-sm mb-6 space-y-1">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Your fare rules</div>
|
||||
<div>Change fee: {leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` : "Free"}. A higher new fare is payable; a lower one is not refunded.</div>
|
||||
<div>Route change: {leg.policy.routeChangeAllowed ? "allowed" : "not permitted"}. Same-day change: {leg.policy.sameDayAllowed ? (leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` : "free") : "not permitted"}.</div>
|
||||
<div>Changes close {leg.policy.cutoffMinutes} minutes before departure.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!leg.canReschedule && (
|
||||
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 p-4 text-sm text-red-700 mb-6 flex gap-2">
|
||||
<AlertCircle className="w-5 h-5 shrink-0" />
|
||||
<div>{leg.blockers.map((b) => <div key={b}>{b}</div>)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leg.canReschedule && (
|
||||
<>
|
||||
{/* Step 1: route + date */}
|
||||
<div className="grid md:grid-cols-4 gap-3 mb-4">
|
||||
<select className="input" value={originId} disabled={routeLocked} onChange={(e) => setOriginId(e.target.value)}>
|
||||
{stations.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<select className="input" value={destinationId} disabled={routeLocked} onChange={(e) => setDestinationId(e.target.value)}>
|
||||
{stations.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
<ModernDatePicker value={date} onChange={setDate} minDate={new Date()} />
|
||||
<button className="btn-primary" disabled={!canSearch || searching} onClick={() => { setSchedule(null); setSeatIds([]); setSearched({ originId, destinationId, date: format(date!, "yyyy-MM-dd") }); }}>
|
||||
{searching ? "Searching..." : "Find trains"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 2: schedule */}
|
||||
{searched && !searching && schedules.length === 0 && <p className="text-sm text-gray-500 mb-4">No trains on that day.</p>}
|
||||
{schedules.length > 0 && (
|
||||
<div className="space-y-2 mb-6">
|
||||
{schedules.map((s: any) => {
|
||||
const id = s.scheduleId || s.id;
|
||||
const selected = scheduleId === id;
|
||||
return (
|
||||
<button key={id} disabled={s.hasAvailability === false} onClick={() => { setSchedule(s); setSeatIds([]); }}
|
||||
className={`w-full text-left rounded-xl border p-4 flex items-center justify-between ${selected ? "border-primary bg-primary/5" : "border-gray-200 dark:border-gray-700"} disabled:opacity-50`}>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{s.trainName || s.trainNumber}</div>
|
||||
<div className="text-sm text-gray-500">{formatTime(s.departureAt)} <ArrowRight className="inline w-3 h-3" /> {formatTime(s.arrivalAt)}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{s.hasAvailability === false ? "Sold out" : "Select"}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: seats (same class as booked) */}
|
||||
{scheduleId && (
|
||||
<div className="mb-6">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white mb-2">Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({seatIds.length}/{leg.seatCount})</h2>
|
||||
{loadingSeats && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
{(seatMap?.coaches ?? []).map((coach: any) => (
|
||||
<div key={coach.id} className="mb-3">
|
||||
<div className="text-xs text-gray-500 mb-1">{coach.name} · {coach.coachTypeName}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(coach.seats ?? []).map((seat: any) => {
|
||||
const picked = seatIds.includes(seat.id);
|
||||
const free = seat.status === "AVAILABLE";
|
||||
return (
|
||||
<button key={seat.id} disabled={!free} onClick={() => toggleSeat(seat.id)} title={`${seat.seatNumber} ${seat.bedPosition ?? ""} ${seat.status}`}
|
||||
className={`w-11 h-9 rounded text-xs border ${picked ? "bg-primary text-white border-primary" : free ? "bg-white dark:bg-gray-800 border-gray-300" : "bg-gray-200 dark:bg-gray-700 text-gray-400 border-transparent"}`}>
|
||||
{seat.seatNumber}{seat.bedPosition ? seat.bedPosition[0].toUpperCase() : ""}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{seatMap && (seatMap.coaches ?? []).length === 0 && <p className="text-sm text-gray-500">No coach of your class on this train.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: quote + confirm */}
|
||||
{quoteBody && (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-2 text-sm">
|
||||
{quoting && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
{quote && (
|
||||
<>
|
||||
<Row label="Original fare" value={etb(quote.oldFareMinor)} />
|
||||
<Row label="New fare" value={etb(quote.newFareMinor)} />
|
||||
<Row label={quote.fareDifferenceMinor >= 0 ? "Fare difference" : "Fare difference (not refunded)"} value={etb(Math.max(0, quote.fareDifferenceMinor))} />
|
||||
<Row label={`Change fee${quote.isSameDay ? " (same-day)" : ""}`} value={etb(quote.feeMinor)} />
|
||||
<div className="flex justify-between font-bold text-base pt-2 border-t border-gray-200 dark:border-gray-700"><span>Total due now</span><span>{etb(quote.amountDueMinor)}</span></div>
|
||||
{quote.blockers.length > 0 && (
|
||||
<div className="text-red-600 flex gap-2"><AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /><div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div></div>
|
||||
)}
|
||||
{error && <div className="text-red-600">{error}</div>}
|
||||
<button className="btn-primary w-full mt-2" disabled={!quote.allowed || confirm.isPending} onClick={() => { setError(null); confirm.mutate(); }}>
|
||||
{confirm.isPending ? "Processing..." : quote.amountDueMinor > 0 ? `Continue to payment · ${etb(quote.amountDueMinor)}` : "Confirm reschedule"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex justify-between text-gray-700 dark:text-gray-300"><span>{label}</span><span>{value}</span></div>;
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReschedulePage() {
|
||||
return (
|
||||
<Suspense fallback={<Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>}>
|
||||
<ReschedulePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user