Tour package booking, app release, new endpoints, more updates and fixes

This commit is contained in:
Stephanos A
2026-07-05 00:28:06 +03:00
parent 868639084c
commit 595be6e123
68 changed files with 2773 additions and 787 deletions

View File

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

View File

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

View File

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