Tour package booking, app release, new endpoints, more updates and fixes

This commit is contained in:
Stephanos A
2026-07-05 00:28:06 +03:00
parent 868639084c
commit 595be6e123
68 changed files with 2773 additions and 787 deletions

View File

@@ -0,0 +1,50 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger';
import { AppReleasesService, AppReleaseDto } from './app-releases.service';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('App Releases')
@Controller('app-releases')
export class AppReleasesController {
constructor(private service: AppReleasesService) {}
@Get()
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'List all app releases (public)' })
getAll() {
return this.service.getAll();
}
@Get('latest/:os')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Get latest release for a given OS (public)' })
@ApiParam({ name: 'os', enum: ['android', 'ios'] })
getLatest(@Param('os') os: string) {
return this.service.getLatest(os);
}
@Post()
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create an app release (admin)' })
create(@Body() dto: AppReleaseDto) {
return this.service.create(dto);
}
@Patch(':id')
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update an app release (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<AppReleaseDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete an app release (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AppReleasesController } from './app-releases.controller';
import { AppReleasesService } from './app-releases.service';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AppReleasesController],
providers: [AppReleasesService],
})
export class AppReleasesModule {}

View File

@@ -0,0 +1,71 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PrismaService } from '../../common/prisma.service';
export class AppReleaseDto {
@ApiProperty({ enum: ['android', 'ios'] })
@IsIn(['android', 'ios'])
os: string;
@ApiProperty({ example: '1.2.3' })
@IsString()
version: string;
@ApiProperty({ default: false })
@IsBoolean()
forceUpdate: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
storeLink?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@Injectable()
export class AppReleasesService {
constructor(private prisma: PrismaService) {}
private get db() {
return (this.prisma as any);
}
getAll() {
return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] });
}
async getLatest(os: string) {
const release = await this.db.appRelease.findFirst({
where: { os },
orderBy: { createdAt: 'desc' },
});
if (!release) throw new NotFoundException(`No release found for ${os}`);
return release;
}
async create(dto: AppReleaseDto) {
const existing = await this.db.appRelease.findUnique({
where: { os_version: { os: dto.os, version: dto.version } },
});
if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`);
return this.db.appRelease.create({ data: dto });
}
async update(id: string, dto: Partial<AppReleaseDto>) {
const release = await this.db.appRelease.findUnique({ where: { id } });
if (!release) throw new NotFoundException('App release not found');
return this.db.appRelease.update({ where: { id }, data: dto });
}
async remove(id: string) {
const release = await this.db.appRelease.findUnique({ where: { id } });
if (!release) throw new NotFoundException('App release not found');
await this.db.appRelease.delete({ where: { id } });
return { deleted: true, id };
}
}

View File

@@ -30,8 +30,8 @@ export class AuditController {
entityType: entityType || undefined,
};
const items = await this.auditService.getLogs(filters);
return { items };
const result = await this.auditService.getLogs(filters);
return { items: result.data, total: result.total, limit: result.limit, offset: result.offset };
}
@Get('logs/:id')

View File

@@ -133,6 +133,12 @@ export class CreateBookingDto {
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' })
@IsOptional() @IsString() packageId?: string;
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;

View File

@@ -202,9 +202,12 @@ export class BookingsService {
async findAll(filters: BookingFilters = {}) {
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const onlyPackages = bookingType === 'PACKAGE';
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
const where: any = {};
if (search) {
const iamRows = await this.dataSource.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u
@@ -229,10 +232,10 @@ export class BookingsService {
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) where.status = status;
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
if (bookingType) where.bookingType = bookingType;
if (bookingType && !onlyPackages) where.bookingType = bookingType;
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
@@ -240,17 +243,125 @@ export class BookingsService {
};
}
if (paymentStatus) {
const statusMap: Record<string, string> = {
PAID: 'SUCCEEDED',
PENDING: 'REQUIRES_ACTION',
FAILED: 'FAILED',
REFUNDED: 'REFUNDED',
};
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
const mapped = statusMap[paymentStatus] ?? paymentStatus;
where.paymentIntent = { is: { status: mapped } };
}
const [items, total] = await Promise.all([
const pkgWhere: any = {};
if (search) {
pkgWhere.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } },
{ passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) pkgWhere.status = status;
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
if (onlyPackages) {
// Package bookings live in two places:
// 1. PackageBooking table (dedicated package bookings)
// 2. Booking table with packageId != null (round-trip bookings linked to a package)
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 (search) bookingPkgWhere.OR = where.OR;
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
this.prisma.packageBooking.findMany({
where: pkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
}),
this.prisma.packageBooking.count({ where: pkgWhere }),
this.prisma.booking.findMany({
where: bookingPkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
},
}),
this.prisma.booking.count({ where: bookingPkgWhere }),
]);
const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const mappedRegPkg = regPkgItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.totalMinor, currency: 'ETB',
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount, childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: booking.schedule ? {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
} : null,
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status,
totalMinor: b.totalMinor, currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true,
packageName: b.package?.name, packageCode: b.package?.code,
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
createdAt: b.createdAt, passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount,
}));
const total = pkgTotal + regPkgTotal;
const allItems = [...mappedPkg, ...mappedRegPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
@@ -264,9 +375,22 @@ export class BookingsService {
},
}),
this.prisma.booking.count({ where }),
includePackageBookings
? this.prisma.packageBooking.findMany({
where: pkgWhere,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
})
: Promise.resolve([] as any[]),
includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0),
]);
const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
@@ -275,59 +399,80 @@ export class BookingsService {
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
packageId: booking.packageId ?? null,
isPackageBooking: !!booking.packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency,
displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail,
contactPhone: b.contactPhone,
bookingType: 'PACKAGE',
packageId: b.packageId,
isPackageBooking: true,
packageName: b.package?.name,
packageCode: b.package?.code,
returnLegStatus: null,
adultCount: b.passengerCount,
childCount: 0,
createdAt: b.createdAt,
passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null,
paymentIntent: b.paymentIntent,
seatCount: b.passengerCount,
}));
const total = regularTotal + pkgTotal;
const allItems = [...mappedRegular, ...mappedPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: items.map(booking => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
// Build passenger list with categories
const passengerDetails = booking.seats.map((s: any) => ({
name: s.passengerName,
category: s.passengerCategory // 'ADULT' or 'CHILD'
}));
// Get unique names with their categories
const uniquePassengers = Array.from(
new Map(passengerDetails.map(p => [p.name, p])).values()
);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers, // Include category info
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
async create(dto: CreateBookingDto) {
async create(dto: CreateBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
@@ -363,7 +508,9 @@ export class BookingsService {
const passengersData = await this.processPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const fareCalculation = dto.packageId && dto.priceTierId
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = fareCalculation.totalMinor;
@@ -401,6 +548,7 @@ export class BookingsService {
childCount,
displayCurrency,
displayTotalMinor,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
@@ -421,6 +569,12 @@ export class BookingsService {
});
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
return { ...booking, fareBreakdown: fareCalculation };
}
@@ -468,23 +622,38 @@ export class BookingsService {
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const [outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
// Package bookings use fixed tier price split equally across both legs
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
let combinedBaseFareMinor: number;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
let loyaltyMinor = 0;
let totalMinor: number;
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
if (dto.packageId && dto.priceTierId) {
const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount);
// Split evenly across both legs for per-seat fare recording
const halfMinor = Math.round(pkgFare.baseFareMinor / 2);
outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) };
returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) };
combinedBaseFareMinor = pkgFare.totalBaseFareMinor;
totalMinor = pkgFare.totalMinor;
} else {
[outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
}
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -541,6 +710,7 @@ export class BookingsService {
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: [
...passengersWithFares.map(p => ({
@@ -586,6 +756,13 @@ export class BookingsService {
this.seatsService.confirmSeats(returnSeatIds)
]);
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
return {
@@ -1048,6 +1225,30 @@ export class BookingsService {
return { adultCount, childCount };
}
private async calculatePackageFare(
priceTierId: string,
adultCount: number,
childCount: number,
) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
const passengerCount = adultCount + childCount;
const totalBaseFareMinor = tier.priceMinor * passengerCount;
return {
baseFareMinor: tier.priceMinor,
adultCount,
adultFareMinor: tier.priceMinor * adultCount,
childCount,
freeChildrenCount: 0,
paidChildrenCount: childCount,
childFareMinor: tier.priceMinor * childCount,
totalBaseFareMinor,
discountMinor: 0,
loyaltyRedemptionMinor: 0,
taxesFeesMinor: 0,
totalMinor: totalBaseFareMinor,
};
}
private async calculateFare(
scheduleId: string,
seatClassId: string,
@@ -1182,7 +1383,64 @@ export class BookingsService {
paymentIntent: true, tickets: { take: 1 },
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) {
// Fall back to PackageBooking
const pkgBooking = await this.prisma.packageBooking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } },
priceTier: true,
passengers: true,
paymentIntent: true,
},
});
if (!pkgBooking) throw new NotFoundException('Booking not found');
return {
id: pkgBooking.id,
bookingRef: pkgBooking.bookingRef,
status: pkgBooking.status,
totalMinor: pkgBooking.totalMinor,
currency: pkgBooking.currency || 'ETB',
adultCount: pkgBooking.passengerCount,
childCount: 0,
displayCurrency: pkgBooking.displayCurrency,
displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined,
bookingType: 'PACKAGE',
packageId: pkgBooking.packageId,
priceTierId: pkgBooking.priceTierId,
packageName: (pkgBooking as any).package?.name,
packageCode: (pkgBooking as any).package?.code,
tierLabel: (pkgBooking as any).priceTier?.label,
isPackageBooking: true,
returnLegStatus: null,
contactEmail: pkgBooking.contactEmail,
contactPhone: pkgBooking.contactPhone,
createdAt: pkgBooking.createdAt,
schedule: (pkgBooking as any).package?.outboundSchedule ? {
id: (pkgBooking as any).package.outboundSchedule.id,
trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number,
trainName: (pkgBooking as any).package.outboundSchedule.train?.name,
origin: (pkgBooking as any).package.outboundSchedule.originStation,
destination: (pkgBooking as any).package.outboundSchedule.destinationStation,
departureAt: (pkgBooking as any).package.outboundSchedule.departureAt,
arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt,
} : null,
passengers: (pkgBooking as any).passengers?.map((p: any) => ({
fullName: p.passengerName,
category: 'ADULT',
leg: 1,
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
verifaydaVerified: false,
seat: null,
})),
payment: (pkgBooking as any).paymentIntent
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
: undefined,
ticket: undefined,
};
}
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.totalMinor, currency: 'ETB',

View File

@@ -81,8 +81,10 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
@@ -306,10 +308,16 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
const synth = (sched: any, stationId: string, seq: number) => {
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
return { stationId, sequence: seq, station };
};
const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');

View File

@@ -130,7 +130,7 @@ export class FareEngineService {
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - 1);
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
@@ -169,7 +169,7 @@ export class FareEngineService {
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
``,
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
`Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`,
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
``,

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service';
import { PassengerStaff } from '../../common/passenger-guards';
@@ -48,6 +48,31 @@ export class FraudController {
return { data: rule, message: 'Rule updated successfully' };
}
/**
* Acknowledge a fraud alert
*/
@Patch('alerts/:id/acknowledge')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Acknowledge a fraud alert' })
async acknowledgeAlert(@Param('id') id: string) {
const alert = await this.fraudService.acknowledgeAlert(id);
return { data: alert, message: 'Alert acknowledged' };
}
/**
* Block user via userId
*/
@Post('users/:userId/block')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Block user by userId' })
async blockUserById(
@Param('userId') userId: string,
@Body() body: { reason?: string; durationMinutes?: number },
) {
await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60);
return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` };
}
/**
* Block user temporarily
*/

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FraudService } from './fraud.service';
import { FraudController } from './fraud.controller';
@Module({
imports: [HttpModule],
imports: [HttpModule, TypeOrmModule],
providers: [FraudService],
controllers: [FraudController],
exports: [FraudService],

View File

@@ -164,6 +164,16 @@ export class FraudService {
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
}
/**
* Acknowledge a fraud alert
*/
async acknowledgeAlert(id: string) {
return this.prisma.fraudAlert.update({
where: { id },
data: { acknowledged: true, acknowledgedAt: new Date() },
});
}
/**
* Get all fraud alerts
*/

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LoyaltyService } from './loyalty.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class LoyaltyController {
constructor(private service: LoyaltyService) {}
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
@Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); }
@Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); }
@Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); }
@Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); }
}

View File

@@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service';
export class LoyaltyService {
constructor(private prisma: PrismaService) {}
async getAccounts(params: { search?: string; tier?: string; page?: string; pageSize?: string } = {}) {
const { search, tier, page = '1', pageSize = '20' } = params;
const skip = (parseInt(page) - 1) * parseInt(pageSize);
const where: any = {};
if (tier) where.tier = tier;
if (search) {
where.passenger = {
OR: [
{ user: { fullName: { contains: search, mode: 'insensitive' } } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
],
};
}
const [items, total] = await Promise.all([
this.prisma.loyaltyAccount.findMany({
where,
skip,
take: parseInt(pageSize),
orderBy: { pointsBalance: 'desc' },
include: { passenger: { include: { user: true } } },
}),
this.prisma.loyaltyAccount.count({ where }),
]);
return {
items: items.map(a => ({
...a,
passenger: a.passenger ? {
id: a.passenger.id,
fullName: (a.passenger as any).user?.fullName ?? null,
email: (a.passenger as any).user?.email ?? null,
phone: (a.passenger as any).user?.phone ?? null,
} : null,
})),
meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) },
};
}
async getAccount(passengerId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
if (!account) throw new NotFoundException('Loyalty account not found');
@@ -40,4 +76,15 @@ export class LoyaltyService {
await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } });
return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance };
}
async deleteAccount(id: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('Loyalty account not found');
await this.prisma.$transaction([
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { accountId: id } }),
this.prisma.loyaltyReward.deleteMany({ where: { accountId: id } }),
this.prisma.loyaltyAccount.delete({ where: { id } }),
]);
return { deleted: true, accountId: id };
}
}

View File

@@ -1,8 +1,8 @@
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@@ -63,6 +63,19 @@ export class PackagesController {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
}
@Get('bookings')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all package bookings (backoffice)' })
listBookings(
@Query('packageId') packageId?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.listBookings({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 });
}
@Get('my-bookings')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@@ -78,6 +91,21 @@ export class PackagesController {
return this.service.getBookingByRef(ref);
}
@Get(':id/booking-context')
@IsPublic()
@ApiOperation({ summary: 'Get booking context for self-service package booking' })
@ApiQuery({ name: 'tierId', required: true })
@ApiQuery({ name: 'adultCount', required: true })
@ApiQuery({ name: 'childCount', required: false })
getBookingContext(
@Param('id') id: string,
@Query('tierId') tierId: string,
@Query('adultCount') adultCount: string,
@Query('childCount') childCount?: string,
) {
return this.service.getBookingContext(id, tierId, parseInt(adultCount), childCount ? parseInt(childCount) : 0);
}
@Get(':id')
@IsPublic()
@ApiOperation({ summary: 'Get package details' })

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID, IsPositive } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@@ -93,6 +93,12 @@ export class BookPackagePassengerDto {
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
}
export class PackageBookingContextDto {
@ApiProperty() @IsUUID() tierId: string;
@ApiProperty({ example: 1 }) @IsInt() @IsPositive() adultCount: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
}
export class BookPackageDto {
@ApiProperty() @IsUUID() packageId: string;
@ApiProperty() @IsUUID() priceTierId: string;

View File

@@ -3,9 +3,10 @@ import { PrismaModule } from '../../common/prisma.module';
import { PackagesController } from './packages.controller';
import { PackagesService } from './packages.service';
import { CurrencyModule } from '../currency/currency.module';
import { BookingsModule } from '../bookings/bookings.module';
@Module({
imports: [PrismaModule, CurrencyModule],
imports: [PrismaModule, CurrencyModule, BookingsModule],
controllers: [PackagesController],
providers: [PackagesService],
exports: [PackagesService],

View File

@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
function generateRef(): string {
return 'PKG-' + Array.from({ length: 6 }, () =>
@@ -15,8 +17,94 @@ export class PackagesService {
constructor(
private readonly prisma: PrismaService,
private readonly currencyService: CurrencyService,
private readonly bookingsService: BookingsService,
private readonly guestBookingService: GuestBookingService,
) {}
async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id: packageId },
include: {
priceTiers: true,
outboundSchedule: {
include: {
originStation: true,
destinationStation: true,
coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } },
},
},
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
});
if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available');
const tier = pkg.priceTiers.find(t => t.id === tierId);
if (!tier) throw new NotFoundException('Price tier not found');
const passengerCount = adultCount + childCount;
if (passengerCount < 1) throw new BadRequestException('At least one passenger required');
const remaining = tier.availableSeats - tier.bookedSeats;
if (passengerCount > remaining)
throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`);
const totalMinor = tier.priceMinor * passengerCount;
// Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches
let seatClassId: string | null = null;
let coachTypeId: string | null = null;
for (const a of pkg.outboundSchedule.coachAssignments) {
const sc = a.coach.coachType?.seatClasses?.find(
(s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) ||
tier.seatType.toLowerCase().includes(s.name.toLowerCase()),
);
if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; }
}
// Fallback: use the first coach assignment's coachTypeId if no match found
if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) {
const first = pkg.outboundSchedule.coachAssignments[0];
coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null;
}
return {
packageId: pkg.id,
packageName: pkg.name,
priceTierId: tier.id,
tierLabel: tier.label,
seatType: tier.seatType,
seatClassId,
coachTypeId,
adultCount,
childCount,
passengerCount,
pricePerPassengerMinor: tier.priceMinor,
totalMinor,
currency: tier.currency,
remainingSeats: remaining,
outboundSchedule: {
scheduleId: pkg.outboundScheduleId,
originStationId: pkg.originStationId,
destinationStationId: pkg.destinationStationId,
departureAt: pkg.outboundSchedule.departureAt,
arrivalAt: pkg.outboundSchedule.arrivalAt,
originStation: pkg.outboundSchedule.originStation,
destinationStation: pkg.outboundSchedule.destinationStation,
},
returnSchedule: pkg.returnSchedule ? {
scheduleId: pkg.returnScheduleId,
originStationId: pkg.destinationStationId,
destinationStationId: pkg.originStationId,
departureAt: pkg.returnSchedule.departureAt,
arrivalAt: pkg.returnSchedule.arrivalAt,
originStation: pkg.returnSchedule.destinationStation,
destinationStation: pkg.returnSchedule.originStation,
} : null,
includedServices: pkg.includedServices,
busTransferIncluded: pkg.busTransferIncluded,
busTransferRoute: pkg.busTransferRoute,
};
}
async createInquiry(dto: CreateInquiryDto) {
return this.prisma.packageInquiry.create({
data: {
@@ -74,7 +162,7 @@ export class PackagesService {
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
orderBy: { validFrom: 'asc' },
});
}).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' })));
}
async getById(id: string) {
@@ -87,7 +175,7 @@ export class PackagesService {
},
});
if (!pkg) throw new NotFoundException('Package not found');
return pkg;
return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' };
}
create(dto: CreatePackageDto) {
@@ -296,6 +384,29 @@ export class PackagesService {
return booking;
}
async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) {
const where: any = {};
if (packageId) where.packageId = packageId;
if (status) where.status = status;
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.packageBooking.findMany({
where,
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.packageBooking.count({ where }),
]);
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
async listAll(page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([

View File

@@ -433,39 +433,49 @@ export class PassengersService {
}
async deletePassenger(id: string) {
const passenger = await this.prisma.passenger.findUnique({
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
let passenger = await this.prisma.passenger.findUnique({
where: { id },
include: {
user: true
}
include: { user: true },
});
if (!passenger) throw new NotFoundException('Passenger not found');
if (!passenger) {
const profile = await this.prisma.travelerProfile.findUnique({ where: { id } });
if (!profile?.passengerId) throw new NotFoundException('Passenger not found');
passenger = await this.prisma.passenger.findUnique({
where: { id: profile.passengerId },
include: { user: true },
});
if (!passenger) throw new NotFoundException('Passenger not found');
}
const passengerId = passenger.id;
// Check usage before allowing deletion
const usage = await this.checkPassengerUsage(id);
const usage = await this.checkPassengerUsage(passengerId);
if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
}
await this.prisma.$transaction([
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }),
this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }),
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }),
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
this.prisma.passenger.delete({ where: { id } }),
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }),
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }),
this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }),
this.prisma.walletAccount.deleteMany({ where: { passengerId } }),
this.prisma.notification.deleteMany({ where: { passengerId } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId } }),
this.prisma.packageBooking.deleteMany({ where: { passengerId } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }),
this.prisma.booking.deleteMany({ where: { passengerId } }),
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }),
this.prisma.journey.deleteMany({ where: { passengerId } }),
this.prisma.passenger.delete({ where: { id: passengerId } }),
]);
return { deleted: true, passengerId: id };
return { deleted: true, passengerId };
}
async checkPassengerUsage(id: string) {

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
@@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
export class PaymentsController {
constructor(private service: PaymentsService) {}
@Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Delete a payment intent record (admin only)" })
deletePayment(@Param("id") id: string) {
return this.service.deletePayment(id);
}
@Get("all")
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")

View File

@@ -55,6 +55,13 @@ export class PaymentsService {
private currencyService: CurrencyService,
) {}
async deletePayment(id: string) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
if (!intent) throw new NotFoundException('Payment intent not found');
await this.prisma.paymentIntent.delete({ where: { id } });
return { deleted: true, id };
}
async getAll(filters: {
search?: string;
status?: string;

View File

@@ -1,8 +1,8 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SearchService } from './search.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
@ApiTags('Search')
@Controller('search')
@@ -66,4 +66,29 @@ Nationality-Based:
getFareQuote(@Body() dto: FareQuoteDto) {
return this.service.getFareQuote(dto);
}
@Get('fare-breakdown')
@ApiOperation({
summary: 'Per-passenger fare breakdown for booking review page',
description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class.
- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs)
- First CHILD in the list travels free (pays only premium + insurance fees)
- Each passenger can have a different seat class and nationality
- Returns per-passenger lines plus subtotal, discount, and grand total
**passengers** must be a URL-encoded JSON array, e.g.:
\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``,
})
@ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
@ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' })
@ApiQuery({ name: 'promoCode', required: false })
@ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] })
@ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
getFareBreakdown(@Query() dto: FareBreakdownRequestDto) {
return this.service.getFareBreakdown(dto);
}
}

View File

@@ -75,6 +75,43 @@ export class CoachTypeOptionClass {
@ApiProperty({ example: 35000 }) baseFareMinor: number;
}
export class FareBreakdownPassengerDto {
@ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' })
@IsString() passengerName: string;
@ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' })
@IsDateString() dateOfBirth: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' })
@IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' })
@IsOptional() @IsString() nationality?: string;
}
export class FareBreakdownRequestDto {
@ApiProperty({ example: 'schedule-uuid' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({
example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]',
description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }',
})
@IsString() passengers: string;
@ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 'USD', enum: Currency })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}
export class CoachTypeOption {
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
@ApiProperty({ example: 'Economy' }) coachTypeName: string;

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
@@ -477,6 +477,124 @@ export class SearchService {
};
}
async getFareBreakdown(dto: FareBreakdownRequestDto) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const now = new Date();
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
let parsedPassengers: FareBreakdownPassengerDto[];
try {
parsedPassengers = JSON.parse(dto.passengers as unknown as string);
} catch {
throw new NotFoundException('passengers must be a valid JSON array');
}
// Categorise passengers by age
const categorised = parsedPassengers.map(p => {
const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime();
const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25);
return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears };
});
const adultCount = categorised.filter(p => p.category === 'ADULT').length;
const childCount = categorised.filter(p => p.category === 'CHILD').length;
// Ask the fare engine for the authoritative free-child count using the full group
// Use the first passenger's seatClassId as a representative — freeChildrenCount
// depends only on adultCount/childCount, not on seat class.
const groupFare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: categorised[0].seatClassId,
nationality: categorised[0].nationality,
scheduleId: dto.scheduleId,
adultCount,
childCount,
});
const freeChildrenAllowed = groupFare.freeChildrenCount;
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
let freeChildrenUsed = 0;
const passengerLines = await Promise.all(
categorised.map(async (p) => {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: p.seatClassId,
nationality: p.nationality,
scheduleId: dto.scheduleId,
adultCount: 1,
childCount: 0,
});
const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed;
if (isFree) freeChildrenUsed++;
const fareMinor = isFree
? fare.premiumPerPassenger + fare.insurancePerPassenger
: fare.farePerPassengerMinor;
const displayFareMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency)
: fareMinor;
return {
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
category: p.category,
ageYears: Math.floor(p.ageYears),
seatClassId: fare.seatClassId,
seatClassName: fare.seatClassName,
nationality: p.nationality ?? null,
baseFareMinor: fare.baseFarePerPassengerMinor,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
fareMinor,
isFree,
displayCurrency,
displayFareMinor,
};
}),
);
let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0);
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > now) {
discountMinor = promo.percentOff
? Math.round(subtotalMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const totalMinor = subtotalMinor - discountMinor;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
return {
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: passengerLines,
subtotalMinor,
discountMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
};
}
private async calculateFaresForSegment(
schedule: ScheduleWithIncludes,
originStationId: string,

View File

@@ -1,5 +1,6 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
@Injectable()
export class SeatClassesService {
@@ -45,8 +46,24 @@ export class SeatClassesService {
}
async deleteSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
const sc = await this.prisma.seatClass.findUnique({
where: { id },
include: {
_count: { select: { fareRules: true, routeFareRules: true, segmentFares: true } },
},
});
if (!sc) throw new NotFoundException('SeatClass not found');
const totalFareRules =
(sc as any)._count.fareRules +
(sc as any)._count.routeFareRules +
(sc as any)._count.segmentFares;
if (totalFareRules > 0)
throw new DeleteOperationException('Seat Class', sc.name, [
{ entityName: 'fare rule', count: totalFareRules, action: 'delete' },
]);
return this.prisma.seatClass.delete({ where: { id } });
}
}

View File

@@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter";
export class SeatsController {
constructor(private service: SeatsService) {}
// ── Coach Availability ────────────────────────────────────────────────────
@Get('coaches/:scheduleId')
@SetMetadata('isPublic', true)
@ApiOperation({
summary: 'List coaches with remaining seat counts for a schedule',
description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.',
})
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' })
@ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' })
@ApiResponse({ status: 200, description: 'Coaches with seat availability counts' })
getCoachesWithAvailability(
@Param('scheduleId') scheduleId: string,
@Query('originStationId') originStationId?: string,
@Query('destinationStationId') destinationStationId?: string,
) {
return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId);
}
// ── Seat Map ──────────────────────────────────────────────────────────────
@Get("seatmap/:scheduleId")
@SetMetadata('isPublic', true)

View File

@@ -367,7 +367,24 @@ export class SeatsService {
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
// When no stop times exist, fall back to the schedule's own origin/destination
// with synthetic sequences so the hold can still be created.
let effectiveStopTimes = stopTimes;
if (stopTimes.length === 0) {
const sched = await tx.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { originStationId: true, destinationStationId: true },
});
if (sched) {
effectiveStopTimes = [
{ stationId: sched.originStationId, sequence: 0 },
{ stationId: sched.destinationStationId, sequence: 1 },
];
}
}
const seqOf = (stationId: string) => effectiveStopTimes.find(s => s.stationId === stationId)?.sequence;
const reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
@@ -604,6 +621,56 @@ export class SeatsService {
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
}
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId },
include: {
coach: {
include: {
seats: { select: { id: true, status: true, seatNumber: true } },
coachType: { include: { seatClasses: { select: { name: true } } } },
},
},
},
orderBy: { positionNumber: 'asc' },
});
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(
scheduleId,
allSeatIds,
originStationId ?? schedule.originStationId,
destinationStationId ?? schedule.destinationStationId,
);
return assignments.map(a => {
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
const totalSeats = seats.length;
const unavailable = seats.filter(s => {
const status = effectiveStatuses.get(s.id) ?? s.status;
return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
}).length;
return {
coachId: a.coach.id,
coachNumber: a.coach.number,
positionNumber: a.positionNumber,
coachTypeName: a.coach.coachType?.name ?? '',
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
totalSeats,
availableSeats: totalSeats - unavailable,
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length,
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length,
};
});
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {

View File

@@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { CreateStationDto } from './stations.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
interface StationFilters {
search?: string;
@@ -96,7 +97,35 @@ export class StationsService {
}
async remove(id: string) {
const station = await this.findOne(id);
const station = await this.prisma.station.findUnique({
where: { id },
include: {
_count: { select: { stopTimes: true } },
originSchedules: { take: 1, select: { id: true } },
destinationSchedules: { take: 1, select: { id: true } },
},
});
if (!station) throw new NotFoundException('Station not found');
const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([
this.prisma.routeStop.count({ where: { stationId: id } }),
this.prisma.trainSchedule.count({ where: { originStationId: id } }),
this.prisma.trainSchedule.count({ where: { destinationStationId: id } }),
(station as any)._count.stopTimes as number,
]);
const constraints = [];
if (routeStopCount > 0)
constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const });
const scheduleCount = originCount + destCount;
if (scheduleCount > 0)
constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const });
if (stopTimeCount > 0)
constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const });
if (constraints.length > 0)
throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints);
const deleted = await this.prisma.station.delete({ where: { id } });
await this.auditService.log({

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { WalletService } from './wallet.service';
@@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard';
@Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class WalletController {
constructor(private service: WalletService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }
@Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); }
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }
@Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); }
@Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); }
}

View File

@@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service';
export class WalletService {
constructor(private prisma: PrismaService) {}
async getAccounts(params: { search?: string; page?: string; pageSize?: string } = {}) {
const { search, page = '1', pageSize = '20' } = params;
const skip = (parseInt(page) - 1) * parseInt(pageSize);
const where: any = {};
if (search) {
where.passenger = {
OR: [
{ user: { fullName: { contains: search, mode: 'insensitive' } } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
],
};
}
const [items, total] = await Promise.all([
this.prisma.walletAccount.findMany({
where,
skip,
take: parseInt(pageSize),
orderBy: { balanceMinor: 'desc' },
include: { passenger: { include: { user: true } } },
}),
this.prisma.walletAccount.count({ where }),
]);
return {
items: items.map(w => ({
...w,
passenger: w.passenger ? {
id: w.passenger.id,
fullName: (w.passenger as any).user?.fullName ?? null,
email: (w.passenger as any).user?.email ?? null,
phone: (w.passenger as any).user?.phone ?? null,
} : null,
})),
meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) },
};
}
async getWallet(passengerId: string) {
const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
if (!wallet) throw new NotFoundException('Wallet not found');
@@ -18,4 +54,14 @@ export class WalletService {
await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } });
return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } });
}
async deleteAccount(id: string) {
const wallet = await this.prisma.walletAccount.findUnique({ where: { id } });
if (!wallet) throw new NotFoundException('Wallet account not found');
await this.prisma.$transaction([
this.prisma.walletLedgerEntry.deleteMany({ where: { walletId: id } }),
this.prisma.walletAccount.delete({ where: { id } }),
]);
return { deleted: true, accountId: id };
}
}