mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Implemened verifayda and currency modules
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
@@ -12,25 +12,51 @@ export class BookingsController {
|
||||
constructor(private service: BookingsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create booking from seat hold' })
|
||||
@ApiOperation({
|
||||
summary: 'Create booking with age-based pricing and Verifayda verification',
|
||||
description: `Creates a booking with the following features:
|
||||
- Age-based pricing: CHILD (<5 years) first child free, ADULT (>=5 years) full fare
|
||||
- Ethiopian nationals: Verified via Verifayda 2.0 (national ID NOT stored)
|
||||
- Non-Ethiopians: Passport required, no verification
|
||||
- Multi-currency: Display in ETB, DJF, or USD (transaction always in ETB)
|
||||
- All passengers require dateOfBirth for age calculation`
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Booking created with fare breakdown' })
|
||||
@ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' })
|
||||
@ApiResponse({ status: 404, description: 'Trip or seat hold not found' })
|
||||
create(@Body() dto: CreateBookingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
@ApiOperation({
|
||||
summary: 'Get booking details by reference',
|
||||
description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking details with adult/child counts and currency conversion' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
getByRef(@Param('bookingRef') ref: string) {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Patch(':bookingRef/modify')
|
||||
@ApiOperation({ summary: 'Modify booking seats or trip' })
|
||||
@ApiOperation({
|
||||
summary: 'Modify booking seats or trip',
|
||||
description: 'Allows modification of confirmed bookings before departure'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Booking modified successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Cannot modify cancelled or past bookings' })
|
||||
modify(@Body() dto: ModifyBookingDto) {
|
||||
return this.service.modify(dto);
|
||||
}
|
||||
|
||||
@Delete(':bookingRef')
|
||||
@ApiOperation({ summary: 'Cancel booking' })
|
||||
@ApiOperation({
|
||||
summary: 'Cancel booking with refund',
|
||||
description: 'Cancels booking and processes refund (80% for confirmed bookings)'
|
||||
})
|
||||
@ApiResponse({ status: 200, 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);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum } from 'class-validator';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
|
||||
export class PassengerInputDto {
|
||||
@ApiProperty() @IsString() fullName: string;
|
||||
@ApiProperty() @IsString() phone: string;
|
||||
@ApiProperty() @IsString() email: string;
|
||||
@ApiProperty() @IsString() seatId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiProperty({ example: 'John Doe' }) @IsString() passengerName: string;
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth for age calculation' }) @IsDateString() dateOfBirth: string;
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
@ApiPropertyOptional({ example: 'ET123456789', description: 'For Ethiopian nationals only - used for Verifayda verification' }) @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiPropertyOptional({ example: 'P1234567', description: 'For non-Ethiopians' }) @IsOptional() @IsString() passportNumber?: string;
|
||||
@ApiPropertyOptional({ example: 'Kenya', description: 'For non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
@@ -16,15 +18,15 @@ export class CreateBookingDto {
|
||||
@ApiProperty() @IsString() tripId: string;
|
||||
@ApiProperty() @IsString() holdId: string;
|
||||
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
|
||||
@ApiPropertyOptional({
|
||||
@ApiProperty({
|
||||
example: 'ECONOMY_REGULAR',
|
||||
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
|
||||
})
|
||||
@IsOptional() @IsString() serviceClass?: string;
|
||||
@IsString() serviceClass: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
|
||||
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
|
||||
@ApiPropertyOptional({ description: 'Auto-assign seats instead of manual selection' }) @IsOptional() autoAssign?: boolean;
|
||||
@ApiPropertyOptional({ example: 'ETB', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
export class ModifyBookingDto {
|
||||
|
||||
@@ -2,7 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { SeatsModule } from '../seats/seats.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({ imports: [SeatsModule, SearchModule], controllers: [BookingsController], providers: [BookingsService], exports: [BookingsService] })
|
||||
@Module({
|
||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService],
|
||||
exports: [BookingsService]
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -4,16 +4,34 @@ import { SeatsService } from '../seats/seats.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SearchService } from '../search/search.service';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(private prisma: PrismaService, private seatsService: SeatsService, private eventEmitter: EventEmitter2, private searchService: SearchService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private seatsService: SeatsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private verifaydaService: VerifaydaService,
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateBookingDto) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||
@@ -21,45 +39,150 @@ export class BookingsService {
|
||||
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
|
||||
if (!trip) throw new NotFoundException('Trip not found');
|
||||
|
||||
let seatIds: string[];
|
||||
if (dto.autoAssign) {
|
||||
seatIds = await this.seatsService.autoAssignSeats(
|
||||
dto.tripId,
|
||||
dto.passengers.length,
|
||||
dto.serviceClass ?? 'ECONOMY_REGULAR',
|
||||
);
|
||||
await this.seatsService.confirmSeats(seatIds);
|
||||
} else {
|
||||
seatIds = dto.passengers.map((p) => p.seatId);
|
||||
const seatIds = dto.passengers.map((p) => p.seatId);
|
||||
|
||||
// Calculate passenger categories and verify Ethiopian nationals
|
||||
const passengersData = [];
|
||||
let adultCount = 0;
|
||||
let childCount = 0;
|
||||
|
||||
for (const passenger of dto.passengers) {
|
||||
const dateOfBirth = new Date(passenger.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
|
||||
if (category === PassengerCategory.ADULT) adultCount++;
|
||||
else childCount++;
|
||||
|
||||
let passengerName = passenger.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined = undefined;
|
||||
|
||||
// Verify Ethiopian nationals via Verifayda
|
||||
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException(
|
||||
`Verifayda verification failed for passenger ${passenger.passengerName}: ${verification.failureReason}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Use verified data from Verifayda
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
// Non-Ethiopian: require passport details
|
||||
if (!passenger.passportNumber || !passenger.passportCountry) {
|
||||
throw new BadRequestException(
|
||||
`Passport number and country required for non-Ethiopian passenger ${passenger.passengerName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
passengersData.push({
|
||||
...passenger,
|
||||
passengerName,
|
||||
dateOfBirth,
|
||||
category,
|
||||
verifaydaVerified,
|
||||
verifaydaData,
|
||||
});
|
||||
}
|
||||
|
||||
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY_REGULAR', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
|
||||
|
||||
// Calculate fare with age-based pricing
|
||||
const baseFareMinor = await this.getBaseFare(dto.tripId, dto.serviceClass);
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||
|
||||
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(totalBaseFareMinor * promo.percentOff / 100)
|
||||
: (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
let displayTotalMinor = totalMinor;
|
||||
|
||||
if (displayCurrency !== Currency.ETB) {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
tripId: dto.tripId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor: fareQuote.totalMinor,
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: dto.passengerId,
|
||||
tripId: dto.tripId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: dto.bookingType ?? 'ONE_WAY',
|
||||
seats: { create: dto.passengers.map((p, i) => ({ seatId: seatIds[i], passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) }
|
||||
seats: {
|
||||
create: passengersData.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
idDocumentType: p.idDocumentType,
|
||||
idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
|
||||
});
|
||||
|
||||
await this.seatsService.confirmSeats(seatIds);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
return {
|
||||
...booking,
|
||||
|
||||
return {
|
||||
...booking,
|
||||
fareBreakdown: {
|
||||
baseFare: fareQuote.baseFareMinor / 100,
|
||||
discount: fareQuote.discountMinor / 100,
|
||||
loyaltyRedemption: fareQuote.loyaltyRedemptionMinor / 100,
|
||||
taxesFees: fareQuote.taxesFeesMinor / 100,
|
||||
total: fareQuote.totalMinor / 100,
|
||||
currency: fareQuote.currency
|
||||
}
|
||||
baseFareMinor,
|
||||
adultCount,
|
||||
adultFareMinor,
|
||||
childCount,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
childFareMinor,
|
||||
totalBaseFareMinor,
|
||||
discountMinor,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getBaseFare(tripId: string, serviceClass: string): Promise<number> {
|
||||
const fareRule = await this.prisma.fareRule.findFirst({
|
||||
where: { tripId, serviceClass: serviceClass as any },
|
||||
});
|
||||
return fareRule?.baseFareMinor ?? 35000;
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, ticket: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
@@ -68,6 +191,10 @@ export class BookingsService {
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalFare: booking.totalMinor / 100,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
|
||||
bookingType: booking.bookingType,
|
||||
createdAt: booking.createdAt,
|
||||
trip: {
|
||||
@@ -79,6 +206,8 @@ export class BookingsService {
|
||||
},
|
||||
passengers: booking.seats.map((bs) => ({
|
||||
fullName: bs.passengerName,
|
||||
category: bs.passengerCategory,
|
||||
verifaydaVerified: bs.verifaydaVerified,
|
||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass },
|
||||
})),
|
||||
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
||||
|
||||
Reference in New Issue
Block a user