mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 18:18:18 +00:00
Tour package booking, app release, new endpoints, more updates and fixes
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
||||
|
||||
@ApiTags('Search')
|
||||
@Controller('search')
|
||||
@@ -66,4 +66,29 @@ Nationality-Based:
|
||||
getFareQuote(@Body() dto: FareQuoteDto) {
|
||||
return this.service.getFareQuote(dto);
|
||||
}
|
||||
|
||||
@Get('fare-breakdown')
|
||||
@ApiOperation({
|
||||
summary: 'Per-passenger fare breakdown for booking review page',
|
||||
description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class.
|
||||
|
||||
- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs)
|
||||
- First CHILD in the list travels free (pays only premium + insurance fees)
|
||||
- Each passenger can have a different seat class and nationality
|
||||
- Returns per-passenger lines plus subtotal, discount, and grand total
|
||||
|
||||
**passengers** must be a URL-encoded JSON array, e.g.:
|
||||
\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``,
|
||||
})
|
||||
@ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
|
||||
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
|
||||
@ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' })
|
||||
@ApiQuery({ name: 'promoCode', required: false })
|
||||
@ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] })
|
||||
@ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getFareBreakdown(@Query() dto: FareBreakdownRequestDto) {
|
||||
return this.service.getFareBreakdown(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,43 @@ export class CoachTypeOptionClass {
|
||||
@ApiProperty({ example: 35000 }) baseFareMinor: number;
|
||||
}
|
||||
|
||||
export class FareBreakdownPassengerDto {
|
||||
@ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' })
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' })
|
||||
@IsDateString() dateOfBirth: string;
|
||||
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' })
|
||||
@IsString() seatClassId: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' })
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
}
|
||||
|
||||
export class FareBreakdownRequestDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]',
|
||||
description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }',
|
||||
})
|
||||
@IsString() passengers: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'WEEKEND15' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'USD', enum: Currency })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
export class CoachTypeOption {
|
||||
@ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string;
|
||||
@ApiProperty({ example: 'Economy' }) coachTypeName: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
@@ -477,6 +477,124 @@ export class SearchService {
|
||||
};
|
||||
}
|
||||
|
||||
async getFareBreakdown(dto: FareBreakdownRequestDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
||||
|
||||
const now = new Date();
|
||||
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
|
||||
|
||||
let parsedPassengers: FareBreakdownPassengerDto[];
|
||||
try {
|
||||
parsedPassengers = JSON.parse(dto.passengers as unknown as string);
|
||||
} catch {
|
||||
throw new NotFoundException('passengers must be a valid JSON array');
|
||||
}
|
||||
|
||||
// Categorise passengers by age
|
||||
const categorised = parsedPassengers.map(p => {
|
||||
const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime();
|
||||
const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25);
|
||||
return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears };
|
||||
});
|
||||
|
||||
const adultCount = categorised.filter(p => p.category === 'ADULT').length;
|
||||
const childCount = categorised.filter(p => p.category === 'CHILD').length;
|
||||
|
||||
// Ask the fare engine for the authoritative free-child count using the full group
|
||||
// Use the first passenger's seatClassId as a representative — freeChildrenCount
|
||||
// depends only on adultCount/childCount, not on seat class.
|
||||
const groupFare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId!,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
seatClassId: categorised[0].seatClassId,
|
||||
nationality: categorised[0].nationality,
|
||||
scheduleId: dto.scheduleId,
|
||||
adultCount,
|
||||
childCount,
|
||||
});
|
||||
const freeChildrenAllowed = groupFare.freeChildrenCount;
|
||||
|
||||
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
|
||||
let freeChildrenUsed = 0;
|
||||
const passengerLines = await Promise.all(
|
||||
categorised.map(async (p) => {
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId!,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
seatClassId: p.seatClassId,
|
||||
nationality: p.nationality,
|
||||
scheduleId: dto.scheduleId,
|
||||
adultCount: 1,
|
||||
childCount: 0,
|
||||
});
|
||||
|
||||
const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed;
|
||||
if (isFree) freeChildrenUsed++;
|
||||
|
||||
const fareMinor = isFree
|
||||
? fare.premiumPerPassenger + fare.insurancePerPassenger
|
||||
: fare.farePerPassengerMinor;
|
||||
const displayFareMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency)
|
||||
: fareMinor;
|
||||
|
||||
return {
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
category: p.category,
|
||||
ageYears: Math.floor(p.ageYears),
|
||||
seatClassId: fare.seatClassId,
|
||||
seatClassName: fare.seatClassName,
|
||||
nationality: p.nationality ?? null,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
premiumMinor: fare.premiumPerPassenger,
|
||||
insuranceFeeMinor: fare.insurancePerPassenger,
|
||||
fareMinor,
|
||||
isFree,
|
||||
displayCurrency,
|
||||
displayFareMinor,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0);
|
||||
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > now) {
|
||||
discountMinor = promo.percentOff
|
||||
? Math.round(subtotalMinor * promo.percentOff / 100)
|
||||
: (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const totalMinor = subtotalMinor - discountMinor;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
return {
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
passengers: passengerLines,
|
||||
subtotalMinor,
|
||||
discountMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
}
|
||||
|
||||
private async calculateFaresForSegment(
|
||||
schedule: ScheduleWithIncludes,
|
||||
originStationId: string,
|
||||
|
||||
Reference in New Issue
Block a user