|
|
|
@@ -56,11 +56,122 @@ const createSchema = z.object({
|
|
|
|
|
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([]),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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 },
|
|
|
|
@@ -178,6 +289,7 @@ router.post('/', async (req, res, next) => {
|
|
|
|
|
totalAmount,
|
|
|
|
|
depositAmount: body.depositAmount,
|
|
|
|
|
notes: body.notes ?? null,
|
|
|
|
|
extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined,
|
|
|
|
|
pricingRulesApplied: applied,
|
|
|
|
|
pricingRulesTotal: pricingTotal,
|
|
|
|
|
},
|
|
|
|
@@ -209,6 +321,181 @@ router.get('/:id', async (req, res, next) => {
|
|
|
|
|
} 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,
|
|
|
|
|
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 } })
|
|
|
|
|