Payment amount for non ETB and exchange fixes

This commit is contained in:
Stephanos A
2026-07-15 10:27:17 +03:00
parent c0b99b50a9
commit e2529b9317
9 changed files with 216 additions and 125 deletions

View File

@@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDate, MaxDate } from 'class-validator';
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsNumber, IsEnum, IsDate, MaxDate } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -21,8 +21,8 @@ export class PassengerInputDto {
@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: '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;
@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() @IsNumber() seatFareMinor?: number;
@ApiPropertyOptional({ example: 35000, description: 'Return leg fare for this passenger in minor units (ETB). Used for ROUND_TRIP berth-specific pricing.' }) @IsOptional() @IsNumber() returnSeatFareMinor?: number;
}
export class RoundTripPassengerDto {
@@ -146,7 +146,7 @@ export class CreateBookingDto {
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Total amount in display-currency minor units as computed and displayed on the review page. When displayCurrency is ETB this equals ETB minor units; for DJF/USD it is the converted display amount. The backend uses this directly as displayTotalMinor and back-converts to ETB for storage.' })
@IsOptional() @IsInt() reviewedTotalMinor?: number;
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;

View File

@@ -846,23 +846,22 @@ export class BookingsService {
// 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);
// reviewedTotalMinor is now sent in display-currency minor units from the review page.
// When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB.
// seatFareMinor values from the client are in display-currency minor units (matching
// displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
// In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
let resolvedTotalMinor: number;
let displayTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
} else {
resolvedTotalMinor = dto.reviewedTotalMinor;
displayTotalMinor = dto.reviewedTotalMinor;
}
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
} else if (allFaresProvided) {
resolvedTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
: resolvedTotalMinor;
// seatFareMinor is in display currency — sum is already the display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
@@ -880,7 +879,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor / 100,
totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,
@@ -1001,10 +1000,10 @@ export class BookingsService {
const taxesMinor = 0;
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
// displayTotalMinor will be overridden below when reviewedTotalMinor is provided.
let displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
// present (berth-specific pricing). Fall back to fare engine values.
@@ -1036,20 +1035,16 @@ export class BookingsService {
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
} else {
totalMinor = dto.reviewedTotalMinor;
displayTotalMinor = dto.reviewedTotalMinor;
}
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
} 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;
}
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
}
const booking = await this.prisma.booking.create({

View File

@@ -1,4 +1,4 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt } from 'class-validator';
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -158,7 +158,7 @@ export class CreateGuestBookingDto {
@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;
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
}
export class SavedPassengerProfileDto {

View File

@@ -221,20 +221,28 @@ export class GuestBookingService {
return { ...p, fareMinor };
});
// Use reviewedTotalMinor from frontend as authoritative total when provided.
// Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
// reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
// Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
const displayCurrency = dto.displayCurrency || Currency.ETB;
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;
let displayTotalMinor = resolvedTotalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
let displayTotalMinor: number;
let resolvedTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
} else if (allFaresProvided) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else {
// fare engine returns ETB — convert forward to display currency
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency)
: etbTotal;
}
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
// Resolve or create the guest Passenger record
const firstPassenger = passengersData[0];
@@ -501,16 +509,17 @@ export class GuestBookingService {
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;
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
} 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;
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
}
// Create or resolve guest passenger (same as one-way)