mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
Adding ticket generation and booking for staff employees logic
This commit is contained in:
@@ -32,9 +32,11 @@ import {
|
||||
import {
|
||||
CreateGuestBookingDto,
|
||||
GetSavedPassengersDto,
|
||||
IssueReservationBookingDto,
|
||||
} from "./guest-booking.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin } from "../../common/passenger-guards";
|
||||
import { PassengerAdmin, PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@Controller("bookings")
|
||||
@@ -349,6 +351,35 @@ export class BookingsController {
|
||||
return this.guestService.createGuestBooking(dto, req);
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Issue a booking from a reserved (blocked) seat",
|
||||
description:
|
||||
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone.",
|
||||
})
|
||||
@ApiBody({ type: IssueReservationBookingDto })
|
||||
issueBookingFromReservation(
|
||||
@Param("seatId") seatId: string,
|
||||
@Body() dto: IssueReservationBookingDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
||||
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
||||
}
|
||||
|
||||
@Get("pay/:token")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary: "Resolve a reservation booking by its pay token (public)",
|
||||
description:
|
||||
"Used by the portal's standalone pay-by-link page for a reservation booking awaiting passenger payment — no login required.",
|
||||
})
|
||||
getByPayToken(@Param("token") token: string) {
|
||||
return this.service.getByPayToken(token);
|
||||
}
|
||||
|
||||
@Get("saved-passengers")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -10,9 +10,11 @@ import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule, PaymentsModule, NotificationsModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -1860,6 +1860,52 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a reservation booking by its standalone payToken — the public entry point for
|
||||
* the portal's pay-by-link page (a traveler who never had a portal session, texted a link
|
||||
* by a reservation-issuing staff member). Payment itself still goes through the already-
|
||||
* public /payments/* endpoints (initiate/methods/booking-amount/status), keyed by the
|
||||
* booking id this returns — no change needed there.
|
||||
*/
|
||||
async getByPayToken(token: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { payToken: token },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Payment link not found');
|
||||
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('This booking is no longer awaiting payment');
|
||||
if ((booking as any).payTokenExpiresAt && (booking as any).payTokenExpiresAt < new Date()) {
|
||||
throw new BadRequestException('This payment link has expired');
|
||||
}
|
||||
|
||||
const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId);
|
||||
return {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
schedule: {
|
||||
trainNumber: (booking as any).schedule.train.number,
|
||||
trainName: (booking as any).schedule.train.name,
|
||||
origin: { id: segment.origin.id, name: segment.origin.name, code: segment.origin.code },
|
||||
destination: { id: segment.destination.id, name: segment.destination.name, code: segment.destination.code },
|
||||
departureAt: segment.departureAt,
|
||||
arrivalAt: segment.arrivalAt,
|
||||
},
|
||||
seats: (booking as any).seats.map((s: any) => ({
|
||||
passengerName: s.passengerName,
|
||||
seatNumber: s.seat?.seatNumber ?? null,
|
||||
coach: s.seat?.coach?.number ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getByRef(bookingRefOrId: string) {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber } from 'class-validator';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsDateString, IsBoolean, IsInt, IsNumber, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Currency, IdDocumentType } from '@prisma/client';
|
||||
@@ -174,6 +174,67 @@ export class SavedPassengerProfileDto {
|
||||
}
|
||||
|
||||
export class GetSavedPassengersDto {
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
||||
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID to retrieve saved passengers' })
|
||||
@IsOptional() @IsString() deviceId?: string;
|
||||
}
|
||||
|
||||
export enum ReservationBookingKind {
|
||||
STAFF = 'STAFF',
|
||||
PASSENGER = 'PASSENGER',
|
||||
}
|
||||
|
||||
export const RESERVATION_NATIONALITIES = ['Ethiopian', 'Djiboutian', 'Other'] as const;
|
||||
export type ReservationNationality = (typeof RESERVATION_NATIONALITIES)[number];
|
||||
|
||||
/**
|
||||
* Issues a real booking against a seat an admin/staff previously reserved (SeatBlock) —
|
||||
* one traveler per seat, fare always server-computed (no seatFareMinor/reviewedTotalMinor
|
||||
* override: unlike the guest DTO, there's no untrusted client-displayed price to defend
|
||||
* against here). seatId comes from the route param, not the body.
|
||||
*
|
||||
* No seatClassId — the seat (and therefore its coach/class) is already fixed by the
|
||||
* reservation being converted; the service resolves the correct seat class itself from the
|
||||
* seat's own coach type and nationality (LOCAL vs INTERNATIONAL pricing tier), the same
|
||||
* matching search results already use. No passportCountry — nationality alone is what
|
||||
* drives both fare-tier selection and passport-vs-national-ID validation.
|
||||
*/
|
||||
export class IssueReservationBookingDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ enum: ReservationBookingKind, description: 'STAFF: no fee, ticket issued immediately. PASSENGER: a payment link is sent to phone.' })
|
||||
@IsEnum(ReservationBookingKind) bookingKind: ReservationBookingKind;
|
||||
|
||||
@ApiProperty({ example: 'Abebe Kebede' })
|
||||
@IsString() passengerName: string;
|
||||
|
||||
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation' })
|
||||
@IsDateString() dateOfBirth: string;
|
||||
|
||||
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType })
|
||||
@IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ET123456789' })
|
||||
@IsOptional() @IsString() idDocumentNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'P1234567' })
|
||||
@IsOptional() @IsString() passportNumber?: string;
|
||||
|
||||
@ApiProperty({ example: 'Ethiopian', enum: RESERVATION_NATIONALITIES, description: 'Drives both the LOCAL/INTERNATIONAL fare tier and passport-vs-national-ID validation.' })
|
||||
@IsIn(RESERVATION_NATIONALITIES) nationality: ReservationNationality;
|
||||
|
||||
@ApiPropertyOptional({ example: '+251912345678', description: 'Required when bookingKind is PASSENGER — the payment link is sent here.' })
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'abebe@email.com' })
|
||||
@IsOptional() @IsString() email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ETB', enum: Currency })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { PassengerAuthService } from '../auth/passenger-auth.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto';
|
||||
import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client';
|
||||
import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Throws if the given boarding stop's own configurable check-in cutoff (route/stop
|
||||
@@ -25,6 +30,29 @@ function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the seat class for a specific, already-known seat (its coach type only ever
|
||||
* offers a fixed set of classes) given the traveler's nationality tier — mirrors
|
||||
* search.service.ts's own LOCAL/INTERNATIONAL + bed-position matching so the price a
|
||||
* reservation-issued booking charges is the exact same "already configured price setup"
|
||||
* search results would have quoted, without asking the admin to redundantly re-pick a class
|
||||
* for a seat whose class is already fixed.
|
||||
*/
|
||||
function resolveSeatClassForSeat(seat: any, nationality: string): { id: string; name: string } {
|
||||
const nationalityUpper = (nationality ?? '').toUpperCase();
|
||||
const resolvedNationalityType = nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
|
||||
const candidates = (seat.coach?.coachType?.seatClasses ?? []).filter(
|
||||
(sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType,
|
||||
);
|
||||
const matchingClass = seat.bedPosition
|
||||
? candidates.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition)
|
||||
: candidates[0];
|
||||
if (!matchingClass) {
|
||||
throw new BadRequestException('No seat class is configured for this seat and nationality — set up seat classes for this coach type first.');
|
||||
}
|
||||
return matchingClass;
|
||||
}
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
@@ -65,6 +93,9 @@ export class GuestBookingService {
|
||||
private passengerAuthService: PassengerAuthService,
|
||||
private fareEngine: FareEngineService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private paymentsService: PaymentsService,
|
||||
private auditService: AuditService,
|
||||
private smsClient: SmsClientService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -384,6 +415,228 @@ export class GuestBookingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an admin-reserved seat (SeatBlock) into a real booking for one traveler —
|
||||
* no SeatHold involved (the seat was already set aside), so this mirrors
|
||||
* createGuestOneWayBooking's schedule/fare/passenger resolution but skips the hold check
|
||||
* and instead validates+releases the SeatBlock. STAFF bookings are fee-waived and
|
||||
* finalized immediately via the same PaymentsService.finalizePaymentSuccess() path every
|
||||
* real payment webhook uses; PASSENGER bookings are left PENDING_PAYMENT with a payToken
|
||||
* texted to the traveler so they can pay via the existing, already-public /payments/*
|
||||
* endpoints without a portal session.
|
||||
*/
|
||||
async issueBookingFromReservation(
|
||||
seatId: string,
|
||||
dto: IssueReservationBookingDto,
|
||||
actingUserId: string | null,
|
||||
): Promise<{ booking: any; payUrl?: string }> {
|
||||
if (dto.bookingKind === ReservationBookingKind.PASSENGER && !dto.phone) {
|
||||
throw new BadRequestException('Phone number is required for a passenger booking');
|
||||
}
|
||||
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID && dto.nationality !== 'Ethiopian') {
|
||||
throw new BadRequestException('National ID is only valid for Ethiopian nationality — use a passport instead');
|
||||
}
|
||||
|
||||
const seatBlock = await this.prisma.seatBlock.findFirst({
|
||||
where: { seatId, OR: [{ scheduleId: dto.scheduleId }, { scheduleId: null }] },
|
||||
});
|
||||
if (!seatBlock) throw new NotFoundException('Seat is not reserved');
|
||||
|
||||
// The seat (and therefore its coach) is already fixed by the reservation — resolve the
|
||||
// seat class from the seat's own coach type + the traveler's nationality tier, the same
|
||||
// LOCAL/INTERNATIONAL + bed-position matching search results already use, instead of
|
||||
// asking the admin to redundantly pick a class.
|
||||
const seat = await this.prisma.seat.findUnique({
|
||||
where: { id: seatId },
|
||||
include: { coach: { include: { coachType: { include: { seatClasses: true } } } } },
|
||||
});
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
const resolvedSeatClass = resolveSeatClassForSeat(seat, dto.nationality);
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: dto.scheduleId },
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
route: { include: { stops: true } },
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
|
||||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
|
||||
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
|
||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||
|
||||
assertWithinCheckinCutoff(schedule, originStop, dto.originStationId);
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
|
||||
// Single-traveler passenger processing. nationality is a fixed dropdown value (Ethiopian/
|
||||
// Djiboutian/Other), so — unlike the guest-booking loop this mirrors — there's no need to
|
||||
// infer it from document type/country; only the Verifayda check (NATIONAL_ID) and the
|
||||
// passport-number requirement (PASSPORT) still depend on the chosen document type.
|
||||
const dateOfBirth = new Date(dto.dateOfBirth);
|
||||
const age = calculateAge(dateOfBirth);
|
||||
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
|
||||
|
||||
let passengerName = dto.passengerName;
|
||||
let verifaydaVerified = false;
|
||||
let verifaydaData: Record<string, any> | undefined;
|
||||
|
||||
if (dto.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (dto.idDocumentNumber) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(dto.idDocumentNumber);
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException(`Verifayda verification failed for ${dto.passengerName}: ${verification.failureReason}`);
|
||||
}
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
verifaydaVerified = true;
|
||||
verifaydaData = verification.passengerData?.profileData;
|
||||
}
|
||||
} else if (dto.idDocumentType === IdDocumentType.PASSPORT) {
|
||||
if (!dto.passportNumber) {
|
||||
throw new BadRequestException(`Passport number required for ${dto.passengerName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const passengerData = {
|
||||
passengerName,
|
||||
dateOfBirth,
|
||||
category,
|
||||
verifaydaVerified,
|
||||
verifaydaData,
|
||||
nationality: dto.nationality,
|
||||
idDocumentType: dto.idDocumentType,
|
||||
idDocumentNumber: dto.idDocumentNumber,
|
||||
passportNumber: dto.passportNumber,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
};
|
||||
|
||||
const baseFareMinor = await this.getBaseFare(
|
||||
dto.scheduleId,
|
||||
resolvedSeatClass.id,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
dto.nationality,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
|
||||
const isStaff = dto.bookingKind === ReservationBookingKind.STAFF;
|
||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||
const totalMinor = isStaff ? 0 : baseFareMinor;
|
||||
if (!isStaff) {
|
||||
// Defense-in-depth — there's no client-forgeable price on this DTO, but keep the
|
||||
// same authoritative-fare floor every other booking path enforces.
|
||||
this.assertTotalNotUnderAuthoritative(totalMinor, baseFareMinor, 'issueBookingFromReservation');
|
||||
}
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const { guestPassengerId } = await this.resolveGuestPassenger({}, passengerData);
|
||||
|
||||
const payToken = isStaff ? undefined : randomUUID();
|
||||
const payTokenExpiresAt = isStaff ? undefined : computePaymentDeadline(new Date(), schedule.departureAt);
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
passengerId: guestPassengerId,
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
totalMinor,
|
||||
currency: Currency.ETB,
|
||||
adultCount: category === PassengerCategory.ADULT ? 1 : 0,
|
||||
childCount: category === PassengerCategory.CHILD ? 1 : 0,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
bookingType: 'ONE_WAY',
|
||||
source: 'BACKOFFICE_RESERVATION',
|
||||
contactEmail: dto.email || null,
|
||||
contactPhone: dto.phone || null,
|
||||
payToken,
|
||||
payTokenExpiresAt,
|
||||
seats: {
|
||||
create: [{
|
||||
seat: { connect: { id: seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: passengerData.passengerName,
|
||||
dateOfBirth: passengerData.dateOfBirth,
|
||||
passengerCategory: passengerData.category,
|
||||
idDocumentType: passengerData.idDocumentType,
|
||||
passportNumber: passengerData.passportNumber,
|
||||
verifaydaVerified: passengerData.verifaydaVerified,
|
||||
verifaydaData: passengerData.verifaydaData || undefined,
|
||||
fareMinor: totalMinor,
|
||||
displayCurrency,
|
||||
}],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, [passengerData]);
|
||||
|
||||
// Release the reservation using the SAME scope it was created with (global vs
|
||||
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
|
||||
// block; reimplementing that here would risk missing that reset.
|
||||
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
|
||||
await this.seatsService.confirmSeats([seatId]);
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
|
||||
if (isStaff) {
|
||||
await this.auditService.log({
|
||||
userId: actingUserId ?? undefined,
|
||||
action: 'CREATE',
|
||||
entityType: 'Booking',
|
||||
entityId: booking.id,
|
||||
newData: { feeWaived: true, waivedBy: actingUserId, originalFareMinor: baseFareMinor },
|
||||
});
|
||||
|
||||
const intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
amountMinor: 0,
|
||||
currency: 'ETB',
|
||||
// WALLET is an internal-only method that never leaves this app (see
|
||||
// payments.service.ts) — safe, inert placeholder for a zero-charge waiver.
|
||||
method: PaymentMethodType.WALLET,
|
||||
status: PaymentIntentStatus.REQUIRES_ACTION,
|
||||
},
|
||||
});
|
||||
await this.paymentsService.finalizePaymentSuccess({ intentId: intent.id });
|
||||
// finalizePaymentSuccess mutates the booking (status -> CONFIRMED) in the DB —
|
||||
// re-fetch so the caller sees the actual outcome, not the pre-finalization snapshot.
|
||||
const confirmedBooking = await this.prisma.booking.findUnique({ where: { id: booking.id } });
|
||||
return { booking: confirmedBooking };
|
||||
}
|
||||
|
||||
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
|
||||
const payUrl = `${portalUrl}/reserve/pay/${payToken}`;
|
||||
const amountStr = (totalMinor / 100).toFixed(2);
|
||||
try {
|
||||
await this.smsClient.sendSms({
|
||||
to: dto.phone!,
|
||||
message: `EDR: Your seat is reserved. Pay ${amountStr} ETB to confirm your ticket: ${payUrl}`,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Reservation payment-link SMS failed for booking ${booking.bookingRef}: ${err}`);
|
||||
}
|
||||
|
||||
return { booking, payUrl };
|
||||
}
|
||||
|
||||
private async createGuestRoundTripBooking(dto: CreateGuestBookingDto, req?: any) {
|
||||
if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) {
|
||||
throw new BadRequestException('returnScheduleId, returnHoldId, returnOriginStationId and returnDestinationStationId are required for ROUND_TRIP');
|
||||
|
||||
Reference in New Issue
Block a user