Price issue on sign in path addressed, Djibouti side pricing updates added

This commit is contained in:
Stephanos A
2026-07-14 16:00:39 +03:00
parent 68f9dd878d
commit ed901777a6
15 changed files with 1237 additions and 580 deletions

View File

@@ -857,7 +857,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor / 100,
totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,

View File

@@ -103,6 +103,18 @@ export class FareEngineService {
},
});
// Route-level fare override: checked after segment (most specific) but before
// schedule-scoped rules and the global seat-class tariff (least specific).
const routeFareOverride = segmentOverride ? null : await this.prisma.routeFareRule.findFirst({
where: {
routeId: route.id,
seatClassId: nationalitySeatClass.id,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
orderBy: { validFrom: 'desc' },
});
if (segmentOverride) {
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
@@ -110,6 +122,20 @@ export class FareEngineService {
}
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE';
} else if (routeFareOverride) {
// Stored as a per-km rate (same unit as SeatClass.baseFareMinor × 100).
// Insurance factor and USD→ETB conversion are applied identically to the
// global seat-class formula so the override is a pure rate substitution.
const ratePerKmEtb = routeFareOverride.baseFareMinor / 100;
insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0
? nationalitySeatClass.insuranceFeeMinor / 100 : 1;
usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB);
ratePerKmMinor = routeFareOverride.baseFareMinor;
baseFarePerPassengerMinor = Math.round(
totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate,
);
fareSource = 'ROUTE_FARE_OVERRIDE';
insuranceAlreadyInBase = true;
} else if (fareRule?.tripId) {
baseFarePerPassengerMinor = fareRule.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
@@ -172,7 +198,7 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''}${nationalitySeatClass.name}`,
`Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
`Rate per km: ${routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor} minor → ${(routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor) / 100} ETB/km${routeFareOverride ? ' [ROUTE OVERRIDE]' : ''}`,
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
`USD→ETB rate: ${usdToEtbRate}`,
`Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,

View File

@@ -69,6 +69,40 @@ export class SchedulesController {
@ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
@Delete('routes/fare-rules/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
deleteRouteFareRule(@Param('id') id: string) {
return this.service.deleteRouteFareRule(id);
}
@Patch('routes/fare-rules/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
return this.service.updateRouteFareRule(id, dto);
}
@Get('routes/:routeId/fare-rules')
@IsPublic()
@ApiOperation({ summary: 'List route-level fare overrides for a route' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
listRouteFareRules(@Param('routeId') routeId: string) {
return this.service.listRouteFareRules(routeId);
}
@Post('routes/:routeId/fare-rules')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a route-level fare override' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
return this.service.createRouteFareRule({ ...dto, routeId });
}
@Get('routes/:routeId/segment-fares')
@IsPublic()
@ApiOperation({ summary: 'List all segment fare rules for a route' })

View File

@@ -676,4 +676,63 @@ export class SchedulesService {
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' };
}
// ── Route Fare Rule Overrides ──────────────────────────────────────────────
listRouteFareRules(routeId: string) {
return this.prisma.routeFareRule.findMany({
where: { routeId },
include: { seatClass: true, route: true },
orderBy: { createdAt: 'desc' },
});
}
async createRouteFareRule(dto: {
routeId: string;
seatClassId: string;
passengerCategory?: string;
baseFareMinor: number;
validFrom: string;
validUntil?: string;
}) {
const [route, seatClass] = await Promise.all([
this.prisma.route.findUnique({ where: { id: dto.routeId } }),
this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }),
]);
if (!route) throw new NotFoundException('Route not found');
if (!seatClass) throw new NotFoundException('Seat class not found');
return this.prisma.routeFareRule.create({
data: {
routeId: dto.routeId,
seatClassId: dto.seatClassId,
passengerCategory: (dto.passengerCategory as any) ?? 'ADULT',
baseFareMinor: dto.baseFareMinor,
validFrom: parseEthiopianTime(dto.validFrom),
validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
return this.prisma.routeFareRule.update({
where: { id },
data: {
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
...(dto.surchargeMinor !== undefined && { surchargeMinor: dto.surchargeMinor }),
...(dto.validFrom && { validFrom: parseEthiopianTime(dto.validFrom) }),
...(dto.validUntil !== undefined && { validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null }),
},
include: { seatClass: true, route: true },
});
}
async deleteRouteFareRule(id: string) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
await this.prisma.routeFareRule.delete({ where: { id } });
return { deleted: true, id };
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service';
@@ -49,5 +49,7 @@ export class SeatClassesController {
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiResponse({ status: 200, description: 'Seat class deleted' })
@ApiResponse({ status: 404, description: 'Seat class not found' })
deleteSeatClass(@Param('id') id: string) { return this.service.deleteSeatClass(id); }
deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteSeatClass(id, cascade === 'true');
}
}

View File

@@ -45,7 +45,7 @@ export class SeatClassesService {
}
}
async deleteSeatClass(id: string) {
async deleteSeatClass(id: string, cascade = false) {
const sc = await this.prisma.seatClass.findUnique({
where: { id },
include: {
@@ -59,11 +59,17 @@ export class SeatClassesService {
(sc as any)._count.routeFareRules +
(sc as any)._count.segmentFares;
if (totalFareRules > 0)
if (totalFareRules > 0 && !cascade)
throw new DeleteOperationException('Seat Class', sc.name, [
{ entityName: 'fare rule', count: totalFareRules, action: 'delete' },
]);
if (cascade) {
await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
}
return this.prisma.seatClass.delete({ where: { id } });
}
}