Implemened verifayda and currency modules

This commit is contained in:
Stephanos A
2026-05-21 10:22:08 +03:00
parent 69b27ecb4b
commit 860170f229
22 changed files with 1032 additions and 220 deletions

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