import { Router } from 'express' import { z } from 'zod' import { prisma } from '../lib/prisma' import { requireCompanyAuth } from '../middleware/requireCompanyAuth' import { requireTenant } from '../middleware/requireTenant' import { requireSubscription } from '../middleware/requireSubscription' import { applyAdditionalDriversToReservation } from '../services/additionalDriverService' import { applyInsurancesToReservation } from '../services/insuranceService' import { applyPricingRules } from '../services/pricingRuleService' import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService' import { sendNotification, sendTransactionalEmail } from '../services/notificationService' import { bookingConfirmedNotif, reviewRequestEmail, type Lang } from '../lib/emailTranslations' import crypto from 'crypto' const router = Router() router.use(requireCompanyAuth, requireTenant, requireSubscription) const additionalDriverSchema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email().optional(), phone: z.string().optional(), driverLicense: z.string().min(1), licenseExpiry: z.string().datetime().optional(), licenseIssuedAt: z.string().datetime().optional(), dateOfBirth: z.string().datetime().optional(), nationality: z.string().optional(), }) const inspectionSchema = z.object({ mileage: z.number().int().optional(), fuelLevel: z.enum(['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']), fuelCharge: z.number().int().min(0).optional(), generalCondition: z.string().optional(), employeeNotes: z.string().optional(), customerAgreed: z.boolean().default(false), damagePoints: z.array( z.object({ viewType: z.enum(['TOP', 'FRONT', 'REAR', 'LEFT', 'RIGHT']), x: z.number(), y: z.number(), damageType: z.enum(['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']), severity: z.enum(['MINOR', 'MODERATE', 'MAJOR']).default('MINOR'), description: z.string().optional(), isPreExisting: z.boolean().default(false), }), ).default([]), }) const createSchema = z.object({ vehicleId: z.string().cuid(), customerId: z.string().cuid(), startDate: z.string().datetime(), endDate: z.string().datetime(), pickupLocation: z.string().optional(), returnLocation: z.string().optional(), offerId: z.string().cuid().optional(), promoCodeUsed: z.string().optional(), depositAmount: z.number().int().min(0).default(0), paymentMode: z.string().max(50).optional(), notes: z.string().optional(), selectedInsurancePolicyIds: z.array(z.string()).default([]), additionalDrivers: z.array(additionalDriverSchema).default([]), }) const updateSchema = z.object({ startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), pickupLocation: z.string().optional().nullable(), returnLocation: z.string().optional().nullable(), depositAmount: z.number().int().min(0).optional(), notes: z.string().optional().nullable(), paymentMode: z.string().max(50).optional().nullable(), damageChargeAmount: z.number().int().min(0).optional(), damageChargeNote: z.string().optional().nullable(), }) function parseReservationExtras(extras: unknown) { if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return {} as Record return { ...(extras as Record) } } function normalizeOptionalString(value: string | null | undefined) { const next = typeof value === 'string' ? value.trim() : value return next ? next : null } function calculateUpdatedInsuranceCharge( chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL', chargeValue: number, totalDays: number, baseRentalAmount: number, ) { switch (chargeType) { case 'PER_DAY': return chargeValue * totalDays case 'PER_RENTAL': return chargeValue case 'PERCENTAGE_OF_RENTAL': return Math.round(baseRentalAmount * chargeValue / 100) default: return 0 } } function calculateUpdatedAdditionalDriverCharge( chargeType: 'PER_DAY' | 'FLAT' | 'FREE', chargeValue: number, totalDays: number, ) { switch (chargeType) { case 'PER_DAY': return chargeValue * totalDays case 'FLAT': return chargeValue default: return 0 } } function buildReservationWorkflow(reservation: { status: string contractNumber: string | null invoiceNumber: string | null extras: unknown }) { const extras = parseReservationExtras(reservation.extras) const closedAt = typeof extras.reservationClosedAt === 'string' ? extras.reservationClosedAt : null const closedBy = typeof extras.reservationClosedBy === 'string' ? extras.reservationClosedBy : null const contractGenerated = Boolean(reservation.contractNumber || reservation.invoiceNumber) const closed = Boolean(closedAt) return { contractGenerated, closed, closedAt, closedBy, coreEditable: !closed && !contractGenerated && ['DRAFT', 'CONFIRMED'].includes(reservation.status), returnEditable: !closed && reservation.status === 'COMPLETED', checkInInspectionEditable: !closed && ['CONFIRMED', 'ACTIVE'].includes(reservation.status), checkOutInspectionEditable: !closed && reservation.status === 'COMPLETED', } } function serializeReservationForDashboard(reservation: T) { const extras = parseReservationExtras(reservation.extras) return { ...reservation, paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null, workflow: buildReservationWorkflow(reservation), } } function formatDocumentNumber(prefix: string, sequence: number) { return `${prefix}-${String(sequence).padStart(6, '0')}` } async function ensureReservationDocumentNumbers(companyId: string, reservationId: string) { return prisma.$transaction(async (tx) => { const reservation = await tx.reservation.findFirstOrThrow({ where: { id: reservationId, companyId }, select: { id: true, contractNumber: true, invoiceNumber: true }, }) if (reservation.contractNumber && reservation.invoiceNumber) return reservation const settings = await tx.contractSettings.upsert({ where: { companyId }, update: {}, create: { companyId }, }) const data: { contractNumber?: string; invoiceNumber?: string } = {} const settingsUpdate: { lastContractSeq?: number; lastInvoiceSeq?: number } = {} if (!reservation.contractNumber) { const nextContractSeq = settings.lastContractSeq + 1 data.contractNumber = formatDocumentNumber(settings.contractPrefix, nextContractSeq) settingsUpdate.lastContractSeq = nextContractSeq } if (!reservation.invoiceNumber) { const nextInvoiceSeq = settings.lastInvoiceSeq + 1 data.invoiceNumber = formatDocumentNumber(settings.invoicePrefix, nextInvoiceSeq) settingsUpdate.lastInvoiceSeq = nextInvoiceSeq } if (Object.keys(settingsUpdate).length > 0) { await tx.contractSettings.update({ where: { companyId }, data: settingsUpdate, }) } return tx.reservation.update({ where: { id: reservationId }, data, select: { id: true, contractNumber: true, invoiceNumber: true }, }) }) } function buildReservationInvoiceLineItems(reservation: { dailyRate: number totalDays: number discountAmount: number depositAmount: number pricingRulesApplied: Array<{ name?: string; amount?: number; type?: string }> | null insurances: Array<{ id: string; policyName: string; chargeType: string; chargeValue: number; totalCharge: number }> additionalDrivers: Array<{ id: string; firstName: string; lastName: string; totalCharge: number }> vehicle: { year: number; make: string; model: string } }) { const baseAmount = reservation.dailyRate * reservation.totalDays const lineItems = [ { description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL', }, ...reservation.insurances.map((insurance) => ({ description: insurance.policyName, qty: insurance.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: insurance.chargeType === 'PER_DAY' ? insurance.chargeValue : insurance.totalCharge, total: insurance.totalCharge, category: 'INSURANCE', })), ...reservation.additionalDrivers .filter((driver) => driver.totalCharge > 0) .map((driver) => ({ description: `Additional driver - ${driver.firstName} ${driver.lastName}`, qty: 1, unitPrice: driver.totalCharge, total: driver.totalCharge, category: 'ADDITIONAL_DRIVER', })), ...((reservation.pricingRulesApplied ?? []).map((rule, index) => ({ description: rule.name?.trim() || `Pricing adjustment ${index + 1}`, qty: 1, unitPrice: Number(rule.amount ?? 0), total: Number(rule.amount ?? 0), category: 'PRICING_RULE', }))), ...(reservation.discountAmount > 0 ? [{ description: 'Discount', qty: 1, unitPrice: -reservation.discountAmount, total: -reservation.discountAmount, category: 'DISCOUNT', }] : []), ...(reservation.depositAmount > 0 ? [{ description: 'Security deposit', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT', }] : []), ] return lineItems } async function assertReservationLicenseCompliance(reservationId: string, companyId: string) { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: reservationId, companyId }, include: { customer: true, additionalDrivers: true }, }) const customerLicense = validateLicense(reservation.customer.licenseExpiry) const customerDenied = ['DENIED', 'EXPIRED'].includes(reservation.customer.licenseValidationStatus) if (customerDenied || customerLicense.status === 'EXPIRED') { throw Object.assign(new Error('Primary driver license is not valid for this reservation'), { statusCode: 400, code: 'invalid_primary_license', }) } if (customerLicense.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') { throw Object.assign(new Error('Primary driver license requires manager approval before this reservation can proceed'), { statusCode: 400, code: 'primary_license_requires_approval', }) } const blockedDriver = reservation.additionalDrivers.find((driver) => { if (driver.licenseExpired) return true return driver.requiresApproval && !driver.approvedAt }) if (blockedDriver) { throw Object.assign(new Error(`Additional driver ${blockedDriver.firstName} ${blockedDriver.lastName} requires approval before this reservation can proceed`), { statusCode: 400, code: 'additional_driver_requires_approval', }) } } router.get('/', async (req, res, next) => { try { const { status, vehicleId, source, startDate, endDate, page = '1', pageSize = '20' } = req.query as Record const where: any = { companyId: req.companyId } if (status) where.status = status if (vehicleId) where.vehicleId = vehicleId if (source) where.source = source if (startDate) where.startDate = { gte: new Date(startDate) } if (endDate) where.endDate = { lte: new Date(endDate) } const [reservations, total] = await Promise.all([ prisma.reservation.findMany({ where, include: { vehicle: true, customer: true }, skip: (parseInt(page) - 1) * parseInt(pageSize), take: parseInt(pageSize), orderBy: { createdAt: 'desc' }, }), prisma.reservation.count({ where }), ]) res.json({ data: reservations.map((reservation) => serializeReservationForDashboard(reservation)), total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)), }) } catch (err) { next(err) } }) router.post('/', async (req, res, next) => { try { const body = createSchema.parse(req.body) // Validate vehicle belongs to this company const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: body.vehicleId, companyId: req.companyId } }) const customer = await prisma.customer.findFirstOrThrow({ where: { id: body.customerId, companyId: req.companyId } }) const start = new Date(body.startDate) const end = new Date(body.endDate) const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) // Check availability const conflict = await prisma.reservation.findFirst({ where: { vehicleId: body.vehicleId, status: { in: ['CONFIRMED', 'ACTIVE'] }, startDate: { lt: end }, endDate: { gt: start } }, }) if (conflict) return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 }) let discountAmount = 0 let offerId: string | null = body.offerId ?? null if (body.promoCodeUsed) { const offer = await prisma.offer.findFirst({ where: { companyId: req.companyId, promoCode: body.promoCodeUsed, isActive: true, validFrom: { lte: new Date() }, validUntil: { gte: new Date() } }, }) if (offer) { offerId = offer.id const base = vehicle.dailyRate * totalDays if (offer.type === 'PERCENTAGE') discountAmount = Math.round(base * offer.discountValue / 100) else if (offer.type === 'FIXED_AMOUNT') discountAmount = offer.discountValue else if (offer.type === 'FREE_DAY') discountAmount = vehicle.dailyRate * offer.discountValue await prisma.offer.update({ where: { id: offer.id }, data: { redemptionCount: { increment: 1 } } }) } } const baseAmount = vehicle.dailyRate * totalDays const { applied, total: pricingTotal } = await applyPricingRules(req.companyId, body.customerId, body.additionalDrivers as any[], vehicle.dailyRate, totalDays) const totalAmount = baseAmount - discountAmount + pricingTotal + body.depositAmount const reservation = await prisma.reservation.create({ data: { companyId: req.companyId, vehicleId: body.vehicleId, customerId: body.customerId, startDate: start, endDate: end, pickupLocation: body.pickupLocation ?? null, returnLocation: body.returnLocation ?? null, offerId, promoCodeUsed: body.promoCodeUsed ?? null, source: 'DASHBOARD', dailyRate: vehicle.dailyRate, discountAmount, totalDays, totalAmount, depositAmount: body.depositAmount, notes: body.notes ?? null, extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined, pricingRulesApplied: applied, pricingRulesTotal: pricingTotal, }, include: { vehicle: true, customer: true }, }) if (body.selectedInsurancePolicyIds.length > 0) { await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount) } if (body.additionalDrivers.length > 0) { await applyAdditionalDriversToReservation(reservation.id, req.companyId, body.additionalDrivers, totalDays) } // Validate customer license await validateAndFlagLicense(body.customerId).catch(() => null) res.status(201).json({ data: reservation }) } catch (err) { next(err) } }) router.get('/:id', async (req, res, next) => { try { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true }, }) res.json({ data: serializeReservationForDashboard(reservation) }) } catch (err) { next(err) } }) router.patch('/:id', async (req, res, next) => { try { const body = updateSchema.parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { insurances: true, additionalDrivers: true, }, }) if (reservation.status === 'CANCELLED' || reservation.status === 'NO_SHOW') { return res.status(400).json({ error: 'invalid_status', message: 'This reservation can no longer be edited', statusCode: 400 }) } const workflow = buildReservationWorkflow(reservation) const isReturnEdit = workflow.returnEditable const requestedFields = Object.keys(body).filter((key) => body[key as keyof typeof body] !== undefined) const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode'] const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote'] if (!workflow.coreEditable && !isReturnEdit) { return res.status(400).json({ error: 'reservation_locked', message: 'This reservation is locked for editing', statusCode: 400 }) } if (workflow.coreEditable) { const invalidFields = requestedFields.filter((field) => !bookingFields.includes(field)) if (invalidFields.length > 0) { return res.status(400).json({ error: 'invalid_fields', message: 'Only booking details can be edited at this stage', statusCode: 400 }) } const nextStartDate = body.startDate ? new Date(body.startDate) : reservation.startDate const nextEndDate = body.endDate ? new Date(body.endDate) : reservation.endDate const totalDays = Math.ceil((nextEndDate.getTime() - nextStartDate.getTime()) / (1000 * 60 * 60 * 24)) if (nextEndDate <= nextStartDate || totalDays <= 0) { return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 }) } if (body.startDate || body.endDate) { const conflict = await prisma.reservation.findFirst({ where: { id: { not: reservation.id }, vehicleId: reservation.vehicleId, status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] }, startDate: { lt: nextEndDate }, endDate: { gt: nextStartDate }, }, }) if (conflict) { return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 }) } } const baseAmount = reservation.dailyRate * totalDays const { applied, total: pricingRulesTotal } = await applyPricingRules( req.companyId, reservation.customerId, reservation.additionalDrivers.map((driver) => ({ dateOfBirth: driver.dateOfBirth, licenseIssuedAt: driver.licenseIssuedAt, })), reservation.dailyRate, totalDays, ) const insuranceUpdates = reservation.insurances.map((insurance) => ({ id: insurance.id, totalCharge: calculateUpdatedInsuranceCharge(insurance.chargeType, insurance.chargeValue, totalDays, baseAmount), })) const additionalDriverUpdates = reservation.additionalDrivers.map((driver) => ({ id: driver.id, totalCharge: calculateUpdatedAdditionalDriverCharge(driver.chargeType, driver.chargeValue, totalDays), })) const insuranceTotal = insuranceUpdates.reduce((sum, insurance) => sum + insurance.totalCharge, 0) const additionalDriverTotal = additionalDriverUpdates.reduce((sum, driver) => sum + driver.totalCharge, 0) const depositAmount = body.depositAmount ?? reservation.depositAmount const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount const extras = parseReservationExtras(reservation.extras) if (body.paymentMode !== undefined) { const nextPaymentMode = normalizeOptionalString(body.paymentMode) if (nextPaymentMode) extras.paymentMode = nextPaymentMode else delete extras.paymentMode } await prisma.$transaction([ prisma.reservation.update({ where: { id: reservation.id }, data: { startDate: nextStartDate, endDate: nextEndDate, totalDays, pickupLocation: body.pickupLocation !== undefined ? normalizeOptionalString(body.pickupLocation) : reservation.pickupLocation, returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation, depositAmount, notes: body.notes !== undefined ? normalizeOptionalString(body.notes) : reservation.notes, pricingRulesApplied: applied, pricingRulesTotal, insuranceTotal, additionalDriverTotal, totalAmount, extras: (Object.keys(extras).length > 0 ? extras : {}) as any, }, }), ...insuranceUpdates.map((insurance) => prisma.reservationInsurance.update({ where: { id: insurance.id }, data: { totalCharge: insurance.totalCharge }, }) ), ...additionalDriverUpdates.map((driver) => prisma.additionalDriver.update({ where: { id: driver.id }, data: { totalCharge: driver.totalCharge }, }) ), ]) } else { const invalidFields = requestedFields.filter((field) => !returnFields.includes(field)) if (invalidFields.length > 0) { return res.status(400).json({ error: 'invalid_fields', message: 'Only return details can be edited after vehicle return', statusCode: 400 }) } await prisma.reservation.update({ where: { id: reservation.id }, data: { returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation, damageChargeAmount: body.damageChargeAmount !== undefined ? body.damageChargeAmount : reservation.damageChargeAmount, damageChargeNote: body.damageChargeNote !== undefined ? normalizeOptionalString(body.damageChargeNote) : reservation.damageChargeNote, }, }) } const updated = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true }, }) res.json({ data: serializeReservationForDashboard(updated) }) } catch (err) { next(err) } }) router.get('/:id/contract', async (req, res, next) => { try { await ensureReservationDocumentNumbers(req.companyId, req.params.id) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: { orderBy: { createdAt: 'asc' } }, inspections: { include: { damagePoints: true }, orderBy: { inspectedAt: 'asc' } }, company: { include: { brand: true, contractSettings: true, accountingSettings: true, }, }, }, }) const contractSettings = reservation.company.contractSettings ?? await prisma.contractSettings.upsert({ where: { companyId: req.companyId }, update: {}, create: { companyId: req.companyId }, }) const paymentMode = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras) ? String((reservation.extras as Record).paymentMode ?? '') : '' const invoiceLineItems = buildReservationInvoiceLineItems({ dailyRate: reservation.dailyRate, totalDays: reservation.totalDays, discountAmount: reservation.discountAmount, depositAmount: reservation.depositAmount, pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied) ? reservation.pricingRulesApplied as Array<{ name?: string; amount?: number; type?: string }> : [], insurances: reservation.insurances, additionalDrivers: reservation.additionalDrivers, vehicle: reservation.vehicle, }) const subtotal = invoiceLineItems.reduce((sum, item) => sum + item.total, 0) const taxes = contractSettings.showTax && contractSettings.taxRate ? [{ label: contractSettings.taxLabel?.trim() || 'Tax', rate: contractSettings.taxRate, amount: Math.round(subtotal * contractSettings.taxRate / 100), }] : [] const taxTotal = taxes.reduce((sum, tax) => sum + tax.amount, 0) const grandTotal = subtotal + taxTotal const amountPaid = reservation.rentalPayments .filter((payment) => payment.status === 'SUCCEEDED') .reduce((sum, payment) => sum + payment.amount, 0) const checkInInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKIN') ?? null const checkOutInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKOUT') ?? null res.json({ data: { reservationId: reservation.id, contractLocale: reservation.company.brand?.defaultLocale ?? 'en', contractNumber: reservation.contractNumber, invoiceNumber: reservation.invoiceNumber, generatedAt: new Date().toISOString(), status: reservation.status, paymentStatus: reservation.paymentStatus, paymentMode: paymentMode || null, notes: reservation.notes, company: { name: reservation.company.brand?.displayName ?? reservation.company.name, legalName: contractSettings.legalName || reservation.company.name, email: reservation.company.brand?.publicEmail ?? reservation.company.email, phone: reservation.company.brand?.publicPhone ?? reservation.company.phone, address: reservation.company.brand?.publicAddress ?? reservation.company.address ?? null, city: reservation.company.brand?.publicCity ?? null, country: reservation.company.brand?.publicCountry ?? null, registrationNumber: contractSettings.registrationNumber, taxId: contractSettings.taxId, logoUrl: reservation.company.brand?.logoUrl ?? null, }, driver: { firstName: reservation.customer.firstName, lastName: reservation.customer.lastName, email: reservation.customer.email, phone: reservation.customer.phone, dateOfBirth: reservation.customer.dateOfBirth, driverLicense: reservation.customer.driverLicense, licenseCountry: reservation.customer.licenseCountry, licenseCategory: reservation.customer.licenseCategory, licenseIssuedAt: reservation.customer.licenseIssuedAt, licenseExpiry: reservation.customer.licenseExpiry, nationality: reservation.customer.nationality, }, additionalDrivers: reservation.additionalDrivers.map((driver) => ({ id: driver.id, firstName: driver.firstName, lastName: driver.lastName, email: driver.email, phone: driver.phone, driverLicense: driver.driverLicense, licenseIssuedAt: driver.licenseIssuedAt, licenseExpiry: driver.licenseExpiry, licenseCountry: driver.nationality, dateOfBirth: driver.dateOfBirth, totalCharge: driver.totalCharge, })), vehicle: { make: reservation.vehicle.make, model: reservation.vehicle.model, year: reservation.vehicle.year, color: reservation.vehicle.color, licensePlate: reservation.vehicle.licensePlate, vin: reservation.vehicle.vin, category: reservation.vehicle.category, }, rentalPeriod: { startDate: reservation.startDate, endDate: reservation.endDate, totalDays: reservation.totalDays, pickupLocation: reservation.pickupLocation, returnLocation: reservation.returnLocation, }, insurance: { total: reservation.insuranceTotal, policies: reservation.insurances.map((insurance) => ({ id: insurance.id, name: insurance.policyName, chargeType: insurance.chargeType, unitPrice: insurance.chargeValue, totalCharge: insurance.totalCharge, })), }, inspections: { checkIn: checkInInspection, checkOut: checkOutInspection, }, terms: { terms: contractSettings.terms, fuelPolicy: contractSettings.fuelPolicy, fuelPolicyType: contractSettings.fuelPolicyType, depositPolicy: contractSettings.depositPolicy, lateFeePolicy: contractSettings.lateFeePolicy, damagePolicy: contractSettings.damagePolicy, footerNote: contractSettings.contractFooterNote, signatureRequired: contractSettings.signatureRequired, }, invoice: { currency: reservation.company.accountingSettings?.currency ?? reservation.company.brand?.defaultCurrency ?? 'MAD', lineItems: invoiceLineItems, subtotal, taxes, taxTotal, total: grandTotal, amountPaid, balanceDue: grandTotal - amountPaid, payments: reservation.rentalPayments.map((payment) => ({ id: payment.id, amount: payment.amount, currency: payment.currency, type: payment.type, status: payment.status, provider: payment.paymentProvider, paymentMethod: payment.paymentMethod, paidAt: payment.paidAt, createdAt: payment.createdAt, })), }, }, }) } catch (err) { next(err) } }) router.post('/:id/confirm', async (req, res, next) => { try { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) if (reservation.status !== 'DRAFT') return res.status(400).json({ error: 'invalid_status', message: 'Only DRAFT reservations can be confirmed', statusCode: 400 }) await assertReservationLicenseCompliance(reservation.id, req.companyId) const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } }) // Notify const [customer, companyBrand] = await Promise.all([ prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }), prisma.brandSettings.findUnique({ where: { companyId: req.companyId }, select: { defaultLocale: true } }), ]) const lang = ((companyBrand?.defaultLocale ?? 'fr') as Lang) await sendNotification({ type: 'BOOKING_CONFIRMED', title: bookingConfirmedNotif.title(lang), body: bookingConfirmedNotif.body(lang), companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'], locale: lang }).catch(() => null) res.json({ data: updated }) } catch (err) { next(err) } }) router.post('/:id/checkin', async (req, res, next) => { try { const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) if (reservation.status !== 'CONFIRMED') return res.status(400).json({ error: 'invalid_status', message: 'Only CONFIRMED reservations can be checked in', statusCode: 400 }) await assertReservationLicenseCompliance(reservation.id, req.companyId) const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'ACTIVE', checkedInAt: new Date(), checkInMileage: mileage ?? null } }) await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'RENTED' } }) res.json({ data: updated }) } catch (err) { next(err) } }) router.post('/:id/checkout', async (req, res, next) => { try { const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicle: true }, }) if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 }) const reviewToken = crypto.randomBytes(32).toString('hex') const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null, reviewToken }, }) await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } }) // Send "How was your rental?" email with secure one-time review link const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } }) const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true, defaultLocale: true } } } }) const lang = ((company?.brand?.defaultLocale ?? 'fr') as Lang) const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company' const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}` const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000' const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}` const reviewOpts = { firstName: customer.firstName, companyName, vehicleLabel, reviewUrl } sendTransactionalEmail({ to: customer.email, subject: reviewRequestEmail.subject(vehicleLabel, lang), html: reviewRequestEmail.html(reviewOpts, lang), text: reviewRequestEmail.text(reviewOpts, lang), }).catch(() => null) res.json({ data: updated }) } catch (err) { next(err) } }) router.post('/:id/close', async (req, res, next) => { try { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, }) const workflow = buildReservationWorkflow(reservation) if (reservation.status !== 'COMPLETED') { return res.status(400).json({ error: 'invalid_status', message: 'Only completed reservations can be closed', statusCode: 400 }) } if (workflow.closed) { return res.status(400).json({ error: 'already_closed', message: 'This reservation is already closed', statusCode: 400 }) } const extras = parseReservationExtras(reservation.extras) extras.reservationClosedAt = new Date().toISOString() extras.reservationClosedBy = `${req.employee.firstName} ${req.employee.lastName}` 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 }, }) res.json({ data: serializeReservationForDashboard(updated) }) } catch (err) { next(err) } }) router.post('/:id/cancel', async (req, res, next) => { try { const { reason } = z.object({ reason: z.string().optional() }).parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) { return res.status(400).json({ error: 'invalid_status', message: 'Reservation cannot be cancelled', statusCode: 400 }) } const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' } }) if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) { await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE' } }) } res.json({ data: updated }) } catch (err) { next(err) } }) router.get('/:id/billing', async (req, res, next) => { try { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicle: true, insurances: true, additionalDrivers: true }, }) const baseAmount = reservation.dailyRate * reservation.totalDays const lineItems = [ { description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model} — ${reservation.totalDays} day(s)`, qty: reservation.totalDays, unitPrice: reservation.dailyRate, total: baseAmount, category: 'RENTAL' }, ...reservation.insurances.map((ins: (typeof reservation.insurances)[number]) => ({ description: ins.policyName, qty: ins.chargeType === 'PER_DAY' ? reservation.totalDays : 1, unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge, total: ins.totalCharge, category: 'INSURANCE' })), ...reservation.additionalDrivers.filter((d: (typeof reservation.additionalDrivers)[number]) => d.totalCharge > 0).map((d: (typeof reservation.additionalDrivers)[number]) => ({ description: `Additional Driver — ${d.firstName} ${d.lastName}`, qty: 1, unitPrice: d.totalCharge, total: d.totalCharge, category: 'ADDITIONAL_DRIVER' })), ...(reservation.depositAmount > 0 ? [{ description: 'Security Deposit (refundable)', qty: 1, unitPrice: reservation.depositAmount, total: reservation.depositAmount, category: 'DEPOSIT' }] : []), ] const grandTotal = lineItems.reduce((s, i) => s + i.total, 0) - reservation.discountAmount res.json({ data: { lineItems, discountAmount: reservation.discountAmount, pricingRulesApplied: reservation.pricingRulesApplied, pricingRulesTotal: reservation.pricingRulesTotal, grandTotal } }) } catch (err) { next(err) } }) router.get('/:id/inspections', async (req, res, next) => { try { const inspections = await prisma.damageInspection.findMany({ where: { reservationId: req.params.id, companyId: req.companyId }, include: { damagePoints: true }, orderBy: { inspectedAt: 'asc' }, }) res.json({ data: inspections }) } catch (err) { next(err) } }) router.put('/:id/inspections/:type', async (req, res, next) => { try { const type = z.enum(['CHECKIN', 'CHECKOUT']).parse(req.params.type.toUpperCase()) const body = inspectionSchema.parse(req.body) const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId }, include: { vehicle: true, customer: true }, }) const workflow = buildReservationWorkflow(reservation) if (workflow.closed) { return res.status(400).json({ error: 'reservation_closed', message: 'This reservation has already been closed', statusCode: 400 }) } if (type === 'CHECKIN' && !workflow.checkInInspectionEditable) { return res.status(400).json({ error: 'invalid_status', message: 'Check-in inspection is not editable at this stage', statusCode: 400 }) } if (type === 'CHECKOUT' && !workflow.checkOutInspectionEditable) { return res.status(400).json({ error: 'invalid_status', message: 'Check-out inspection is only editable after the vehicle is returned', statusCode: 400 }) } const inspection = await prisma.damageInspection.upsert({ where: { reservationId_type: { reservationId: reservation.id, type } }, update: { mileage: body.mileage ?? null, fuelLevel: body.fuelLevel, fuelCharge: body.fuelCharge ?? null, generalCondition: body.generalCondition ?? null, employeeNotes: body.employeeNotes ?? null, customerAgreed: body.customerAgreed, inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, inspectedAt: new Date(), damagePoints: { deleteMany: {}, create: body.damagePoints, }, }, create: { reservationId: reservation.id, companyId: req.companyId, type, mileage: body.mileage ?? null, fuelLevel: body.fuelLevel, fuelCharge: body.fuelCharge ?? null, generalCondition: body.generalCondition ?? null, employeeNotes: body.employeeNotes ?? null, customerAgreed: body.customerAgreed, inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, damagePoints: { create: body.damagePoints, }, }, include: { damagePoints: true }, }) await prisma.damageReport.upsert({ where: { reservationId_type: { reservationId: reservation.id, type } }, update: { damages: body.damagePoints.map((point) => ({ viewType: point.viewType, x: point.x, y: point.y, damageType: point.damageType, severity: point.severity, note: point.description ?? '', isPreExisting: point.isPreExisting, })), fuelLevel: body.fuelLevel, mileage: body.mileage ?? null, inspectedAt: new Date(), inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, customerSignedAt: body.customerAgreed ? new Date() : null, customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, }, create: { reservationId: reservation.id, companyId: req.companyId, type, damages: body.damagePoints.map((point) => ({ viewType: point.viewType, x: point.x, y: point.y, damageType: point.damageType, severity: point.severity, note: point.description ?? '', isPreExisting: point.isPreExisting, })), photos: [], fuelLevel: body.fuelLevel, mileage: body.mileage ?? null, inspectedAt: new Date(), inspectedBy: `${req.employee.firstName} ${req.employee.lastName}`, customerSignedAt: body.customerAgreed ? new Date() : null, customerName: `${reservation.customer.firstName} ${reservation.customer.lastName}`, }, }) await prisma.reservation.update({ where: { id: reservation.id }, data: type === 'CHECKIN' ? { checkInMileage: body.mileage ?? null, checkInFuelLevel: body.fuelLevel, } : { checkOutMileage: body.mileage ?? null, checkOutFuelLevel: body.fuelLevel, }, }) res.json({ data: inspection }) } catch (err) { next(err) } }) router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next) => { try { const { approved, note } = z.object({ approved: z.boolean(), note: z.string().optional() }).parse(req.body) const driver = await prisma.additionalDriver.findFirstOrThrow({ where: { id: req.params.driverId, reservationId: req.params.id, companyId: req.companyId }, }) const updated = await prisma.additionalDriver.update({ where: { id: driver.id }, data: { approvedAt: approved ? new Date() : null, approvedBy: approved ? `${req.employee.firstName} ${req.employee.lastName}` : null, approvalNote: note ?? driver.approvalNote, }, }) res.json({ data: updated }) } catch (err) { next(err) } }) export default router