mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 21:45:03 +00:00
Tour package booking, app release, new endpoints, more updates and fixes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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`,
|
||||
``,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user