Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Abubeker Yasin
2026-07-29 17:30:20 +03:00
203 changed files with 9350 additions and 4436 deletions

View File

@@ -294,8 +294,12 @@ export class GuestBookingService {
let displayTotalMinor: number;
let resolvedTotalMinor: number;
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
let usedClientSubtotal = false;
if (allFaresProvided && !isPackageOneway) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
usedClientSubtotal = true;
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
if (seatedPassengers.length > 0) {
@@ -306,6 +310,7 @@ export class GuestBookingService {
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
usedClientSubtotal = true;
} else {
// fare engine returns ETB — convert forward to display currency
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
@@ -313,6 +318,18 @@ export class GuestBookingService {
? await this.currencyService.convertAmount(etbTotal, Currency.ETB, displayCurrency)
: etbTotal;
}
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
// customer is charged the discounted price. The non-client-subtotal branch above already
// nets the discount out of etbTotal, so it's excluded here to avoid double-subtracting.
if (usedClientSubtotal && discountMinor > 0) {
const discountDisplayMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
: discountMinor;
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
}
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
@@ -834,17 +851,16 @@ export class GuestBookingService {
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
// True when the total came from a client-summed subtotal (per-seat sum or reviewedTotalMinor),
// which the portal computes UNDISCOUNTED — the promo must still be applied to it (H-13).
let usedClientSubtotal = false;
if (allRTFaresProvided && !isPackageRoundTrip) {
// Server-computed sum is authoritative — prevents race-condition under-count.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
usedClientSubtotal = true;
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
const seatedCount = passengersData.filter(p => p.seatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
@@ -855,11 +871,24 @@ export class GuestBookingService {
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
usedClientSubtotal = true;
}
// H-13: the portal sums UNDISCOUNTED per-passenger fares into a client subtotal, silently
// dropping the promo the fare engine recognized. Apply the authoritative discount now so the
// customer is charged the discounted price. The no-override case above already starts from a
// discounted displayTotalMinor, so it's excluded here to avoid double-subtracting.
if (usedClientSubtotal && discountMinor > 0) {
const discountDisplayMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(discountMinor, Currency.ETB, displayCurrency)
: discountMinor;
displayTotalMinor = Math.max(0, displayTotalMinor - discountDisplayMinor);
}
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
// C-1 guard: never charge less than the server-recomputed authoritative round-trip fare.
this.assertTotalNotUnderAuthoritative(totalMinor, authoritativeTotalMinor, 'createGuestRoundTripBooking');

View File

@@ -224,13 +224,10 @@ export class SearchService {
childCount?: number,
nationality?: string,
) {
const [y, m, d] = dateStr.split("-").map(Number);
const requestedDate = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const requestedNextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const requestedDate = new Date(`${dateStr}T00:00:00+03:00`);
const requestedNextDay = new Date(requestedDate.getTime() + 24 * 60 * 60 * 1000);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
@@ -314,13 +311,10 @@ export class SearchService {
childCount?: number,
nationality?: string,
) {
const [y, m, d] = dateStr.split("-").map(Number);
const date = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const nextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const date = new Date(`${dateStr}T00:00:00+03:00`);
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
const totalPassengers = adultCount + (childCount ?? 0);
// Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
@@ -390,13 +384,10 @@ export class SearchService {
// 2. A route exists — is there any schedule at all on the requested date for this pair
// (regardless of status/package/coach/cutoff — those are checked next)?
const [y, m, d] = dateStr.split("-").map(Number);
const date = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const nextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const date = new Date(`${dateStr}T00:00:00+03:00`);
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
const dayCandidates = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
select: {
@@ -557,13 +548,10 @@ export class SearchService {
childCount?: number,
nationality?: string,
) {
const [y, m, d] = dateStr.split("-").map(Number);
const dayStart = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const dayEnd = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
const dayStart = new Date(`${dateStr}T00:00:00+03:00`);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
const leg2WindowEnd = new Date(
dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000,
);

View File

@@ -56,7 +56,7 @@ export class TicketsController {
}
@Get()
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.view)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all tickets with optional filters' })
@ApiQuery({ name: 'search', required: false })
@@ -99,7 +99,7 @@ export class TicketsController {
}
@Get('by-order/:merchantOrderId')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.view)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Get ticket by merchant order ID',
@@ -117,7 +117,7 @@ export class TicketsController {
}
@Post('scan-board/:qrCodeOrRef')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Scan QR code or booking ref and automatically board ticket',
@@ -142,7 +142,7 @@ export class TicketsController {
}
@Post(':bookingRef/validate')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
@@ -173,7 +173,7 @@ export class TicketsController {
}
@Get(':ticketId/validation-logs')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.view)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Get validation logs for ticket' })
getValidationLogs(@Param('ticketId') ticketId: string) {
@@ -181,7 +181,7 @@ export class TicketsController {
}
@Get('offline/export')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.view)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('scheduleId') scheduleId: string) {
@@ -189,7 +189,7 @@ export class TicketsController {
}
@Post('validate/offline')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Batch import offline validations',
@@ -232,7 +232,7 @@ export class TicketsController {
}
@Patch(':id/restore')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
restore(@Param('id') id: string) {