mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Fare engine formula updates
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user