Implemened verifayda and currency modules

This commit is contained in:
Stephanos A
2026-05-21 10:22:08 +03:00
parent 51bc906792
commit 3f60836e5d
22 changed files with 1032 additions and 220 deletions

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IdDocumentType } from '@prisma/client';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -35,9 +36,9 @@ export class AgentsService {
totalMinor,
seats: {
create: dto.passengers.map(p => ({
seatId: p.seatId,
seat: { connect: { id: p.seatId } },
passengerName: p.fullName,
idDocumentType: p.idDocumentType,
idDocumentType: p.idDocumentType as IdDocumentType | undefined,
idDocumentNumber: p.idDocumentNumber
}))
}

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CurrencyService } from './currency.service';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
providers: [CurrencyService],
exports: [CurrencyService],
})
export class CurrencyModule {}

View File

@@ -0,0 +1,83 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { Currency } from '@prisma/client';
@Injectable()
export class CurrencyService {
private readonly logger = new Logger(CurrencyService.name);
constructor(private readonly prisma: PrismaService) {}
async convertAmount(
amountMinor: number,
fromCurrency: Currency,
toCurrency: Currency,
): Promise<number> {
if (fromCurrency === toCurrency) {
return amountMinor;
}
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
return Math.round(amountMinor * rate);
}
async getExchangeRate(
fromCurrency: Currency,
toCurrency: Currency,
): Promise<number> {
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
where: {
fromCurrency,
toCurrency,
},
orderBy: {
effectiveDate: 'desc',
},
});
if (!exchangeRate) {
this.logger.warn(
`No exchange rate found for ${fromCurrency} to ${toCurrency}, using 1.0`,
);
return 1.0;
}
return Number(exchangeRate.rate);
}
async syncExchangeRates(): Promise<void> {
this.logger.log('Syncing exchange rates from external provider');
// In production, fetch from external API
// For now, using static rates
const rates = [
{ from: 'ETB', to: 'ETB', rate: 1.0 },
{ from: 'ETB', to: 'DJF', rate: 3.25 },
{ from: 'ETB', to: 'USD', rate: 0.018 },
{ from: 'DJF', to: 'ETB', rate: 0.3077 },
{ from: 'USD', to: 'ETB', rate: 55.56 },
];
for (const { from, to, rate } of rates) {
await this.prisma.currencyExchangeRate.upsert({
where: {
fromCurrency_toCurrency_effectiveDate: {
fromCurrency: from as Currency,
toCurrency: to as Currency,
effectiveDate: new Date(),
},
},
update: { rate },
create: {
fromCurrency: from as Currency,
toCurrency: to as Currency,
rate,
effectiveDate: new Date(),
source: 'EXTERNAL_API',
},
});
}
this.logger.log('Exchange rates synced successfully');
}
}

View File

@@ -1,5 +1,5 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { SearchService } from './search.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
@@ -7,6 +7,26 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto';
@Controller('search')
export class SearchController {
constructor(private service: SearchService) {}
@Post() @ApiOperation({ summary: 'Search trips' }) searchTrips(@Body() dto: SearchTripsDto) { return this.service.searchTrips(dto); }
@Post('fare-quote')@ApiOperation({ summary: 'Get fare quote' }) getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); }
@Post()
@ApiOperation({
summary: 'Search trips by origin, destination, and passenger counts',
description: 'Returns available trips WITHOUT pricing. Requires adult count (mandatory) and optional child count. Pricing is shown only in fare quote endpoint.'
})
@ApiResponse({ status: 200, description: 'List of available trips with seat availability' })
@ApiResponse({ status: 400, description: 'Invalid search parameters' })
searchTrips(@Body() dto: SearchTripsDto) {
return this.service.searchTrips(dto);
}
@Post('fare-quote')
@ApiOperation({
summary: 'Get detailed fare quote with age-based pricing',
description: 'Calculates fare based on adult/child counts. First child travels free, subsequent children pay full fare. Supports multi-currency display (ETB, DJF, USD).'
})
@ApiResponse({ status: 200, description: 'Detailed fare breakdown with adult/child pricing and currency conversion' })
@ApiResponse({ status: 404, description: 'Trip not found' })
getFareQuote(@Body() dto: FareQuoteDto) {
return this.service.getFareQuote(dto);
}
}

View File

@@ -1,12 +1,14 @@
import { IsString, IsDateString, IsInt, IsOptional, Min } from 'class-validator';
import { IsString, IsDateString, IsInt, IsOptional, Min, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
export class SearchTripsDto {
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
@ApiProperty({ example: '2026-05-11' }) @IsDateString() date: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengers?: number;
@ApiProperty({ example: 2, description: 'Number of adults (5 years and above)' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1, description: 'Number of children (below 5 years)' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
}
export class FareQuoteDto {
@@ -16,7 +18,9 @@ export class FareQuoteDto {
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsString() serviceClass: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number;
@ApiProperty({ example: 2, description: 'Number of adults' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1, description: 'Number of children' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ETB', enum: ['ETB', 'DJF', 'USD'] }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}

View File

@@ -1,6 +1,12 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
@Module({ controllers: [SearchController], providers: [SearchService], exports: [SearchService] })
@Module({
imports: [CurrencyModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService]
})
export class SearchModule {}

View File

@@ -1,12 +1,17 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { CurrencyService } from '../currency/currency.service';
import { Currency } from '@prisma/client';
const POINTS_TO_MINOR = 10;
@Injectable()
export class SearchService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000);
@@ -14,6 +19,9 @@ export class SearchService {
where: { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: { gte: date, lt: nextDay }, status: { in: ['SCHEDULED', 'BOARDING'] } },
include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } } },
});
const totalPassengers = dto.adultCount + (dto.childCount || 0);
return trips.map((trip) => {
const seatsByClass = (cls: string) => trip.coaches.filter((c) => c.serviceClass === cls).flatMap((c) => c.seats);
const avail = (cls: string) => seatsByClass(cls).filter((s) => s.status === 'AVAILABLE').length;
@@ -24,20 +32,12 @@ export class SearchService {
destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city },
departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status,
availability: {
ECONOMY_REGULAR: avail('ECONOMY_REGULAR'),
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER'),
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE'),
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER'),
VIP_BED_LOWER: avail('VIP_BED_LOWER'),
VIP_BED_UPPER: avail('VIP_BED_UPPER')
},
fares: {
ECONOMY_REGULAR: this.defaultFare('ECONOMY_REGULAR') / 100,
ECONOMY_BED_LOWER: this.defaultFare('ECONOMY_BED_LOWER') / 100,
ECONOMY_BED_MIDDLE: this.defaultFare('ECONOMY_BED_MIDDLE') / 100,
ECONOMY_BED_UPPER: this.defaultFare('ECONOMY_BED_UPPER') / 100,
VIP_BED_LOWER: this.defaultFare('VIP_BED_LOWER') / 100,
VIP_BED_UPPER: this.defaultFare('VIP_BED_UPPER') / 100
ECONOMY_REGULAR: avail('ECONOMY_REGULAR') >= totalPassengers,
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER') >= totalPassengers,
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE') >= totalPassengers,
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER') >= totalPassengers,
VIP_BED_LOWER: avail('VIP_BED_LOWER') >= totalPassengers,
VIP_BED_UPPER: avail('VIP_BED_UPPER') >= totalPassengers
},
};
});
@@ -46,26 +46,69 @@ export class SearchService {
async getFareQuote(dto: FareQuoteDto) {
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
if (!trip) throw new NotFoundException('Trip not found');
const count = dto.passengerCount ?? 1;
const baseFareMinor = this.defaultFare(dto.serviceClass) * count;
const adultCount = dto.adultCount;
const childCount = dto.childCount || 0;
const baseFareMinor = this.defaultFare(dto.serviceClass);
// Adult fare: 100% of base fare
const adultFareMinor = baseFareMinor * adultCount;
// Child fare: First child free, subsequent children pay full fare
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(baseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(baseFareMinor * 0.05);
return { tripId: dto.tripId, serviceClass: dto.serviceClass, passengerCount: count, baseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor: Math.max(0, baseFareMinor - discountMinor - loyaltyMinor + taxesMinor), currency: 'ETB' };
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);
}
return {
tripId: dto.tripId,
serviceClass: dto.serviceClass,
adultCount,
childCount,
baseFareMinor,
adultFareMinor,
childFareMinor,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
totalBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
};
}
private defaultFare(serviceClass: string): number {
const fares: Record<string, number> = {
ECONOMY_REGULAR: 35000, // 350 ETB
ECONOMY_BED_LOWER: 55000, // 550 ETB
ECONOMY_BED_MIDDLE: 50000, // 500 ETB
ECONOMY_BED_UPPER: 45000, // 450 ETB
VIP_BED_LOWER: 85000, // 850 ETB
VIP_BED_UPPER: 80000 // 800 ETB
ECONOMY_REGULAR: 35000,
ECONOMY_BED_LOWER: 55000,
ECONOMY_BED_MIDDLE: 50000,
ECONOMY_BED_UPPER: 45000,
VIP_BED_LOWER: 85000,
VIP_BED_UPPER: 80000
};
return fares[serviceClass] ?? 35000;
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { VerifaydaService } from './verifayda.service';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
providers: [VerifaydaService],
exports: [VerifaydaService],
})
export class VerifaydaModule {}

View File

@@ -0,0 +1,142 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../common/prisma.service';
import axios, { AxiosInstance } from 'axios';
export interface VerifaydaPassengerData {
fullName: string;
dateOfBirth: Date;
gender?: string;
nationality?: string;
profileData?: Record<string, any>;
}
export interface VerifaydaVerificationResult {
verified: boolean;
passengerData?: VerifaydaPassengerData;
failureReason?: string;
}
@Injectable()
export class VerifaydaService {
private readonly logger = new Logger(VerifaydaService.name);
private readonly httpClient: AxiosInstance;
private readonly enabled: boolean;
private readonly apiUrl: string;
private readonly apiKey: string;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {
this.enabled = this.config.get<boolean>('VERIFAYDA_ENABLED', false);
this.apiUrl = this.config.get<string>('VERIFAYDA_API_URL', 'https://api.verifayda.gov.et/v2');
this.apiKey = this.config.get<string>('VERIFAYDA_API_KEY', '');
this.httpClient = axios.create({
baseURL: this.apiUrl,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
},
});
}
async verifyNationalId(
nationalId: string,
bookingId?: string,
): Promise<VerifaydaVerificationResult> {
if (!this.enabled) {
this.logger.warn('Verifayda is disabled - skipping verification');
return {
verified: false,
failureReason: 'Verifayda integration is disabled',
};
}
const requestPayload = {
nationalId,
requestedFields: ['fullName', 'dateOfBirth', 'gender', 'nationality'],
timestamp: new Date().toISOString(),
};
try {
this.logger.log(`Verifying national ID via Verifayda 2.0`);
const response = await this.httpClient.post('/verify', requestPayload);
const { data } = response;
if (data.status === 'verified' && data.citizen) {
const passengerData: VerifaydaPassengerData = {
fullName: data.citizen.fullName,
dateOfBirth: new Date(data.citizen.dateOfBirth),
gender: data.citizen.gender,
nationality: data.citizen.nationality || 'Ethiopian',
profileData: data.citizen,
};
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
nationalId,
requestPayload,
responsePayload: data,
verified: true,
verifiedAt: new Date(),
},
});
this.logger.log('Verifayda verification successful');
return {
verified: true,
passengerData,
};
} else {
const failureReason = data.message || 'Verification failed';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
nationalId,
requestPayload,
responsePayload: data,
verified: false,
failureReason,
},
});
this.logger.warn(`Verifayda verification failed: ${failureReason}`);
return {
verified: false,
failureReason,
};
}
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Unknown error';
await this.prisma.verifaydaVerification.create({
data: {
bookingId,
nationalId,
requestPayload,
verified: false,
failureReason: errorMessage,
},
});
this.logger.error(`Verifayda API error: ${errorMessage}`);
throw new BadRequestException(
`National ID verification failed: ${errorMessage}`,
);
}
}
isEnabled(): boolean {
return this.enabled;
}
}