mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -45,8 +45,8 @@
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@sendgrid/mail": "^8.1.0",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||
export const MAX_PAYMENT_HOURS = 2;
|
||||
export const MAX_PAYMENT_HOURS = 240;
|
||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||
export const CUTOFF_MINUTES = 30;
|
||||
|
||||
|
||||
@@ -5,12 +5,9 @@ import { BlockedSeatRevenueLossStat } from '@edr/types';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { ReportsService } from '../reports/reports.service';
|
||||
|
||||
/** Window the dashboard's blocked-seat loss roll-up covers. Matches the report's default. */
|
||||
const BLOCKED_SEAT_LOSS_PERIOD_DAYS = 30;
|
||||
|
||||
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
|
||||
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
|
||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
||||
periodDays: null,
|
||||
lossByCurrency: [],
|
||||
schedulesAffected: 0,
|
||||
blockedSeatCount: 0,
|
||||
@@ -85,7 +82,7 @@ export class DashboardService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact roll-up of the Blocked Seat Revenue Loss report over the last 30 days.
|
||||
* Compact roll-up of the Blocked Seat Revenue Loss report over its full history.
|
||||
*
|
||||
* Reuses the report service rather than re-deriving the rule — there is exactly one
|
||||
* definition of what a blocked seat costs. A failure here degrades to zeroes instead of
|
||||
@@ -98,7 +95,7 @@ export class DashboardService {
|
||||
const { summary } = report;
|
||||
|
||||
return {
|
||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
||||
periodDays: null,
|
||||
lossByCurrency: summary.lossByCurrency,
|
||||
schedulesAffected: summary.schedulesAffected,
|
||||
blockedSeatCount: summary.blockedSeatCount,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
import { ExcessBaggageService } from './excess-baggage.service';
|
||||
@@ -107,6 +107,7 @@ export class ExcessBaggageAgentController {
|
||||
|
||||
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
|
||||
@ApiTags('Excess Luggage')
|
||||
@SetMetadata('isPublic', true)
|
||||
@Controller('excess-baggage')
|
||||
export class ExcessBaggagePublicController {
|
||||
constructor(private service: ExcessBaggageService) {}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { IsString, IsInt, IsOptional, IsPositive } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class LogExcessBaggageDto {
|
||||
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
|
||||
@ApiPropertyOptional({ example: 'booking-uuid', description: 'Booking UUID for the passenger booking' })
|
||||
@IsOptional() @IsString() bookingId?: string;
|
||||
@ApiPropertyOptional({ example: 'JS6MJ9', description: 'Booking reference for the passenger booking' })
|
||||
@IsOptional() @IsString() bookingReference?: string;
|
||||
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
|
||||
@IsOptional() @IsString() agentId?: string;
|
||||
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
|
||||
|
||||
@@ -39,12 +39,25 @@ export class ExcessBaggageService {
|
||||
) {}
|
||||
|
||||
async logCharge(dto: LogExcessBaggageDto) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: dto.bookingId },
|
||||
include: {
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
});
|
||||
const bookingRef = dto.bookingReference?.trim();
|
||||
const bookingId = dto.bookingId?.trim();
|
||||
|
||||
const booking = bookingRef
|
||||
? await this.prisma.booking.findFirst({
|
||||
where: { bookingRef: { equals: bookingRef, mode: 'insensitive' } },
|
||||
include: {
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
})
|
||||
: bookingId
|
||||
? await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
|
||||
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
|
||||
@@ -64,7 +77,7 @@ export class ExcessBaggageService {
|
||||
|
||||
const charge = await this.prisma.excessBaggageCharge.create({
|
||||
data: {
|
||||
bookingId: dto.bookingId,
|
||||
bookingId: booking.id,
|
||||
agentId: dto.agentId ?? '',
|
||||
excessWeightKg: dto.excessWeightKg,
|
||||
feePerKgMinor,
|
||||
@@ -81,7 +94,7 @@ export class ExcessBaggageService {
|
||||
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
|
||||
}
|
||||
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: dto.bookingId, excessWeightKg: dto.excessWeightKg, totalMinor, status } });
|
||||
await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: booking.id, excessWeightKg: dto.excessWeightKg, totalMinor, status } });
|
||||
return charge;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, SetMetadata } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
@@ -15,6 +15,11 @@ import { PaymentsService } from './payments.service';
|
||||
|
||||
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
|
||||
// @tria-plc/auditlog's global ClientLoggerInterceptor (present in deployed builds)
|
||||
// crashes on non-HTTP contexts (`originalUrl.split` on a RabbitMQ message) and the
|
||||
// resulting requeue storm blocks payment.succeeded forever. Its IgnoreLoggerAudit
|
||||
// decorator is just this metadata key — set it directly so we don't need the package.
|
||||
@SetMetadata('ignoreAuditLogger', true)
|
||||
@Injectable()
|
||||
export class PaymentEventsConsumer {
|
||||
private readonly logger = new Logger(PaymentEventsConsumer.name);
|
||||
|
||||
@@ -346,6 +346,7 @@ export function assembleReport(
|
||||
// so a plain sum here never crosses currencies.
|
||||
const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0);
|
||||
const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB';
|
||||
const blockedByNames = [...new Set(blocks.map(blockerDisplayName))];
|
||||
|
||||
scheduleRows.push({
|
||||
scheduleId: schedule.id,
|
||||
@@ -359,6 +360,7 @@ export function assembleReport(
|
||||
soldSeats,
|
||||
loadFactorPercent: +(loadFactor * 100).toFixed(1),
|
||||
blockedSeatCount: blocks.length,
|
||||
blockedByNames,
|
||||
estimatedLossMinor,
|
||||
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
|
||||
currency,
|
||||
@@ -525,6 +527,11 @@ function groupByReasonCategory(
|
||||
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
||||
}
|
||||
|
||||
/** Legacy rows carry no name; 'SYSTEM' blocks are not a person. */
|
||||
function blockerDisplayName(block: Pick<BlockedSeatLossDetail, 'blockedBy' | 'blockedByName'>): string {
|
||||
return block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown');
|
||||
}
|
||||
|
||||
function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] {
|
||||
const groups = new Map<string, BlockedSeatLossByBlocker>();
|
||||
for (const row of rows) {
|
||||
@@ -532,9 +539,7 @@ function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlock
|
||||
const key = `${block.blockedBy}|${block.currency}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
blockedBy: block.blockedBy,
|
||||
// Legacy rows carry no name; 'SYSTEM' blocks are not a person.
|
||||
blockedByName:
|
||||
block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'),
|
||||
blockedByName: blockerDisplayName(block),
|
||||
count: 0,
|
||||
estimatedLossMinor: 0,
|
||||
currency: block.currency,
|
||||
|
||||
@@ -46,13 +46,13 @@ export enum BlockedSeatsLossSortBy {
|
||||
export class BlockedSeatsRevenueLossQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
example: '2026-07-01',
|
||||
description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to 30 days ago.',
|
||||
description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the earliest scheduled departure on record.',
|
||||
})
|
||||
@IsOptional() @IsDateString() dateFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2026-07-31',
|
||||
description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to today.',
|
||||
description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the latest scheduled departure on record.',
|
||||
})
|
||||
@IsOptional() @IsDateString() dateTo?: string;
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
|
||||
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
||||
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
|
||||
/** Window used when the caller supplies neither `dateFrom` nor `dateTo`. */
|
||||
const DEFAULT_LOSS_WINDOW_DAYS = 30;
|
||||
const DEFAULT_LOSS_PAGE_SIZE = 25;
|
||||
/** How many schedules are priced in parallel. Keeps the DB from being flooded. */
|
||||
const FARE_QUOTE_CONCURRENCY = 4;
|
||||
@@ -42,25 +40,6 @@ const EMPTY_LOSS_INPUT: LossCalculatorInput = {
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
|
||||
* matching `generateReport`. Defaults to the last 30 days of departures.
|
||||
*/
|
||||
function resolveWindow(
|
||||
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
||||
now: Date,
|
||||
): { dateFrom: Date; dateTo: Date } {
|
||||
const dateTo = query.dateTo ? new Date(query.dateTo) : new Date(now);
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
const dateFrom = query.dateFrom
|
||||
? new Date(query.dateFrom)
|
||||
: new Date(dateTo.getTime() - DEFAULT_LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
return { dateFrom, dateTo };
|
||||
}
|
||||
|
||||
/** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */
|
||||
function resolveNationalityType(nationality: string): string {
|
||||
const upper = nationality.toUpperCase();
|
||||
@@ -1235,6 +1214,37 @@ export class ReportsService {
|
||||
|
||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
|
||||
* matching `generateReport`. When the caller supplies neither bound, defaults to the full
|
||||
* history of scheduled departures on record — the earliest `TrainSchedule.departureAt` to
|
||||
* the latest — not a rolling window, so nothing ages out of the report on its own.
|
||||
*/
|
||||
private async resolveWindow(
|
||||
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
||||
now: Date,
|
||||
): Promise<{ dateFrom: Date; dateTo: Date }> {
|
||||
let dateFrom: Date;
|
||||
let dateTo: Date;
|
||||
|
||||
if (query.dateFrom && query.dateTo) {
|
||||
dateFrom = new Date(query.dateFrom);
|
||||
dateTo = new Date(query.dateTo);
|
||||
} else {
|
||||
const bounds = await this.prisma.trainSchedule.aggregate({
|
||||
_min: { departureAt: true },
|
||||
_max: { departureAt: true },
|
||||
});
|
||||
dateFrom = query.dateFrom ? new Date(query.dateFrom) : (bounds._min.departureAt ?? new Date(now));
|
||||
dateTo = query.dateTo ? new Date(query.dateTo) : (bounds._max.departureAt ?? new Date(now));
|
||||
}
|
||||
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
return { dateFrom, dateTo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Potential revenue lost to seats that were blocked and therefore never sellable.
|
||||
*
|
||||
@@ -1247,7 +1257,7 @@ export class ReportsService {
|
||||
query: BlockedSeatsRevenueLossQueryDto,
|
||||
): Promise<BlockedSeatRevenueLossReport> {
|
||||
const now = new Date();
|
||||
const { dateFrom, dateTo } = resolveWindow(query, now);
|
||||
const { dateFrom, dateTo } = await this.resolveWindow(query, now);
|
||||
const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY;
|
||||
const nationalityType = resolveNationalityType(nationalityAssumption);
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ export class TicketsService {
|
||||
} : null,
|
||||
status: t.status,
|
||||
validatedAt: t.validatedAt,
|
||||
boardedAt: t.validatedAt,
|
||||
boardedAt: t.boardedAt ?? t.validatedAt,
|
||||
qrCode: t.qrPayload ?? null,
|
||||
createdAt: t.issuedAt,
|
||||
};
|
||||
@@ -663,6 +663,13 @@ export class TicketsService {
|
||||
|
||||
// Use existing validation logic to handle round trips properly
|
||||
const result = await this.validate(bookingRef, validatorId, gateId);
|
||||
if ((result as any).alreadyValidated) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Ticket already used',
|
||||
errorCode: 'ALREADY_USED',
|
||||
};
|
||||
}
|
||||
|
||||
// Get seat information
|
||||
const seatInfo = (booking as any).seats[0];
|
||||
@@ -756,12 +763,23 @@ export class TicketsService {
|
||||
const type = booking.bookingType;
|
||||
const now = new Date();
|
||||
|
||||
const markTicketUsed = async () => {
|
||||
if (ticket.status !== 'USED') {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'USED' } });
|
||||
ticket.status = 'USED';
|
||||
}
|
||||
};
|
||||
|
||||
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
|
||||
if (type === 'ONE_WAY') {
|
||||
if (ticket.validatedAt) {
|
||||
await markTicketUsed();
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' },
|
||||
});
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
this.fireBoardingPassNotification(booking, ticket, null);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } });
|
||||
@@ -778,13 +796,22 @@ export class TicketsService {
|
||||
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
|
||||
if (alreadyValidated) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
await markTicketUsed();
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
|
||||
const validatedAt = ticket.validatedAt ?? now;
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' },
|
||||
});
|
||||
} else {
|
||||
await markTicketUsed();
|
||||
}
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt };
|
||||
}
|
||||
|
||||
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
|
||||
@@ -798,12 +825,14 @@ export class TicketsService {
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
|
||||
await markTicketUsed();
|
||||
throw new BadRequestException('Outbound leg already validated');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
} else if (resolvedLeg === 'RETURN') {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
|
||||
await markTicketUsed();
|
||||
throw new BadRequestException('Return leg already validated');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
@@ -812,13 +841,19 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
const validatedAt = ticket.validatedAt ?? now;
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: now, boardedAt: now, validatorId: resolvedValidatorId, status: 'USED' },
|
||||
});
|
||||
} else {
|
||||
await markTicketUsed();
|
||||
}
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt };
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Unsupported booking type: ${type}`);
|
||||
|
||||
@@ -111,6 +111,43 @@ describe("Money integrity (Tier-2 direct instantiation)", () => {
|
||||
expect(walletAfter?.balanceMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts a booking reference when logging an excess baggage charge", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
const schedule = await makeSchedule(prisma, passenger.id);
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: "BAG-REF-001",
|
||||
passengerId: passenger.id,
|
||||
scheduleId: schedule.id,
|
||||
totalMinor: 30_000,
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.baggageAllowance.create({
|
||||
data: { seatClassId: IDS.seatClassLocal, maxWeightKg: 20, maxPiecesCount: 2, excessFeePerKg: 80 },
|
||||
});
|
||||
|
||||
const service = new ExcessBaggageService(
|
||||
prisma as any,
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
asyncStub(),
|
||||
);
|
||||
|
||||
const charge: any = await service.logCharge({
|
||||
bookingReference: booking.bookingRef,
|
||||
excessWeightKg: 2,
|
||||
collectCash: true,
|
||||
} as any);
|
||||
|
||||
expect(charge.bookingId).toBe(booking.id);
|
||||
expect(charge.feePerKgMinor).toBe(80);
|
||||
expect(charge.totalMinor).toBe(160);
|
||||
});
|
||||
|
||||
// ── E1 / E2 ────────────────────────────────────────────────────────────────
|
||||
it("E1/E2 🔴 excess-baggage uses the OLDEST allowance globally (ignores seat class); fee = rate×kg", async () => {
|
||||
const passenger = await prisma.passenger.create({ data: {} });
|
||||
|
||||
@@ -228,6 +228,24 @@ describe("Ticketing — generate / scanAndBoard / validate / smart-reassign", ()
|
||||
|
||||
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
|
||||
expect(ticket?.validatedAt).toBeTruthy();
|
||||
expect(ticket?.status).toBe('USED');
|
||||
});
|
||||
|
||||
it("does not allow boarding the same ticket twice", async () => {
|
||||
const { schedule, seats } = await createTestSchedule({ trainNumber: `TIX-BOARD-REUSE-${Date.now()}`, departureAt: future(60), arrivalAt: future(120) });
|
||||
const booking = await createOneWayBooking(schedule.id, seats[0].id);
|
||||
await markSucceeded(booking.id);
|
||||
await ticketsService.generate(booking.id);
|
||||
|
||||
const first = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
const second = await ticketsService.scanAndBoard(booking.bookingRef, "gate-validator-1");
|
||||
expect(second.success).toBe(false);
|
||||
expect(second.error).toMatch(/already used/i);
|
||||
|
||||
const ticket = await harness.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
|
||||
expect(ticket?.status).toBe('USED');
|
||||
});
|
||||
|
||||
it("refuses boarding before the boarding window opens", async () => {
|
||||
|
||||
Reference in New Issue
Block a user