mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -152,6 +152,12 @@ export class BookingsController {
|
||||
@ApiQuery({ name: "returnLegStatus", required: false })
|
||||
@ApiQuery({ name: "bookingType", required: false })
|
||||
@ApiQuery({ name: "paymentStatus", required: false })
|
||||
@ApiQuery({
|
||||
name: "providerTxnId",
|
||||
required: false,
|
||||
description:
|
||||
"Payment provider transaction / order / merchant reference (partial, case-insensitive)",
|
||||
})
|
||||
@ApiQuery({ name: "dateFrom", required: false })
|
||||
@ApiQuery({ name: "dateTo", required: false })
|
||||
@ApiQuery({ name: "page", required: false })
|
||||
@@ -162,6 +168,7 @@ export class BookingsController {
|
||||
@Query("returnLegStatus") returnLegStatus?: string,
|
||||
@Query("bookingType") bookingType?: string,
|
||||
@Query("paymentStatus") paymentStatus?: string,
|
||||
@Query("providerTxnId") providerTxnId?: string,
|
||||
@Query("dateFrom") dateFrom?: string,
|
||||
@Query("dateTo") dateTo?: string,
|
||||
@Query("page") page?: string,
|
||||
@@ -173,6 +180,7 @@ export class BookingsController {
|
||||
returnLegStatus,
|
||||
bookingType,
|
||||
paymentStatus,
|
||||
providerTxnId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
|
||||
@@ -91,6 +91,7 @@ interface BookingFilters {
|
||||
returnLegStatus?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
providerTxnId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
@@ -453,8 +454,9 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, providerTxnId, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const txn = providerTxnId?.trim() || undefined;
|
||||
|
||||
const onlyPackages = bookingType === 'PACKAGE';
|
||||
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
|
||||
@@ -495,11 +497,24 @@ export class BookingsService {
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
// paymentStatus and providerTxnId both narrow the same relation — build one `is` filter
|
||||
// so the second doesn't overwrite the first.
|
||||
const paymentIntentIs: any = {};
|
||||
if (paymentStatus) {
|
||||
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
|
||||
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
||||
where.paymentIntent = { is: { status: mapped } };
|
||||
paymentIntentIs.status = statusMap[paymentStatus] ?? paymentStatus;
|
||||
}
|
||||
if (txn) {
|
||||
// Providers are inconsistent about which reference they hand back to the customer —
|
||||
// match the transaction id, the provider/merchant order ids, and the generic ref.
|
||||
paymentIntentIs.OR = [
|
||||
{ providerTxnId: { contains: txn, mode: 'insensitive' } },
|
||||
{ providerOrderId: { contains: txn, mode: 'insensitive' } },
|
||||
{ merchantOrderId: { contains: txn, mode: 'insensitive' } },
|
||||
{ providerRef: { contains: txn, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (Object.keys(paymentIntentIs).length) where.paymentIntent = { is: paymentIntentIs };
|
||||
|
||||
const pkgWhere: any = {};
|
||||
if (search) {
|
||||
@@ -512,7 +527,12 @@ export class BookingsService {
|
||||
}
|
||||
if (status) pkgWhere.status = status;
|
||||
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
|
||||
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
|
||||
const pkgPaymentIntentIs: any = {};
|
||||
if (paymentStatus) pkgPaymentIntentIs.status = paymentIntentIs.status;
|
||||
// PackagePaymentIntent has no providerTxnId/providerOrderId/merchantOrderId columns —
|
||||
// providerRef is the only reference we can match a package booking on.
|
||||
if (txn) pkgPaymentIntentIs.providerRef = { contains: txn, mode: 'insensitive' };
|
||||
if (Object.keys(pkgPaymentIntentIs).length) pkgWhere.paymentIntent = { is: pkgPaymentIntentIs };
|
||||
|
||||
if (onlyPackages) {
|
||||
// Package bookings live in two places:
|
||||
@@ -521,7 +541,7 @@ export class BookingsService {
|
||||
const bookingPkgWhere: any = { packageId: { not: null } };
|
||||
if (status) bookingPkgWhere.status = status;
|
||||
if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt;
|
||||
if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent;
|
||||
if (where.paymentIntent) bookingPkgWhere.paymentIntent = where.paymentIntent;
|
||||
if (search) bookingPkgWhere.OR = where.OR;
|
||||
|
||||
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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