mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
feat: ( reschedule ) implement booking reschedule
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user