add first files
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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
|
||||
Reference in New Issue
Block a user