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
+161
View File
@@ -0,0 +1,161 @@
/**
* User Input Validation and Formatting Library
*
* Implements the rules defined in docs/input_validation_plan.md.
* Provides formatting functions and a Zod-integrated pipeline that:
* 1. Removes disallowed characters
* 2. Trims leading/trailing whitespace
* 3. Validates format (email)
* 4. Applies case transformation
* 5. Truncates to max length
*/
// ─── Character class definitions ───────────────────────────────
const LETTERS_NUMBERS = 'a-zA-Z0-9'
/** Letters, numbers, spaces, hyphen, slash */
const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/`
/** Letters, spaces, hyphen */
const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-'
/** Letters, numbers, spaces, hyphen, slash, comma (full address / company names) */
const EXTENDED_TEXT = `${BASE_TEXT},`
/** Letters only */
const LETTERS_ONLY = 'a-zA-Z'
// ─── Field type definitions ────────────────────────────────────
type FieldType =
| 'name'
| 'nationality'
| 'streetAddress'
| 'city'
| 'pickupLocation'
| 'returnLocation'
| 'country'
| 'fullAddress'
| 'commercialName'
| 'legalCompanyName'
| 'email'
| 'licensePlate'
| 'vin'
| 'cin'
| 'passportNumber'
| 'internationalPermitNumber'
| 'driverLicenseNumber'
| 'licenseCategory'
| 'iceNumber'
| 'commercialRegistry'
| 'carMark'
| 'carModel'
| 'carColor'
| 'zipCode'
interface FieldConfig {
maxLength: number
allowedPattern: RegExp
transform: (value: string) => string
}
// ─── Formatting functions ──────────────────────────────────────
/** Uppercase first letter of each word, lowercase the rest */
export function toTitleCase(str: string): string {
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
}
/** Uppercase first letter of every word (same logic as toTitleCase at word boundaries) */
export function toTitleCaseAll(str: string): string {
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
}
export function toLowerCase(str: string): string {
return str.toLowerCase()
}
/** Uppercase letters only, leave numbers unchanged */
export function toUpperCase(str: string): string {
return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase())
}
/** Identity — no transformation */
export function preserveOriginal(str: string): string {
return str
}
// ─── Field configuration ───────────────────────────────────────
const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
// Group 1: Title Case
name: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase },
nationality: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase },
streetAddress: { maxLength: 100, allowedPattern: new RegExp(`^[${BASE_TEXT}]*$`), transform: toTitleCase },
city: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase },
pickupLocation: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase },
returnLocation: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase },
country: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase },
// Group 2: Title Case All
fullAddress: { maxLength: 200, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll },
commercialName: { maxLength: 100, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll },
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll },
// Group 3: Lowercase — email handled separately with format validation
email: { maxLength: 100, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}@._%\\+\\-]*$`), transform: toLowerCase },
// Group 4: Uppercase (letters and numbers only, no spaces/hyphens)
licensePlate: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
vin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
cin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
passportNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
licenseCategory: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
iceNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
commercialRegistry: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase },
// Group 5: Car fields
carMark: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase },
carModel: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase },
carColor: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase },
// Group 6: Preserved
zipCode: { maxLength: 10, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\-]*$`), transform: preserveOriginal },
}
// ─── Public API ────────────────────────────────────────────────
/** Full processing pipeline: sanitize → trim → format → truncate */
export function sanitizeAndFormat(value: string, fieldType: FieldType): string {
const config = FIELD_CONFIGS[fieldType]
// Step 1: Remove disallowed characters
let sanitized = ''
for (const char of value) {
if (config.allowedPattern.test(char)) {
sanitized += char
}
}
// Step 2: Trim
sanitized = sanitized.trim()
// Step 3: Apply case transformation
const formatted = config.transform(sanitized)
// Step 4: Truncate
if (formatted.length > config.maxLength) {
return formatted.slice(0, config.maxLength)
}
return formatted
}
/** Get the max length for a given field type */
export function getMaxLength(fieldType: FieldType): number {
return FIELD_CONFIGS[fieldType].maxLength
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Zod integration helpers for the input validation/formatting library.
*
* Provides pre-built Zod refinements and transforms that can be
* chained onto `.string()` schemas throughout the API.
*
* Usage:
* import { textField, emailField } from '../lib/zodValidation'
* const schema = z.object({
* firstName: textField('name'),
* email: emailField(),
* })
*/
import { z } from 'zod'
import { sanitizeAndFormat, getMaxLength } from './inputValidation'
import type { FieldType } from './inputValidation'
// ─── Character validation (inline refine) ──────────────────────
/** Global allowed character pattern from the validation plan */
const GLOBAL_ALLOWED = /^[a-zA-Z0-9@\-/\s]*$/
export const globalCharRefine = (val: string) =>
GLOBAL_ALLOWED.test(val) || { message: 'Only letters, numbers, @, -, and / are allowed' }
// ─── Email validation ──────────────────────────────────────────
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
export const emailFormatRefine = (val: string) =>
EMAIL_REGEX.test(val) || { message: 'Please enter a valid email address' }
// ─── Pre-built Zod string wrappers ─────────────────────────────
/**
* Returns the base Zod chain: .string() → .min(1) → character refine → trim → transform
* If optional is true, wraps in .optional() at the end.
*/
function baseTextField(fieldType: FieldType, optional = false) {
const maxLength = getMaxLength(fieldType)
let schema = z
.string()
.min(1, { message: 'This field is required' })
.refine(globalCharRefine)
.trim()
.transform((val) => sanitizeAndFormat(val, fieldType))
.refine(
(val) => val.length <= maxLength,
{ message: `Maximum ${maxLength} characters allowed` }
)
if (optional) {
// For optional fields: allow undefined input, but if a value is provided, apply all rules
return z
.string()
.optional()
.transform((val) => {
if (val === undefined || val === null) return undefined
return sanitizeAndFormat(val, fieldType)
})
.refine(
(val) => val === undefined || val.length === 0 || (globalCharRefine(val as string) === true),
{ message: 'Only letters, numbers, @, -, and / are allowed' }
)
}
return schema
}
/**
* Text field with title case formatting.
* fieldType must be one of: name, nationality, streetAddress, city,
* pickupLocation, returnLocation, country
*/
export function textField(fieldType: FieldType) {
return baseTextField(fieldType, false)
}
/** Optional version of textField */
export function optionalTextField(fieldType: FieldType) {
return baseTextField(fieldType, true)
}
/**
* Title-case-all fields: fullAddress, commercialName, legalCompanyName
*/
export function titleCaseAllField(fieldType: FieldType) {
return baseTextField(fieldType, false)
}
export function optionalTitleCaseAllField(fieldType: FieldType) {
return baseTextField(fieldType, true)
}
/**
* Uppercase fields (letters only in allowed chars): licensePlate, vin, cin,
* passportNumber, internationalPermitNumber, driverLicenseNumber,
* licenseCategory, iceNumber, commercialRegistry
*/
export function upperField(fieldType: FieldType) {
return baseTextField(fieldType, false)
}
export function optionalUpperField(fieldType: FieldType) {
return baseTextField(fieldType, true)
}
/**
* Car fields (title case, letters/numbers/spaces/hyphen allowed):
* carMark, carModel, carColor
*/
export function carTextField(fieldType: FieldType) {
return baseTextField(fieldType, false)
}
export function optionalCarTextField(fieldType: FieldType) {
return baseTextField(fieldType, true)
}
/**
* Preserved format field: zipCode
*/
export function zipField() {
return baseTextField('zipCode', false)
}
export function optionalZipField() {
return baseTextField('zipCode', true)
}
/**
* Email field with full validation chain
*/
export function emailField() {
return z
.string()
.min(1, { message: 'Email is required' })
.refine(globalCharRefine)
.trim()
.transform((val) => sanitizeAndFormat(val, 'email'))
.refine(emailFormatRefine)
}
export function optionalEmailField() {
return z
.string()
.optional()
.transform((val) => {
if (val === undefined || val === null || val.trim() === '') return undefined
return sanitizeAndFormat(val, 'email')
})
.refine(
(val) => val === undefined || emailFormatRefine(val as string) === true,
{ message: 'Please enter a valid email address' }
)
}
@@ -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' })
})
})
// ────────────────────────────────────────────────────────────────────────────
@@ -22,6 +22,12 @@ interface OnlineReservation {
createdAt: string
vehicle: { make: string; model: string; year: number; licensePlate: string }
customer: { firstName: string; lastName: string; email: string; phone: string | null }
bookingRequest: {
stage: string
documentsRequired: boolean
paymentRequired: boolean
documentsMissing: string[]
}
}
const STATUS_STYLE: Record<string, string> = {
@@ -42,6 +48,33 @@ const STATUS_LABEL: Record<string, string> = {
NO_SHOW: 'No show',
}
const REQUEST_STAGE_STYLE: Record<string, string> = {
REQUEST_SENT: 'bg-orange-100 text-orange-800',
COMPANY_CONFIRMED: 'bg-blue-100 text-blue-800',
DOCUMENTS_REQUIRED: 'bg-amber-100 text-amber-800',
PAYMENT_REQUIRED: 'bg-purple-100 text-purple-800',
BOOKING_CONFIRMED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-rose-100 text-rose-700',
}
const REQUEST_STAGE_LABEL: Record<string, string> = {
REQUEST_SENT: 'Waiting for company review',
COMPANY_CONFIRMED: 'Availability confirmed',
DOCUMENTS_REQUIRED: 'Driver documents missing',
PAYMENT_REQUIRED: 'Payment or deposit pending',
BOOKING_CONFIRMED: 'Ready to proceed',
CANCELLED: 'Request closed',
}
const MISSING_ITEM_LABEL: Record<string, string> = {
DATE_OF_BIRTH: 'Date of birth',
IDENTITY_DOCUMENT: 'Identity document',
FULL_ADDRESS: 'Full address',
DRIVER_LICENSE: 'Driver license number',
LICENSE_IMAGE: 'License image',
LICENSE_APPROVAL: 'License approval',
}
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
const [reason, setReason] = useState('')
return (
@@ -237,6 +270,8 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
onDecline: () => void
}) {
const days = r.totalDays
const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item)
return (
<div className="card overflow-hidden border-l-4 border-l-orange-400">
<div className="p-5">
@@ -267,6 +302,27 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
</div>
)}
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${REQUEST_STAGE_STYLE[r.bookingRequest.stage] ?? 'bg-slate-100 text-slate-700'}`}>
{REQUEST_STAGE_LABEL[r.bookingRequest.stage] ?? r.bookingRequest.stage}
</span>
{r.bookingRequest.paymentRequired ? (
<span className="inline-flex rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
Payment pending
</span>
) : null}
</div>
{missingItems.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{missingItems.map((item) => (
<span key={item} className="inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">
{item}
</span>
))}
</div>
) : null}
</div>
</div>
{/* Right: total + actions */}
@@ -66,6 +66,13 @@ interface ReservationDetail {
checkInInspectionEditable: boolean
checkOutInspectionEditable: boolean
}
bookingRequest: {
stage: string
companyConfirmed: boolean
documentsRequired: boolean
paymentRequired: boolean
documentsMissing: string[]
}
}
type EditMode = 'booking' | 'return' | null
@@ -118,6 +125,24 @@ const detailCopy = {
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
licenseImageLabel: 'License image',
bookingProgress: 'Booking progress',
paymentPending: 'Payment or deposit still pending',
stageLabels: {
REQUEST_SENT: 'Waiting for company review',
COMPANY_CONFIRMED: 'Availability confirmed',
DOCUMENTS_REQUIRED: 'Driver documents missing',
PAYMENT_REQUIRED: 'Payment or deposit pending',
BOOKING_CONFIRMED: 'Ready to proceed',
CANCELLED: 'Request closed',
},
missingItems: {
DATE_OF_BIRTH: 'Date of birth',
IDENTITY_DOCUMENT: 'Identity document',
FULL_ADDRESS: 'Full address',
DRIVER_LICENSE: 'Driver license number',
LICENSE_IMAGE: 'License image',
LICENSE_APPROVAL: 'License approval',
},
noLicenseImage: 'No license image uploaded.',
pickupPhotosTitle: 'Pickup photos',
dropoffPhotosTitle: 'Return photos',
@@ -167,6 +192,24 @@ const detailCopy = {
checkOutReadOnly: 'Linspection de retour devient modifiable une fois le véhicule retourné.',
inspectionClosed: 'Cette réservation est clôturée. Les modifications dinspection sont désactivées.',
licenseImageLabel: 'Image du permis',
bookingProgress: 'Progression de la réservation',
paymentPending: 'Paiement ou dépôt encore en attente',
stageLabels: {
REQUEST_SENT: 'En attente de validation',
COMPANY_CONFIRMED: 'Disponibilité confirmée',
DOCUMENTS_REQUIRED: 'Documents conducteur manquants',
PAYMENT_REQUIRED: 'Paiement ou dépôt en attente',
BOOKING_CONFIRMED: 'Prêt à avancer',
CANCELLED: 'Demande clôturée',
},
missingItems: {
DATE_OF_BIRTH: 'Date de naissance',
IDENTITY_DOCUMENT: 'Pièce didentité',
FULL_ADDRESS: 'Adresse complète',
DRIVER_LICENSE: 'Numéro du permis',
LICENSE_IMAGE: 'Image du permis',
LICENSE_APPROVAL: 'Validation du permis',
},
noLicenseImage: 'Aucune image de permis téléversée.',
pickupPhotosTitle: 'Photos de départ',
dropoffPhotosTitle: 'Photos de retour',
@@ -216,6 +259,24 @@ const detailCopy = {
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
bookingProgress: 'تقدم الحجز',
paymentPending: 'الدفع أو العربون ما زال معلقاً',
stageLabels: {
REQUEST_SENT: 'بانتظار مراجعة الشركة',
COMPANY_CONFIRMED: 'تم تأكيد التوفر',
DOCUMENTS_REQUIRED: 'وثائق السائق ناقصة',
PAYMENT_REQUIRED: 'الدفع أو العربون معلق',
BOOKING_CONFIRMED: 'جاهز للمتابعة',
CANCELLED: 'تم إغلاق الطلب',
},
missingItems: {
DATE_OF_BIRTH: 'تاريخ الميلاد',
IDENTITY_DOCUMENT: 'وثيقة الهوية',
FULL_ADDRESS: 'العنوان الكامل',
DRIVER_LICENSE: 'رقم الرخصة',
LICENSE_IMAGE: 'صورة الرخصة',
LICENSE_APPROVAL: 'اعتماد الرخصة',
},
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
pickupPhotosTitle: 'صور الاستلام',
dropoffPhotosTitle: 'صور الإرجاع',
@@ -534,6 +595,31 @@ export default function ReservationDetailPage() {
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
{reservation.source === 'MARKETPLACE' ? (
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingProgress}</h3>
<div className="flex flex-wrap items-center gap-2">
<span className="badge-gray">
{copy.stageLabels[reservation.bookingRequest.stage as keyof typeof copy.stageLabels] ?? reservation.bookingRequest.stage}
</span>
{reservation.bookingRequest.paymentRequired ? (
<span className="rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
{copy.paymentPending}
</span>
) : null}
</div>
{reservation.bookingRequest.documentsMissing.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{reservation.bookingRequest.documentsMissing.map((item) => (
<span key={item} className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{copy.missingItems[item as keyof typeof copy.missingItems] ?? item}
</span>
))}
</div>
) : null}
</div>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -14,7 +14,10 @@ describe('marketplace API helpers', () => {
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
await expect(marketplaceFetch('/marketplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), { cache: 'no-store' })
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/marketplace\/vehicles$/), {
cache: 'no-store',
credentials: 'include',
})
})
it('throws rich marketplace API errors', async () => {
@@ -53,6 +56,7 @@ describe('marketplace API helpers', () => {
await expect(marketplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' })
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vehicleId: 'vehicle_1' }),
}))
+7 -2
View File
@@ -17,8 +17,12 @@ export class MarketplaceApiError extends Error {
}
}
export async function marketplaceFetch<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
export async function marketplaceFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
cache: 'no-store',
credentials: 'include',
...init,
})
const json = await res.json().catch(() => null)
if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt)
return json.data as T
@@ -36,6 +40,7 @@ export async function marketplacePost<T>(path: string, body: unknown): Promise<T
const base = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const res = await fetch(`${base}${path}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})