feat: ( reschedule ) implement booking reschedule

This commit is contained in:
Abubeker
2026-08-21 08:18:03 +00:00
parent e174efb8e0
commit 80a7a2f702
27 changed files with 1450 additions and 51 deletions

View File

@@ -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 },

View File

@@ -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];

View File

@@ -28,7 +28,7 @@ type EmployeeLike = {
delegatedPositions?: PositionLike[];
};
type MeLikeUser = {
export type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?: EmployeeLike | EmployeeLike[] | null;

View File

@@ -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")

View File

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

View File

@@ -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');

View File

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

View File

@@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] {
PaymentSyncService,
ServiceAuthGuard,
],
exports: [PaymentClientService, PaymentsService],
exports: [PaymentClientService, PaymentsService, SupplementaryChargesService],
})
export class PaymentsModule {}

View File

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

View File

@@ -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;
};

View File

@@ -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!;

View File

@@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => {
{} as any,
paymentClient as any,
new CurrencyService(prisma as any),
{ emit: jest.fn() } as any,
);
};

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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');
});
});

View File

@@ -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;
}
}

View File

@@ -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() {

View File

@@ -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();

View File

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