reservation implementation

This commit is contained in:
root
2026-06-11 15:35:25 -04:00
parent 6eeba99c45
commit 37a6ed8a76
30 changed files with 2598 additions and 477 deletions
@@ -48,6 +48,17 @@ export async function findVehicleForMarketplace(vehicleId: string, companySlug:
})
}
export async function findVehicleForMarketplaceById(vehicleId: string) {
return prisma.vehicle.findFirst({
where: {
id: vehicleId,
isPublished: true,
company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } },
},
include: { company: { include: { brand: true } } },
})
}
export async function upsertMarketplaceCustomer(companyId: string, data: {
email: string
firstName: string
@@ -107,13 +118,45 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
export async function createMarketplaceReservation(data: {
companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
}) {
return prisma.reservation.create({
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
})
}
export async function createMarketplaceFunnelEvent(data: {
eventName: string
companySlug: string
vehicleId: string
renterId?: string | null
sessionId?: string
path?: string
metadata?: Record<string, string | number | boolean | null>
}) {
const vehicle = await prisma.vehicle.findFirst({
where: {
id: data.vehicleId,
isPublished: true,
company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } },
},
select: { companyId: true },
})
return prisma.marketplaceFunnelEvent.create({
data: {
eventName: data.eventName,
companyId: vehicle?.companyId ?? null,
companySlug: data.companySlug,
vehicleId: data.vehicleId,
renterId: data.renterId ?? null,
sessionId: data.sessionId,
path: data.path,
metadata: (data.metadata ?? {}) as any,
},
})
}
export async function findCompanyPage(slug: string) {
return prisma.company.findFirst({
where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } },
@@ -7,6 +7,7 @@ import * as service from './marketplace.service'
import {
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
} from './marketplace.schemas'
const router = Router()
@@ -65,6 +66,18 @@ router.post('/reservations', async (req, res, next) => {
}
})
router.post('/events', async (req, res, next) => {
try {
const body = parseBody(marketplaceFunnelEventSchema, req)
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/review/:token', async (req, res, next) => {
try {
const { token } = parseParams(reviewTokenSchema, req)
@@ -137,6 +150,19 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
}
})
router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
try {
const { slug, id } = parseParams(vehicleParamSchema, req)
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate }))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
}
next(err)
}
})
router.get('/:slug/offers', async (req, res, next) => {
try {
const { slug } = parseParams(slugParamSchema, req)
@@ -24,9 +24,14 @@ export const companiesQuerySchema = z.object({
hasOffer: z.string().optional(),
})
export const vehicleAvailabilityQuerySchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
})
export const marketplaceReservationSchema = z.object({
vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100),
companySlug: z.string().trim().max(100).optional(),
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
email: z.string().email(),
@@ -51,6 +56,23 @@ export const marketplaceReservationSchema = z.object({
language: z.enum(['en', 'fr', 'ar']).default('fr'),
})
export const marketplaceFunnelEventSchema = z.object({
eventName: z.enum([
'booking_form_viewed',
'trip_dates_selected',
'contact_details_started',
'driver_details_expanded',
'booking_request_submitted',
'booking_request_failed',
'booking_request_success',
]),
companySlug: z.string().trim().max(100),
vehicleId: z.string().cuid(),
sessionId: z.string().trim().max(100).optional(),
path: z.string().trim().max(500).optional(),
metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
})
export const reviewBodySchema = z.object({
overallRating: z.number().int().min(1).max(5),
vehicleRating: z.number().int().min(1).max(5).optional(),
@@ -120,8 +120,42 @@ export async function searchVehicles(params: {
}))
}
export async function getMarketplaceVehicleAvailability(
slug: string,
vehicleId: string,
params: { startDate: string; endDate: string },
) {
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const startDate = new Date(params.startDate)
const endDate = new Date(params.endDate)
if (endDate <= startDate) {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
companyId: vehicle.companyId,
range: { startDate, endDate },
})
return {
available: availability.available,
availabilityStatus: availability.status,
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
blockingReason: availability.blockingReason,
}
}
function generateBookingReference() {
const year = new Date().getUTCFullYear()
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
return `BK-${year}-${random}`
}
export async function createMarketplaceReservation(body: {
vehicleId: string; companySlug: string; firstName: string; lastName: string
vehicleId: string; companySlug?: string; firstName: string; lastName: string
email: string; phone?: string; dateOfBirth?: string; nationality?: string
identityDocumentNumber?: string; fullAddress?: string
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
@@ -136,7 +170,7 @@ export async function createMarketplaceReservation(body: {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation)
@@ -185,8 +219,18 @@ export async function createMarketplaceReservation(body: {
const totalAmount = vehicle.dailyRate * totalDays
const reservation = await repo.createMarketplaceReservation({
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
companyId: vehicle.companyId,
vehicleId: body.vehicleId,
customerId: customer.id,
startDate,
endDate,
pickupLocation,
returnLocation,
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount,
notes: body.notes,
bookingReference: generateBookingReference(),
})
const lang = (body.language ?? 'fr') as Lang
@@ -214,7 +258,32 @@ export async function createMarketplaceReservation(body: {
channels: ['IN_APP'],
}).catch(() => null)
return { reservationId: reservation.id }
return {
reservationId: reservation.id,
bookingReference: (reservation as any).bookingReference ?? null,
companyName,
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
status: 'PENDING',
message: 'The company will review your request.',
}
}
export async function trackMarketplaceFunnelEvent(body: {
eventName: string
companySlug: string
vehicleId: string
sessionId?: string
path?: string
metadata?: Record<string, string | number | boolean | null>
}, renterId?: string) {
await repo.createMarketplaceFunnelEvent({
...body,
renterId: renterId ?? null,
})
return { success: true }
}
export async function getCompanyPage(slug: string) {
@@ -7,6 +7,7 @@ vi.mock('./marketplace.repo', () => ({
findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleForMarketplace: vi.fn(),
findVehicleForMarketplaceById: vi.fn(),
upsertMarketplaceCustomer: vi.fn(),
createMarketplaceReservation: vi.fn(),
findCompanyPage: vi.fn(),
@@ -136,13 +137,14 @@ describe('createMarketplaceReservation', () => {
}
it('creates reservation and returns reservationId', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any)
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
expect(result.reservationId).toBe('r-1')
expect(result.bookingReference).toBe('BK-2026-ABC123')
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
identityDocumentNumber: 'CIN123456',
fullAddress: '123 Avenue Hassan II, Casablanca',
@@ -155,20 +157,20 @@ describe('createMarketplaceReservation', () => {
})
it('throws NotFoundError when vehicle not found', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(null)
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
})
it('throws AppError when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('rejects different return locations when the vehicle requires same drop-off', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
await expect(
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
@@ -5,7 +5,7 @@ import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import { coerceNotificationLocale } from '../../services/notificationLocalizationService'
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import { buildBookingRequestProgress, buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import * as repo from './reservation.repo'
function buildPickupAddress(value: unknown) {
@@ -47,7 +47,6 @@ async function assertLicenseCompliance(reservationId: string, companyId: string)
export async function confirmReservation(id: string, companyId: string) {
const reservation = await repo.findByIdSimple(id, companyId)
if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status')
await assertLicenseCompliance(reservation.id, companyId)
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
@@ -97,6 +96,44 @@ export async function confirmReservation(id: string, companyId: string) {
},
}).catch(() => null)
if (reservation.source === 'MARKETPLACE') {
const progress = buildBookingRequestProgress({
source: reservation.source,
status: 'CONFIRMED',
paymentStatus: updated.paymentStatus,
customer,
})
const reminderChannels = reservation.renterId ? ['EMAIL', 'IN_APP'] as const : ['EMAIL'] as const
if (progress.documentsRequired) {
await sendNotification({
type: 'DOCUMENTS_REQUIRED',
companyId,
renterId: reservation.renterId ?? undefined,
email: customer.email,
channels: [...reminderChannels],
locale: reservation.renterId ? undefined : fallbackLocale,
title: 'Driver documents still needed',
body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} confirmed availability. Please send the remaining driver documents so the booking can move forward.`,
data: { reservationId: reservation.id, missingItems: progress.documentsMissing },
}).catch(() => null)
}
if (progress.paymentRequired) {
await sendNotification({
type: 'PAYMENT_REQUIRED',
companyId,
renterId: reservation.renterId ?? undefined,
email: customer.email,
channels: [...reminderChannels],
locale: reservation.renterId ? undefined : fallbackLocale,
title: 'Payment or deposit is still pending',
body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} accepted your request. Complete any required payment or deposit to finalize the booking.`,
data: { reservationId: reservation.id, paymentStatus: updated.paymentStatus },
}).catch(() => null)
}
}
return updated
}
@@ -60,10 +60,19 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({
id: 'reservation_1',
status: 'DRAFT',
source: 'MARKETPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
extras: { paymentMode: 'CARD' },
customer: { id: 'customer_1', licenseImageUrl: '/storage/raw-license.jpg' },
customer: {
id: 'customer_1',
driverLicense: null,
dateOfBirth: null,
address: {},
licenseImageUrl: '/storage/raw-license.jpg',
licenseValidationStatus: 'PENDING',
},
})
expect(result.paymentMode).toBe('CARD')
@@ -75,10 +84,19 @@ describe('reservation.presenter boundary behavior', () => {
const result = serializeReservationForDashboard({
id: 'reservation_1',
status: 'DRAFT',
source: 'MARKETPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
extras: { paymentMode: 42 },
customer: { id: 'customer_1', licenseImageUrl: null },
customer: {
id: 'customer_1',
driverLicense: null,
dateOfBirth: null,
address: {},
licenseImageUrl: null,
licenseValidationStatus: 'PENDING',
},
})
expect(result.paymentMode).toBeNull()
@@ -19,12 +19,18 @@ describe('serializeReservationForDashboard', () => {
const result = serializeReservationForDashboard({
id: 'reservation-1',
status: 'CONFIRMED',
source: 'MARKETPLACE',
contractNumber: null,
invoiceNumber: null,
paymentStatus: 'UNPAID',
extras: {},
customer: {
id: 'customer-1',
driverLicense: 'DL-1',
dateOfBirth: new Date('1990-01-01T00:00:00.000Z'),
address: { identityDocumentNumber: 'CIN-1', fullAddress: 'Casablanca' },
licenseImageUrl: 'http://localhost:4000/storage/companies/company-1/customers/customer-1/license.jpg',
licenseValidationStatus: 'APPROVED',
},
})
@@ -10,6 +10,12 @@ export function normalizeOptionalString(value: string | null | undefined): strin
return trimmed || null
}
function readAddressField(address: unknown, key: string): string | null {
const extras = parseReservationExtras(address)
const value = extras[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
export function buildReservationWorkflow(reservation: {
status: string
contractNumber: string | null
@@ -33,16 +39,95 @@ export function buildReservationWorkflow(reservation: {
}
}
export function buildBookingRequestProgress(reservation: {
source: string
status: string
paymentStatus?: string | null
customer?: {
driverLicense?: string | null
dateOfBirth?: Date | null
address?: unknown
licenseImageUrl?: string | null
licenseValidationStatus?: string | null
} | null
}) {
const customer = reservation.customer
const paymentStatus = reservation.paymentStatus ?? 'UNPAID'
const identityDocumentNumber = readAddressField(customer?.address, 'identityDocumentNumber')
const fullAddress = readAddressField(customer?.address, 'fullAddress')
const documentsMissing: string[] = []
if (!customer?.dateOfBirth) documentsMissing.push('DATE_OF_BIRTH')
if (!identityDocumentNumber) documentsMissing.push('IDENTITY_DOCUMENT')
if (!fullAddress) documentsMissing.push('FULL_ADDRESS')
if (!customer?.driverLicense) documentsMissing.push('DRIVER_LICENSE')
if (!customer?.licenseImageUrl) documentsMissing.push('LICENSE_IMAGE')
if ((customer?.licenseValidationStatus ?? 'PENDING') !== 'APPROVED') {
documentsMissing.push('LICENSE_APPROVAL')
}
const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status)
const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0
const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus)
let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED'
if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED'
else if (!companyConfirmed) stage = 'REQUEST_SENT'
else if (documentsRequired) stage = 'DOCUMENTS_REQUIRED'
else if (paymentRequired) stage = 'PAYMENT_REQUIRED'
else if (companyConfirmed) stage = 'BOOKING_CONFIRMED'
else stage = 'COMPANY_CONFIRMED'
const activeStepKey =
stage === 'REQUEST_SENT'
? 'REQUEST_SENT'
: stage === 'CANCELLED'
? 'BOOKING_CONFIRMED'
: stage === 'DOCUMENTS_REQUIRED'
? 'DOCUMENTS_COMPLETED'
: stage === 'PAYMENT_REQUIRED'
? 'PAYMENT_COMPLETED'
: 'BOOKING_CONFIRMED'
const steps = [
{ key: 'REQUEST_SENT', completed: true, active: activeStepKey === 'REQUEST_SENT' },
{ key: 'AVAILABILITY_CONFIRMED', completed: companyConfirmed, active: companyConfirmed && activeStepKey === 'REQUEST_SENT' },
{ key: 'DOCUMENTS_COMPLETED', completed: companyConfirmed && !documentsRequired, active: activeStepKey === 'DOCUMENTS_COMPLETED' },
{ key: 'PAYMENT_COMPLETED', completed: companyConfirmed && !paymentRequired, active: activeStepKey === 'PAYMENT_COMPLETED' },
{ key: 'BOOKING_CONFIRMED', completed: companyConfirmed && !documentsRequired && !paymentRequired, active: activeStepKey === 'BOOKING_CONFIRMED' },
]
return {
stage,
companyConfirmed,
documentsRequired,
paymentRequired,
documentsMissing,
steps,
}
}
export function serializeReservationForDashboard<T extends {
extras: unknown
status: string
source: string
contractNumber: string | null
invoiceNumber: string | null
paymentStatus?: string | null
customer?: {
id: string
driverLicense?: string | null
dateOfBirth?: Date | null
address?: unknown
licenseImageUrl?: string | null
licenseValidationStatus?: string | null
} | null
}>(reservation: T): T & { paymentMode: string | null; workflow: ReturnType<typeof buildReservationWorkflow> } {
}>(reservation: T): T & {
paymentMode: string | null
workflow: ReturnType<typeof buildReservationWorkflow>
bookingRequest: ReturnType<typeof buildBookingRequestProgress>
} {
const extras = parseReservationExtras(reservation.extras)
const customer =
reservation.customer
@@ -59,5 +144,6 @@ export function serializeReservationForDashboard<T extends {
...(customer !== undefined ? { customer } : {}),
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
workflow: buildReservationWorkflow(reservation),
bookingRequest: buildBookingRequestProgress({ ...reservation, customer }),
}
}
+6 -1
View File
@@ -53,9 +53,13 @@ function maskPhone(phone?: string | null) {
export function presentPublicBooking(reservation: any) {
return {
id: reservation.id,
status: reservation.status,
bookingReference: reservation.bookingReference ?? null,
status: reservation.status === 'DRAFT' ? 'PENDING' : reservation.status,
companyName: reservation.company?.brand?.displayName ?? reservation.company?.name ?? null,
pickupAt: reservation.startDate instanceof Date ? reservation.startDate.toISOString() : reservation.startDate,
returnAt: reservation.endDate instanceof Date ? reservation.endDate.toISOString() : reservation.endDate,
pickupLocation: reservation.pickupLocation ?? null,
returnLocation: reservation.returnLocation ?? null,
vehicle: {
make: reservation.vehicle?.make,
model: reservation.vehicle?.model,
@@ -72,5 +76,6 @@ export function presentPublicBooking(reservation: any) {
currency: 'MAD',
},
paymentStatus: reservation.paymentStatus,
message: 'The company will review your request.',
}
}
+36 -29
View File
@@ -45,46 +45,53 @@ export async function upsertCustomer(companyId: string, data: {
firstName: string
lastName: string
phone: string
driverLicense: string
dateOfBirth: string
licenseExpiry: string
licenseIssuedAt: string
nationality: string
identityDocumentNumber: string
fullAddress: string
licenseCountry: string
driverLicense?: string
dateOfBirth?: string
licenseExpiry?: string
licenseIssuedAt?: string
nationality?: string
identityDocumentNumber?: string
fullAddress?: string
licenseCountry?: string
licenseNumber?: string | null
licenseCategory: string
licenseCategory?: string
internationalLicenseNumber?: string | null
}) {
const normalizedEmail = data.email.trim().toLowerCase()
const existing = await prisma.customer.findUnique({
where: { companyId_email: { companyId, email: data.email } },
where: { companyId_email: { companyId, email: normalizedEmail } },
select: { id: true, address: true },
})
const address = {
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
? existing.address as Record<string, unknown>
: {}),
fullAddress: data.fullAddress,
identityDocumentNumber: data.identityDocumentNumber,
internationalLicenseNumber: data.internationalLicenseNumber ?? null,
const addressPatch = {
...(data.fullAddress ? { fullAddress: data.fullAddress } : {}),
...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}),
...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}),
}
const address = Object.keys(addressPatch).length > 0
? {
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
? existing.address as Record<string, unknown>
: {}),
...addressPatch,
}
: undefined
const payload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
email: normalizedEmail,
phone: data.phone,
driverLicense: data.driverLicense,
dateOfBirth: new Date(data.dateOfBirth),
nationality: data.nationality,
address,
licenseExpiry: new Date(data.licenseExpiry),
licenseIssuedAt: new Date(data.licenseIssuedAt),
licenseCountry: data.licenseCountry,
licenseNumber: data.licenseNumber ?? data.driverLicense,
licenseCategory: data.licenseCategory,
...(data.driverLicense ? { driverLicense: data.driverLicense } : {}),
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
...(data.nationality ? { nationality: data.nationality } : {}),
...(address ? { address } : {}),
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
...((data.licenseNumber ?? data.driverLicense) ? { licenseNumber: data.licenseNumber ?? data.driverLicense } : {}),
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
}
if (!existing) {
@@ -106,14 +113,14 @@ export async function createReservation(data: object) {
export async function findReservationWithDetails(reservationId: string) {
return prisma.reservation.findUniqueOrThrow({
where: { id: reservationId },
include: { insurances: true, additionalDrivers: true },
include: { insurances: true, additionalDrivers: true, vehicle: true, customer: true, company: { include: { brand: true } } },
})
}
export async function findBooking(reservationId: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
include: { vehicle: true, customer: true },
include: { vehicle: true, customer: true, company: { include: { brand: true } } },
})
}
+3 -10
View File
@@ -10,15 +10,8 @@ describe('site.schemas', () => {
lastName: 'Benali',
email: 'aya@example.com',
phone: '+212600000000',
driverLicense: 'DL-123',
dateOfBirth: '1995-01-01T00:00:00.000Z',
licenseExpiry: '2028-01-01T00:00:00.000Z',
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
nationality: 'Moroccan',
identityDocumentNumber: 'ID-123',
fullAddress: '12 Main Street',
licenseCountry: 'MA',
licenseCategory: 'B',
pickupLocation: 'Casablanca Airport',
returnLocation: 'Casablanca Airport',
}
it('defaults optional public booking arrays and source', () => {
@@ -53,7 +46,7 @@ describe('site.schemas', () => {
expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD')
})
it('keeps contact, PayPal capture, and required identity fields strict', () => {
it('keeps contact, PayPal capture, and guest booking essentials strict', () => {
expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({
name: 'Aya', email: 'aya@example.com', message: 'Hello',
})
+11 -9
View File
@@ -19,16 +19,18 @@ export const bookSchema = z.object({
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1).max(30),
driverLicense: z.string().min(1).max(50),
dateOfBirth: z.string().datetime(),
licenseExpiry: z.string().datetime(),
licenseIssuedAt: z.string().datetime(),
nationality: z.string().min(1).max(100),
identityDocumentNumber: z.string().min(1).max(100),
fullAddress: z.string().min(1).max(500),
licenseCountry: z.string().min(1).max(100),
pickupLocation: z.string().trim().min(1).max(100),
returnLocation: z.string().trim().min(1).max(100),
driverLicense: z.string().min(1).max(50).optional(),
dateOfBirth: z.string().datetime().optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
nationality: z.string().min(1).max(100).optional(),
identityDocumentNumber: z.string().min(1).max(100).optional(),
fullAddress: z.string().min(1).max(500).optional(),
licenseCountry: z.string().min(1).max(100).optional(),
licenseNumber: z.string().min(1).max(50).optional(),
licenseCategory: z.string().min(1).max(20),
licenseCategory: z.string().min(1).max(20).optional(),
internationalLicenseNumber: z.string().trim().max(100).optional(),
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
@@ -18,7 +18,13 @@ vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn()
vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) }))
vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() }))
vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() }))
vi.mock('./site.presenter', () => ({ presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })) }))
vi.mock('./site.presenter', () => ({
presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })),
presentPublicBooking: vi.fn((reservation: any) => ({
bookingReference: reservation.bookingReference ?? null,
status: reservation.status === 'DRAFT' ? 'PENDING' : reservation.status,
})),
}))
vi.mock('./site.repo', () => ({
findCompanyBySlug: vi.fn(),
findPublishedVehicles: vi.fn(),
@@ -62,15 +68,8 @@ function bookingBody(overrides: Record<string, unknown> = {}) {
lastName: 'Renter',
email: 'nora@example.test',
phone: '+212600000000',
driverLicense: 'DL-1',
dateOfBirth: '1992-01-01',
licenseExpiry: '2027-01-01',
licenseIssuedAt: '2022-01-01',
nationality: 'MA',
identityDocumentNumber: 'ID-1',
fullAddress: 'Casablanca',
licenseCountry: 'MA',
licenseCategory: 'B',
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
...overrides,
} as any
}
@@ -108,7 +107,23 @@ describe('site.service public booking/payment boundaries', () => {
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'reservation_1', additionalDrivers: [{ requiresApproval: true }] } as never)
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({
id: 'reservation_1',
bookingReference: 'BK-2026-AB12CD',
status: 'DRAFT',
startDate: new Date('2026-07-01T10:00:00.000Z'),
endDate: new Date('2026-07-04T10:00:00.000Z'),
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
vehicle: { make: 'Dacia', model: 'Duster', year: 2024 },
customer: { firstName: 'Aya', lastName: 'Benali', email: 'aya@example.test', phone: '+212600000000' },
company: { name: 'Atlas Cars', brand: { displayName: 'Atlas Cars' } },
additionalDrivers: [],
insurances: [],
totalAmount: 1090,
paidAmount: 0,
paymentStatus: 'UNPAID',
} as never)
const result = await service.createBooking('atlas', bookingBody({
promoCodeUsed: 'FREEDAY',
@@ -122,13 +137,16 @@ describe('site.service public booking/payment boundaries', () => {
discountAmount: 500,
pricingRulesTotal: 90,
totalAmount: 1090,
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
status: 'DRAFT',
source: 'PUBLIC_SITE',
}))
expect(applyInsurancesToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', ['insurance_1'], 3, 1500)
expect(applyAdditionalDriversToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', [{ firstName: 'Second' }], 3)
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1')
expect(result.requiresManualApproval).toBe(true)
expect(applyInsurancesToReservation).not.toHaveBeenCalled()
expect(applyAdditionalDriversToReservation).not.toHaveBeenCalled()
expect(validateAndFlagLicense).not.toHaveBeenCalled()
expect(result.status).toBe('PENDING')
expect(result.bookingReference).toBe('BK-2026-AB12CD')
})
it('rejects payment initialization for already-paid reservations before provider calls', async () => {
+18 -27
View File
@@ -1,10 +1,8 @@
import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types'
import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyInsurancesToReservation } from '../../services/insuranceService'
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
import { validateLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
@@ -48,6 +46,12 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) {
}
}
function generateBookingReference() {
const year = new Date().getUTCFullYear()
const random = Math.random().toString(36).slice(2, 8).toUpperCase()
return `BK-${year}-${random}`
}
export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
}
@@ -132,8 +136,9 @@ export async function validatePromoCode(slug: string, code: string) {
export async function createBooking(slug: string, body: {
vehicleId: string; startDate: string; endDate: string
firstName: string; lastName: string; email: string; phone: string
driverLicense: string; dateOfBirth: string; licenseExpiry: string; licenseIssuedAt: string; nationality: string
identityDocumentNumber: string; fullAddress: string; licenseCountry: string; licenseNumber?: string; licenseCategory: string; internationalLicenseNumber?: string
pickupLocation: string; returnLocation: string
driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string
identityDocumentNumber?: string; fullAddress?: string; licenseCountry?: string; licenseNumber?: string; licenseCategory?: string; internationalLicenseNumber?: string
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
}) {
@@ -143,6 +148,7 @@ export async function createBooking(slug: string, body: {
const start = new Date(body.startDate)
const end = new Date(body.endDate)
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
if (start < new Date()) throw new AppError('Start date cannot be in the past', 400, 'invalid_dates')
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, range: { startDate: start, endDate: end } })
if (!availability.available) {
@@ -182,16 +188,10 @@ export async function createBooking(slug: string, body: {
}
}
const additionalDriversList = body.additionalDrivers ?? []
const { applied, total: pricingRulesTotal } = await applyPricingRules(
company.id, customer.id, additionalDriversList as any[], vehicle.dailyRate, totalDays,
company.id, customer.id, [], vehicle.dailyRate, totalDays,
)
const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null)
if (primaryLicenseResult.status === 'EXPIRED') {
throw new AppError('The primary driver license is expired', 400, 'license_expired')
}
const reservation = await repo.createReservation({
companyId: company.id,
vehicleId: vehicle.id,
@@ -202,6 +202,8 @@ export async function createBooking(slug: string, body: {
source: body.source ?? 'PUBLIC_SITE',
startDate: start,
endDate: end,
pickupLocation: body.pickupLocation,
returnLocation: body.returnLocation,
dailyRate: vehicle.dailyRate,
totalDays,
totalAmount: baseAmount - discountAmount + pricingRulesTotal,
@@ -209,20 +211,10 @@ export async function createBooking(slug: string, body: {
pricingRulesApplied: applied,
pricingRulesTotal,
notes: body.notes ?? null,
bookingReference: generateBookingReference(),
status: 'DRAFT',
})
const insuranceIds = body.selectedInsurancePolicyIds ?? []
if (insuranceIds.length > 0) {
await applyInsurancesToReservation(reservation.id, company.id, insuranceIds, totalDays, baseAmount)
}
if (additionalDriversList.length > 0) {
await applyAdditionalDriversToReservation(reservation.id, company.id, additionalDriversList, totalDays)
}
if (body.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null)
}
const publicAccess = generatePublicAccessToken()
await repo.createReservationPublicAccess(
reservation.id,
@@ -232,11 +224,10 @@ export async function createBooking(slug: string, body: {
const refreshed = await repo.findReservationWithDetails(reservation.id)
return {
...refreshed,
...presentPublicBooking({ ...refreshed, company }),
publicAccessToken: publicAccess.token,
requiresManualApproval:
primaryLicenseResult.requiresApproval ||
refreshed.additionalDrivers.some((d: any) => d.requiresApproval),
bookingReference: (refreshed as any).bookingReference,
message: 'The company will review your request.',
}
}
+26 -12
View File
@@ -175,9 +175,12 @@ describe('validatePromoCode', () => {
describe('createBooking', () => {
const baseBody = {
vehicleId: 'v-1',
startDate: '2025-06-01T00:00:00.000Z',
endDate: '2025-06-04T00:00:00.000Z',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-07-04T00:00:00.000Z',
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
phone: '+212600000000',
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}
beforeEach(() => {
@@ -186,20 +189,36 @@ describe('createBooking', () => {
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [], total: 0 })
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any)
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'r-1' } as any)
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'r-1', additionalDrivers: [], insurances: [] } as any)
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({
id: 'r-1',
bookingReference: 'BK-2026-ABC123',
status: 'DRAFT',
startDate: new Date('2026-07-01T00:00:00.000Z'),
endDate: new Date('2026-07-04T00:00:00.000Z'),
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
vehicle: { make: 'Toyota', model: 'Camry', year: 2022 },
customer: { firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', phone: '+212600000000' },
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
additionalDrivers: [],
insurances: [],
totalAmount: 30000,
paidAmount: 0,
paymentStatus: 'UNPAID',
} as any)
})
it('creates a booking and returns reservation with requiresManualApproval flag', async () => {
it('creates a booking and returns a pending guest confirmation', async () => {
const result = await createBooking(SLUG, baseBody)
expect(repo.createReservation).toHaveBeenCalledOnce()
expect((result as any).requiresManualApproval).toBe(false)
expect((result as any).status).toBe('PENDING')
expect((result as any).bookingReference).toBe('BK-2026-ABC123')
})
it('throws AppError for invalid dates', async () => {
await expect(
createBooking(SLUG, { ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
createBooking(SLUG, { ...baseBody, startDate: '2026-07-04T00:00:00.000Z', endDate: '2026-07-01T00:00:00.000Z' }),
).rejects.toMatchObject({ error: 'invalid_dates' })
})
@@ -207,11 +226,6 @@ describe('createBooking', () => {
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('throws AppError when primary driver license is expired', async () => {
vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRED', requiresApproval: false } as any)
await expect(createBooking(SLUG, { ...baseBody, licenseExpiry: '2020-01-01T00:00:00.000Z' })).rejects.toMatchObject({ error: 'license_expired' })
})
})
// ────────────────────────────────────────────────────────────────────────────