Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Abubeker Yasin
2026-07-15 00:45:39 +03:00
26 changed files with 169 additions and 64 deletions

View File

@@ -525,8 +525,11 @@ export class BookingsController {
"Missing required fields for bookingType, or Verifayda verification failed",
})
@ApiResponse({ status: 404, description: "Schedule or seat hold not found" })
create(@Body() dto: CreateBookingDto) {
return this.service.create(dto);
create(@Req() req: any, @Body() dto: CreateBookingDto) {
// Always resolve passengerId from the authenticated JWT — never trust the request body
const iamUserId = req.user?.id;
if (!iamUserId) throw new UnauthorizedException();
return this.service.create({ ...dto, passengerId: iamUserId });
}
@Get(":id/usage")
@@ -612,8 +615,8 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
status: 400,
description: "Cannot modify cancelled or past bookings",
})
modify(@Body() dto: ModifyBookingDto) {
return this.service.modify(dto);
modify(@Req() req: any, @Body() dto: ModifyBookingDto) {
return this.service.modify(dto, req.user?.id);
}
@Delete(":id")
@@ -658,7 +661,7 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
description: "Booking cancelled with refund amount",
})
@ApiResponse({ status: 400, description: "Booking already cancelled" })
cancel(@Param("bookingRef") ref: string, @Body() dto: CancelBookingDto) {
return this.service.cancel(ref, dto.reason);
cancel(@Req() req: any, @Param("bookingRef") ref: string, @Body() dto: CancelBookingDto) {
return this.service.cancel(ref, dto.reason, req.user?.id);
}
}

View File

@@ -88,8 +88,8 @@ export class RoundTripPassengerDto {
}
export class CreateBookingDto {
@ApiProperty({ description: 'Passenger ID' })
@IsString() passengerId: string;
@ApiPropertyOptional({ description: 'Passenger ID — resolved automatically from JWT token; only required for agent/back-office calls' })
@IsOptional() @IsString() passengerId: string;
@ApiProperty({ description: 'Outbound / leg-1 schedule ID' })
@IsString() scheduleId: string;

View File

@@ -12,6 +12,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -104,6 +105,7 @@ export class BookingsService {
private readonly verifaydaService: VerifaydaService,
private readonly currencyService: CurrencyService,
private readonly fareEngine: FareEngineService,
private readonly auditService: AuditService,
) {}
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
@@ -151,7 +153,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
@@ -295,7 +297,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
@@ -408,7 +410,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
@@ -681,7 +683,7 @@ export class BookingsService {
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: resolvedEmail,
@@ -756,7 +758,14 @@ export class BookingsService {
};
}
async create(dto: CreateBookingDto) {
async create(dto: CreateBookingDto) {
// Resolve passengerId from iamUserId when the caller is authenticated
if (dto.passengerId && !dto.passengerId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
// passengerId is actually an iamUserId — resolve the passenger record
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId: dto.passengerId }, select: { id: true } });
if (!passenger) throw new NotFoundException('Passenger profile not found for this account');
dto = { ...dto, passengerId: passenger.id };
}
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);
@@ -906,6 +915,7 @@ export class BookingsService {
});
}
this.eventEmitter.emit('booking.created', { booking });
await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ONE_WAY', totalMinor: resolvedTotalMinor } });
return { ...booking, fareBreakdown: fareCalculation };
}
@@ -1118,6 +1128,7 @@ export class BookingsService {
}
this.eventEmitter.emit('booking.created', { booking });
await this.auditService.log({ userId: dto.passengerId, action: 'CREATE', entityType: 'Booking', entityId: booking.id, newData: { bookingRef: booking.bookingRef, bookingType: 'ROUND_TRIP', totalMinor } });
return {
...booking,
@@ -1129,7 +1140,7 @@ export class BookingsService {
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
currency: booking.displayCurrency,
displayCurrency,
displayTotalMinor
}
@@ -1941,7 +1952,7 @@ export class BookingsService {
};
}
async modify(dto: ModifyBookingDto) {
async modify(dto: ModifyBookingDto, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
@@ -1953,10 +1964,11 @@ export class BookingsService {
});
await this.seatsService.releaseSeats(booking.id);
await this.seatsService.confirmSeats(dto.newSeatIds);
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } });
return { modified: true, bookingRef: dto.bookingRef };
}
async cancel(bookingRef: string, reason?: string) {
async cancel(bookingRef: string, reason?: string, iamUserId?: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
@@ -1965,6 +1977,7 @@ export class BookingsService {
await this.seatsService.releaseSeats(booking.id);
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'DELETE', entityType: 'Booking', entityId: booking.id, oldData: { bookingRef, status: booking.status }, newData: { status: 'CANCELLED', reason, refundAmount } });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
@@ -2031,6 +2044,7 @@ export class BookingsService {
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
await this.prisma.booking.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Booking', entityId: id, oldData: { bookingRef: booking.bookingRef } });
return { deleted: true, bookingRef: booking.bookingRef };
}

View File

@@ -324,7 +324,7 @@ export class GuestBookingService {
discountMinor,
taxesFeesMinor: taxesMinor,
totalMinor: resolvedTotalMinor,
currency: 'ETB',
currency: booking.displayCurrency,
displayCurrency,
displayTotalMinor,
},
@@ -805,7 +805,7 @@ export class GuestBookingService {
paidChildrenCount,
combinedBaseFareMinor: combinedBase,
discountMinor, taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
currency: displayCurrency, displayTotalMinor,
},
};
}

View File

@@ -4,9 +4,10 @@ import { CurrenciesController } from './currencies.controller';
import { CurrenciesService } from './currencies.service';
import { CurrencyModule } from '../currency/currency.module';
import { PrismaModule } from '../../common/prisma.module';
import { AuditModule } from '../../common/audit.module';
@Module({
imports: [HttpModule, PrismaModule, CurrencyModule],
imports: [HttpModule, PrismaModule, CurrencyModule, AuditModule],
controllers: [CurrenciesController],
providers: [CurrenciesService],
exports: [CurrenciesService],

View File

@@ -2,12 +2,14 @@ import { Injectable, BadRequestException, NotFoundException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { CurrencyService } from '../currency/currency.service';
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
import { AuditService } from '../../common/audit.service';
@Injectable()
export class CurrenciesService {
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
private auditService: AuditService,
) {}
async getAllCurrencies() {
@@ -57,6 +59,7 @@ export class CurrenciesService {
},
});
await this.auditService.log({ action: 'CREATE', entityType: 'Currency', entityId: rate.id, newData: { code, exchangeRate } });
return {
id: rate.id,
code: rate.toCurrency,
@@ -92,6 +95,7 @@ export class CurrenciesService {
'MANUAL',
);
await this.auditService.log({ action: 'UPDATE', entityType: 'Currency', entityId: updated.id, newData: { exchangeRate: Number(updated.rate) } });
return {
id: updated.id,
code: updated.toCurrency,
@@ -125,7 +129,7 @@ export class CurrenciesService {
await this.prisma.currencyExchangeRate.deleteMany({
where: { fromCurrency: existing.fromCurrency, toCurrency: existing.toCurrency },
});
await this.auditService.log({ action: 'DELETE', entityType: 'Currency', entityId: id, oldData: { toCurrency: existing.toCurrency } });
return { message: 'Currency deleted successfully' };
}

View File

@@ -7,9 +7,10 @@ import {
} from './excess-baggage.controller';
import { PaymentsModule } from '../payments/payments.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { AuditModule } from '../../common/audit.module';
@Module({
imports: [HttpModule, PaymentsModule, NotificationsModule],
imports: [HttpModule, PaymentsModule, NotificationsModule, AuditModule],
controllers: [ExcessBaggageAgentController, ExcessBaggagePublicController],
providers: [ExcessBaggageService],
exports: [ExcessBaggageService],

View File

@@ -5,6 +5,7 @@ import {
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
@@ -30,6 +31,7 @@ export class ExcessBaggageService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private paymentClient: PaymentClientService,
private notifications: NotificationsService,
private smsClient: SmsClientService,
@@ -91,6 +93,7 @@ export class ExcessBaggageService {
await this.sendPaymentLink(charge, booking, contactPhone, contactEmail);
}
await this.auditService.log({ action: 'CREATE', entityType: 'ExcessBaggageCharge', entityId: charge.id, newData: { bookingId: dto.bookingId, excessWeightKg: dto.excessWeightKg, totalMinor, status } });
return charge;
}
@@ -208,10 +211,12 @@ export class ExcessBaggageService {
if (['PAID', 'CASH_COLLECTED'].includes(charge.status)) {
throw new BadRequestException('Cannot waive a charge that has already been paid');
}
return this.prisma.excessBaggageCharge.update({
const waived = await this.prisma.excessBaggageCharge.update({
where: { id },
data: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'ExcessBaggageCharge', entityId: id, newData: { status: 'WAIVED', waivedBy: dto.waivedBy, waivedReason: dto.waivedReason } });
return waived;
}
async resendLink(id: string) {

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { FleetController } from './fleet.controller';
import { FleetService } from './fleet.service';
import { AuditModule } from '../../common/audit.module';
@Module({ controllers: [FleetController], providers: [FleetService], exports: [FleetService] })
@Module({ imports: [AuditModule], controllers: [FleetController], providers: [FleetService], exports: [FleetService] })
export class FleetModule {}

View File

@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { SeatKind } from '@prisma/client';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
function parseArrangement(arrangement: string): number[] {
@@ -147,7 +148,7 @@ type SeatRow = {
@Injectable()
export class FleetService {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService, private auditService: AuditService) {}
async createCoachType(dto: CreateCoachTypeDto) {
return this.prisma.coachType.create({
@@ -333,8 +334,8 @@ export class FleetService {
});
}
createTrain(dto: CreateTrainDto) {
return this.prisma.train.create({
async createTrain(dto: CreateTrainDto) {
const train = await this.prisma.train.create({
data: {
number: dto.number,
name: dto.name,
@@ -344,12 +345,14 @@ export class FleetService {
isActive: dto.isActive ?? true,
},
});
await this.auditService.log({ action: 'CREATE', entityType: 'Train', entityId: train.id, newData: { number: train.number, name: train.name } });
return train;
}
async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.update({
const updated = await this.prisma.train.update({
where: { id },
data: {
number: dto.number,
@@ -360,6 +363,8 @@ export class FleetService {
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
await this.auditService.log({ action: 'UPDATE', entityType: 'Train', entityId: id, newData: { number: dto.number, name: dto.name } });
return updated;
}
async deleteTrain(id: string, cascade = false) {
@@ -436,7 +441,9 @@ export class FleetService {
await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } });
}
return this.prisma.train.delete({ where: { id } });
const deleted = await this.prisma.train.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Train', entityId: id, oldData: { number: train.number, name: train.name } });
return deleted;
}
async restoreTrain(id: string) {
@@ -519,6 +526,7 @@ export class FleetService {
await this.prisma.seat.createMany({ data: seats });
}
await this.auditService.log({ action: 'CREATE', entityType: 'Coach', entityId: coach.id, newData: { number: coach.number, capacity: coach.capacity } });
return coach;
}
@@ -526,7 +534,7 @@ export class FleetService {
const coach = await this.prisma.coach.findUnique({ where: { id } });
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coach.update({
const updated = await this.prisma.coach.update({
where: { id },
data: {
number: dto.number,
@@ -537,6 +545,8 @@ export class FleetService {
},
include: { coachType: true },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'Coach', entityId: id, newData: { number: dto.number, status: dto.status } });
return updated;
}
async deleteCoach(id: string, cascade = false) {
@@ -611,7 +621,9 @@ export class FleetService {
await this.prisma.seat.deleteMany({ where: { coachId: id } });
return this.prisma.coach.delete({ where: { id } });
const deleted = await this.prisma.coach.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Coach', entityId: id, oldData: { number: coach.number } });
return deleted;
}
async assignCoach(dto: AssignCoachDto) {

View File

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

View File

@@ -5,6 +5,7 @@ import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDt
import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service';
import { AuditService } from '../../common/audit.service';
/** Package-specific fare rules */
const PKG_MAX_ADULTS = 5;
@@ -49,6 +50,7 @@ export class PackagesService {
private readonly currencyService: CurrencyService,
private readonly bookingsService: BookingsService,
private readonly guestBookingService: GuestBookingService,
private readonly auditService: AuditService,
) {}
async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) {
@@ -254,8 +256,8 @@ export class PackagesService {
};
}
create(dto: CreatePackageDto) {
return this.prisma.travelPackage.create({
async create(dto: CreatePackageDto) {
const pkg = await this.prisma.travelPackage.create({
data: {
code: dto.code,
name: dto.name,
@@ -279,12 +281,14 @@ export class PackagesService {
},
include: { priceTiers: true },
});
await this.auditService.log({ action: 'CREATE', entityType: 'Package', entityId: pkg.id, newData: { code: pkg.code, name: pkg.name } });
return pkg;
}
async update(id: string, dto: Partial<CreatePackageDto>) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({
const updated = await this.prisma.travelPackage.update({
where: { id },
data: {
...(dto.code && { code: dto.code }),
@@ -307,6 +311,8 @@ export class PackagesService {
},
include: { priceTiers: true },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { code: dto.code, name: dto.name } });
return updated;
}
async addTier(packageId: string, dto: CreatePriceTierDto) {
@@ -353,19 +359,24 @@ export class PackagesService {
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
await this.prisma.travelPackage.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id });
return { deleted: true };
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
const activated = await this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { status: 'ACTIVE' } });
return activated;
}
async deactivate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
const deactivated = await this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { status: 'DRAFT' } });
return deactivated;
}
async book(dto: BookPackageDto, passengerId?: string) {
@@ -478,7 +489,7 @@ export class PackagesService {
paidChildFareMinor: adultFareMinor,
childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`,
totalMinor,
currency: 'ETB',
currency: booking.displayCurrency,
displayCurrency,
displayTotalMinor,
},

View File

@@ -4,10 +4,11 @@ import { PassengersController } from './passengers.controller';
import { PassengersService } from './passengers.service';
import { VerifaydaModule } from '../verifayda/verifayda.module';
import { PrismaModule } from '../../common/prisma.module';
import { AuditModule } from '../../common/audit.module';
@Module({
imports: [VerifaydaModule, HttpModule, PrismaModule],
controllers: [PassengersController],
providers: [PassengersService]
@Module({
imports: [VerifaydaModule, HttpModule, PrismaModule, AuditModule],
controllers: [PassengersController],
providers: [PassengersService],
})
export class PassengersModule {}

View File

@@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
interface PassengerFilters {
search?: string;
@@ -30,6 +31,7 @@ export class PassengersService {
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService,
private readonly auditService: AuditService,
) {}
async findAll(filters: PassengerFilters = {}) {
@@ -509,7 +511,7 @@ export class PassengersService {
await this.prisma.travelerProfile.deleteMany({ where: { passengerId } });
await this.prisma.savedRoute.deleteMany({ where: { passengerId } });
await this.prisma.passenger.delete({ where: { id: passengerId } });
await this.auditService.log({ action: 'DELETE', entityType: 'Passenger', entityId: passengerId });
return { deleted: true, passengerId };
}

View File

@@ -19,6 +19,7 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { SeatsModule } from "../seats/seats.module";
import { TicketsModule } from "../tickets/tickets.module";
import { CurrencyModule } from "../currency/currency.module";
import { AuditModule } from "../../common/audit.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
@@ -53,6 +54,7 @@ function rabbitMQImport(): DynamicModule[] {
SeatsModule,
TicketsModule,
CurrencyModule,
AuditModule,
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
HttpModule.register({

View File

@@ -26,6 +26,7 @@ import {
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import { PaymentClientService } from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { AuditService } from "../../common/audit.service";
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
import {
PaymentService as PaymentServiceEnum,
@@ -64,6 +65,7 @@ export class PaymentsService {
private eventEmitter: EventEmitter2,
private paymentClient: PaymentClientService,
private currencyService: CurrencyService,
private auditService: AuditService,
) {}
async deletePayment(id: string) {
@@ -593,6 +595,7 @@ export class PaymentsService {
data: { status: "CANCELLED" },
});
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } });
return { refunded: true, bookingRef: booking?.bookingRef };
}
@@ -899,6 +902,9 @@ export class PaymentsService {
return this.finalizePaymentSuccess({
intentId: intent.id,
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
}).then(async (result) => {
await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
return result;
});
}

View File

@@ -2,10 +2,11 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException }
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
@Injectable()
export class RoutesService {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService, private auditService: AuditService) {}
// ── Route CRUD ─────────────────────────────────────────────────────────────
@@ -22,7 +23,7 @@ export class RoutesService {
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
return this.prisma.route.create({
const route = await this.prisma.route.create({
data: {
code: dto.code,
name: dto.name,
@@ -40,6 +41,8 @@ export class RoutesService {
},
include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } },
});
await this.auditService.log({ action: 'CREATE', entityType: 'Route', entityId: route.id, newData: { code: route.code, name: route.name } });
return route;
}
async listRoutes(activeOnly = false) {
@@ -104,6 +107,7 @@ export class RoutesService {
});
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
return this.prisma.route.findUnique({
where: { id },
include: { stops: { orderBy: { sequence: 'asc' } } },
@@ -192,6 +196,7 @@ export class RoutesService {
}
await this.prisma.route.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Route', entityId: id, oldData: { code: route.code, name: route.name } });
return { deleted: true, id };
}

View File

@@ -4,9 +4,10 @@ import { SchedulesService } from './schedules.service';
import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { AuditModule } from '../../common/audit.module';
@Module({
imports: [FareEngineModule],
imports: [FareEngineModule, AuditModule],
controllers: [RoutesController, SchedulesController],
providers: [RoutesService, SchedulesService],
exports: [RoutesService, SchedulesService],

View File

@@ -5,6 +5,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
import { AuditService } from '../../common/audit.service';
@Injectable()
export class SchedulesService {
@@ -12,6 +13,7 @@ export class SchedulesService {
private prisma: PrismaService,
private routesService: RoutesService,
private fareEngine: FareEngineService,
private auditService: AuditService,
) { }
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
@@ -191,7 +193,9 @@ export class SchedulesService {
);
}
return this.getSchedule(schedule.id);
const result = await this.getSchedule(schedule.id);
await this.auditService.log({ action: 'CREATE', entityType: 'Schedule', entityId: schedule.id, newData: { trainId: dto.trainId, routeId: dto.routeId, departureAt: dep } });
return result;
}
async getSchedule(id: string) {
@@ -325,10 +329,12 @@ export class SchedulesService {
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
return this.getSchedule(id);
const result = await this.getSchedule(id);
await this.auditService.log({ action: 'UPDATE', entityType: 'Schedule', entityId: id, newData: { trainId: dto.trainId, departureAt: dep } });
return result;
}
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
}
@@ -409,7 +415,9 @@ export class SchedulesService {
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
}
return this.prisma.trainSchedule.delete({ where: { id } });
await this.prisma.trainSchedule.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'Schedule', entityId: id });
return { deleted: true, id };
}
getStops(scheduleId: string) {
@@ -467,7 +475,7 @@ export class SchedulesService {
createFareRule(dto: CreateFareRuleDto) {
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
return this.prisma.fareRule.create({
const result = this.prisma.fareRule.create({
data: {
...rest,
tripId: scheduleId,
@@ -477,6 +485,8 @@ export class SchedulesService {
},
include: { seatClass: true },
});
result.then(r => this.auditService.log({ action: 'CREATE', entityType: 'FareRule', entityId: r.id, newData: { seatClassId: r.seatClassId, baseFareMinor: r.baseFareMinor } }));
return result;
}
async updateFareRule(id: string, dto: Partial<CreateFareRuleDto>) {
@@ -501,6 +511,7 @@ export class SchedulesService {
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('Fare rule not found');
await this.prisma.fareRule.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'FareRule', entityId: id });
return { deleted: true, id };
}
@@ -701,7 +712,7 @@ export class SchedulesService {
]);
if (!route) throw new NotFoundException('Route not found');
if (!seatClass) throw new NotFoundException('Seat class not found');
return this.prisma.routeFareRule.create({
const rule = await this.prisma.routeFareRule.create({
data: {
routeId: dto.routeId,
seatClassId: dto.seatClassId,
@@ -712,6 +723,8 @@ export class SchedulesService {
},
include: { seatClass: true, route: true },
});
await this.auditService.log({ action: 'CREATE', entityType: 'RouteFareRule', entityId: rule.id, newData: { routeId: dto.routeId, seatClassId: dto.seatClassId, baseFareMinor: dto.baseFareMinor } });
return rule;
}
async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
@@ -733,6 +746,7 @@ export class SchedulesService {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
await this.prisma.routeFareRule.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'RouteFareRule', entityId: id });
return { deleted: true, id };
}
}

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { SeatClassesController } from './seat-classes.controller';
import { SeatClassesService } from './seat-classes.service';
import { AuditModule } from '../../common/audit.module';
@Module({ controllers: [SeatClassesController], providers: [SeatClassesService], exports: [SeatClassesService] })
@Module({ imports: [AuditModule], controllers: [SeatClassesController], providers: [SeatClassesService], exports: [SeatClassesService] })
export class SeatClassesModule {}

View File

@@ -1,10 +1,11 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { AuditService } from '../../common/audit.service';
@Injectable()
export class SeatClassesService {
constructor(private prisma: PrismaService) {}
constructor(private prisma: PrismaService, private auditService: AuditService) {}
listSeatClasses() {
return this.prisma.seatClass.findMany({
@@ -28,7 +29,9 @@ export class SeatClassesService {
...rest,
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
return this.prisma.seatClass.update({ where: { id }, data });
const updated = await this.prisma.seatClass.update({ where: { id }, data });
await this.auditService.log({ action: 'UPDATE', entityType: 'SeatClass', entityId: id, newData: { name: updated.name } });
return updated;
}
async createSeatClass(dto: any) {
@@ -38,7 +41,9 @@ export class SeatClassesService {
...rest,
...(basePrice !== undefined && { baseFareMinor: basePrice }),
};
return await this.prisma.seatClass.create({ data });
const sc = await this.prisma.seatClass.create({ data });
await this.auditService.log({ action: 'CREATE', entityType: 'SeatClass', entityId: sc.id, newData: { name: sc.name } });
return sc;
} catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
throw e;
@@ -70,6 +75,8 @@ export class SeatClassesService {
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
}
return this.prisma.seatClass.delete({ where: { id } });
const deleted = await this.prisma.seatClass.delete({ where: { id } });
await this.auditService.log({ action: 'DELETE', entityType: 'SeatClass', entityId: id, oldData: { name: sc.name } });
return deleted;
}
}

View File

@@ -4,9 +4,10 @@ import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { AuditModule } from '../../common/audit.module';
@Module({
imports: [SegmentsModule, HttpModule, SystemConfigModule],
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],

View File

@@ -4,6 +4,7 @@ import { HoldSeatsDto, JourneyDirection } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
@Injectable()
export class SeatsService {
@@ -11,6 +12,7 @@ export class SeatsService {
private prisma: PrismaService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
private auditService: AuditService,
) {}
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
@@ -803,7 +805,7 @@ export class SeatsService {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } });
return { blocked: true, seatId, reason };
}
@@ -813,7 +815,7 @@ export class SeatsService {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } });
return { unblocked: true, seatId };
}
@@ -846,7 +848,7 @@ export class SeatsService {
});
await this.renumberCoachSeats(seat.coachId);
await this.auditService.log({ action: 'DELETE', entityType: 'Seat', entityId: seatId, oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId } });
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}

View File

@@ -4,9 +4,10 @@ import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { NotificationsModule } from '../notifications/notifications.module';
import { SystemConfigModule } from '../system-config/system-config.module';
import { AuditModule } from '../../common/audit.module';
@Module({
imports: [NotificationsModule, SystemConfigModule],
imports: [NotificationsModule, SystemConfigModule, AuditModule],
controllers: [TicketsController],
providers: [TicketsService, JwtGuard],
exports: [TicketsService, JwtGuard],

View File

@@ -4,6 +4,7 @@ import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
import { AuditService } from '../../common/audit.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
@@ -22,6 +23,7 @@ export class TicketsService {
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly systemConfig: SystemConfigService,
private readonly auditService: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
@@ -229,7 +231,6 @@ export class TicketsService {
}
}
// Delete existing tickets if any
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Generate one ticket per unique passenger (grouped by passengerName)
@@ -291,6 +292,7 @@ export class TicketsService {
}).catch(() => null);
}
await this.auditService.log({ action: 'CREATE', entityType: 'Ticket', entityId: booking.id, newData: { bookingRef: booking.bookingRef, totalTickets: tickets.length } });
return { tickets, totalTickets: tickets.length };
}
@@ -557,6 +559,7 @@ export class TicketsService {
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
this.fireBoardingPassNotification(booking, ticket, null);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: 'ONE_WAY' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
@@ -575,6 +578,7 @@ export class TicketsService {
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
@@ -608,6 +612,7 @@ export class TicketsService {
}
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
await this.auditService.log({ action: 'VERIFY', entityType: 'Ticket', entityId: ticket.id, newData: { bookingRef, validatorId: resolvedValidatorId, leg: resolvedLeg } });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}

View File

@@ -56,9 +56,13 @@ export default function ReviewPage() {
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
// Derive display currency from nationality so fares show in the passenger's home currency.
// Use the display currency stored on the schedule (set at search/selection time).
// Fall back to nationality-based derivation only if the schedule has no displayCurrency.
const scheduleCurrency = isRoundTrip
? outboundSchedule?.displayCurrency
: selectedSchedule?.displayCurrency;
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
const displayCurrencyCode = scheduleCurrency || (nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD');
useEffect(() => {
if (!seatHold?.expiresAt) return;