From 73e8dc19b35a5a71aaa59f1e77689aef50716a9d Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 8 Jul 2026 00:43:05 +0300 Subject: [PATCH] Fare engine formula updates --- .../fare-engine/fare-engine.service.ts | 79 ++++++++++--------- .../modules/tickets/tickets.service.spec.ts | 11 ++- .../backoffice/src/app/support/page.tsx | 2 +- 3 files changed, 48 insertions(+), 44 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 70f18564d..515a00655 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -30,13 +30,10 @@ export class FareEngineService { if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); - // Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL const nationalityUpper = (dto.nationality ?? '').toUpperCase(); const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') ? 'LOCAL' : 'INTERNATIONAL'; - // Find the nationality-specific seat class for the same coach type and bed position. - // Falls back to the requested seatClass if no nationality-specific one exists. const nationalitySeatClass = await this.prisma.seatClass.findFirst({ where: { coachTypeId: seatClass.coachTypeId, @@ -46,13 +43,10 @@ export class FareEngineService { }, }) ?? seatClass; - // Calculate distance: distanceKm represents cumulative distance from route origin - // For a segment, distance = destination.distanceKm - origin.distanceKm const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!; if (totalDistanceKm < 0 || isNaN(totalDistanceKm)) throw new BadRequestException('Invalid distance calculation - check route stop distances'); - // Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate const now = new Date(); const [originStation, destStation] = await Promise.all([ this.prisma.station.findUnique({ where: { id: dto.originStationId } }), @@ -81,8 +75,12 @@ export class FareEngineService { let baseFarePerPassengerMinor: number; let ratePerKmMinor: number; let fareSource: string; + let insuranceFactor = 1; + let usdToEtbRate = 1; + // When insuranceFeeMinor is used as a multiplier in the formula it must not + // be added again as a flat fee. This flag tracks that. + let insuranceAlreadyInBase = false; - // 1. Segment override: exact origin→destination stop pair on this route const segmentOverride = await this.prisma.segmentFareRule.findFirst({ where: { routeId: route.id, @@ -106,39 +104,46 @@ export class FareEngineService { }); if (segmentOverride) { - // Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate baseFarePerPassengerMinor = segmentOverride.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SEGMENT_FARE_RULE'; } else if (fareRule?.tripId) { - // Schedule-scoped flat override baseFarePerPassengerMinor = fareRule.baseFareMinor; ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SCHEDULE_FARE_RULE'; } else { - // Default: distance-based using tariff formula: km × rate × 1.02 - // baseFareMinor stores the per-km rate (tariff decimal × 100000) + // Distance-based formula: + // baseFare (minor) = distanceKm × (baseFareMinor / 100) × insuranceFactor × usdToEtbRate + // baseFareMinor stored as integer (e.g. 300 = 3.00 ETB/km), divided by 100 to get ETB/km. + // insuranceFeeMinor stored as integer (e.g. 102 = 1.02 multiplier), divided by 100; defaults to 1 if unset. + // usdToEtbRate fetched live from CurrencyExchangeRate table. + // Insurance is already baked into baseFarePerPassengerMinor — do NOT add it again as a flat fee. + const ratePerKmEtb = nationalitySeatClass.baseFareMinor / 100; + insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0 + ? nationalitySeatClass.insuranceFeeMinor / 100 + : 1; + usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB); ratePerKmMinor = nationalitySeatClass.baseFareMinor; - baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02); + baseFarePerPassengerMinor = Math.round( + totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate, + ); fareSource = 'SEAT_CLASS_BASE_FARE'; + insuranceAlreadyInBase = true; } - // Premium and insurance fees applied per passenger - const premiumPerPassenger = seatClass.premiumMinor ?? 0; - const insurancePerPassenger = seatClass.insuranceFeeMinor ?? 0; - const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger; + const premiumPerPassenger = seatClass.premiumMinor ?? 0; + const insurancePerPassenger = insuranceAlreadyInBase ? 0 : (seatClass.insuranceFeeMinor ?? 0); + const farePerPassengerMinor = baseFarePerPassengerMinor + premiumPerPassenger + insurancePerPassenger; const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; const freeChildrenCount = Math.min(childCount, adultCount); const paidChildrenCount = Math.max(0, childCount - freeChildrenCount); - // Subtotal includes: (distance-based fare + premium + insurance) × passengers - // First child is free, but pays premium and insurance - const adultSubtotal = farePerPassengerMinor * adultCount; + const adultSubtotal = farePerPassengerMinor * adultCount; const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount; const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount; - const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; + const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; let discountMinor = 0; let promoLabel = 'none'; @@ -152,8 +157,7 @@ export class FareEngineService { } } - const afterDiscountMinor = subtotalMinor - discountMinor; - const totalEtbMinor = afterDiscountMinor; + const totalEtbMinor = subtotalMinor - discountMinor; const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); @@ -162,8 +166,10 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`, - `Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`, - `Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`, + `Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`, + `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`, `Premium/pax: ${premiumPerPassenger} ETB minor`, `Insurance/pax: ${insurancePerPassenger} ETB minor`, `Total fare/pax: ${farePerPassengerMinor} ETB minor`, @@ -191,6 +197,8 @@ export class FareEngineService { seatClassName: nationalitySeatClass.name, totalDistanceKm, ratePerKmMinor, + insuranceFactor, + usdToEtbRate, baseFarePerPassengerMinor, premiumPerPassenger, insurancePerPassenger, @@ -323,19 +331,16 @@ export class FareEngineService { if (fareRules.length > 0) { const billingCurrency = resolveCurrencyFromNationality(nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); - return fareRules.map(rule => { - const seatClassId = rule.seatClassId; - return { - seatClassId, - seatClassName: 'Unknown', - baseFareMinor: rule.baseFareMinor, - totalMinor: rule.baseFareMinor, - billingCurrency, - totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), - exchangeRate, - source: 'FARE_RULE', - }; - }); + return fareRules.map(rule => ({ + seatClassId: rule.seatClassId, + seatClassName: 'Unknown', + baseFareMinor: rule.baseFareMinor, + totalMinor: rule.baseFareMinor, + billingCurrency, + totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate), + exchangeRate, + source: 'FARE_RULE', + })); } throw new BadRequestException( diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts index 7f6ad7f52..96b3e2ef8 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.spec.ts @@ -72,9 +72,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.success).toBe(1); + expect(result.successful).toBe(1); expect(result.failed).toBe(0); - expect(result.duplicate).toBe(0); }); it('should detect duplicate validations', async () => { @@ -98,8 +97,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.success).toBe(1); - expect(result.duplicate).toBe(1); + expect(result.successful).toBe(1); + expect(result.failed).toBe(1); }); it('should handle already validated tickets', async () => { @@ -119,8 +118,8 @@ describe('TicketsService - Offline Validation', () => { const result = await service.validateOfflineBatch(validations); - expect(result.duplicate).toBe(1); - expect(result.success).toBe(0); + expect(result.successful).toBe(0); + expect(result.failed).toBe(1); }); }); }); diff --git a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx index 8102cc556..246a6f4f9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/support/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/support/page.tsx @@ -42,7 +42,7 @@ export default function SupportPage() { const { data, isLoading } = useConversations( status === 'ALL' ? { search } : { status, search }, ); - const items = data?.items ?? []; + const items = useMemo(() => data?.items ?? [], [data?.items]); useSupportSocket(true);