fix the contract view and edit

This commit is contained in:
root
2026-05-15 11:34:29 -04:00
committed by Administrator
parent f317fa12dd
commit 593ff202a3
13 changed files with 2331 additions and 381 deletions
+33 -8
View File
@@ -12,9 +12,15 @@ const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
companyName: z.string().min(2).max(120),
companyPhone: z.string().optional(),
country: z.string().optional(),
city: z.string().optional(),
legalForm: z.string().min(1).max(80),
registrationNumber: z.string().min(1).max(120),
streetAddress: z.string().min(1).max(200),
city: z.string().min(1).max(120),
country: z.string().min(1).max(120),
zipCode: z.string().min(1).max(40),
companyPhone: z.string().min(1).max(80),
fax: z.string().max(80).optional(),
yearsActive: z.string().min(1).max(80),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
@@ -129,7 +135,17 @@ router.post('/signup', async (req, res, next) => {
name: body.companyName,
slug,
email: body.email,
phone: body.companyPhone || null,
phone: body.companyPhone,
address: {
streetAddress: body.streetAddress,
city: body.city,
country: body.country,
zipCode: body.zipCode,
legalForm: body.legalForm,
managerName: `${body.firstName} ${body.lastName}`.trim(),
fax: body.fax || null,
yearsActive: body.yearsActive,
},
status: 'TRIALING',
},
})
@@ -140,13 +156,22 @@ router.post('/signup', async (req, res, next) => {
displayName: body.companyName,
subdomain: slug,
publicEmail: body.email,
publicPhone: body.companyPhone || null,
publicCountry: body.country || null,
publicCity: body.city || null,
publicPhone: body.companyPhone,
publicAddress: body.streetAddress,
publicCity: body.city,
publicCountry: body.country,
defaultCurrency: body.currency,
},
})
await tx.contractSettings.create({
data: {
companyId: company.id,
legalName: body.companyName,
registrationNumber: body.registrationNumber,
},
})
await tx.subscription.create({
data: {
companyId: company.id,
@@ -168,7 +193,7 @@ router.post('/signup', async (req, res, next) => {
firstName: body.firstName,
lastName: body.lastName,
email: body.email,
phone: body.companyPhone || null,
phone: body.companyPhone,
passwordHash,
role: 'OWNER',
isActive: true,
+55
View File
@@ -99,6 +99,13 @@ const chargeSchema = z.object({
failureUrl: z.string().url(),
})
const manualPaymentSchema = z.object({
amount: z.number().int().positive(),
currency: z.enum(['MAD', 'USD', 'EUR']).default('MAD'),
type: z.enum(['CHARGE', 'DEPOSIT']).default('CHARGE'),
paymentMethod: z.enum(['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD', 'PAYPAL']),
})
router.get('/company', async (req, res, next) => {
try {
const payments = await prisma.rentalPayment.findMany({
@@ -226,6 +233,51 @@ router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/reservations/:id/manual', async (req, res, next) => {
try {
const { amount, currency, type, paymentMethod } = manualPaymentSchema.parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
})
const remainingBalance = Math.max(reservation.totalAmount - reservation.paidAmount, 0)
if (remainingBalance <= 0) {
return res.status(409).json({ error: 'already_paid', message: 'Reservation is already fully paid', statusCode: 409 })
}
if (amount > remainingBalance) {
return res.status(400).json({ error: 'amount_exceeds_balance', message: 'Payment amount exceeds remaining balance', statusCode: 400 })
}
const payment = await prisma.rentalPayment.create({
data: {
companyId: req.companyId,
reservationId: reservation.id,
amount,
currency,
status: 'SUCCEEDED',
type,
paymentProvider: paymentMethod === 'PAYPAL' ? 'PAYPAL' : 'AMANPAY',
paymentMethod,
paidAt: new Date(),
},
})
const newPaidAmount = reservation.paidAmount + amount
const paymentStatus = newPaidAmount >= reservation.totalAmount ? 'PAID' : 'PARTIAL'
await prisma.reservation.update({
where: { id: reservation.id },
data: {
paidAmount: newPaidAmount,
paymentStatus,
},
})
res.json({ data: payment })
} catch (err) { next(err) }
})
router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => {
try {
const { amount, reason } = z.object({
@@ -240,6 +292,9 @@ router.post('/reservations/:reservationId/payments/:paymentId/refund', async (re
if (payment.status !== 'SUCCEEDED') {
return res.status(400).json({ error: 'not_capturable', message: 'Only succeeded payments can be refunded', statusCode: 400 })
}
if (!payment.amanpayTransactionId && !payment.paypalCaptureId) {
return res.status(400).json({ error: 'manual_refund_unsupported', message: 'Manual payments must be refunded outside the online gateway flow', statusCode: 400 })
}
const refundAmount = amount ?? payment.amount
+289 -2
View File
@@ -62,6 +62,99 @@ const createSchema = z.object({
additionalDrivers: z.array(additionalDriverSchema).default([]),
})
const updateSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
pickupLocation: z.string().optional().nullable(),
returnLocation: z.string().optional().nullable(),
depositAmount: z.number().int().min(0).optional(),
notes: z.string().optional().nullable(),
paymentMode: z.string().max(50).optional().nullable(),
damageChargeAmount: z.number().int().min(0).optional(),
damageChargeNote: z.string().optional().nullable(),
})
function parseReservationExtras(extras: unknown) {
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return {} as Record<string, unknown>
return { ...(extras as Record<string, unknown>) }
}
function normalizeOptionalString(value: string | null | undefined) {
const next = typeof value === 'string' ? value.trim() : value
return next ? next : null
}
function calculateUpdatedInsuranceCharge(
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL',
chargeValue: number,
totalDays: number,
baseRentalAmount: number,
) {
switch (chargeType) {
case 'PER_DAY':
return chargeValue * totalDays
case 'PER_RENTAL':
return chargeValue
case 'PERCENTAGE_OF_RENTAL':
return Math.round(baseRentalAmount * chargeValue / 100)
default:
return 0
}
}
function calculateUpdatedAdditionalDriverCharge(
chargeType: 'PER_DAY' | 'FLAT' | 'FREE',
chargeValue: number,
totalDays: number,
) {
switch (chargeType) {
case 'PER_DAY':
return chargeValue * totalDays
case 'FLAT':
return chargeValue
default:
return 0
}
}
function buildReservationWorkflow(reservation: {
status: string
contractNumber: string | null
invoiceNumber: string | null
extras: unknown
}) {
const extras = parseReservationExtras(reservation.extras)
const closedAt = typeof extras.reservationClosedAt === 'string' ? extras.reservationClosedAt : null
const closedBy = typeof extras.reservationClosedBy === 'string' ? extras.reservationClosedBy : null
const contractGenerated = Boolean(reservation.contractNumber || reservation.invoiceNumber)
const closed = Boolean(closedAt)
return {
contractGenerated,
closed,
closedAt,
closedBy,
coreEditable: !closed && !contractGenerated && ['DRAFT', 'CONFIRMED'].includes(reservation.status),
returnEditable: !closed && reservation.status === 'COMPLETED',
checkInInspectionEditable: !closed && ['CONFIRMED', 'ACTIVE'].includes(reservation.status),
checkOutInspectionEditable: !closed && reservation.status === 'COMPLETED',
}
}
function serializeReservationForDashboard<T extends {
extras: unknown
status: string
contractNumber: string | null
invoiceNumber: string | null
}>(reservation: T) {
const extras = parseReservationExtras(reservation.extras)
return {
...reservation,
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
workflow: buildReservationWorkflow(reservation),
}
}
function formatDocumentNumber(prefix: string, sequence: number) {
return `${prefix}-${String(sequence).padStart(6, '0')}`
}
@@ -228,7 +321,13 @@ router.get('/', async (req, res, next) => {
prisma.reservation.count({ where }),
])
res.json({ data: reservations, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
res.json({
data: reservations.map((reservation) => serializeReservationForDashboard(reservation)),
total,
page: parseInt(page),
pageSize: parseInt(pageSize),
totalPages: Math.ceil(total / parseInt(pageSize)),
})
} catch (err) { next(err) }
})
@@ -317,7 +416,152 @@ router.get('/:id', async (req, res, next) => {
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 })
res.json({ data: serializeReservationForDashboard(reservation) })
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
try {
const body = updateSchema.parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: {
insurances: true,
additionalDrivers: true,
},
})
if (reservation.status === 'CANCELLED' || reservation.status === 'NO_SHOW') {
return res.status(400).json({ error: 'invalid_status', message: 'This reservation can no longer be edited', statusCode: 400 })
}
const workflow = buildReservationWorkflow(reservation)
const isReturnEdit = workflow.returnEditable
const requestedFields = Object.keys(body).filter((key) => body[key as keyof typeof body] !== undefined)
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode']
const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote']
if (!workflow.coreEditable && !isReturnEdit) {
return res.status(400).json({ error: 'reservation_locked', message: 'This reservation is locked for editing', statusCode: 400 })
}
if (workflow.coreEditable) {
const invalidFields = requestedFields.filter((field) => !bookingFields.includes(field))
if (invalidFields.length > 0) {
return res.status(400).json({ error: 'invalid_fields', message: 'Only booking details can be edited at this stage', statusCode: 400 })
}
const nextStartDate = body.startDate ? new Date(body.startDate) : reservation.startDate
const nextEndDate = body.endDate ? new Date(body.endDate) : reservation.endDate
const totalDays = Math.ceil((nextEndDate.getTime() - nextStartDate.getTime()) / (1000 * 60 * 60 * 24))
if (nextEndDate <= nextStartDate || totalDays <= 0) {
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
}
if (body.startDate || body.endDate) {
const conflict = await prisma.reservation.findFirst({
where: {
id: { not: reservation.id },
vehicleId: reservation.vehicleId,
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
startDate: { lt: nextEndDate },
endDate: { gt: nextStartDate },
},
})
if (conflict) {
return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
}
}
const baseAmount = reservation.dailyRate * totalDays
const { applied, total: pricingRulesTotal } = await applyPricingRules(
req.companyId,
reservation.customerId,
reservation.additionalDrivers.map((driver) => ({
dateOfBirth: driver.dateOfBirth,
licenseIssuedAt: driver.licenseIssuedAt,
})),
reservation.dailyRate,
totalDays,
)
const insuranceUpdates = reservation.insurances.map((insurance) => ({
id: insurance.id,
totalCharge: calculateUpdatedInsuranceCharge(insurance.chargeType, insurance.chargeValue, totalDays, baseAmount),
}))
const additionalDriverUpdates = reservation.additionalDrivers.map((driver) => ({
id: driver.id,
totalCharge: calculateUpdatedAdditionalDriverCharge(driver.chargeType, driver.chargeValue, totalDays),
}))
const insuranceTotal = insuranceUpdates.reduce((sum, insurance) => sum + insurance.totalCharge, 0)
const additionalDriverTotal = additionalDriverUpdates.reduce((sum, driver) => sum + driver.totalCharge, 0)
const depositAmount = body.depositAmount ?? reservation.depositAmount
const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount
const extras = parseReservationExtras(reservation.extras)
if (body.paymentMode !== undefined) {
const nextPaymentMode = normalizeOptionalString(body.paymentMode)
if (nextPaymentMode) extras.paymentMode = nextPaymentMode
else delete extras.paymentMode
}
await prisma.$transaction([
prisma.reservation.update({
where: { id: reservation.id },
data: {
startDate: nextStartDate,
endDate: nextEndDate,
totalDays,
pickupLocation: body.pickupLocation !== undefined ? normalizeOptionalString(body.pickupLocation) : reservation.pickupLocation,
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
depositAmount,
notes: body.notes !== undefined ? normalizeOptionalString(body.notes) : reservation.notes,
pricingRulesApplied: applied,
pricingRulesTotal,
insuranceTotal,
additionalDriverTotal,
totalAmount,
extras: (Object.keys(extras).length > 0 ? extras : {}) as any,
},
}),
...insuranceUpdates.map((insurance) =>
prisma.reservationInsurance.update({
where: { id: insurance.id },
data: { totalCharge: insurance.totalCharge },
})
),
...additionalDriverUpdates.map((driver) =>
prisma.additionalDriver.update({
where: { id: driver.id },
data: { totalCharge: driver.totalCharge },
})
),
])
} else {
const invalidFields = requestedFields.filter((field) => !returnFields.includes(field))
if (invalidFields.length > 0) {
return res.status(400).json({ error: 'invalid_fields', message: 'Only return details can be edited after vehicle return', statusCode: 400 })
}
await prisma.reservation.update({
where: { id: reservation.id },
data: {
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
damageChargeAmount: body.damageChargeAmount !== undefined ? body.damageChargeAmount : reservation.damageChargeAmount,
damageChargeNote: body.damageChargeNote !== undefined ? normalizeOptionalString(body.damageChargeNote) : reservation.damageChargeNote,
},
})
}
const updated = 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: serializeReservationForDashboard(updated) })
} catch (err) { next(err) }
})
@@ -385,6 +629,7 @@ router.get('/:id/contract', async (req, res, next) => {
res.json({
data: {
reservationId: reservation.id,
contractLocale: reservation.company.brand?.defaultLocale ?? 'en',
contractNumber: reservation.contractNumber,
invoiceNumber: reservation.invoiceNumber,
generatedAt: new Date().toISOString(),
@@ -571,6 +816,35 @@ router.post('/:id/checkout', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/close', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
})
const workflow = buildReservationWorkflow(reservation)
if (reservation.status !== 'COMPLETED') {
return res.status(400).json({ error: 'invalid_status', message: 'Only completed reservations can be closed', statusCode: 400 })
}
if (workflow.closed) {
return res.status(400).json({ error: 'already_closed', message: 'This reservation is already closed', statusCode: 400 })
}
const extras = parseReservationExtras(reservation.extras)
extras.reservationClosedAt = new Date().toISOString()
extras.reservationClosedBy = `${req.employee.firstName} ${req.employee.lastName}`
const updated = await prisma.reservation.update({
where: { id: reservation.id },
data: { extras: extras as any },
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
})
res.json({ data: serializeReservationForDashboard(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)
@@ -626,6 +900,19 @@ router.put('/:id/inspections/:type', async (req, res, next) => {
where: { id: req.params.id, companyId: req.companyId },
include: { vehicle: true, customer: true },
})
const workflow = buildReservationWorkflow(reservation)
if (workflow.closed) {
return res.status(400).json({ error: 'reservation_closed', message: 'This reservation has already been closed', statusCode: 400 })
}
if (type === 'CHECKIN' && !workflow.checkInInspectionEditable) {
return res.status(400).json({ error: 'invalid_status', message: 'Check-in inspection is not editable at this stage', statusCode: 400 })
}
if (type === 'CHECKOUT' && !workflow.checkOutInspectionEditable) {
return res.status(400).json({ error: 'invalid_status', message: 'Check-out inspection is only editable after the vehicle is returned', statusCode: 400 })
}
const inspection = await prisma.damageInspection.upsert({
where: { reservationId_type: { reservationId: reservation.id, type } },