Nationality, guest booking, waafi adapter, overall booking flow updates

This commit is contained in:
Stephanos A
2026-05-24 16:20:55 +03:00
parent 2f17f9c9ce
commit 87a423c859
37 changed files with 2284 additions and 486 deletions

View File

@@ -10,14 +10,16 @@ export class SearchController {
@Post()
@ApiOperation({
summary: 'Search schedules by any origindestination stop pair',
description: `Finds all train schedules where both origin and destination appear as stops (not just terminals).
summary: 'Search trips by origin, destination, date, passengers, and nationality',
description: `Finds all train schedules matching search criteria with real-time seat availability.
Example: A train running A→B→C→D will appear in results for A→B, A→C, A→D, B→C, B→D, and C→D searches.
Availability is computed per seat per segment — a seat booked A→B is still shown as available for B→D.
Returns departure/arrival times for the requested leg, the full stop list, and per-class seat counts.`
- Any origin→destination stop pair (not just terminals)
- Age-based passenger counts (adults ≥5 years, children <5 years)
- Nationality filtering (Ethiopian, Djiboutian, Other)
- Real-time seat availability per class
- Multi-currency fare display
- Example: Train A→B→C→D appears in results for A→B, A→C, A→D, B→C, B→D, C→D
- Availability: Segment-based (seat booked A→B is still available B→D)`
})
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
searchTrips(@Body() dto: SearchTripsDto) {
@@ -26,17 +28,29 @@ Returns departure/arrival times for the requested leg, the full stop list, and p
@Post('fare-quote')
@ApiOperation({
summary: 'Get fare quote for a specific schedule leg',
description: `Calculates fare for the requested origin→destination leg on a schedule.
summary: 'Get fare quote with age-based pricing and multi-currency support',
description: `Calculates detailed fare breakdown for a specific schedule leg.
Pricing rules (in priority order):
Age-Based Pricing:
- ADULT (≥5 years): 100% of base fare
- CHILD (<5 years): First child FREE, subsequent children 100%
- Example: 2 adults + 3 children = 4× base fare
Pricing Rules (priority order):
1. Schedule-scoped FareRule (tripId = scheduleId)
2. Segment route FareRule (e.g. ADD-DRE)
3. Full-route FareRule (e.g. ADD-DJI)
4. Default hardcoded fare
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
Supports multi-currency display (ETB, DJF, USD).`
Multi-Currency:
- Transaction currency: ETB
- Display currencies: ETB, DJF, USD
- Real-time exchange rate conversion
Nationality-Based:
- Ethiopian: National ID verification required
- Djiboutian: Passport details, Waafi payment available
- Other: Passport details, international payments`
})
@ApiResponse({ status: 200, description: 'Fare breakdown with adult/child pricing, discounts, taxes, and currency conversion' })
@ApiResponse({ status: 404, description: 'Schedule not found or origin/destination not on schedule' })

View File

@@ -13,11 +13,14 @@ export class SearchTripsDto {
@ApiProperty({ example: '2026-06-15', description: 'Departure date (YYYY-MM-DD)' })
@IsDateString() date: string;
@ApiProperty({ example: 2, description: 'Number of adult passengers (age ≥ 5)' })
@ApiProperty({ example: 2, description: 'Number of adult passengers (age ≥5 years) - pay 100% of base fare' })
@Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1, description: 'Number of child passengers (age < 5). First child travels free.' })
@ApiPropertyOptional({ example: 1, description: 'Number of child passengers (age <5 years). First child travels FREE, subsequent children pay 100%.' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
@IsOptional() @IsString() nationality?: string;
}
export class FareQuoteDto {
@@ -33,10 +36,10 @@ export class FareQuoteDto {
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed"' })
@IsString() seatClassName: string;
@ApiProperty({ example: 2, description: 'Number of adult passengers' })
@ApiProperty({ example: 2, description: 'Number of adult passengers (≥5 years) - each pays 100% of base fare' })
@Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1 })
@ApiPropertyOptional({ example: 1, description: 'Number of child passengers (<5 years) - first child FREE, subsequent children 100%' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' })
@@ -45,6 +48,9 @@ export class FareQuoteDto {
@ApiPropertyOptional({ example: 450, description: 'Loyalty points to redeem (10 points = 1 ETB minor unit)' })
@IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ETB', enum: Currency })
@ApiPropertyOptional({ example: 'USD', enum: Currency, description: 'Display currency: ETB (default), DJF, USD. Transaction always in ETB.' })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
@IsOptional() @IsString() nationality?: string;
}

View File

@@ -129,11 +129,23 @@ export class SearchService {
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
// Look up fare rule: prefer schedule-scoped, then segment route, then global
// Compute route codes for fare lookup
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const nationality = dto.nationality;
// Query fare rules with specificity ordering:
// 1. schedule+segment+nationality
// 2. schedule+segment
// 3. schedule+full-route+nationality
// 4. schedule+full-route
// 5. schedule+global
// 6. segment+nationality
// 7. segment
// 8. full-route+nationality
// 9. full-route
// 10. global
const fareRule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
@@ -144,13 +156,36 @@ export class SearchService {
],
},
orderBy: [
// Most specific first: schedule-scoped > segment route > full route > global
{ tripId: 'desc' },
// Prioritize schedule-specific rules
{ tripId: { sort: 'desc', nulls: 'last' } },
// Then prioritize nationality match
{ nationality: { sort: 'desc', nulls: 'last' } },
// Most recent validFrom
{ validFrom: 'desc' },
],
});
const baseFareMinor = fareRule?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
// Manual specificity filtering to find best match
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
const bestMatch = this.selectBestFareRule(
candidates,
dto.scheduleId,
segmentRoute,
fullRoute,
nationality,
);
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
@@ -184,6 +219,7 @@ export class SearchService {
destinationStationId: dto.destinationStationId,
segmentRoute,
seatClassName: dto.seatClassName,
nationality: dto.nationality,
adultCount, childCount,
baseFareMinor, adultFareMinor, childFareMinor,
freeChildrenCount: Math.min(childCount, 1),
@@ -266,4 +302,55 @@ export class SearchService {
};
return fares[seatClassName] ?? 45000;
}
/**
* Select the best matching fare rule based on specificity:
* 1. schedule+segment+nationality
* 2. schedule+segment
* 3. schedule+full-route+nationality
* 4. schedule+full-route
* 5. schedule+global
* 6. segment+nationality
* 7. segment
* 8. full-route+nationality
* 9. full-route
* 10. global
*/
private selectBestFareRule(
candidates: any[],
scheduleId: string,
segmentRoute: string,
fullRoute: string,
nationality?: string,
): any | null {
const priorities = [
// Schedule-specific rules
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
// Route-specific rules (no schedule)
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
// Global rules
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
);
if (match) return match;
}
return null;
}
}