mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Update fare display based on currency and fix seat allocation
This commit is contained in:
@@ -3,15 +3,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
// Nationality → home currency mapping
|
||||
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
|
||||
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
|
||||
Ethiopian: Currency.ETB,
|
||||
Djiboutian: Currency.DJF,
|
||||
ETHIOPIAN: Currency.ETB,
|
||||
DJIBOUTIAN: Currency.DJF,
|
||||
};
|
||||
|
||||
export function resolveCurrencyFromNationality(nationality?: string): Currency {
|
||||
if (!nationality) return Currency.ETB;
|
||||
return NATIONALITY_CURRENCY_MAP[nationality] ?? Currency.USD;
|
||||
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
|
||||
}
|
||||
|
||||
export class FareCalculateDto {
|
||||
@@ -29,7 +29,7 @@ export class FareCalculateDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Ethiopian',
|
||||
description: 'Passenger nationality. Determines the billing currency: Ethiopian → ETB, Djiboutian → DJF, other → USD. Defaults to ETB.',
|
||||
description: 'Passenger nationality. Determines the billing currency: ETHIOPIAN → ETB, DJIBOUTIAN → DJF, other → USD. Case-insensitive. Defaults to ETB.',
|
||||
})
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
|
||||
|
||||
@@ -295,20 +295,34 @@ export class NotificationsService {
|
||||
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
|
||||
);
|
||||
|
||||
// Resolve SMS phone: prefer the IAM user's stored number, fall back to the phone
|
||||
// the passenger entered on the booking form (contactPhone).
|
||||
const contactPhone: string | null = (booking as any)?.contactPhone ?? (payload.booking as any)?.contactPhone ?? null;
|
||||
const iamPhone = passengerId ? await this.getRecipientAddress(passengerId, 'SMS').catch(() => null) : null;
|
||||
const smsPhone = iamPhone ?? contactPhone;
|
||||
|
||||
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
|
||||
if (!ticket || !booking) {
|
||||
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
|
||||
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
|
||||
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
|
||||
await this.deliverSms(passengerId, text);
|
||||
if (smsPhone) {
|
||||
await this.smsClient.sendSms({ to: smsPhone, message: text }).catch(() => null);
|
||||
} else {
|
||||
this.logger.warn(`No SMS phone for booking ${ref}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS — short pointer (no HTML/QR over SMS).
|
||||
await this.deliverSms(
|
||||
passengerId,
|
||||
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
||||
);
|
||||
if (smsPhone) {
|
||||
await this.smsClient.sendSms({
|
||||
to: smsPhone,
|
||||
message: `EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
||||
}).catch(() => null);
|
||||
} else {
|
||||
this.logger.warn(`No SMS phone for booking ${ref}`);
|
||||
}
|
||||
|
||||
// EMAIL — rich HTML ticket with plain-text fallback.
|
||||
await this.deliverEmail(
|
||||
|
||||
@@ -128,12 +128,16 @@ export class PaymentsController {
|
||||
@ApiOperation({
|
||||
summary: "List payment systems supported by the platform",
|
||||
description:
|
||||
"Returns the global catalog of accepted payment systems. Not user-specific. Optionally filter by region to match a passenger's nationality.",
|
||||
"Returns the global catalog of accepted payment systems. Filter by `currency` (e.g. ETB, DJF, USD) to get methods that settle in that currency, and/or by `region` to match a passenger's nationality. Both filters can be combined.",
|
||||
})
|
||||
@ApiQuery({ name: "currency", required: false, example: "DJF", description: "Settlement currency — ETB, DJF, USD, etc." })
|
||||
@ApiQuery({ name: "region", enum: PaymentRegionEnum, required: false })
|
||||
@ApiOkResponse({ type: [SupportedPaymentMethodDto] })
|
||||
getMethods(@Query("region") region?: PaymentRegionEnum) {
|
||||
return this.service.getSupportedPaymentMethods(region);
|
||||
getMethods(
|
||||
@Query("currency") currency?: string,
|
||||
@Query("region") region?: PaymentRegionEnum,
|
||||
) {
|
||||
return this.service.getSupportedPaymentMethods(region, currency);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
|
||||
@@ -473,7 +473,7 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum) {
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
@@ -487,6 +487,7 @@ export class PaymentsService {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(currency ? { currency: currency.toUpperCase() } : {}),
|
||||
},
|
||||
orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
@@ -307,9 +308,13 @@ export class SearchService {
|
||||
(new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000,
|
||||
);
|
||||
|
||||
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
||||
const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
|
||||
const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
|
||||
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
|
||||
const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0);
|
||||
const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB;
|
||||
|
||||
results.push({
|
||||
type: 'TRANSIT',
|
||||
@@ -318,7 +323,9 @@ export class SearchService {
|
||||
connectionMinutes,
|
||||
leg1: leg1Result,
|
||||
leg2: leg2Result,
|
||||
displayCurrency,
|
||||
combinedMinFareMinor,
|
||||
combinedMinFareDisplay,
|
||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
||||
departureAt: leg1Result.departureAt,
|
||||
arrivalAt: leg2Result.arrivalAt,
|
||||
@@ -379,6 +386,8 @@ export class SearchService {
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality);
|
||||
|
||||
return {
|
||||
type: 'DIRECT',
|
||||
scheduleId: schedule.id,
|
||||
@@ -395,6 +404,7 @@ export class SearchService {
|
||||
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
displayCurrency,
|
||||
faresByClass,
|
||||
coachTypes,
|
||||
};
|
||||
@@ -467,7 +477,7 @@ export class SearchService {
|
||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
|
||||
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
@@ -494,7 +504,9 @@ export class SearchService {
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
nationality?: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||
|
||||
const seatClassIds: string[] = Array.from(
|
||||
new Set(
|
||||
schedule.coachAssignments
|
||||
@@ -535,8 +547,10 @@ export class SearchService {
|
||||
scheduleId: schedule.id,
|
||||
});
|
||||
return {
|
||||
seatClassName: fare.seatClassName,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
seatClassName: fare.seatClassName,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
displayCurrency: fare.billingCurrency as Currency,
|
||||
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
||||
@@ -545,7 +559,9 @@ export class SearchService {
|
||||
}),
|
||||
);
|
||||
|
||||
const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null);
|
||||
const validResults = results.filter(
|
||||
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
||||
);
|
||||
if (validResults.length > 0) {
|
||||
return validResults;
|
||||
}
|
||||
@@ -573,9 +589,12 @@ export class SearchService {
|
||||
if (fareRules.length > 0) {
|
||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
displayCurrency,
|
||||
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -586,13 +605,13 @@ export class SearchService {
|
||||
|
||||
private async buildCoachTypeDetails(
|
||||
schedule: any,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
|
||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||
): Promise<Array<{
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
coachId: string;
|
||||
classes: Array<{ name: string; baseFareMinor: number }>;
|
||||
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
||||
}>> {
|
||||
const coachTypeMap = new Map<
|
||||
string,
|
||||
@@ -621,9 +640,14 @@ export class SearchService {
|
||||
.map((className) => {
|
||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||
if (!fareInfo) return null;
|
||||
return { name: className, baseFareMinor: fareInfo.baseFareMinor };
|
||||
return {
|
||||
name: className,
|
||||
baseFareMinor: fareInfo.baseFareMinor,
|
||||
displayCurrency: fareInfo.displayCurrency,
|
||||
displayAmountMinor: fareInfo.displayAmountMinor,
|
||||
};
|
||||
})
|
||||
.filter((c): c is { name: string; baseFareMinor: number } => c !== null)
|
||||
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
|
||||
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
result.push({
|
||||
|
||||
@@ -307,9 +307,9 @@ export class SeatsService {
|
||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
@@ -334,28 +334,34 @@ export class SeatsService {
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
const rawSeatIds = h.seatIds as string[];
|
||||
try {
|
||||
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(h.createdBy);
|
||||
const holdFrom = seqOf(meta.originStationId);
|
||||
const holdTo = seqOf(meta.destinationStationId);
|
||||
if (holdFrom !== undefined && holdTo !== undefined) {
|
||||
parsedHolds.push({
|
||||
seatIds: h.seatIds,
|
||||
from: holdFrom,
|
||||
to: holdTo,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
});
|
||||
}
|
||||
parsedHolds.push({
|
||||
seatIds: rawSeatIds,
|
||||
from: holdFrom ?? 0,
|
||||
to: holdTo ?? Number.MAX_SAFE_INTEGER,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
legUnknown: holdFrom === undefined || holdTo === undefined,
|
||||
});
|
||||
} else {
|
||||
// Legacy plain-string createdBy — can't determine leg; block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Malformed JSON — block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
for (const hold of parsedHolds) {
|
||||
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
||||
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
if (hold.seatIds.includes(seatId)) {
|
||||
@@ -364,7 +370,7 @@ export class SeatsService {
|
||||
);
|
||||
}
|
||||
|
||||
if (hold.passengerIds.includes(passengerId)) {
|
||||
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(
|
||||
`Passenger already holds a seat on this journey leg`,
|
||||
);
|
||||
@@ -386,12 +392,14 @@ export class SeatsService {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
if (segFrom !== undefined && segTo !== undefined) {
|
||||
if (segFrom < reqTo && reqFrom < segTo) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
}
|
||||
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
|
||||
const overlaps = (segFrom === undefined || segTo === undefined)
|
||||
? true
|
||||
: segFrom < reqTo && reqFrom < segTo;
|
||||
if (overlaps) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +409,13 @@ export class SeatsService {
|
||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||
};
|
||||
|
||||
// Mark seats as HELD so the status check catches them immediately on any
|
||||
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
|
||||
await tx.seat.updateMany({
|
||||
where: { id: { in: seatIds } },
|
||||
data: { status: 'HELD' },
|
||||
});
|
||||
|
||||
return tx.seatHold.create({
|
||||
data: {
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -541,11 +556,16 @@ export class SeatsService {
|
||||
async releaseHold(holdId: string) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||
if (!hold) throw new NotFoundException('Hold not found');
|
||||
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.seat.updateMany({
|
||||
where: { id: { in: hold.seatIds as string[] }, status: 'HELD' },
|
||||
data: { status: 'AVAILABLE' },
|
||||
}),
|
||||
this.prisma.seatHold.delete({ where: { id: holdId } }),
|
||||
]);
|
||||
return { released: true, holdId };
|
||||
}
|
||||
|
||||
// Physical seat.status stays AVAILABLE — segment rows are the source of truth for occupancy.
|
||||
async confirmSeats(_seatIds: string[]) {}
|
||||
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||
@@ -719,7 +739,18 @@ export class SeatsService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
// Holds are temporary and don't create Journey rows — just delete expired ones.
|
||||
const expired = await this.prisma.seatHold.findMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
select: { id: true, seatIds: true },
|
||||
});
|
||||
if (expired.length === 0) return;
|
||||
|
||||
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]);
|
||||
// Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched.
|
||||
await this.prisma.seat.updateMany({
|
||||
where: { id: { in: expiredSeatIds }, status: 'HELD' },
|
||||
data: { status: 'AVAILABLE' },
|
||||
});
|
||||
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
@@ -15,6 +15,8 @@ interface OfflineValidation {
|
||||
|
||||
@Injectable()
|
||||
export class TicketsService {
|
||||
private readonly logger = new Logger(TicketsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
@@ -154,17 +156,29 @@ export class TicketsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Booking not in CONFIRMED state (safety net — should align with SUCCEEDED)
|
||||
// Booking not in CONFIRMED state — could be a webhook delivery failure.
|
||||
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
|
||||
// self-heal here rather than rejecting a legitimately paid booking.
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Booking status: ${booking.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
this.logger.warn(
|
||||
`Booking ${bookingId} is PENDING_PAYMENT but payment intent SUCCEEDED — webhook likely missed. Auto-confirming before ticket generation.`,
|
||||
);
|
||||
await this.prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: 'CONFIRMED' },
|
||||
});
|
||||
} else {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Booking status: ${booking.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||
|
||||
Reference in New Issue
Block a user