mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
@@ -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 { Type, Transform } 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';
|
||||||
@@ -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: '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: '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() @IsInt() returnSeatFareMinor?: 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 {
|
export class RoundTripPassengerDto {
|
||||||
@@ -146,7 +146,7 @@ export class CreateBookingDto {
|
|||||||
@IsOptional() @IsString() priceTierId?: string;
|
@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.' })
|
@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)' })
|
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
|
||||||
@IsOptional() @IsString() promoCode?: string;
|
@IsOptional() @IsString() promoCode?: string;
|
||||||
|
|||||||
@@ -846,23 +846,22 @@ export class BookingsService {
|
|||||||
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
// Free children have no seatId and no seatFareMinor — exclude them from the check.
|
||||||
const seatedPassengers = passengersData.filter(p => p.seatId);
|
const seatedPassengers = passengersData.filter(p => p.seatId);
|
||||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
||||||
// reviewedTotalMinor is now sent in display-currency minor units from the review page.
|
// seatFareMinor values from the client are in display-currency minor units (matching
|
||||||
// When displayCurrency != ETB, use it directly as displayTotalMinor and back-convert to ETB.
|
// 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 resolvedTotalMinor: number;
|
||||||
let displayTotalMinor: number;
|
let displayTotalMinor: number;
|
||||||
if (dto.reviewedTotalMinor != null) {
|
if (dto.reviewedTotalMinor != null) {
|
||||||
if (displayCurrency !== Currency.ETB) {
|
displayTotalMinor = dto.reviewedTotalMinor;
|
||||||
displayTotalMinor = dto.reviewedTotalMinor;
|
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||||
resolvedTotalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
|
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||||
} else {
|
: dto.reviewedTotalMinor;
|
||||||
resolvedTotalMinor = dto.reviewedTotalMinor;
|
|
||||||
displayTotalMinor = dto.reviewedTotalMinor;
|
|
||||||
}
|
|
||||||
} else if (allFaresProvided) {
|
} else if (allFaresProvided) {
|
||||||
resolvedTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
// seatFareMinor is in display currency — sum is already the display total
|
||||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||||
? await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency)
|
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||||
: resolvedTotalMinor;
|
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||||
|
: displayTotalMinor;
|
||||||
} else {
|
} else {
|
||||||
resolvedTotalMinor = fareCalculation.totalMinor;
|
resolvedTotalMinor = fareCalculation.totalMinor;
|
||||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
@@ -880,7 +879,7 @@ export class BookingsService {
|
|||||||
destinationStationId: dto.destinationStationId,
|
destinationStationId: dto.destinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
totalMinor: resolvedTotalMinor / 100,
|
totalMinor: resolvedTotalMinor,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
@@ -1001,10 +1000,10 @@ export class BookingsService {
|
|||||||
const taxesMinor = 0;
|
const taxesMinor = 0;
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||||
let displayTotalMinor = totalMinor;
|
// displayTotalMinor will be overridden below when reviewedTotalMinor is provided.
|
||||||
if (displayCurrency !== Currency.ETB) {
|
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
}
|
: totalMinor;
|
||||||
|
|
||||||
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
||||||
// present (berth-specific pricing). Fall back to fare engine values.
|
// present (berth-specific pricing). Fall back to fare engine values.
|
||||||
@@ -1036,20 +1035,16 @@ export class BookingsService {
|
|||||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||||
if (dto.reviewedTotalMinor != null) {
|
if (dto.reviewedTotalMinor != null) {
|
||||||
if (displayCurrency !== Currency.ETB) {
|
displayTotalMinor = dto.reviewedTotalMinor;
|
||||||
displayTotalMinor = dto.reviewedTotalMinor;
|
totalMinor = displayCurrency !== Currency.ETB
|
||||||
totalMinor = await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB);
|
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||||
} else {
|
: dto.reviewedTotalMinor;
|
||||||
totalMinor = dto.reviewedTotalMinor;
|
|
||||||
displayTotalMinor = dto.reviewedTotalMinor;
|
|
||||||
}
|
|
||||||
} else if (allRTFaresProvided && !dto.packageId) {
|
} else if (allRTFaresProvided && !dto.packageId) {
|
||||||
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
||||||
if (displayCurrency !== Currency.ETB) {
|
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
totalMinor = displayCurrency !== Currency.ETB
|
||||||
} else {
|
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||||
displayTotalMinor = totalMinor;
|
: displayTotalMinor;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const booking = await this.prisma.booking.create({
|
const booking = await this.prisma.booking.create({
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
@@ -158,7 +158,7 @@ export class CreateGuestBookingDto {
|
|||||||
@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.' })
|
@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 {
|
export class SavedPassengerProfileDto {
|
||||||
|
|||||||
@@ -221,20 +221,28 @@ export class GuestBookingService {
|
|||||||
return { ...p, fareMinor };
|
return { ...p, fareMinor };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use reviewedTotalMinor from frontend as authoritative total when provided.
|
// reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
|
||||||
// Fall back to per-seat sum when all seated passengers supplied seatFareMinor.
|
// 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 seatedPassengers = passengersData.filter(p => p.seatId);
|
||||||
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
|
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: number;
|
||||||
let displayTotalMinor = resolvedTotalMinor;
|
let resolvedTotalMinor: number;
|
||||||
if (displayCurrency !== Currency.ETB) {
|
if (dto.reviewedTotalMinor != null) {
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(resolvedTotalMinor, Currency.ETB, displayCurrency);
|
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
|
// Resolve or create the guest Passenger record
|
||||||
const firstPassenger = passengersData[0];
|
const firstPassenger = passengersData[0];
|
||||||
@@ -501,16 +509,17 @@ export class GuestBookingService {
|
|||||||
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
|
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
|
||||||
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
|
||||||
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
|
||||||
if (dto.reviewedTotalMinor) {
|
if (dto.reviewedTotalMinor != null) {
|
||||||
totalMinor = dto.reviewedTotalMinor;
|
displayTotalMinor = dto.reviewedTotalMinor;
|
||||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
totalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||||
: totalMinor;
|
: displayTotalMinor;
|
||||||
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||||
totalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
|
||||||
displayTotalMinor = displayCurrency !== Currency.ETB
|
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
totalMinor = displayCurrency !== Currency.ETB
|
||||||
: totalMinor;
|
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||||
|
: displayTotalMinor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or resolve guest passenger (same as one-way)
|
// Create or resolve guest passenger (same as one-way)
|
||||||
|
|||||||
@@ -83,16 +83,31 @@ export class CurrencyService {
|
|||||||
toCurrency: Currency,
|
toCurrency: Currency,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (fromCurrency === toCurrency) return 1;
|
if (fromCurrency === toCurrency) return 1;
|
||||||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
|
||||||
|
// Direct rate
|
||||||
|
const direct = await this.prisma.currencyExchangeRate.findFirst({
|
||||||
where: { fromCurrency, toCurrency },
|
where: { fromCurrency, toCurrency },
|
||||||
orderBy: { effectiveDate: 'desc' },
|
orderBy: { effectiveDate: 'desc' },
|
||||||
});
|
});
|
||||||
if (!exchangeRate) {
|
if (direct) return Number(direct.rate);
|
||||||
throw new BadRequestException(
|
|
||||||
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
// Inverse rate
|
||||||
);
|
const inverse = await this.prisma.currencyExchangeRate.findFirst({
|
||||||
|
where: { fromCurrency: toCurrency, toCurrency: fromCurrency },
|
||||||
|
orderBy: { effectiveDate: 'desc' },
|
||||||
|
});
|
||||||
|
if (inverse) return 1 / Number(inverse.rate);
|
||||||
|
|
||||||
|
// Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
|
||||||
|
if (fromCurrency !== Currency.ETB && toCurrency !== Currency.ETB) {
|
||||||
|
const toEtb = await this.getRateOrThrow(fromCurrency, Currency.ETB);
|
||||||
|
const etbToTarget = await this.getRateOrThrow(Currency.ETB, toCurrency);
|
||||||
|
return toEtb * etbToTarget;
|
||||||
}
|
}
|
||||||
return Number(exchangeRate.rate);
|
|
||||||
|
throw new BadRequestException(
|
||||||
|
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private roundTo(value: number, decimals: number): number {
|
private roundTo(value: number, decimals: number): number {
|
||||||
@@ -110,7 +125,7 @@ export class CurrencyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
|
const rate = await this.getExchangeRate(fromCurrency, toCurrency);
|
||||||
return Math.round(amountMinor * rate);
|
return amountMinor * rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExchangeRate(
|
async getExchangeRate(
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export class PaymentsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get booking amount in a specific currency",
|
summary: "Get booking amount in a specific currency",
|
||||||
description:
|
description:
|
||||||
"Returns the booking total converted from ETB to the requested currency using the latest exchange rate. " +
|
"Returns the booking total converted from the booking's stored currency to the requested currency using the latest exchange rate. " +
|
||||||
"If currency is ETB the stored amount is returned as-is (no conversion). " +
|
"If currency is ETB the stored amount is returned as-is (no conversion). " +
|
||||||
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
|
"Amounts are returned in major currency units (e.g. 162.50 DJF, not centimes).",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ describe("PaymentsService", () => {
|
|||||||
paymentMethod: {
|
paymentMethod: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
},
|
},
|
||||||
|
currencyExchangeRate: {
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
},
|
||||||
walletAccount: {
|
walletAccount: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -338,6 +341,38 @@ describe("PaymentsService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getBookingAmountByCurrency", () => {
|
||||||
|
it("should convert from the booking currency to the requested currency", async () => {
|
||||||
|
mockPrisma.booking.findUnique.mockResolvedValue({
|
||||||
|
id: "booking-1",
|
||||||
|
totalMinor: 100000,
|
||||||
|
bookingType: "ONE_WAY",
|
||||||
|
packageId: null,
|
||||||
|
priceTierId: null,
|
||||||
|
currency: "USD",
|
||||||
|
displayCurrency: "USD",
|
||||||
|
displayTotalMinor: 125000,
|
||||||
|
});
|
||||||
|
mockPrisma.currencyExchangeRate.findFirst.mockResolvedValue({ rate: 2.5 });
|
||||||
|
|
||||||
|
const result = await service.getBookingAmountByCurrency("booking-1", "DJF");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
booking_id: "booking-1",
|
||||||
|
currency: "DJF",
|
||||||
|
amount: 3125,
|
||||||
|
});
|
||||||
|
expect(mockPrisma.currencyExchangeRate.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
fromCurrency: "USD",
|
||||||
|
toCurrency: "DJF",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getIntentByBookingId", () => {
|
describe("getIntentByBookingId", () => {
|
||||||
it("should return the cached local intent when the payment service has none", async () => {
|
it("should return the cached local intent when the payment service has none", async () => {
|
||||||
const mockIntent = {
|
const mockIntent = {
|
||||||
|
|||||||
@@ -234,19 +234,36 @@ export class PaymentsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
|
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
|
||||||
// settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into
|
// settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency
|
||||||
// that currency here so the payment microservice stays currency-agnostic and charges it as-is.
|
// already matches the charge currency, use displayTotalMinor directly — the rate is already
|
||||||
|
// baked in at booking creation time. Only fall back to ETB→target conversion when they differ.
|
||||||
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||||||
where: { type: method },
|
where: { type: method },
|
||||||
});
|
});
|
||||||
const chargeCurrency = (
|
const chargeCurrency = (
|
||||||
paymentMethod?.currency ?? booking.currency
|
paymentMethod?.currency ?? booking.currency
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
const chargeAmount = await this.currencyService.convertMinorToChargeMajor(
|
|
||||||
booking.totalMinor,
|
const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
|
||||||
booking.currency,
|
const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;
|
||||||
chargeCurrency,
|
|
||||||
);
|
let chargeAmount: number;
|
||||||
|
if (
|
||||||
|
chargeCurrency === bookingDisplayCurrency &&
|
||||||
|
chargeCurrency !== 'ETB' &&
|
||||||
|
bookingDisplayTotalMinor != null
|
||||||
|
) {
|
||||||
|
// Display currency matches charge currency — use the pre-converted amount directly.
|
||||||
|
chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
|
||||||
|
} else if (chargeCurrency === 'ETB') {
|
||||||
|
chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
|
||||||
|
} else {
|
||||||
|
// Booking is in ETB — convert to the provider's settlement currency.
|
||||||
|
chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor(
|
||||||
|
booking.totalMinor,
|
||||||
|
chargeCurrency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.PASSENGER,
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
@@ -658,28 +675,54 @@ export class PaymentsService {
|
|||||||
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
): Promise<{ booking_id: string; currency: string; amount: number }> {
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
select: { id: true, totalMinor: true, bookingType: true, packageId: true, priceTierId: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
totalMinor: true,
|
||||||
|
bookingType: true,
|
||||||
|
packageId: true,
|
||||||
|
priceTierId: true,
|
||||||
|
currency: true,
|
||||||
|
displayCurrency: true,
|
||||||
|
displayTotalMinor: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
if (!booking) throw new NotFoundException('Booking not found');
|
||||||
|
|
||||||
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
const correctTotalMinor = await this.resolveBookingTotal(booking as any);
|
||||||
const requestedCurrency = currency.toUpperCase();
|
const requestedCurrency = currency.toUpperCase();
|
||||||
const amountInETB = correctTotalMinor / 100;
|
|
||||||
|
|
||||||
if (requestedCurrency === 'ETB') {
|
// Source of truth: displayTotalMinor in displayCurrency when available,
|
||||||
return { booking_id: bookingId, currency: 'ETB', amount: amountInETB };
|
// otherwise totalMinor in ETB (bookings with no display currency override).
|
||||||
|
const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
|
||||||
|
const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;
|
||||||
|
|
||||||
|
// Same currency — return directly, no conversion needed.
|
||||||
|
if (requestedCurrency === sourceCurrency) {
|
||||||
|
return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||||
where: { fromCurrency: 'ETB' as any, toCurrency: requestedCurrency as any },
|
where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
|
||||||
orderBy: { effectiveDate: 'desc' },
|
orderBy: { effectiveDate: 'desc' },
|
||||||
});
|
});
|
||||||
if (!exchangeRate) {
|
|
||||||
throw new NotFoundException(`Exchange rate not found for ETB → ${requestedCurrency}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rate = Number(exchangeRate.rate);
|
let rate: number;
|
||||||
const converted = parseFloat((amountInETB * rate).toFixed(2));
|
if (exchangeRate) {
|
||||||
|
rate = Number(exchangeRate.rate);
|
||||||
|
} else {
|
||||||
|
// Try inverse rate
|
||||||
|
const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||||
|
where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
|
||||||
|
orderBy: { effectiveDate: 'desc' },
|
||||||
|
});
|
||||||
|
if (inverseRate) {
|
||||||
|
rate = 1 / Number(inverseRate.rate);
|
||||||
|
} else {
|
||||||
|
// Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
|
||||||
|
rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const converted = (sourceMinor / 100) * rate;
|
||||||
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
|
return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export default function PaymentPage() {
|
|||||||
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
|
const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore();
|
||||||
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
|
const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore();
|
||||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||||
const [selectedMethodCurrency, setSelectedMethodCurrency] = useState<string | null>(null);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||||
// CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP.
|
// CAC Bank OTP debit: on Pay, collect the payer's mobile in a modal, then the SMS'd OTP.
|
||||||
@@ -47,40 +46,40 @@ export default function PaymentPage() {
|
|||||||
const [otpError, setOtpError] = useState<string | null>(null);
|
const [otpError, setOtpError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
const isPackage = !!packageName;
|
|
||||||
|
|
||||||
// Use the same display currency as the review page (derived from nationality)
|
// Use the same display currency as the review page — stored on the schedule at search time.
|
||||||
|
const scheduleCurrency = isRoundTrip
|
||||||
|
? outboundSchedule?.displayCurrency
|
||||||
|
: selectedSchedule?.displayCurrency;
|
||||||
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||||
const displayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
const displayCurrency = scheduleCurrency || (nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD');
|
||||||
|
|
||||||
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
const { data: paymentMethods = [], isLoading: loadingMethods, error } = useQuery<PaymentMethod[]>({
|
||||||
queryKey: ['paymentMethods', displayCurrency],
|
queryKey: ['paymentMethods'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.get<PaymentMethod[]>(`/payments/methods?currency=${displayCurrency}`);
|
const response = await apiClient.get<PaymentMethod[]>(`/payments/methods`);
|
||||||
return Array.isArray(response) ? response : [];
|
return Array.isArray(response) ? response : [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
|
const selectedPaymentMethod = paymentMethods.find(m => m.type === selectedMethod) || null;
|
||||||
|
|
||||||
// A payment method only needs a currency conversion when its own currency differs from
|
// Derive charge currency directly from the selected method — no separate state that can lag.
|
||||||
// the default booking currency (e.g. Waafi settles in USD) — otherwise the reviewed ETB
|
const amountCurrency = (selectedPaymentMethod?.currency || 'ETB').toUpperCase();
|
||||||
// total already shown on the review page is exact and there's nothing to convert.
|
|
||||||
const isConversionNeeded = !!selectedMethodCurrency && selectedMethodCurrency !== displayCurrency;
|
|
||||||
const amountCurrency = isConversionNeeded ? selectedMethodCurrency! : displayCurrency;
|
|
||||||
|
|
||||||
// Fetch the converted booking amount from the booking-amount-changer API whenever a
|
const { data: bookingAmountData, isFetching: fetchingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
||||||
// currency-specific payment method is selected.
|
|
||||||
const { data: bookingAmountData, isLoading: loadingAmount } = useQuery<{ amount: number; currency: string; booking_id: string }>({
|
|
||||||
queryKey: ['bookingAmount', bookingId, amountCurrency],
|
queryKey: ['bookingAmount', bookingId, amountCurrency],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const url = `/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`;
|
const response: any = await apiClient.get(`/payments/booking-amount?bookingId=${bookingId}¤cy=${amountCurrency}`);
|
||||||
const response: any = await apiClient.get(url);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
enabled: !!bookingId && isConversionNeeded,
|
enabled: !!bookingId && !!selectedMethod,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Data is only usable when it belongs to the currently-selected method's currency.
|
||||||
|
const dataReady = !fetchingAmount && bookingAmountData != null && bookingAmountData.currency.toUpperCase() === amountCurrency.toUpperCase();
|
||||||
|
|
||||||
// Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
|
// Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare
|
||||||
// split equally across both legs. This guarantees leg totals are consistent with the
|
// split equally across both legs. This guarantees leg totals are consistent with the
|
||||||
// per-passenger breakdown rows and the overall reviewed total.
|
// per-passenger breakdown rows and the overall reviewed total.
|
||||||
@@ -91,39 +90,33 @@ export default function PaymentPage() {
|
|||||||
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
|
? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display
|
// reviewedTotalMinor is in display-currency minor units — matches what was shown on the review page.
|
||||||
// in the booking's default currency (ETB) — they were computed and shown to the user on
|
// When a method with a different currency is selected, bookingAmountData gives the converted charge amount.
|
||||||
// the review page. But once a payment method with its own currency is selected (e.g.
|
// When the method's currency matches displayCurrency (or no method selected), use reviewedTotal directly.
|
||||||
// Waafi/USD), the converted amount from the booking-amount API takes over so the user
|
|
||||||
// sees the actual amount they'll be charged in that currency.
|
|
||||||
const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
|
const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null);
|
||||||
const totalAmountDisplay = isConversionNeeded
|
|
||||||
? (bookingAmountData != null ? bookingAmountData.amount : null)
|
|
||||||
: (reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null));
|
|
||||||
const totalAmount = isConversionNeeded
|
|
||||||
? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : (reviewedTotal ?? 0))
|
|
||||||
: (reviewedTotal ?? (bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : 0));
|
|
||||||
const confirmedCurrency = isConversionNeeded ? (bookingAmountData?.currency || amountCurrency) : displayCurrency;
|
|
||||||
|
|
||||||
// Show loading spinner while the converted amount is still in flight for a
|
// When a method is selected: show spinner until dataReady, then show converted amount.
|
||||||
// currency-specific method; ETB methods always have the reviewed total instantly.
|
// When no method is selected: show the reviewed total in displayCurrency.
|
||||||
const awaitingAmount = !isPackage && isConversionNeeded && loadingAmount && totalAmountDisplay === null;
|
const totalAmountDisplay = selectedMethod
|
||||||
|
? (dataReady ? bookingAmountData!.amount : null)
|
||||||
|
: (reviewedTotal != null ? reviewedTotal / 100 : null);
|
||||||
|
const totalAmount = selectedMethod && dataReady
|
||||||
|
? Math.round(bookingAmountData!.amount * 100)
|
||||||
|
: (reviewedTotal ?? 0);
|
||||||
|
const confirmedCurrency = selectedMethod
|
||||||
|
? (dataReady ? bookingAmountData!.currency : amountCurrency)
|
||||||
|
: displayCurrency;
|
||||||
|
const awaitingAmount = !!selectedMethod && !dataReady;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Once a currency-specific payment method's converted amount has loaded, that's the
|
if (selectedMethod && dataReady) {
|
||||||
// real charge amount and currency — store it as the paid amount. Otherwise fall back
|
setCurrency(bookingAmountData!.currency as 'ETB' | 'DJF' | 'USD');
|
||||||
// to the reviewed ETB total shown on the review page.
|
setPaidAmount(Math.round(bookingAmountData!.amount * 100));
|
||||||
if (isConversionNeeded && bookingAmountData != null) {
|
} else if (!selectedMethod && reviewedTotal != null) {
|
||||||
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
|
setCurrency(displayCurrency as 'ETB' | 'DJF' | 'USD');
|
||||||
setPaidAmount(Math.round(bookingAmountData.amount * 100));
|
|
||||||
} else if (reviewedTotal != null) {
|
|
||||||
setCurrency('ETB');
|
|
||||||
setPaidAmount(reviewedTotal);
|
setPaidAmount(reviewedTotal);
|
||||||
} else if (bookingAmountData != null) {
|
|
||||||
setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD');
|
|
||||||
setPaidAmount(Math.round(bookingAmountData.amount * 100));
|
|
||||||
}
|
}
|
||||||
}, [isConversionNeeded, bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]);
|
}, [selectedMethod, dataReady, bookingAmountData, reviewedTotal, displayCurrency, setCurrency, setPaidAmount]);
|
||||||
|
|
||||||
const paymentMutation = useMutation({
|
const paymentMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
@@ -603,7 +596,7 @@ export default function PaymentPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={method.id}
|
key={method.id}
|
||||||
onClick={() => { setSelectedMethod(method.type); setSelectedMethodCurrency(method.currency ?? null); }}
|
onClick={() => setSelectedMethod(method.type)}
|
||||||
disabled={isProcessing || !method.enabled}
|
disabled={isProcessing || !method.enabled}
|
||||||
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
|
||||||
isSelected
|
isSelected
|
||||||
|
|||||||
Reference in New Issue
Block a user