Files
carmanagement/apps/api/src/routes/reservations.ts
T
2026-05-20 14:52:26 -04:00

1026 lines
43 KiB
TypeScript

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<string, unknown>
return { ...(extras as Record<string, unknown>) }
}
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<T extends {
extras: unknown
status: string
contractNumber: string | null
invoiceNumber: string | null
}>(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<string, string>
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<string, unknown>).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