216 lines
11 KiB
TypeScript
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
|