import crypto from 'crypto' import { prisma } from '../../lib/prisma' import { AppError } from '../../http/errors' import { validateLicense } from '../../services/licenseValidationService' import { sendNotification, sendTransactionalEmail } from '../../services/notificationService' import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations' import { coerceNotificationLocale } from '../../services/notificationLocalizationService' import { buildBookingRequestProgress, buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter' import * as repo from './reservation.repo' function buildPickupAddress(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) return '' const address = value as Record return [ typeof address.streetAddress === 'string' ? address.streetAddress.trim() : '', typeof address.city === 'string' ? address.city.trim() : '', typeof address.country === 'string' ? address.country.trim() : '', ].filter(Boolean).join(', ') } async function assertLicenseCompliance(reservationId: string, companyId: string) { const reservation = await repo.findForLicenseCheck(reservationId, companyId) const customerLicense = validateLicense(reservation.customer.licenseExpiry) const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus) if (customerDenied || customerLicense.status === 'EXPIRED') { throw new AppError('Primary driver license is not valid for this reservation', 400, 'invalid_primary_license') } if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') { throw new AppError('Primary driver license requires manager approval before this reservation can proceed', 400, 'primary_license_requires_approval') } const blockedDriver = reservation.additionalDrivers.find((d: any) => { if (d.licenseExpired) return true return d.requiresApproval && !d.approvedAt }) if (blockedDriver) { throw new AppError( `Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`, 400, 'additional_driver_requires_approval', ) } } export async function confirmReservation(id: string, companyId: string) { const reservation = await repo.findByIdSimple(id, companyId) if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status') const updated = await repo.updateById(id, { status: 'CONFIRMED' }) await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED') const [customer, company, vehicle] = await Promise.all([ repo.findCustomerById(reservation.customerId), repo.findCompanyWithBrand(companyId), repo.findVehicle(reservation.vehicleId, companyId), ]) const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale) const pickupLocation = reservation.pickupLocation?.trim() || buildPickupAddress(company?.address) await sendNotification({ type: 'BOOKING_CONFIRMED', companyId, renterId: reservation.renterId ?? undefined, email: customer.email, channels: ['EMAIL', 'IN_APP'], locale: reservation.renterId ? undefined : fallbackLocale, templateKey: 'booking.confirmed', templateVariables: { firstName: customer.firstName, companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo', startDate: { type: 'date', value: reservation.startDate, options: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }, }, endDate: { type: 'date', value: reservation.endDate, options: { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }, }, pickupLocation, vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`, }, }).catch(() => null) if (reservation.source === 'MARKETPLACE') { const progress = buildBookingRequestProgress({ source: reservation.source, status: 'CONFIRMED', paymentStatus: updated.paymentStatus, customer, }) const reminderChannels = reservation.renterId ? ['EMAIL', 'IN_APP'] as const : ['EMAIL'] as const if (progress.documentsRequired) { await sendNotification({ type: 'DOCUMENTS_REQUIRED', companyId, renterId: reservation.renterId ?? undefined, email: customer.email, channels: [...reminderChannels], locale: reservation.renterId ? undefined : fallbackLocale, title: 'Driver documents still needed', body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} confirmed availability. Please send the remaining driver documents so the booking can move forward.`, data: { reservationId: reservation.id, missingItems: progress.documentsMissing }, }).catch(() => null) } if (progress.paymentRequired) { await sendNotification({ type: 'PAYMENT_REQUIRED', companyId, renterId: reservation.renterId ?? undefined, email: customer.email, channels: [...reminderChannels], locale: reservation.renterId ? undefined : fallbackLocale, title: 'Payment or deposit is still pending', body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} accepted your request. Complete any required payment or deposit to finalize the booking.`, data: { reservationId: reservation.id, paymentStatus: updated.paymentStatus }, }).catch(() => null) } } return updated } export async function checkinReservation(id: string, companyId: string, mileage?: number) { const reservation = await repo.findByIdSimple(id, companyId) if (reservation.status !== 'CONFIRMED') throw new AppError('Only CONFIRMED reservations can be checked in', 400, 'invalid_status') await assertLicenseCompliance(reservation.id, companyId) const updated = await repo.updateById(id, { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null, }) await repo.updateVehicleStatus(reservation.vehicleId, 'RENTED') return updated } export async function checkoutReservation(id: string, companyId: string, mileage?: number) { const reservation = await repo.findByIdForCheckout(id, companyId) if (reservation.status !== 'ACTIVE') throw new AppError('Only ACTIVE reservations can be checked out', 400, 'invalid_status') const reviewToken = reservation.reviewToken ?? crypto.randomBytes(32).toString('hex') const updated = await repo.updateById(id, { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null, reviewToken, }) await repo.updateVehicleStatus(reservation.vehicleId, 'RETURNED', mileage) return updated } export async function closeReservation(id: string, companyId: string, closedBy: string) { const reservation = await repo.findForClose(id, companyId) const workflow = buildReservationWorkflow(reservation) if (reservation.status !== 'COMPLETED') throw new AppError('Only completed reservations can be closed', 400, 'invalid_status') if (workflow.closed) throw new AppError('This reservation is already closed', 400, 'already_closed') const extras = parseReservationExtras(reservation.extras) extras.reservationClosedAt = new Date().toISOString() extras.reservationClosedBy = closedBy const updated = await prisma.reservation.update({ where: { id: reservation.id }, data: { extras: extras as any }, include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true }, }) // Send review request email if eligible if (reservation.reviewToken && !(reservation as any).reviewPaused) { try { const customer = updated.customer as any if (!customer.reviewOptOut && customer.email) { const company = await repo.findCompanyWithBrand(companyId) const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company' const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}` const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}` sendTransactionalEmail({ to: customer.email, subject: reviewRequestEmail.subject(vehicleLabel, lang), html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang), text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang), }).catch(() => null) await prisma.reservation.update({ where: { id: reservation.id }, data: { reviewRequestSentAt: new Date() }, }) } } catch { // Non-blocking: do not fail close if email fails } } return serializeReservationForDashboard(updated) } export async function extendReservation(id: string, companyId: string, newEndDate: Date, reason: string) { const reservation = await repo.findByIdSimple(id, companyId) if (!['CONFIRMED', 'ACTIVE'].includes(reservation.status)) { throw new AppError('Only CONFIRMED or ACTIVE reservations can be extended', 400, 'invalid_status') } if (newEndDate <= reservation.endDate) { throw new AppError('New end date must be after the current end date', 400, 'invalid_end_date') } const conflict = await repo.findConflict(reservation.vehicleId, reservation.endDate, newEndDate, id) if (conflict) { throw new AppError('Vehicle is unavailable for the requested extension period', 409, 'vehicle_unavailable') } const additionalDays = Math.ceil((newEndDate.getTime() - reservation.endDate.getTime()) / (1000 * 60 * 60 * 24)) const additionalAmount = additionalDays * reservation.dailyRate const extras = parseReservationExtras(reservation.extras) if (!Array.isArray(extras.extensions)) extras.extensions = [] ;(extras.extensions as unknown[]).push({ previousEndDate: reservation.endDate.toISOString(), newEndDate: newEndDate.toISOString(), additionalDays, additionalAmount, reason, approvedAt: new Date().toISOString(), }) const updated = await prisma.reservation.update({ where: { id }, data: { endDate: newEndDate, totalDays: reservation.totalDays + additionalDays, totalAmount: reservation.totalAmount + additionalAmount, extras: extras as any, }, }) return updated } export async function cancelReservation(id: string, companyId: string, reason?: string) { const reservation = await repo.findByIdSimple(id, companyId) if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) { throw new AppError('Reservation cannot be cancelled', 400, 'invalid_status') } const updated = await repo.updateById(id, { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' }) if (['CONFIRMED', 'ACTIVE', 'DRAFT'].includes(reservation.status)) { await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE') } return updated }