mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user