Files
carmanagement/apps/api/src/routes/reservations.ts
T
2026-04-30 14:59:57 -04:00

216 lines
11 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 { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense } from '../services/licenseValidationService'
import { sendNotification } from '../services/notificationService'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
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),
notes: z.string().optional(),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
})
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, 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, [], 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,
pricingRulesApplied: applied,
pricingRulesTotal: pricingTotal,
},
include: { vehicle: true, customer: true },
})
if (body.selectedInsurancePolicyIds.length > 0) {
await applyInsurancesToReservation(reservation.id, req.companyId, body.selectedInsurancePolicyIds, totalDays, baseAmount)
}
// 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: reservation })
} 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 })
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'CONFIRMED' } })
// Notify
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
await sendNotification({ type: 'BOOKING_CONFIRMED', title: 'Booking Confirmed', body: `Your booking has been confirmed.`, companyId: req.companyId, email: customer.email, channels: ['EMAIL', 'IN_APP'] }).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 })
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 } })
if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 })
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null } })
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } })
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
await sendNotification({ type: 'REVIEW_REQUEST', title: 'How was your rental?', body: 'Please leave a review for your recent rental.', companyId: req.companyId, email: customer.email, channels: ['EMAIL'] }).catch(() => null)
res.json({ data: 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) => ({ 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) => d.totalCharge > 0).map((d) => ({ 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) }
})
export default router