Round trip journeys, feedback items, backoffice documentation

This commit is contained in:
Stephanos A
2026-06-16 13:33:03 +03:00
parent 28e183b086
commit 75c8423f50
46 changed files with 7222 additions and 570 deletions

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { Body, Controller, Post, Get, Query, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { FareEngineService } from './fare-engine.service';
@@ -16,23 +16,10 @@ export class FareEngineController {
@Post('calculate')
@ApiOperation({
summary: 'Calculate fare for a journey leg',
description: `Computes fare using the formula:
**Fare = totalKm × ratePerKm × exchangeRate**
- \`totalKm\` — sum of \`distanceKm\` on RouteStop records between origin and destination
- \`ratePerKm\`\`SeatClass.basePrice\` (stored in ETB minor units per km)
- \`exchangeRate\` — derived from passenger nationality:
- **Ethiopian** → ETB (rate = 1.0)
- **Djiboutian** → DJF (rate ≈ 3.25)
- **Other / unspecified** → USD (rate ≈ 0.018)
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
5% tax applied after promo discount.
Returns a full breakdown including a human-readable calculation trace.`,
description: `Computes fare using the formula:\n\n**Fare = totalKm × ratePerKm × exchangeRate**`,
})
@ApiResponse({ status: 201, type: FareBreakdownDto, description: 'Full fare breakdown with calculation trace' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination or missing distanceKm on route stops' })
@ApiResponse({ status: 400, description: 'Invalid route/station combination' })
@ApiResponse({ status: 404, description: 'Route or seat class not found' })
calculate(@Body() dto: FareCalculateDto) {
return this.service.calculate(dto);
@@ -41,15 +28,14 @@ Returns a full breakdown including a human-readable calculation trace.`,
@Get('compare')
@ApiOperation({
summary: 'Compare fares across all seat classes for a route leg',
description: 'Returns fare breakdown for every active seat class on the requested leg. Useful for rendering a class-selection table on the booking screen.',
})
@ApiQuery({ name: 'routeId', description: 'Route UUID' })
@ApiQuery({ name: 'originStationId', description: 'Origin station UUID' })
@ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality (Ethiopian | Djiboutian | other). Determines billing currency.' })
@ApiQuery({ name: 'adultCount', required: false, type: Number, description: 'Number of adults (default 1)' })
@ApiQuery({ name: 'childCount', required: false, type: Number, description: 'Number of children (default 0)' })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns, one per active seat class, ordered by price ascending' })
@ApiQuery({ name: 'nationality', required: false })
@ApiQuery({ name: 'adultCount', required: false, type: Number })
@ApiQuery({ name: 'childCount', required: false, type: Number })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns' })
compareClasses(
@Query('routeId') routeId: string,
@Query('originStationId') originStationId: string,
@@ -67,6 +53,8 @@ Returns a full breakdown including a human-readable calculation trace.`,
childCount ? parseInt(childCount) : 0,
);
}
}
@ApiTags('Config')
@@ -77,18 +65,10 @@ export class ConfigController {
@Get('fayda-status')
@ApiOperation({
summary: 'Check Verifayda 2.0 configuration status',
description: 'Returns whether Verifayda integration is enabled and ready to use'
})
@ApiResponse({
status: 200,
description: 'Verifayda status retrieved successfully',
schema: {
example: {
enabled: true,
mode: 'production',
apiUrl: 'https://api.verifayda.gov.et/v2'
}
}
})
getFaydaStatus() {
const faydaConfig = this.configService.get<FaydaConfig>('fayda');

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { FareEngineController, ConfigController } from './fare-engine.controller';
import { FareEngineService } from './fare-engine.service';
import { CurrencyController } from './currency.controller';
import { CurrencyModule } from '../currency/currency.module';
@Module({
imports: [CurrencyModule],
imports: [HttpModule, CurrencyModule],
controllers: [FareEngineController, CurrencyController, ConfigController],
providers: [FareEngineService],
exports: [FareEngineService],

View File

@@ -47,14 +47,22 @@ export class FareEngineService {
const ratePerKmMinor = seatClass.baseFareMinor;
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
// Premium and insurance fees applied per passenger
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0;
const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger;
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const paidChildrenCount = Math.max(0, childCount - 1);
const subtotalMinor =
baseFarePerPassengerMinor * adultCount +
baseFarePerPassengerMinor * paidChildrenCount;
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
// First child is free, but pays premium and insurance
const adultSubtotal = farePerPassengerMinor * adultCount;
const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount;
const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount;
const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal;
let discountMinor = 0;
let promoLabel = 'none';
@@ -85,12 +93,20 @@ export class FareEngineService {
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
`Passengers: ${adultCount} adult(s) × ${baseFarePerPassengerMinor} = ${baseFarePerPassengerMinor * adultCount} ETB minor`,
`Children: ${childCount} child(ren) — ${freeChildrenCount} free, ${paidChildrenCount} paid`,
`Premium/pax: ${premiumPerPassenger} ETB minor`,
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
``,
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
``,
`Subtotal: ${subtotalMinor} ETB minor`,
`Promo: ${promoLabel} → -${discountMinor} ETB minor`,
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
`Tax (5%): +${taxMinor} ETB minor`,
`Total (ETB): ${totalEtbMinor} ETB minor`,
``,
`Nationality: ${dto.nationality ?? 'unspecified'}${billingCurrency}`,
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
@@ -104,6 +120,9 @@ export class FareEngineService {
totalDistanceKm,
ratePerKmMinor,
baseFarePerPassengerMinor,
premiumPerPassenger,
insurancePerPassenger,
farePerPassengerMinor,
adultCount,
childCount,
freeChildrenCount,
@@ -142,7 +161,6 @@ export class FareEngineService {
return results.filter(Boolean);
}
/** Resolve schedule → route/origin/destination, then calculate fare for one seat class. */
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -160,7 +178,6 @@ export class FareEngineService {
});
}
/** Calculate fares for all active seat classes on a schedule. */
async calculateAllForSchedule(scheduleId: string, nationality?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -168,7 +185,6 @@ export class FareEngineService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
// ── Route-based calculation (fare engine) ────────────────────────────────
if (schedule.routeId) {
const seatClasses = await this.prisma.seatClass.findMany({
where: { isActive: true },
@@ -190,7 +206,6 @@ export class FareEngineService {
return results.filter(Boolean);
}
// ── Fallback: FareRule records scoped to this schedule ───────────────────
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {