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 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