mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
Fare engine updates, seat class amount updates
This commit is contained in:
@@ -21,6 +21,8 @@ export class PassengerInputDto {
|
|||||||
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
|
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
|
||||||
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
|
@ApiPropertyOptional({ example: 'Djibouti', description: 'Passport issuing country for non-Ethiopians' }) @IsOptional() @IsString() passportCountry?: string;
|
||||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
|
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' }) @IsOptional() @IsString() nationality?: string;
|
||||||
|
@ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). When provided, overrides the fare engine calculation — use for berth-specific pricing (Upper/Middle/Lower).' }) @IsOptional() @IsInt() seatFareMinor?: number;
|
||||||
|
@ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsInt() returnSeatFareMinor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RoundTripPassengerDto {
|
export class RoundTripPassengerDto {
|
||||||
@@ -143,6 +145,9 @@ export class CreateBookingDto {
|
|||||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||||
@IsOptional() @IsString() priceTierId?: string;
|
@IsOptional() @IsString() priceTierId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, this overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||||
|
@IsOptional() @IsInt() reviewedTotalMinor?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||||
@IsOptional() @IsString() promoCode?: string;
|
@IsOptional() @IsString() promoCode?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
@@ -60,6 +60,8 @@ interface BookingFilters {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
|
private readonly logger = new Logger(BookingsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
@@ -546,30 +548,42 @@ export class BookingsService {
|
|||||||
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
let displayTotalMinor = fareCalculation.totalMinor;
|
|
||||||
if (displayCurrency !== Currency.ETB) {
|
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
||||||
// for regular bookings the first child is free.
|
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
||||||
let freeChildUsed = false;
|
let freeChildUsed = false;
|
||||||
|
let pkgChildIdx = 0;
|
||||||
const passengersWithFares = passengersData.map(p => {
|
const passengersWithFares = passengersData.map(p => {
|
||||||
let fareMinor: number;
|
let fareMinor: number;
|
||||||
if (p.category === PassengerCategory.ADULT) {
|
if (p.category === PassengerCategory.ADULT) {
|
||||||
fareMinor = fareCalculation.baseFareMinor;
|
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||||
} else if (dto.packageId) {
|
} else if (dto.packageId) {
|
||||||
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
|
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
|
||||||
// passengersWithFares is built in adult-first order so we track paid children by count.
|
pkgChildIdx++;
|
||||||
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
|
|
||||||
fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
|
|
||||||
} else {
|
} else {
|
||||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||||
else fareMinor = fareCalculation.baseFareMinor;
|
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||||
}
|
}
|
||||||
return { ...p, fareMinor };
|
return { ...p, fareMinor };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use the sum of per-seat fares as the authoritative total when the client supplied
|
||||||
|
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
|
||||||
|
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
|
||||||
|
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||||||
|
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||||
|
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||||
|
const resolvedTotalMinor = dto.reviewedTotalMinor ??
|
||||||
|
(allFaresProvided
|
||||||
|
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
|
||||||
|
: fareCalculation.totalMinor);
|
||||||
|
this.logger.log(`createOneWayBooking: resolvedTotalMinor=${resolvedTotalMinor} (reviewedTotalMinor=${dto.reviewedTotalMinor} allFaresProvided=${allFaresProvided} fareEngine=${fareCalculation.totalMinor})`);
|
||||||
|
|
||||||
|
let displayTotalMinor = resolvedTotalMinor;
|
||||||
|
if (displayCurrency !== Currency.ETB) {
|
||||||
|
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
@@ -577,7 +591,7 @@ export class BookingsService {
|
|||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
totalMinor: fareCalculation.totalMinor,
|
totalMinor: resolvedTotalMinor / 100,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
@@ -697,8 +711,8 @@ export class BookingsService {
|
|||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
||||||
// for regular bookings the first child is free per leg.
|
// present (berth-specific pricing). Fall back to fare engine values.
|
||||||
let outboundFreeChildUsed = false;
|
let outboundFreeChildUsed = false;
|
||||||
let returnFreeChildUsed = false;
|
let returnFreeChildUsed = false;
|
||||||
const passengersWithFares = passengersData.map(p => {
|
const passengersWithFares = passengersData.map(p => {
|
||||||
@@ -706,24 +720,40 @@ export class BookingsService {
|
|||||||
let returnFareMinor: number;
|
let returnFareMinor: number;
|
||||||
|
|
||||||
if (p.category === PassengerCategory.ADULT) {
|
if (p.category === PassengerCategory.ADULT) {
|
||||||
outboundFareMinor = outboundFare.baseFareMinor;
|
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||||
returnFareMinor = returnFare.baseFareMinor;
|
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||||
} else if (dto.packageId) {
|
} else if (dto.packageId) {
|
||||||
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
|
outboundFareMinor = 0;
|
||||||
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
|
returnFareMinor = 0;
|
||||||
const isFreeChild = childIdx < adultCount;
|
|
||||||
outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
|
|
||||||
returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
|
|
||||||
} else {
|
} else {
|
||||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||||
else outboundFareMinor = outboundFare.baseFareMinor;
|
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||||
else returnFareMinor = returnFare.baseFareMinor;
|
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...p, outboundFareMinor, returnFareMinor };
|
return { ...p, outboundFareMinor, returnFareMinor };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
|
||||||
|
// supplied their fares — free children (no seatId) are excluded from the check.
|
||||||
|
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
|
||||||
|
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||||
|
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||||
|
if (dto.reviewedTotalMinor) {
|
||||||
|
totalMinor = dto.reviewedTotalMinor;
|
||||||
|
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
|
: totalMinor;
|
||||||
|
} else if (allRTFaresProvided && !dto.packageId) {
|
||||||
|
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||||
|
if (displayCurrency !== Currency.ETB) {
|
||||||
|
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||||
|
} else {
|
||||||
|
displayTotalMinor = totalMinor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
data: {
|
data: {
|
||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean } from 'class-validator';
|
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Currency, IdDocumentType } from '@prisma/client';
|
import { Currency, IdDocumentType } from '@prisma/client';
|
||||||
@@ -42,6 +42,12 @@ export class GuestPassengerDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' })
|
@ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' })
|
||||||
@IsOptional() @IsString() email?: string;
|
@IsOptional() @IsString() email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 35000, description: 'Actual fare for this passenger in minor units (ETB). Overrides fare engine — use for berth-specific pricing (Upper/Middle/Lower).' })
|
||||||
|
@IsOptional() @IsInt() seatFareMinor?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' })
|
||||||
|
@IsOptional() @IsInt() returnSeatFareMinor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateGuestBookingDto {
|
export class CreateGuestBookingDto {
|
||||||
@@ -150,6 +156,9 @@ export class CreateGuestBookingDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||||
@IsOptional() @IsString() priceTierId?: string;
|
@IsOptional() @IsString() priceTierId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||||
|
@IsOptional() @IsInt() reviewedTotalMinor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SavedPassengerProfileDto {
|
export class SavedPassengerProfileDto {
|
||||||
|
|||||||
@@ -185,12 +185,38 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taxesMinor = 0;
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
|
|
||||||
|
// Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
|
||||||
|
// Free children (first child, non-package) get fareMinor=0.
|
||||||
|
let freeChildUsed = false;
|
||||||
|
let pkgChildIdx = 0;
|
||||||
|
const passengersWithFares = passengersData.map(p => {
|
||||||
|
let fareMinor: number;
|
||||||
|
if (p.category === PassengerCategory.ADULT) {
|
||||||
|
fareMinor = p.seatFareMinor ?? baseFareMinor;
|
||||||
|
} else if (isPackageOneway) {
|
||||||
|
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
|
||||||
|
pkgChildIdx++;
|
||||||
|
} else {
|
||||||
|
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||||
|
else fareMinor = p.seatFareMinor ?? childUnitFare;
|
||||||
|
}
|
||||||
|
return { ...p, fareMinor };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use reviewedTotalMinor from frontend as authoritative total when provided.
|
||||||
|
// Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
|
||||||
|
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||||
|
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||||
|
const resolvedTotalMinor = dto.reviewedTotalMinor ??
|
||||||
|
(allFaresProvided
|
||||||
|
? passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0)
|
||||||
|
: Math.max(0, totalBaseFareMinor - discountMinor));
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
let displayTotalMinor = totalMinor;
|
let displayTotalMinor = resolvedTotalMinor;
|
||||||
if (displayCurrency !== Currency.ETB) {
|
if (displayCurrency !== Currency.ETB) {
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve or create the guest Passenger record
|
// Resolve or create the guest Passenger record
|
||||||
@@ -224,7 +250,7 @@ export class GuestBookingService {
|
|||||||
passengerId: guestPassengerId,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
totalMinor,
|
totalMinor: resolvedTotalMinor,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
@@ -235,7 +261,7 @@ export class GuestBookingService {
|
|||||||
contactEmail: firstPassenger.email || null,
|
contactEmail: firstPassenger.email || null,
|
||||||
contactPhone: firstPassenger.phone || null,
|
contactPhone: firstPassenger.phone || null,
|
||||||
seats: {
|
seats: {
|
||||||
create: passengersData.map((p) => ({
|
create: passengersWithFares.map((p) => ({
|
||||||
seat: { connect: { id: p.seatId } },
|
seat: { connect: { id: p.seatId } },
|
||||||
passengerName: p.passengerName,
|
passengerName: p.passengerName,
|
||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
@@ -245,7 +271,7 @@ export class GuestBookingService {
|
|||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
verifaydaData: p.verifaydaData || undefined,
|
verifaydaData: p.verifaydaData || undefined,
|
||||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
fareMinor: p.fareMinor,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -278,7 +304,7 @@ export class GuestBookingService {
|
|||||||
totalBaseFareMinor,
|
totalBaseFareMinor,
|
||||||
discountMinor,
|
discountMinor,
|
||||||
taxesFeesMinor: taxesMinor,
|
taxesFeesMinor: taxesMinor,
|
||||||
totalMinor,
|
totalMinor: resolvedTotalMinor,
|
||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
displayTotalMinor,
|
displayTotalMinor,
|
||||||
@@ -423,13 +449,51 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taxesMinor = 0;
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
|
|
||||||
|
// Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
|
||||||
|
let outboundFreeChildUsed = false;
|
||||||
|
let returnFreeChildUsed = false;
|
||||||
|
const passengersWithFares = passengersData.map(p => {
|
||||||
|
let outboundFareMinor: number;
|
||||||
|
let returnFareMinor: number;
|
||||||
|
if (p.category === PassengerCategory.ADULT) {
|
||||||
|
outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
|
||||||
|
returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
|
||||||
|
} else if (isPackageRoundTrip) {
|
||||||
|
outboundFareMinor = 0;
|
||||||
|
returnFareMinor = 0;
|
||||||
|
} else {
|
||||||
|
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||||
|
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
|
||||||
|
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||||
|
else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
|
||||||
|
}
|
||||||
|
return { ...p, outboundFareMinor, returnFareMinor };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override totalMinor with reviewedTotalMinor when provided, or sum of per-seat fares
|
||||||
|
// when all seated passengers supplied their fares.
|
||||||
|
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
|
||||||
|
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||||
|
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||||
|
if (dto.reviewedTotalMinor) {
|
||||||
|
totalMinor = dto.reviewedTotalMinor;
|
||||||
|
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
|
: totalMinor;
|
||||||
|
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||||
|
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||||
|
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
|
: totalMinor;
|
||||||
|
}
|
||||||
|
|
||||||
// Create or resolve guest passenger (same as one-way)
|
// Create or resolve guest passenger (same as one-way)
|
||||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||||
|
|
||||||
@@ -461,7 +525,7 @@ export class GuestBookingService {
|
|||||||
contactPhone: passengersData[0]?.phone || null,
|
contactPhone: passengersData[0]?.phone || null,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersData.map((p) => ({
|
...passengersWithFares.map((p) => ({
|
||||||
seat: { connect: { id: p.seatId } },
|
seat: { connect: { id: p.seatId } },
|
||||||
leg: 1,
|
leg: 1,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
@@ -473,10 +537,10 @@ export class GuestBookingService {
|
|||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
verifaydaData: p.verifaydaData || undefined,
|
verifaydaData: p.verifaydaData || undefined,
|
||||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
fareMinor: p.outboundFareMinor,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
})),
|
})),
|
||||||
...passengersData.map((p) => ({
|
...passengersWithFares.map((p) => ({
|
||||||
seat: { connect: { id: p.returnSeatId } },
|
seat: { connect: { id: p.returnSeatId } },
|
||||||
leg: 2,
|
leg: 2,
|
||||||
scheduleId: dto.returnScheduleId,
|
scheduleId: dto.returnScheduleId,
|
||||||
@@ -488,7 +552,7 @@ export class GuestBookingService {
|
|||||||
passportCountry: p.passportCountry,
|
passportCountry: p.passportCountry,
|
||||||
verifaydaVerified: p.verifaydaVerified,
|
verifaydaVerified: p.verifaydaVerified,
|
||||||
verifaydaData: p.verifaydaData || undefined,
|
verifaydaData: p.verifaydaData || undefined,
|
||||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
fareMinor: p.returnFareMinor,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -30,23 +30,59 @@ export class CurrencyService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a stored display-currency minor amount to the charge major amount
|
||||||
|
* sent to the payment provider, without hitting the DB for an exchange rate.
|
||||||
|
* Use this when the payment method's settlement currency matches the booking's
|
||||||
|
* displayCurrency — the rate is already baked into displayTotalMinor.
|
||||||
|
*/
|
||||||
|
displayMinorToChargeMajor(displayMinor: number, currency: string): number {
|
||||||
|
const decimals = CHARGE_CURRENCY_DECIMALS[currency.toUpperCase()];
|
||||||
|
if (decimals === undefined) {
|
||||||
|
throw new BadRequestException(`Unsupported charge currency: ${currency}`);
|
||||||
|
}
|
||||||
|
return this.roundTo(displayMinor / 100, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertEtbMinorToChargeMinor(
|
||||||
|
amountMinorEtb: number,
|
||||||
|
targetCurrency: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const target = targetCurrency.toUpperCase();
|
||||||
|
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
|
||||||
|
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === Currency.ETB) {
|
||||||
|
return amountMinorEtb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ETB minor → target minor: apply exchange rate, keep as minor units.
|
||||||
|
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
|
||||||
|
return Math.round(amountMinorEtb * rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an ETB minor-unit amount to the charge major-unit amount sent to the
|
||||||
|
* payment provider. Applies the exchange rate for foreign currencies then divides
|
||||||
|
* by 100 to yield major units (e.g. 300000 ETB minor → 3000.00 ETB major).
|
||||||
|
*/
|
||||||
async convertEtbMinorToChargeMajor(
|
async convertEtbMinorToChargeMajor(
|
||||||
amountMinorEtb: number,
|
amountMinorEtb: number,
|
||||||
targetCurrency: string,
|
targetCurrency: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const target = targetCurrency.toUpperCase();
|
const target = targetCurrency.toUpperCase();
|
||||||
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
|
||||||
if (decimals === undefined) {
|
|
||||||
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||||||
}
|
}
|
||||||
|
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||||||
|
|
||||||
const sourceMajor = amountMinorEtb / 100;
|
|
||||||
if (target === Currency.ETB) {
|
if (target === Currency.ETB) {
|
||||||
return this.roundTo(sourceMajor, decimals);
|
return this.roundTo(amountMinorEtb / 100, decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
|
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
|
||||||
return this.roundTo(sourceMajor * rate, decimals);
|
return this.roundTo((amountMinorEtb * rate) / 100, decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRateOrThrow(
|
async getRateOrThrow(
|
||||||
|
|||||||
@@ -105,10 +105,16 @@ export class FareEngineService {
|
|||||||
|
|
||||||
if (segmentOverride) {
|
if (segmentOverride) {
|
||||||
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
||||||
|
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
|
||||||
|
baseFarePerPassengerMinor *= 2;
|
||||||
|
}
|
||||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||||
fareSource = 'SEGMENT_FARE_RULE';
|
fareSource = 'SEGMENT_FARE_RULE';
|
||||||
} else if (fareRule?.tripId) {
|
} else if (fareRule?.tripId) {
|
||||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||||
|
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
|
||||||
|
baseFarePerPassengerMinor *= 2;
|
||||||
|
}
|
||||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||||
fareSource = 'SCHEDULE_FARE_RULE';
|
fareSource = 'SCHEDULE_FARE_RULE';
|
||||||
} else {
|
} else {
|
||||||
@@ -165,7 +171,7 @@ export class FareEngineService {
|
|||||||
|
|
||||||
const calculation = [
|
const calculation = [
|
||||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.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: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
|
||||||
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
|
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
|
||||||
`USD→ETB rate: ${usdToEtbRate}`,
|
`USD→ETB rate: ${usdToEtbRate}`,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe("PaymentsService", () => {
|
|||||||
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
||||||
const mockCurrencyService = {
|
const mockCurrencyService = {
|
||||||
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||||
Promise.resolve(minor / 100),
|
Promise.resolve(minor),
|
||||||
),
|
),
|
||||||
getRateOrThrow: jest.fn(),
|
getRateOrThrow: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -550,7 +550,6 @@ export class PaymentsService {
|
|||||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||||
return this.prisma.paymentMethod.findMany({
|
return this.prisma.paymentMethod.findMany({
|
||||||
where: {
|
where: {
|
||||||
enabled: true,
|
|
||||||
...(region
|
...(region
|
||||||
? {
|
? {
|
||||||
region: {
|
region: {
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ export class SearchService {
|
|||||||
|
|
||||||
const cutoffHours = await this.getCutoffHours();
|
const cutoffHours = await this.getCutoffHours();
|
||||||
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
|
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
|
||||||
const earliest = new Date(Math.max((date < now ? now : date).getTime(), cutoffThreshold.getTime()));
|
const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
|
||||||
|
const earliest = isToday ? cutoffThreshold : date;
|
||||||
|
|
||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { IsString, IsInt, IsBoolean, IsOptional } from 'class-validator';
|
import { IsString, IsInt, IsBoolean, IsOptional, IsIn } from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class CreateSeatClassDto {
|
export class CreateSeatClassDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
coachTypeId: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Economy Seat' })
|
@ApiProperty({ example: 'Economy Seat' })
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'Standard economy seating' })
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 45000, description: 'Base price in minor currency units' })
|
@ApiPropertyOptional({ enum: ['LOCAL', 'INTERNATIONAL'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['LOCAL', 'INTERNATIONAL'])
|
||||||
|
nationalityType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['UPPER', 'MIDDLE', 'LOWER'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
bedPosition?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 3000, description: 'Per-km rate in minor units (tariff decimal × 100000)' })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
basePrice: number;
|
basePrice: number;
|
||||||
|
|
||||||
|
|||||||
@@ -326,11 +326,6 @@ export default function ClassesPage() {
|
|||||||
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
|
||||||
<p className="font-medium mb-1">Total Fare Calculation:</p>
|
|
||||||
<p>Total = (Base Fare × Distance) + Insurance</p>
|
|
||||||
<p className="mt-2 text-xs">• Insurance applies per passenger</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const;
|
|
||||||
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
|
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
|
||||||
const COACH_TYPE_LABELS: Record<string, string> = {
|
const COACH_TYPE_LABELS: Record<string, string> = {
|
||||||
HSC: 'Regular Seat (Hard Seat)',
|
HSC: 'Regular Seat (Hard Seat)',
|
||||||
@@ -18,7 +17,6 @@ const COACH_TYPE_LABELS: Record<string, string> = {
|
|||||||
SBC: 'VIP Bed (Soft Berth)',
|
SBC: 'VIP Bed (Soft Berth)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tariff reference rates per the official policy document
|
|
||||||
const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
|
const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
|
||||||
LOCAL: {
|
LOCAL: {
|
||||||
'HSC-null': 0.03,
|
'HSC-null': 0.03,
|
||||||
@@ -109,7 +107,7 @@ export default function TariffRatesPage() {
|
|||||||
name: fd.get('name') as string,
|
name: fd.get('name') as string,
|
||||||
nationalityType: selectedNationalityType,
|
nationalityType: selectedNationalityType,
|
||||||
bedPosition: selectedBedPosition || null,
|
bedPosition: selectedBedPosition || null,
|
||||||
baseFareMinor: parseInt(fd.get('baseFareMinor') as string),
|
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
|
||||||
isActive: fd.get('isActive') === 'true',
|
isActive: fd.get('isActive') === 'true',
|
||||||
};
|
};
|
||||||
if (editingClass) {
|
if (editingClass) {
|
||||||
@@ -127,7 +125,6 @@ export default function TariffRatesPage() {
|
|||||||
? classesData
|
? classesData
|
||||||
: (classesData as any)?.items || (classesData as any)?.data || [];
|
: (classesData as any)?.items || (classesData as any)?.data || [];
|
||||||
|
|
||||||
// Only show classes that have nationalityType set (tariff-managed rows)
|
|
||||||
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
|
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
|
||||||
|
|
||||||
const displayed = tariffClasses.filter((c: any) => {
|
const displayed = tariffClasses.filter((c: any) => {
|
||||||
@@ -141,7 +138,6 @@ export default function TariffRatesPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-suggest name from selections
|
|
||||||
const suggestName = () => {
|
const suggestName = () => {
|
||||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||||
if (!ct) return '';
|
if (!ct) return '';
|
||||||
@@ -151,13 +147,12 @@ export default function TariffRatesPage() {
|
|||||||
return `${label}${pos} (${nat})`;
|
return `${label}${pos} (${nat})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-suggest baseFareMinor from tariff reference
|
// Returns the human-readable rate (e.g. 0.03); stored value = this × 100
|
||||||
const suggestRate = () => {
|
const suggestRate = () => {
|
||||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||||
if (!ct) return '';
|
if (!ct) return '';
|
||||||
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
|
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
|
||||||
// baseFareMinor = tariff_decimal × 100000
|
return ref ? String(ref) : '';
|
||||||
return ref ? Math.round(ref * 100000).toString() : '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -184,18 +179,18 @@ export default function TariffRatesPage() {
|
|||||||
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'baseFareMinor', label: 'Rate per km (minor)',
|
key: 'baseFareMinor', label: 'Rate per km',
|
||||||
render: (c: any) => {
|
render: (c: any) => {
|
||||||
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
||||||
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
|
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
|
||||||
const tariffMinor = ref ? Math.round(ref * 100000) : undefined;
|
const tariffMinor = ref ? Math.round(ref * 100) : undefined;
|
||||||
const matches = tariffMinor === c.baseFareMinor;
|
const matches = tariffMinor === c.baseFareMinor;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono font-medium">{c.baseFareMinor}</span>
|
<span className="font-mono font-medium">{c.baseFareMinor / 100}</span>
|
||||||
{tariffMinor !== undefined && (
|
{tariffMinor !== undefined && (
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
|
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
|
||||||
{matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
|
{matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -237,7 +232,6 @@ export default function TariffRatesPage() {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
@@ -367,15 +361,16 @@ export default function TariffRatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Base Fare Minor (per km) *</label>
|
<label className="label">Rate per km *</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
name="baseFareMinor"
|
name="baseFareMinor"
|
||||||
className="input"
|
className="input"
|
||||||
defaultValue={editingClass?.baseFareMinor ?? ''}
|
defaultValue={editingClass ? editingClass.baseFareMinor / 100 : ''}
|
||||||
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
||||||
placeholder={suggestRate() || 'e.g. 3000'}
|
placeholder={suggestRate() || 'e.g. 6'}
|
||||||
min="0"
|
min="0"
|
||||||
|
step="any"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{suggestRate() && (
|
{suggestRate() && (
|
||||||
@@ -391,7 +386,7 @@ export default function TariffRatesPage() {
|
|||||||
>
|
>
|
||||||
{suggestRate()}
|
{suggestRate()}
|
||||||
</button>
|
</button>
|
||||||
{' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
|
{' '}(stored as {Math.round(Number(suggestRate()) * 100)})
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ export default function PaymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paymentMethods.map((method) => {
|
{paymentMethods.filter(m => m.enabled).map((method) => {
|
||||||
const Icon = getIconForMethod(method.type);
|
const Icon = getIconForMethod(method.type);
|
||||||
const isSelected = selectedMethod === method.type;
|
const isSelected = selectedMethod === method.type;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -343,6 +343,9 @@ export default function ReviewPage() {
|
|||||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||||
nationality: p.nationality,
|
nationality: p.nationality,
|
||||||
|
...(isRoundTrip
|
||||||
|
? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined }
|
||||||
|
: { seatFareMinor: p.seatFareMinor ?? undefined }),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -397,8 +400,9 @@ export default function ReviewPage() {
|
|||||||
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
|
||||||
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
|
||||||
nationality: p.nationality,
|
nationality: p.nationality,
|
||||||
phone: p.phone || '',
|
...(isRoundTrip
|
||||||
email: p.email || '',
|
? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined }
|
||||||
|
: { seatFareMinor: p.seatFareMinor ?? undefined }),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
createAccount: createAccount || false,
|
createAccount: createAccount || false,
|
||||||
@@ -444,6 +448,11 @@ export default function ReviewPage() {
|
|||||||
return { fareMinor, isFree: isFreeChild };
|
return { fareMinor, isFree: isFreeChild };
|
||||||
});
|
});
|
||||||
setReviewedTotal(computedTotal, passengerFares);
|
setReviewedTotal(computedTotal, passengerFares);
|
||||||
|
|
||||||
|
// Pass the exact total shown on this page to the backend so it stores the
|
||||||
|
// correct berth-specific amount regardless of what the fare engine calculates.
|
||||||
|
bookingData.reviewedTotalMinor = computedTotal;
|
||||||
|
|
||||||
await createBookingMutation.mutateAsync(bookingData);
|
await createBookingMutation.mutateAsync(bookingData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||||
|
|||||||
@@ -10,20 +10,6 @@ import { useBookingStore } from '@/lib/booking-store';
|
|||||||
import { Station } from '@/types';
|
import { Station } from '@/types';
|
||||||
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
function useDarkMode() {
|
|
||||||
const [dark, setDark] = useState(() =>
|
|
||||||
typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
const obs = new MutationObserver(() =>
|
|
||||||
setDark(document.documentElement.classList.contains('dark'))
|
|
||||||
);
|
|
||||||
obs.observe(document.documentElement, { attributeFilter: ['class'] });
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, []);
|
|
||||||
return dark;
|
|
||||||
}
|
|
||||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
@@ -86,8 +72,6 @@ function CustomSelect({
|
|||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dark = useDarkMode();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -95,14 +79,13 @@ function CustomSelect({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => !disabled && setOpen((o) => !o)}
|
onClick={() => !disabled && setOpen((o) => !o)}
|
||||||
className={`w-full ${icon ? 'pl-11' : 'pl-4'} pr-10 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base text-left flex items-center gap-2
|
className={`w-full ${icon ? 'pl-11' : 'pl-4'} pr-10 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base text-left flex items-center gap-2
|
||||||
bg-white dark:bg-gray-800
|
bg-white dark:bg-gray-800 text-gray-900 dark:text-white
|
||||||
disabled:opacity-60 disabled:cursor-not-allowed
|
disabled:opacity-60 disabled:cursor-not-allowed
|
||||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||||
transition-colors`}
|
transition-colors`}
|
||||||
style={{ color: dark ? '#ffffff' : '#111827' }}
|
|
||||||
>
|
>
|
||||||
{icon && <span className="absolute left-3 top-1/2 -translate-y-1/2">{icon}</span>}
|
{icon && <span className="absolute left-3 top-1/2 -translate-y-1/2">{icon}</span>}
|
||||||
<span style={{ color: selected ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}>
|
<span className={selected ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'}>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? selected.label : placeholder}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
@@ -139,7 +122,6 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||||
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
||||||
const dark = useDarkMode();
|
|
||||||
|
|
||||||
const { data: stations, isLoading } = useQuery<Station[]>({
|
const { data: stations, isLoading } = useQuery<Station[]>({
|
||||||
queryKey: ['stations'],
|
queryKey: ['stations'],
|
||||||
@@ -255,10 +237,9 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
|
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
|
||||||
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-base bg-white dark:bg-gray-800 flex items-center justify-between hover:border-primary transition-colors"
|
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-white flex items-center justify-between hover:border-primary transition-colors"
|
||||||
style={{ color: dark ? '#ffffff' : '#111827' }}
|
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2" style={{ color: 'inherit' }}>
|
<span className="flex items-center gap-2">
|
||||||
<Users className="w-4 h-4 text-primary" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
|
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user