mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
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: '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;
|
||||
}
|
||||
|
||||
export class RoundTripPassengerDto {
|
||||
@@ -143,6 +145,9 @@ export class CreateBookingDto {
|
||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||
@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)' })
|
||||
@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 { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -60,6 +60,8 @@ interface BookingFilters {
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
private readonly logger = new Logger(BookingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@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);
|
||||
|
||||
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;
|
||||
// for regular bookings the first child is free.
|
||||
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
||||
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
||||
let freeChildUsed = false;
|
||||
let pkgChildIdx = 0;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
|
||||
// passengersWithFares is built in adult-first order so we track paid children by count.
|
||||
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
|
||||
fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor;
|
||||
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
|
||||
pkgChildIdx++;
|
||||
} else {
|
||||
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
|
||||
else fareMinor = fareCalculation.baseFareMinor;
|
||||
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
|
||||
}
|
||||
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({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -577,7 +591,7 @@ export class BookingsService {
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: fareCalculation.totalMinor,
|
||||
totalMinor: resolvedTotalMinor / 100,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -697,8 +711,8 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track per-seat fare. For package bookings children pay 10% of adult fare;
|
||||
// for regular bookings the first child is free per leg.
|
||||
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
|
||||
// present (berth-specific pricing). Fall back to fare engine values.
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
@@ -706,24 +720,40 @@ export class BookingsService {
|
||||
let returnFareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
} else if (dto.packageId) {
|
||||
// Free children (first per adult) get fareMinor=0; paid children pay full adult fare.
|
||||
const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length;
|
||||
const isFreeChild = childIdx < adultCount;
|
||||
outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor;
|
||||
returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor;
|
||||
outboundFareMinor = 0;
|
||||
returnFareMinor = 0;
|
||||
} else {
|
||||
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
|
||||
else outboundFareMinor = outboundFare.baseFareMinor;
|
||||
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
|
||||
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
|
||||
else returnFareMinor = returnFare.baseFareMinor;
|
||||
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
|
||||
}
|
||||
|
||||
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({
|
||||
data: {
|
||||
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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
@@ -42,6 +42,12 @@ export class GuestPassengerDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'abebe@email.com', description: 'Contact email' })
|
||||
@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 {
|
||||
@@ -150,6 +156,9 @@ export class CreateGuestBookingDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
|
||||
@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 {
|
||||
|
||||
@@ -185,12 +185,38 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
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;
|
||||
let displayTotalMinor = totalMinor;
|
||||
let displayTotalMinor = resolvedTotalMinor;
|
||||
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
|
||||
@@ -224,7 +250,7 @@ export class GuestBookingService {
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor,
|
||||
totalMinor: resolvedTotalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
displayCurrency,
|
||||
@@ -235,7 +261,7 @@ export class GuestBookingService {
|
||||
contactEmail: firstPassenger.email || null,
|
||||
contactPhone: firstPassenger.phone || null,
|
||||
seats: {
|
||||
create: passengersData.map((p) => ({
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
@@ -245,7 +271,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
|
||||
fareMinor: p.fareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
},
|
||||
@@ -278,7 +304,7 @@ export class GuestBookingService {
|
||||
totalBaseFareMinor,
|
||||
discountMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
totalMinor: resolvedTotalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
@@ -423,13 +449,51 @@ export class GuestBookingService {
|
||||
}
|
||||
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
let totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
let displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: 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)
|
||||
const { guestPassengerId, iamUserId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0], req);
|
||||
|
||||
@@ -461,7 +525,7 @@ export class GuestBookingService {
|
||||
contactPhone: passengersData[0]?.phone || null,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map((p) => ({
|
||||
...passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -473,10 +537,10 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
|
||||
fareMinor: p.outboundFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map((p) => ({
|
||||
...passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.returnSeatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.returnScheduleId,
|
||||
@@ -488,7 +552,7 @@ export class GuestBookingService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData || undefined,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
|
||||
fareMinor: p.returnFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
|
||||
@@ -30,23 +30,59 @@ export class CurrencyService {
|
||||
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(
|
||||
amountMinorEtb: number,
|
||||
targetCurrency: string,
|
||||
): Promise<number> {
|
||||
const target = targetCurrency.toUpperCase();
|
||||
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||||
if (decimals === undefined) {
|
||||
if (CHARGE_CURRENCY_DECIMALS[target] === undefined) {
|
||||
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||||
}
|
||||
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||||
|
||||
const sourceMajor = amountMinorEtb / 100;
|
||||
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);
|
||||
return this.roundTo(sourceMajor * rate, decimals);
|
||||
return this.roundTo((amountMinorEtb * rate) / 100, decimals);
|
||||
}
|
||||
|
||||
async getRateOrThrow(
|
||||
|
||||
@@ -105,10 +105,16 @@ export class FareEngineService {
|
||||
|
||||
if (segmentOverride) {
|
||||
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
||||
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
|
||||
baseFarePerPassengerMinor *= 2;
|
||||
}
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = 'SEGMENT_FARE_RULE';
|
||||
} else if (fareRule?.tripId) {
|
||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
|
||||
baseFarePerPassengerMinor *= 2;
|
||||
}
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = 'SCHEDULE_FARE_RULE';
|
||||
} else {
|
||||
@@ -165,7 +171,7 @@ export class FareEngineService {
|
||||
|
||||
const calculation = [
|
||||
`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`,
|
||||
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
|
||||
`USD→ETB rate: ${usdToEtbRate}`,
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("PaymentsService", () => {
|
||||
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
||||
const mockCurrencyService = {
|
||||
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||
Promise.resolve(minor / 100),
|
||||
Promise.resolve(minor),
|
||||
),
|
||||
getRateOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -557,7 +557,6 @@ export class PaymentsService {
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
...(region
|
||||
? {
|
||||
region: {
|
||||
|
||||
@@ -209,7 +209,8 @@ export class SearchService {
|
||||
|
||||
const cutoffHours = await this.getCutoffHours();
|
||||
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({
|
||||
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';
|
||||
|
||||
export class CreateSeatClassDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
coachTypeId: string;
|
||||
|
||||
@ApiProperty({ example: 'Economy Seat' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Standard economy seating' })
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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()
|
||||
basePrice: number;
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
CreateGuestConversationDto,
|
||||
DeviceIdBodyDto,
|
||||
DeviceSendMessageDto,
|
||||
GuestIdBodyDto,
|
||||
GuestSendMessageDto,
|
||||
ListConversationsQueryDto,
|
||||
@@ -103,7 +105,39 @@ export class SupportController {
|
||||
return this.service.unreadCount('USER', { iamUserId: userId(req) });
|
||||
}
|
||||
|
||||
// ---- customer: guest (unauthenticated) --------------------------------
|
||||
// ---- customer: device-scoped single thread (portal) -------------------
|
||||
// No auth, no forms. One conversation per device id (localStorage). Anyone
|
||||
// with the device id can see that thread — accepted MVP trade-off.
|
||||
|
||||
@Get('device/thread')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: "Get the device's support thread + messages" })
|
||||
deviceThread(@Query('deviceId') deviceId: string) {
|
||||
return this.service.getDeviceThread(deviceId);
|
||||
}
|
||||
|
||||
@Post('device/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Send a message (creates the thread on first send)' })
|
||||
deviceSend(@Body() body: DeviceSendMessageDto) {
|
||||
return this.service.sendDeviceMessage(body.deviceId, body.text);
|
||||
}
|
||||
|
||||
@Post('device/read')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Mark the device thread read' })
|
||||
deviceRead(@Body() body: DeviceIdBodyDto) {
|
||||
return this.service.markDeviceRead(body.deviceId);
|
||||
}
|
||||
|
||||
@Get('device/unread-count')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: "Count the device thread's unread messages" })
|
||||
deviceUnread(@Query('deviceId') deviceId: string) {
|
||||
return this.service.unreadCount('USER', { guestId: deviceId });
|
||||
}
|
||||
|
||||
// ---- customer: guest (unauthenticated, multi-ticket) ------------------
|
||||
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
|
||||
// of access — anyone with it sees that thread; accepted MVP trade-off).
|
||||
|
||||
|
||||
@@ -93,6 +93,26 @@ export class GuestIdBodyDto {
|
||||
guestId!: string;
|
||||
}
|
||||
|
||||
export class DeviceSendMessageDto {
|
||||
@ApiProperty({ description: 'Client device id (localStorage).' })
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class DeviceIdBodyDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
export class UpdateStatusDto {
|
||||
@ApiProperty({ enum: SupportStatusDto })
|
||||
@IsEnum(SupportStatusDto)
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Passenger as PassengerTypes } from '@edr/types';
|
||||
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { WsAuthService } from './ws-auth.service';
|
||||
|
||||
/**
|
||||
@@ -34,27 +33,24 @@ export class SupportGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
private readonly server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly wsAuth: WsAuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
constructor(private readonly wsAuth: WsAuthService) {}
|
||||
|
||||
async handleConnection(socket: Socket): Promise<void> {
|
||||
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||
|
||||
// Authenticated: passenger (own room) or backoffice staff (shared room).
|
||||
// A valid token means a backoffice agent: the portal connects only with a
|
||||
// device/guest id (never a token), so every token-authed socket is staff.
|
||||
// Join the shared backoffice room — no passenger-row heuristic needed.
|
||||
if (userId) {
|
||||
socket.data.userId = userId;
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: userId },
|
||||
socket.data.side = 'AGENT';
|
||||
await socket.join(SupportGateway.BACKOFFICE_ROOM);
|
||||
socket.emit('support:hello', {
|
||||
side: 'AGENT',
|
||||
room: SupportGateway.BACKOFFICE_ROOM,
|
||||
userId,
|
||||
});
|
||||
if (passenger) {
|
||||
await socket.join(`user:${userId}`);
|
||||
socket.data.side = 'USER';
|
||||
} else {
|
||||
await socket.join(SupportGateway.BACKOFFICE_ROOM);
|
||||
socket.data.side = 'AGENT';
|
||||
}
|
||||
this.logger.debug(`support socket ${socket.id} → AGENT (backoffice)`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,61 @@ export class SupportService {
|
||||
return this.firstMessage(conversation, input.initialMessage);
|
||||
}
|
||||
|
||||
// ---- customer: device-scoped single thread (portal) -------------------
|
||||
|
||||
/** The device's single conversation + its messages ({conversation:null} if none). */
|
||||
async getDeviceThread(deviceId: string): Promise<T.PassengerSupportThreadDto> {
|
||||
if (!deviceId) return { conversation: null, messages: [] };
|
||||
const c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as ConversationRow | null;
|
||||
if (!c) return { conversation: null, messages: [] };
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId: c.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const unread = await this.computeUnread([c], 'USER');
|
||||
return {
|
||||
conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
|
||||
messages: rows.map((m) => this.toMessageDto(m)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a message to the device's thread, creating it on first message. */
|
||||
async sendDeviceMessage(
|
||||
deviceId: string,
|
||||
text: string,
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
let c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as ConversationRow | null;
|
||||
if (!c) {
|
||||
c = (await this.prisma.supportConversation.create({
|
||||
data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
|
||||
})) as ConversationRow;
|
||||
}
|
||||
const updated = await this.appendMessage(c, 'USER', text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
}
|
||||
|
||||
/** Mark the device's thread read (customer side). */
|
||||
async markDeviceRead(deviceId: string): Promise<{ unreadCount: number }> {
|
||||
const c = await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (c) {
|
||||
await this.prisma.supportConversation.update({
|
||||
where: { id: c.id },
|
||||
data: { userLastReadAt: new Date() },
|
||||
});
|
||||
}
|
||||
return this.unreadCount('USER', { guestId: deviceId });
|
||||
}
|
||||
|
||||
async listForCustomer(
|
||||
owner: CustomerOwner,
|
||||
query: ListQuery,
|
||||
|
||||
Reference in New Issue
Block a user