import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto'; import { Currency } from '@prisma/client'; @Injectable() export class FareEngineService { constructor( private prisma: PrismaService, private currencyService: CurrencyService, ) {} async calculate(dto: FareCalculateDto) { const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, }); if (!route) throw new NotFoundException('Route not found'); const originStop = route.stops.find(s => s.stationId === dto.originStationId); const destStop = route.stops.find(s => s.stationId === dto.destinationStationId); if (!originStop) throw new BadRequestException('Origin station not found on this route'); if (!destStop) throw new BadRequestException('Destination station not found on this route'); // Origin and destination must be distinct stops, but EITHER direction is valid: a round-trip // return leg traverses the same route high→low (e.g. C→A), so we price the segment by its // absolute distance rather than rejecting the reverse order. if (originStop.sequence === destStop.sequence) throw new BadRequestException('Origin and destination must be different stops on this route'); const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }); if (!seatClass) throw new NotFoundException('Seat class not found'); if (!seatClass.isActive) throw new BadRequestException('Seat class is not active'); const nationalityUpper = (dto.nationality ?? '').toUpperCase(); const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') ? 'LOCAL' : 'INTERNATIONAL'; const nationalitySeatClass = await this.prisma.seatClass.findFirst({ where: { coachTypeId: seatClass.coachTypeId, nationalityType, bedPosition: seatClass.bedPosition ?? null, isActive: true, }, }) ?? seatClass; const totalDistanceKm = Math.abs(destStop.distanceKm! - originStop.distanceKm!); if (totalDistanceKm <= 0 || isNaN(totalDistanceKm)) throw new BadRequestException('Invalid distance calculation - check route stop distances'); const now = new Date(); const [originStation, destStation] = await Promise.all([ this.prisma.station.findUnique({ where: { id: dto.originStationId } }), this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }), ]); const segmentRoute = originStation && destStation ? `${originStation.code}-${destStation.code}` : null; const fullRoute = `${route.code}`; const fareRuleCandidates = await this.prisma.fareRule.findMany({ where: { seatClassId: dto.seatClassId, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); const fareRule = this.pickBestFareRule( fareRuleCandidates, dto.scheduleId, segmentRoute, fullRoute, dto.nationality, ); 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; const segmentOverride = await this.prisma.segmentFareRule.findFirst({ where: { routeId: route.id, seatClassId: dto.seatClassId, originStopSequence: originStop.sequence, destinationStopSequence: destStop.sequence, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], nationality: dto.nationality ?? null, }, }) ?? await this.prisma.segmentFareRule.findFirst({ where: { routeId: route.id, seatClassId: dto.seatClassId, originStopSequence: originStop.sequence, destinationStopSequence: destStop.sequence, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], nationality: null, }, }); // 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) { baseFarePerPassengerMinor *= 2; } 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) { baseFarePerPassengerMinor *= 2; } ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SCHEDULE_FARE_RULE'; } else { // 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( totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate, ); fareSource = 'SEAT_CLASS_BASE_FARE'; insuranceAlreadyInBase = true; } 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); const adultSubtotal = farePerPassengerMinor * adultCount; const freeChildSubtotal = (premiumPerPassenger + insurancePerPassenger) * freeChildrenCount; const paidChildSubtotal = farePerPassengerMinor * paidChildrenCount; const subtotalMinor = adultSubtotal + freeChildSubtotal + paidChildSubtotal; let discountMinor = 0; let promoLabel = 'none'; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(subtotalMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); promoLabel = `${dto.promoCode} (-${promo.percentOff ?? 0}%)`; } } const totalEtbMinor = subtotalMinor - discountMinor; const billingCurrency = resolveCurrencyFromNationality(dto.nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate); const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.name}`, `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`, `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 [1 per adult] + ${paidChildrenCount} paid)`, ` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`, ` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`, ``, `Subtotal: ${subtotalMinor} ETB minor`, `Discount: ${promoLabel} → -${discountMinor} ETB minor`, `Total (ETB): ${totalEtbMinor} ETB minor`, ``, `Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`, `Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`, `Fare source: ${fareSource}`, ].join('\n'); return { fareSource, routeCode: route.code, originName: originStation?.name ?? dto.originStationId, destinationName: destStation?.name ?? dto.destinationStationId, seatClassId: nationalitySeatClass.id, seatClassName: nationalitySeatClass.name, totalDistanceKm, ratePerKmMinor, insuranceFactor, usdToEtbRate, baseFarePerPassengerMinor, premiumPerPassenger, insurancePerPassenger, farePerPassengerMinor, adultCount, childCount, freeChildrenCount, paidChildrenCount, subtotalMinor, discountMinor, totalMinor: totalEtbMinor, billingCurrency, totalInBillingCurrency, exchangeRate, calculation, }; } async compareClasses( routeId: string, originStationId: string, destinationStationId: string, nationality?: string, adultCount = 1, childCount = 0, ) { const seatClasses = await this.prisma.seatClass.findMany({ where: { isActive: true }, orderBy: { baseFareMinor: 'asc' }, }); const results = await Promise.all( seatClasses.map(sc => this.calculate({ routeId, originStationId, destinationStationId, seatClassId: sc.id, nationality, adultCount, childCount }) .catch(() => null), ), ); return results.filter(Boolean); } private pickBestFareRule( candidates: any[], scheduleId?: string, segmentRoute?: string | null, fullRoute?: string, nationality?: string, ): any | null { const nat = nationality ?? null; const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality: nat }, { tripId: scheduleId, route: segmentRoute, nationality: null }, { tripId: scheduleId, route: fullRoute, nationality: nat }, { tripId: scheduleId, route: fullRoute, nationality: null }, { tripId: scheduleId, route: null, nationality: nat }, { tripId: scheduleId, route: null, nationality: null }, { tripId: null, route: segmentRoute, nationality: nat }, { tripId: null, route: segmentRoute, nationality: null }, { tripId: null, route: fullRoute, nationality: nat }, { tripId: null, route: fullRoute, nationality: null }, { tripId: null, route: null, nationality: nat }, { tripId: null, route: null, nationality: null }, ]; for (const p of priorities) { const match = candidates.find( c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality, ); if (match) return match; } return null; } async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route'); return this.calculate({ routeId: schedule.routeId, originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, seatClassId, nationality, scheduleId, }); } async calculateAllForSchedule(scheduleId: string, nationality?: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, }); if (!schedule) throw new NotFoundException('Schedule not found'); if (schedule.routeId) { const seatClasses = await this.prisma.seatClass.findMany({ where: { isActive: true }, orderBy: { baseFareMinor: 'asc' }, }); const results = await Promise.all( seatClasses.map(sc => this.calculate({ routeId: schedule.routeId!, originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, seatClassId: sc.id, nationality, scheduleId, }).catch(() => null), ), ); return results.filter(Boolean); } const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { tripId: scheduleId, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, orderBy: [{ seatClass: { baseFareMinor: 'asc' } }], }); if (fareRules.length > 0) { const billingCurrency = resolveCurrencyFromNationality(nationality); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency); 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( 'Schedule has no associated route and no fare rules. Assign a route or create fare rules for this schedule.', ); } }