fix the booking and contract
Build & Push / Pipeline Tests (push) Failing after 1m31s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Failing after 46s
Test / API Integration Tests (push) Successful in 1m7s

This commit is contained in:
root
2026-07-27 23:04:26 -04:00
parent 8046a1d447
commit 5649ab02c7
40 changed files with 1699 additions and 1066 deletions
@@ -1,5 +1,6 @@
import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo'
import { parseReservationExtras, serializeContractFields } from './reservation.presenter'
export function formatDocumentNumber(prefix: string, sequence: number): string {
return `${prefix}-${String(sequence).padStart(6, '0')}`
@@ -117,6 +118,7 @@ export async function getContract(id: string, companyId: string) {
const extras = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras)
? (reservation.extras as Record<string, unknown>)
: {}
const contractFields = serializeContractFields(extras.contractFields)
const invoiceLineItems = buildReservationInvoiceLineItems({
dailyRate: reservation.dailyRate,
@@ -143,6 +145,7 @@ export async function getContract(id: string, companyId: string) {
const checkInInspection = reservation.inspections.find((i: any) => i.type === 'CHECKIN') ?? null
const checkOutInspection = reservation.inspections.find((i: any) => i.type === 'CHECKOUT') ?? null
const customerAddress = parseReservationExtras(reservation.customer.address)
return {
reservationId: reservation.id,
@@ -153,6 +156,7 @@ export async function getContract(id: string, companyId: string) {
status: reservation.status,
paymentStatus: reservation.paymentStatus,
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
contractFields,
notes: reservation.notes,
company: {
name: reservation.company.brand?.displayName ?? reservation.company.name,
@@ -172,6 +176,9 @@ export async function getContract(id: string, companyId: string) {
email: reservation.customer.email,
phone: reservation.customer.phone,
dateOfBirth: reservation.customer.dateOfBirth,
address: typeof customerAddress.fullAddress === 'string' ? customerAddress.fullAddress : null,
identityDocumentNumber: typeof customerAddress.identityDocumentNumber === 'string' ? customerAddress.identityDocumentNumber : null,
internationalLicenseNumber: typeof customerAddress.internationalLicenseNumber === 'string' ? customerAddress.internationalLicenseNumber : null,
driverLicense: reservation.customer.driverLicense,
licenseCountry: reservation.customer.licenseCountry,
licenseCategory: reservation.customer.licenseCategory,
@@ -189,6 +196,8 @@ export async function getContract(id: string, companyId: string) {
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,
spareWheel: typeof extras.spareWheel === 'boolean' ? extras.spareWheel : false,
radioCd: typeof extras.radioCd === 'boolean' ? extras.radioCd : false,
},
rentalPeriod: {
startDate: reservation.startDate, endDate: reservation.endDate, totalDays: reservation.totalDays,
@@ -229,30 +238,19 @@ export async function getContract(id: string, companyId: string) {
export async function getBilling(id: string, companyId: string) {
const reservation = await repo.findForBilling(id, companyId)
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: any) => ({
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: any) => d.totalCharge > 0)
.map((d: any) => ({
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
const lineItems = buildReservationInvoiceLineItems({
dailyRate: reservation.dailyRate,
totalDays: reservation.totalDays,
discountAmount: reservation.discountAmount,
depositAmount: reservation.depositAmount,
pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied)
? (reservation.pricingRulesApplied as any)
: [],
insurances: reservation.insurances,
additionalDrivers: reservation.additionalDrivers,
vehicle: reservation.vehicle,
})
const grandTotal = lineItems.reduce((s, i) => s + i.total, 0)
return {
lineItems,
@@ -24,9 +24,18 @@ vi.mock('./reservation.repo', () => ({
findConflict: vi.fn(),
}))
vi.mock('./reservation.document.service', () => ({
ensureReservationDocumentNumbers: vi.fn().mockResolvedValue({
id: 'reservation_1',
contractNumber: 'CNT-000001',
invoiceNumber: 'INV-000001',
}),
}))
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo'
import { ensureReservationDocumentNumbers } from './reservation.document.service'
import { cancelReservation, checkinReservation, confirmReservation, extendReservation } from './reservation.lifecycle.service'
const validLicenseReservation = {
@@ -57,8 +66,14 @@ describe('reservation.lifecycle.service', () => {
vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'Atlas Cars', brand: { displayName: 'Atlas', defaultLocale: 'en' } } as never)
vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2024, make: 'Toyota', model: 'Yaris' } as never)
await expect(confirmReservation('reservation_1', 'company_1')).resolves.toEqual({ id: 'reservation_1', status: 'CONFIRMED' })
await expect(confirmReservation('reservation_1', 'company_1')).resolves.toEqual({
id: 'reservation_1',
status: 'CONFIRMED',
contractNumber: 'CNT-000001',
invoiceNumber: 'INV-000001',
})
expect(repo.updateById).toHaveBeenCalledWith('reservation_1', { status: 'CONFIRMED' })
expect(ensureReservationDocumentNumbers).toHaveBeenCalledWith('company_1', 'reservation_1')
expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle_1', 'RESERVED')
})
@@ -6,6 +6,7 @@ import { sendNotification, sendTransactionalEmail } from '../../services/notific
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
import { buildBookingRequestProgress, buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import { ensureReservationDocumentNumbers } from './reservation.document.service'
import * as repo from './reservation.repo'
function buildPickupAddress(value: unknown) {
@@ -49,6 +50,7 @@ export async function confirmReservation(id: string, companyId: string) {
if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status')
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
const documentNumbers = await ensureReservationDocumentNumbers(companyId, id)
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
const [customer, company, vehicle] = await Promise.all([
@@ -134,7 +136,7 @@ export async function confirmReservation(id: string, companyId: string) {
}
}
return updated
return { ...updated, ...documentNumbers }
}
export async function checkinReservation(id: string, companyId: string, mileage?: number) {
@@ -10,6 +10,22 @@ export function normalizeOptionalString(value: string | null | undefined): strin
return trimmed || null
}
const BLOCKED_CONTRACT_FIELD_KEYS = new Set([
'companyName',
'companyAddress',
'contractCity',
'deliveryPlace',
'returnPlace',
])
export function serializeContractFields(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return Object.fromEntries(
Object.entries(value)
.filter(([key, fieldValue]) => !BLOCKED_CONTRACT_FIELD_KEYS.has(key) && typeof fieldValue === 'string'),
) as Record<string, string>
}
function readAddressField(address: unknown, key: string): string | null {
const extras = parseReservationExtras(address)
const value = extras[key]
@@ -125,10 +141,14 @@ export function serializeReservationForDashboard<T extends {
} | null
}>(reservation: T): T & {
paymentMode: string | null
spareWheel: boolean
radioCd: boolean
contractFields: Record<string, string>
workflow: ReturnType<typeof buildReservationWorkflow>
bookingRequest: ReturnType<typeof buildBookingRequestProgress>
} {
const extras = parseReservationExtras(reservation.extras)
const contractFields = serializeContractFields(extras.contractFields)
const customer =
reservation.customer
? {
@@ -143,6 +163,9 @@ export function serializeReservationForDashboard<T extends {
...reservation,
...(customer !== undefined ? { customer } : {}),
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
spareWheel: typeof extras.spareWheel === 'boolean' ? extras.spareWheel : false,
radioCd: typeof extras.radioCd === 'boolean' ? extras.radioCd : false,
contractFields,
workflow: buildReservationWorkflow(reservation),
bookingRequest: buildBookingRequestProgress({ ...reservation, customer }),
}
@@ -42,11 +42,16 @@ export const createSchema = z.object({
promoCodeUsed: z.string().optional(),
depositAmount: z.number().int().min(0).default(0),
paymentMode: z.string().max(50).optional(),
spareWheel: z.boolean().optional(),
radioCd: z.boolean().optional(),
notes: z.string().optional(),
contractFields: z.record(z.string().max(120), z.string().max(500).nullable()).optional(),
selectedInsurancePolicyIds: z.array(z.string()).default([]),
additionalDrivers: z.array(additionalDriverSchema).default([]),
})
export const contractFieldsSchema = z.record(z.string().max(120), z.string().max(500).nullable()).optional()
export const updateSchema = z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
@@ -55,6 +60,9 @@ export const updateSchema = z.object({
depositAmount: z.number().int().min(0).optional(),
notes: z.string().optional().nullable(),
paymentMode: z.string().max(50).optional().nullable(),
spareWheel: z.boolean().optional().nullable(),
radioCd: z.boolean().optional().nullable(),
contractFields: contractFieldsSchema,
damageChargeAmount: z.number().int().min(0).optional(),
damageChargeNote: z.string().optional().nullable(),
})
@@ -4,12 +4,24 @@ import { validateAndFlagLicense } from '../../services/licenseValidationService'
import { applyPricingRules, calculateUpdatedInsuranceCharge, calculateUpdatedAdditionalDriverCharge } from './reservation.pricing.service'
import { applyInsurancesToReservation } from './reservation.insurance.service'
import { applyAdditionalDriversToReservation } from './reservation.additional-driver.service'
import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
import { parseReservationExtras, normalizeOptionalString, serializeContractFields, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
import * as repo from './reservation.repo'
const RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as const
const BOOKING_SOURCES = ['DASHBOARD', 'PUBLIC_SITE', 'CARPLACE', 'API'] as const
function normalizeContractFields(value: Record<string, string | null> | undefined) {
if (!value) return null
const fields: Record<string, string> = {}
for (const [key, raw] of Object.entries(value)) {
const normalizedKey = key.trim()
const normalizedValue = normalizeOptionalString(raw)
if (normalizedKey && normalizedValue) fields[normalizedKey] = normalizedValue
}
const allowedFields = serializeContractFields(fields)
return Object.keys(allowedFields).length > 0 ? allowedFields : null
}
export async function listReservations(companyId: string, query: {
status?: string; vehicleId?: string; source?: string
search?: string
@@ -61,6 +73,8 @@ export async function createReservation(companyId: string, body: {
vehicleId: string; customerId: string; startDate: string; endDate: string
pickupLocation?: string; returnLocation?: string; offerId?: string
promoCodeUsed?: string; depositAmount?: number; paymentMode?: string; notes?: string
spareWheel?: boolean; radioCd?: boolean
contractFields?: Record<string, string | null>
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
}) {
const vehicle = await repo.findVehicle(body.vehicleId, companyId)
@@ -93,6 +107,7 @@ export async function createReservation(companyId: string, body: {
const baseAmount = vehicle.dailyRate * totalDays
const { applied, total: pricingTotal } = await applyPricingRules(companyId, body.customerId, additionalDriversList, vehicle.dailyRate, totalDays)
const totalAmount = baseAmount - discountAmount + pricingTotal + depositAmount
const contractFields = normalizeContractFields(body.contractFields)
const reservation = await repo.create({
companyId,
@@ -111,7 +126,12 @@ export async function createReservation(companyId: string, body: {
totalAmount,
depositAmount,
notes: body.notes ?? null,
extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined,
extras: {
...(body.paymentMode ? { paymentMode: body.paymentMode } : {}),
...(body.spareWheel !== undefined ? { spareWheel: body.spareWheel } : {}),
...(body.radioCd !== undefined ? { radioCd: body.radioCd } : {}),
...(contractFields ? { contractFields } : {}),
},
pricingRulesApplied: applied,
pricingRulesTotal: pricingTotal,
})
@@ -133,6 +153,8 @@ export async function updateReservation(id: string, companyId: string, body: {
startDate?: string; endDate?: string; pickupLocation?: string | null
returnLocation?: string | null; depositAmount?: number; notes?: string | null
paymentMode?: string | null; damageChargeAmount?: number; damageChargeNote?: string | null
spareWheel?: boolean | null; radioCd?: boolean | null
contractFields?: Record<string, string | null>
}) {
const reservation = await repo.findByIdWithRelations(id, companyId)
@@ -142,7 +164,7 @@ export async function updateReservation(id: string, companyId: string, body: {
const workflow = buildReservationWorkflow(reservation)
const requested = Object.keys(body).filter((k) => body[k as keyof typeof body] !== undefined)
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode']
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode', 'spareWheel', 'radioCd', 'contractFields']
const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote']
if (!workflow.coreEditable && !workflow.returnEditable) {
@@ -193,6 +215,13 @@ export async function updateReservation(id: string, companyId: string, body: {
if (next) extras.paymentMode = next
else delete extras.paymentMode
}
if (body.spareWheel !== undefined) extras.spareWheel = Boolean(body.spareWheel)
if (body.radioCd !== undefined) extras.radioCd = Boolean(body.radioCd)
if (body.contractFields !== undefined) {
const contractFields = normalizeContractFields(body.contractFields)
if (contractFields) extras.contractFields = contractFields
else delete extras.contractFields
}
await prisma.$transaction([
prisma.reservation.update({
@@ -42,6 +42,14 @@ vi.mock('./reservation.additional-driver.service', () => ({
approveAdditionalDriver: vi.fn(),
}))
vi.mock('./reservation.document.service', () => ({
ensureReservationDocumentNumbers: vi.fn().mockResolvedValue({
id: 'reservation-1',
contractNumber: 'CNT-000001',
invoiceNumber: 'INV-000001',
}),
}))
vi.mock('../../services/licenseValidationService', () => ({
validateAndFlagLicense: vi.fn().mockResolvedValue(undefined),
validateLicense: vi.fn(),
@@ -63,6 +71,7 @@ vi.mock('../../lib/prisma', () => ({
vi.mock('./reservation.presenter', () => ({
serializeReservationForDashboard: vi.fn((r) => r),
parseReservationExtras: vi.fn(() => ({})),
serializeContractFields: vi.fn((fields) => fields),
buildReservationWorkflow: vi.fn(),
normalizeOptionalString: vi.fn((s) => s),
}))
@@ -74,6 +83,7 @@ import * as additionalDriverService from './reservation.additional-driver.servic
import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification } from '../../services/notificationService'
import { buildReservationWorkflow } from './reservation.presenter'
import { ensureReservationDocumentNumbers } from './reservation.document.service'
import { createReservation, listReservations } from './reservation.service'
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
import { approveAdditionalDriver } from './reservation.additional-driver.service'
@@ -177,6 +187,40 @@ describe('createReservation', () => {
expect(result.id).toBe(RES_ID)
})
it('stores contract fields from the booking create payload', async () => {
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
vi.mocked(repo.findConflict).mockResolvedValue(null)
vi.mocked(repo.findActiveOffer).mockResolvedValue(null)
vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
vi.mocked(repo.create).mockResolvedValue(makeReservation() as any)
await createReservation(COMPANY, {
vehicleId: 'vehicle-1',
customerId: 'customer-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
contractFields: {
driverFirstName: 'Sara',
driverCin: 'AB123',
driverPassport: null,
vehicleDeparture: '01/06/2025 00:00',
},
selectedInsurancePolicyIds: [],
additionalDrivers: [],
})
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
extras: expect.objectContaining({
contractFields: {
driverFirstName: 'Sara',
driverCin: 'AB123',
vehicleDeparture: '01/06/2025 00:00',
},
}),
}))
})
it('throws conflict error when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle())
vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any)
@@ -216,6 +260,7 @@ describe('confirmReservation', () => {
const result = await confirmReservation(RES_ID, COMPANY)
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' })
expect(ensureReservationDocumentNumbers).toHaveBeenCalledWith(COMPANY, RES_ID)
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
templateKey: 'booking.confirmed',
templateVariables: expect.objectContaining({
@@ -231,6 +276,8 @@ describe('confirmReservation', () => {
}),
}))
expect((result as any).status).toBe('CONFIRMED')
expect((result as any).contractNumber).toBe('CNT-000001')
expect((result as any).invoiceNumber).toBe('INV-000001')
})
it('rejects non-DRAFT reservation', async () => {