diff --git a/apps/api/src/modules/vehicles/vehicle.repo.ts b/apps/api/src/modules/vehicles/vehicle.repo.ts index 6e76f3c..b59c112 100644 --- a/apps/api/src/modules/vehicles/vehicle.repo.ts +++ b/apps/api/src/modules/vehicles/vehicle.repo.ts @@ -89,3 +89,54 @@ export async function findMaintenanceLogs(vehicleId: string) { export async function createMaintenanceLog(data: any) { return prisma.maintenanceLog.create({ data }) } + +export async function findPricingConfiguration(vehicleId: string) { + return prisma.vehiclePricingConfiguration.findUnique({ + where: { vehicleId }, + include: { + rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] }, + history: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + }) +} + +export async function createPricingConfiguration(data: any) { + return prisma.vehiclePricingConfiguration.create({ + data, + include: { + rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] }, + history: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + }) +} + +export async function updatePricingConfiguration(id: string, data: any) { + return prisma.vehiclePricingConfiguration.update({ + where: { id }, + data, + include: { + rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] }, + history: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + }) +} + +export async function findPricingRule(id: string, configurationId: string) { + return prisma.vehiclePricingRule.findFirst({ where: { id, configurationId } }) +} + +export async function createPricingRule(data: any) { + return prisma.vehiclePricingRule.create({ data }) +} + +export async function updatePricingRule(id: string, data: any) { + return prisma.vehiclePricingRule.update({ where: { id }, data }) +} + +export async function deletePricingRule(id: string, configurationId: string) { + return prisma.vehiclePricingRule.deleteMany({ where: { id, configurationId } }) +} + +export async function createPriceHistory(data: any) { + return prisma.vehiclePriceHistory.create({ data }) +} diff --git a/apps/api/src/modules/vehicles/vehicle.routes.ts b/apps/api/src/modules/vehicles/vehicle.routes.ts index 582b675..51717b8 100644 --- a/apps/api/src/modules/vehicles/vehicle.routes.ts +++ b/apps/api/src/modules/vehicles/vehicle.routes.ts @@ -10,7 +10,8 @@ import * as service from './vehicle.service' import { vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema, calendarBlockSchema, maintenanceLogSchema, publishSchema, statusSchema, - idParamSchema, photoIdxSchema, blockIdParamSchema, + pricingConfigSchema, pricingRuleSchema, + idParamSchema, photoIdxSchema, blockIdParamSchema, pricingRuleParamSchema, } from './vehicle.schemas' const router = Router() @@ -145,4 +146,42 @@ router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) = } catch (err) { next(err) } }) +router.get('/:id/pricing', async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + ok(res, await service.getVehiclePricing(id, req.companyId)) + } catch (err) { next(err) } +}) + +router.put('/:id/pricing', requireRole('MANAGER'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + const body = parseBody(pricingConfigSchema, req) + ok(res, await service.updateVehiclePricing(id, req.companyId, req.employee.id, body)) + } catch (err) { next(err) } +}) + +router.post('/:id/pricing/rules', requireRole('MANAGER'), async (req, res, next) => { + try { + const { id } = parseParams(idParamSchema, req) + const body = parseBody(pricingRuleSchema, req) + created(res, await service.createVehiclePricingRule(id, req.companyId, req.employee.id, body)) + } catch (err) { next(err) } +}) + +router.patch('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => { + try { + const { id, ruleId } = parseParams(pricingRuleParamSchema, req) + const body = parseBody(pricingRuleSchema.partial(), req) + ok(res, await service.updateVehiclePricingRule(id, req.companyId, ruleId, req.employee.id, body)) + } catch (err) { next(err) } +}) + +router.delete('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => { + try { + const { id, ruleId } = parseParams(pricingRuleParamSchema, req) + ok(res, await service.deleteVehiclePricingRule(id, req.companyId, ruleId, req.employee.id)) + } catch (err) { next(err) } +}) + export default router diff --git a/apps/api/src/modules/vehicles/vehicle.schemas.ts b/apps/api/src/modules/vehicles/vehicle.schemas.ts index 1097545..3dda342 100644 --- a/apps/api/src/modules/vehicles/vehicle.schemas.ts +++ b/apps/api/src/modules/vehicles/vehicle.schemas.ts @@ -12,7 +12,7 @@ export const vehicleSchema = z.object({ transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'), fuelType: z.enum(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID']).default('GASOLINE'), features: z.array(z.string()).default([]), - dailyRate: z.number().int().min(0), + dailyRate: z.number().int().min(0).optional(), mileage: z.number().int().optional(), notes: z.string().optional(), isPublished: z.boolean().default(true), @@ -57,9 +57,45 @@ export const maintenanceLogSchema = z.object({ nextDueMileage: z.number().int().optional(), }) +const nullableRateSchema = z.number().int().min(0).nullable().optional() + +export const pricingConfigSchema = z.object({ + pricingMode: z.enum(['MANUAL', 'AUTOMATIC']), + baseDailyRate: z.number().int().min(0), + weeklyRate: nullableRateSchema, + weekendRate: nullableRateSchema, + holidayRate: nullableRateSchema, + monthlyRate: nullableRateSchema, + longTermDailyRate: nullableRateSchema, + minimumDailyRate: nullableRateSchema, + maximumDailyRate: nullableRateSchema, + maxDailyPriceMovementPct: z.number().int().min(0).max(100).default(10), + automaticPricingEnabled: z.boolean().default(false), + approvalRequired: z.boolean().default(false), + note: z.string().trim().min(1).max(500).optional(), +}) + +export const pricingRuleSchema = z.object({ + name: z.string().trim().min(1).max(120), + ruleType: z.enum(['DATE_RANGE', 'SEASONAL', 'HOLIDAY', 'WEEKEND', 'SPECIAL_EVENT', 'MANUAL_OVERRIDE']).default('DATE_RANGE'), + startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)), + endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)), + dailyRate: nullableRateSchema, + weeklyRate: nullableRateSchema, + weekendRate: nullableRateSchema, + monthlyRate: nullableRateSchema, + minDailyRate: nullableRateSchema, + maxDailyRate: nullableRateSchema, + automaticAdjustmentPct: z.number().int().min(-100).max(200).nullable().optional(), + priceAdjustment: z.number().int().nullable().optional(), + isActive: z.boolean().default(true), + sortOrder: z.number().int().min(0).max(1000).default(0), +}) + export const publishSchema = z.object({ isPublished: z.boolean() }) export const statusSchema = z.object({ status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']) }) export const idParamSchema = z.object({ id: z.string() }) export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() }) export const blockIdParamSchema = z.object({ id: z.string(), blockId: z.string() }) +export const pricingRuleParamSchema = z.object({ id: z.string(), ruleId: z.string() }) diff --git a/apps/api/src/modules/vehicles/vehicle.service.ts b/apps/api/src/modules/vehicles/vehicle.service.ts index 6295ad0..76c17c8 100644 --- a/apps/api/src/modules/vehicles/vehicle.service.ts +++ b/apps/api/src/modules/vehicles/vehicle.service.ts @@ -1,8 +1,29 @@ import { uploadImage } from '../../lib/storage' -import { NotFoundError, ValidationError } from '../../http/errors' +import { AppError, NotFoundError, ValidationError } from '../../http/errors' import { presentVehicle, presentVehicleList } from './vehicle.presenter' import * as repo from './vehicle.repo' +const DAY_MS = 24 * 60 * 60 * 1000 +const RULE_PRIORITY: Record = { + MANUAL_OVERRIDE: 6, + HOLIDAY: 5, + SPECIAL_EVENT: 4, + SEASONAL: 3, + WEEKEND: 2, + DATE_RANGE: 1, +} + +function isPricingStorageMissing(error: unknown) { + if (!error || typeof error !== 'object') return false + const candidate = error as { code?: string; message?: string } + return ( + candidate.code === 'P2021' || + candidate.message?.includes('vehicle_pricing_configurations') === true || + candidate.message?.includes('vehicle_pricing_rules') === true || + candidate.message?.includes('vehicle_price_history') === true + ) +} + function normalizeLocations(locations: unknown) { if (!Array.isArray(locations)) return [] const seen = new Set() @@ -39,6 +60,291 @@ function applyLocationSettings(data: any) { return patch } +function toStartOfDay(value: string | Date) { + const date = new Date(value) + date.setHours(0, 0, 0, 0) + return date +} + +function toEndOfDay(value: string | Date) { + const date = new Date(value) + date.setHours(23, 59, 59, 999) + return date +} + +function isWeekend(date: Date) { + const day = date.getDay() + return day === 0 || day === 6 +} + +function clampRate(value: number, min?: number | null, max?: number | null) { + if (min != null && value < min) return min + if (max != null && value > max) return max + return value +} + +function validateRateBounds(minimum?: number | null, maximum?: number | null, label: string = 'Daily') { + if (minimum != null && maximum != null && minimum > maximum) { + throw new ValidationError(`${label} minimum rate must be less than or equal to the maximum rate`) + } +} + +function normalizePricingConfigurationInput(data: any) { + const normalized = { + pricingMode: data.pricingMode, + baseDailyRate: data.baseDailyRate, + weeklyRate: data.weeklyRate ?? null, + weekendRate: data.weekendRate ?? null, + holidayRate: data.holidayRate ?? null, + monthlyRate: data.monthlyRate ?? null, + longTermDailyRate: data.longTermDailyRate ?? null, + minimumDailyRate: data.minimumDailyRate ?? null, + maximumDailyRate: data.maximumDailyRate ?? null, + maxDailyPriceMovementPct: data.maxDailyPriceMovementPct, + automaticPricingEnabled: data.automaticPricingEnabled, + approvalRequired: data.approvalRequired, + } + + validateRateBounds(normalized.minimumDailyRate, normalized.maximumDailyRate) + validateRateBounds(normalized.baseDailyRate, normalized.maximumDailyRate, 'Base') + return normalized +} + +function normalizePricingRuleInput(data: any) { + const normalized = { + name: data.name, + ruleType: data.ruleType, + startDate: toStartOfDay(data.startDate), + endDate: toEndOfDay(data.endDate), + dailyRate: data.dailyRate ?? null, + weeklyRate: data.weeklyRate ?? null, + weekendRate: data.weekendRate ?? null, + monthlyRate: data.monthlyRate ?? null, + minDailyRate: data.minDailyRate ?? null, + maxDailyRate: data.maxDailyRate ?? null, + automaticAdjustmentPct: data.automaticAdjustmentPct ?? null, + priceAdjustment: data.priceAdjustment ?? null, + isActive: data.isActive, + sortOrder: data.sortOrder, + } + + if (normalized.endDate < normalized.startDate) { + throw new ValidationError('Rule end date must be on or after the start date') + } + + validateRateBounds(normalized.minDailyRate, normalized.maxDailyRate) + + const hasPricingEffect = [ + normalized.dailyRate, + normalized.weeklyRate, + normalized.weekendRate, + normalized.monthlyRate, + normalized.minDailyRate, + normalized.maxDailyRate, + normalized.automaticAdjustmentPct, + normalized.priceAdjustment, + ].some((value) => value != null) + + if (!hasPricingEffect) { + throw new ValidationError('Pricing rules must define at least one rate, bound, or adjustment') + } + + return normalized +} + +async function ensurePricingConfigurationForVehicle(vehicleId: string, companyId: string) { + const vehicle = await repo.findById(vehicleId, companyId) + if (!vehicle) throw new NotFoundError('Vehicle not found') + + let configuration: any = null + let persistenceAvailable = true + try { + configuration = await repo.findPricingConfiguration(vehicleId) + } catch (error) { + if (!isPricingStorageMissing(error)) throw error + persistenceAvailable = false + } + + if (!persistenceAvailable) { + return { + vehicle, + persistenceAvailable, + configuration: { + id: `transient-${vehicle.id}`, + vehicleId: vehicle.id, + pricingMode: 'MANUAL', + baseDailyRate: vehicle.dailyRate, + weeklyRate: vehicle.dailyRate * 7, + weekendRate: null, + holidayRate: null, + monthlyRate: null, + longTermDailyRate: null, + minimumDailyRate: null, + maximumDailyRate: null, + maxDailyPriceMovementPct: 10, + automaticPricingEnabled: false, + approvalRequired: false, + rules: [], + history: [], + createdAt: vehicle.createdAt, + updatedAt: vehicle.updatedAt, + }, + } + } + + if (!configuration) { + configuration = await repo.createPricingConfiguration({ + vehicleId, + pricingMode: 'MANUAL', + baseDailyRate: vehicle.dailyRate, + weeklyRate: vehicle.dailyRate * 7, + automaticPricingEnabled: false, + approvalRequired: false, + maxDailyPriceMovementPct: 10, + }) + } + + return { vehicle, configuration, persistenceAvailable } +} + +async function syncPricingBaseRateFromVehicle(vehicleId: string, previousDailyRate: number, nextDailyRate: number) { + const configuration = await repo.findPricingConfiguration(vehicleId) + if (!configuration) return + + const currentBaseRate = configuration.baseDailyRate ?? previousDailyRate + if (currentBaseRate === nextDailyRate) return + + await repo.updatePricingConfiguration(configuration.id, { baseDailyRate: nextDailyRate }) + await repo.createPriceHistory({ + configurationId: configuration.id, + vehicleId, + source: 'CONFIG_UPDATE', + previousDailyRate: currentBaseRate, + nextDailyRate, + previousWeeklyRate: configuration.weeklyRate ?? null, + nextWeeklyRate: configuration.weeklyRate ?? null, + note: 'Base daily rate synced from vehicle details', + }) +} + +function buildPricePreview(vehicle: any, configuration: any) { + const baseDailyRate = configuration.baseDailyRate ?? vehicle.dailyRate + const activeRules = (configuration.rules ?? []).filter((rule: any) => rule.isActive) + const items = Array.from({ length: 14 }, (_, index) => { + const date = new Date(Date.now() + (index * DAY_MS)) + date.setHours(12, 0, 0, 0) + let dailyRate = baseDailyRate + let pricingType = configuration.pricingMode === 'AUTOMATIC' && configuration.automaticPricingEnabled + ? 'AUTOMATIC_BASE' + : 'MANUAL_BASE' + + if (isWeekend(date) && configuration.weekendRate != null) { + dailyRate = configuration.weekendRate + pricingType = 'WEEKEND_RATE' + } + + const matchingRule = activeRules + .filter((rule: any) => date >= new Date(rule.startDate) && date <= new Date(rule.endDate)) + .sort((a: any, b: any) => { + const priorityDiff = (RULE_PRIORITY[b.ruleType] ?? 0) - (RULE_PRIORITY[a.ruleType] ?? 0) + if (priorityDiff !== 0) return priorityDiff + if (a.sortOrder !== b.sortOrder) return b.sortOrder - a.sortOrder + return new Date(b.startDate).getTime() - new Date(a.startDate).getTime() + })[0] + + if (matchingRule) { + if (matchingRule.dailyRate != null) { + dailyRate = matchingRule.dailyRate + } else { + if (matchingRule.weekendRate != null && isWeekend(date)) { + dailyRate = matchingRule.weekendRate + } + if (matchingRule.priceAdjustment != null) { + dailyRate += matchingRule.priceAdjustment + } + if (matchingRule.automaticAdjustmentPct != null) { + dailyRate = Math.round(dailyRate * (100 + matchingRule.automaticAdjustmentPct) / 100) + } + } + pricingType = configuration.pricingMode === 'AUTOMATIC' ? 'AUTOMATIC_RULE' : 'RULE_RATE' + dailyRate = clampRate( + dailyRate, + matchingRule.minDailyRate ?? configuration.minimumDailyRate, + matchingRule.maxDailyRate ?? configuration.maximumDailyRate, + ) + } else { + dailyRate = clampRate(dailyRate, configuration.minimumDailyRate, configuration.maximumDailyRate) + } + + return { + date: date.toISOString().slice(0, 10), + dailyRate, + pricingType, + matchedRuleName: matchingRule?.name ?? null, + } + }) + + return { + today: items[0], + next14Days: items, + } +} + +function presentVehiclePricing(vehicle: any, configuration: any) { + return { + configuration: { + id: configuration.id, + vehicleId: configuration.vehicleId, + pricingMode: configuration.pricingMode, + baseDailyRate: configuration.baseDailyRate ?? vehicle.dailyRate, + weeklyRate: configuration.weeklyRate ?? null, + weekendRate: configuration.weekendRate ?? null, + holidayRate: configuration.holidayRate ?? null, + monthlyRate: configuration.monthlyRate ?? null, + longTermDailyRate: configuration.longTermDailyRate ?? null, + minimumDailyRate: configuration.minimumDailyRate ?? null, + maximumDailyRate: configuration.maximumDailyRate ?? null, + maxDailyPriceMovementPct: configuration.maxDailyPriceMovementPct, + automaticPricingEnabled: configuration.automaticPricingEnabled, + approvalRequired: configuration.approvalRequired, + createdAt: configuration.createdAt, + updatedAt: configuration.updatedAt, + }, + rules: (configuration.rules ?? []).map((rule: any) => ({ + id: rule.id, + name: rule.name, + ruleType: rule.ruleType, + startDate: rule.startDate, + endDate: rule.endDate, + dailyRate: rule.dailyRate, + weeklyRate: rule.weeklyRate, + weekendRate: rule.weekendRate, + monthlyRate: rule.monthlyRate, + minDailyRate: rule.minDailyRate, + maxDailyRate: rule.maxDailyRate, + automaticAdjustmentPct: rule.automaticAdjustmentPct, + priceAdjustment: rule.priceAdjustment, + isActive: rule.isActive, + sortOrder: rule.sortOrder, + createdAt: rule.createdAt, + updatedAt: rule.updatedAt, + })), + history: (configuration.history ?? []).map((entry: any) => ({ + id: entry.id, + source: entry.source, + changedByEmployeeId: entry.changedByEmployeeId, + previousDailyRate: entry.previousDailyRate, + nextDailyRate: entry.nextDailyRate, + previousWeeklyRate: entry.previousWeeklyRate, + nextWeeklyRate: entry.nextWeeklyRate, + note: entry.note, + effectiveFrom: entry.effectiveFrom, + createdAt: entry.createdAt, + })), + preview: buildPricePreview(vehicle, configuration), + } +} + export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) { const page = query.page ?? 1 const pageSize = query.pageSize ?? 20 @@ -58,7 +364,11 @@ export async function getVehicle(id: string, companyId: string) { } export async function createVehicle(data: any, companyId: string) { - return presentVehicle(await repo.create({ ...applyLocationSettings(data), companyId })) + return presentVehicle(await repo.create({ + dailyRate: 0, + ...applyLocationSettings(data), + companyId, + })) } const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED']) @@ -70,7 +380,11 @@ export async function updateVehicle(id: string, companyId: string, data: any) { } const existing = await repo.findFirst(id, companyId) if (!existing) throw new NotFoundError('Vehicle not found') - return presentVehicle(await repo.updateById(id, patch)) + const updated = await repo.updateById(id, patch) + if (patch.dailyRate !== undefined && typeof patch.dailyRate === 'number') { + await syncPricingBaseRateFromVehicle(id, existing.dailyRate, patch.dailyRate) + } + return presentVehicle(updated) } export async function setStatus(id: string, companyId: string, status: string) { @@ -169,3 +483,156 @@ export async function createMaintenanceLog(id: string, companyId: string, data: nextDueAt: data.nextDueAt ? new Date(data.nextDueAt) : undefined, }) } + +export async function getVehiclePricing(id: string, companyId: string) { + const { vehicle, configuration } = await ensurePricingConfigurationForVehicle(id, companyId) + return presentVehiclePricing(vehicle, configuration) +} + +export async function updateVehiclePricing(id: string, companyId: string, employeeId: string, data: any) { + const { vehicle, configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId) + if (!persistenceAvailable) { + throw new AppError( + 'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.', + 503, + 'pricing_migration_required', + ) + } + const nextConfig = normalizePricingConfigurationInput(data) + const currentBaseDailyRate = configuration.baseDailyRate ?? vehicle.dailyRate + const currentWeeklyRate = configuration.weeklyRate ?? null + + const changed = [ + 'pricingMode', + 'baseDailyRate', + 'weeklyRate', + 'weekendRate', + 'holidayRate', + 'monthlyRate', + 'longTermDailyRate', + 'minimumDailyRate', + 'maximumDailyRate', + 'maxDailyPriceMovementPct', + 'automaticPricingEnabled', + 'approvalRequired', + ].some((key) => ((configuration as any)[key] ?? null) !== ((nextConfig as any)[key] ?? null)) + + await repo.updatePricingConfiguration(configuration.id, nextConfig) + + if (vehicle.dailyRate !== nextConfig.baseDailyRate) { + await repo.updateById(id, { dailyRate: nextConfig.baseDailyRate }) + } + + if (changed) { + await repo.createPriceHistory({ + configurationId: configuration.id, + vehicleId: id, + source: 'CONFIG_UPDATE', + changedByEmployeeId: employeeId, + previousDailyRate: currentBaseDailyRate, + nextDailyRate: nextConfig.baseDailyRate, + previousWeeklyRate: currentWeeklyRate, + nextWeeklyRate: nextConfig.weeklyRate ?? null, + note: data.note, + }) + } + + return getVehiclePricing(id, companyId) +} + +export async function createVehiclePricingRule(id: string, companyId: string, employeeId: string, data: any) { + const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId) + if (!persistenceAvailable) { + throw new AppError( + 'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.', + 503, + 'pricing_migration_required', + ) + } + const payload = normalizePricingRuleInput(data) + + const rule = await repo.createPricingRule({ + configurationId: configuration.id, + ...payload, + }) + + await repo.createPriceHistory({ + configurationId: configuration.id, + vehicleId: id, + source: payload.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_CREATED', + changedByEmployeeId: employeeId, + previousDailyRate: configuration.baseDailyRate ?? null, + nextDailyRate: payload.dailyRate, + previousWeeklyRate: configuration.weeklyRate ?? null, + nextWeeklyRate: payload.weeklyRate, + note: `Created pricing rule: ${rule.name}`, + effectiveFrom: rule.startDate, + }) + + return getVehiclePricing(id, companyId) +} + +export async function updateVehiclePricingRule(id: string, companyId: string, ruleId: string, employeeId: string, data: any) { + const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId) + if (!persistenceAvailable) { + throw new AppError( + 'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.', + 503, + 'pricing_migration_required', + ) + } + const existingRule = await repo.findPricingRule(ruleId, configuration.id) + if (!existingRule) throw new NotFoundError('Pricing rule not found') + + const mergedRule = normalizePricingRuleInput({ + ...existingRule, + ...data, + startDate: data.startDate ?? existingRule.startDate, + endDate: data.endDate ?? existingRule.endDate, + }) + + const updatedRule = await repo.updatePricingRule(ruleId, mergedRule) + await repo.createPriceHistory({ + configurationId: configuration.id, + vehicleId: id, + source: mergedRule.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_UPDATED', + changedByEmployeeId: employeeId, + previousDailyRate: existingRule.dailyRate, + nextDailyRate: updatedRule.dailyRate, + previousWeeklyRate: existingRule.weeklyRate, + nextWeeklyRate: updatedRule.weeklyRate, + note: `Updated pricing rule: ${updatedRule.name}`, + effectiveFrom: updatedRule.startDate, + }) + + return getVehiclePricing(id, companyId) +} + +export async function deleteVehiclePricingRule(id: string, companyId: string, ruleId: string, employeeId: string) { + const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId) + if (!persistenceAvailable) { + throw new AppError( + 'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.', + 503, + 'pricing_migration_required', + ) + } + const existingRule = await repo.findPricingRule(ruleId, configuration.id) + if (!existingRule) throw new NotFoundError('Pricing rule not found') + + await repo.deletePricingRule(ruleId, configuration.id) + await repo.createPriceHistory({ + configurationId: configuration.id, + vehicleId: id, + source: existingRule.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_DELETED', + changedByEmployeeId: employeeId, + previousDailyRate: existingRule.dailyRate, + nextDailyRate: configuration.baseDailyRate ?? null, + previousWeeklyRate: existingRule.weeklyRate, + nextWeeklyRate: configuration.weeklyRate ?? null, + note: `Deleted pricing rule: ${existingRule.name}`, + effectiveFrom: existingRule.startDate, + }) + + return getVehiclePricing(id, companyId) +} diff --git a/apps/api/src/modules/vehicles/vehicle.test.ts b/apps/api/src/modules/vehicles/vehicle.test.ts index d76e9f5..b236b88 100644 --- a/apps/api/src/modules/vehicles/vehicle.test.ts +++ b/apps/api/src/modules/vehicles/vehicle.test.ts @@ -35,6 +35,12 @@ describe('vehicle.service', () => { expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' })) expect(result).toEqual(mockVehicle) }) + + it('defaults dailyRate to zero when omitted', async () => { + vi.mocked(repo.create).mockResolvedValue({ ...mockVehicle, dailyRate: 0 } as any) + await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1') + expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1', dailyRate: 0 })) + }) }) describe('updateVehicle', () => { diff --git a/apps/api/src/tests/integration/vehicles.test.ts b/apps/api/src/tests/integration/vehicles.test.ts index 9f24fe7..e4469c4 100644 --- a/apps/api/src/tests/integration/vehicles.test.ts +++ b/apps/api/src/tests/integration/vehicles.test.ts @@ -77,6 +77,22 @@ describe('Vehicles API', () => { expect(res.body.data.dropoffLocations).toEqual(['Rabat Downtown']) }) + it('creates a vehicle without a daily rate and defaults it to zero', async () => { + const res = await request(app) + .post('/api/v1/vehicles') + .set(authHeader(token)) + .send({ + make: 'Nissan', + model: 'Micra', + year: 2024, + licensePlate: `ZERO-${Date.now()}`, + category: 'ECONOMY', + }) + + expect(res.status).toBe(201) + expect(res.body.data.dailyRate).toBe(0) + }) + it('returns 400 for missing required fields', async () => { const res = await request(app) .post('/api/v1/vehicles') @@ -228,4 +244,101 @@ describe('Vehicles API', () => { expect(res.status).toBe(404) }) }) + + describe('Vehicle pricing management', () => { + it('returns a default pricing configuration for a vehicle', async () => { + const vehicle = await createVehicle(companyId, { dailyRate: 6250 }) + + const res = await request(app) + .get(`/api/v1/vehicles/${vehicle.id}/pricing`) + .set(authHeader(token)) + + expect(res.status).toBe(200) + expect(res.body.data.configuration.baseDailyRate).toBe(6250) + expect(res.body.data.configuration.pricingMode).toBe('MANUAL') + expect(Array.isArray(res.body.data.preview.next14Days)).toBe(true) + expect(res.body.data.preview.next14Days.length).toBe(14) + }) + + it('updates vehicle pricing configuration and syncs the vehicle daily rate', async () => { + const vehicle = await createVehicle(companyId, { dailyRate: 5000 }) + + const res = await request(app) + .put(`/api/v1/vehicles/${vehicle.id}/pricing`) + .set(authHeader(token)) + .send({ + pricingMode: 'AUTOMATIC', + baseDailyRate: 6400, + weeklyRate: 39900, + weekendRate: 7200, + holidayRate: null, + monthlyRate: 118000, + longTermDailyRate: 5600, + minimumDailyRate: 5900, + maximumDailyRate: 8600, + maxDailyPriceMovementPct: 12, + automaticPricingEnabled: true, + approvalRequired: true, + note: 'Summer setup', + }) + + expect(res.status).toBe(200) + expect(res.body.data.configuration.pricingMode).toBe('AUTOMATIC') + expect(res.body.data.configuration.baseDailyRate).toBe(6400) + expect(res.body.data.history[0].note).toBe('Summer setup') + + const updatedVehicle = await prisma.vehicle.findUniqueOrThrow({ where: { id: vehicle.id } }) + expect(updatedVehicle.dailyRate).toBe(6400) + }) + + it('creates, updates, and deletes pricing rules', async () => { + const vehicle = await createVehicle(companyId, { dailyRate: 5400 }) + + const createRes = await request(app) + .post(`/api/v1/vehicles/${vehicle.id}/pricing/rules`) + .set(authHeader(token)) + .send({ + name: 'Summer high season', + ruleType: 'SEASONAL', + startDate: '2026-07-01', + endDate: '2026-08-31', + dailyRate: 7800, + weeklyRate: 46900, + weekendRate: null, + monthlyRate: null, + minDailyRate: null, + maxDailyRate: null, + automaticAdjustmentPct: null, + priceAdjustment: null, + isActive: true, + sortOrder: 30, + }) + + expect(createRes.status).toBe(201) + expect(createRes.body.data.rules.length).toBe(1) + const createdRule = createRes.body.data.rules[0] + + const updateRes = await request(app) + .patch(`/api/v1/vehicles/${vehicle.id}/pricing/rules/${createdRule.id}`) + .set(authHeader(token)) + .send({ + dailyRate: 8100, + weeklyRate: 48900, + sortOrder: 40, + }) + + expect(updateRes.status).toBe(200) + const updatedRule = updateRes.body.data.rules.find((rule: any) => rule.id === createdRule.id) + expect(updatedRule.dailyRate).toBe(8100) + expect(updatedRule.weeklyRate).toBe(48900) + + const deleteRes = await request(app) + .delete(`/api/v1/vehicles/${vehicle.id}/pricing/rules/${createdRule.id}`) + .set(authHeader(token)) + + expect(deleteRes.status).toBe(200) + expect(deleteRes.body.data.rules).toHaveLength(0) + expect(deleteRes.body.data.history.length).toBeGreaterThanOrEqual(1) + }) + }) }) diff --git a/apps/api/src/tests/setup.ts b/apps/api/src/tests/setup.ts index 001f0ce..08a76aa 100644 --- a/apps/api/src/tests/setup.ts +++ b/apps/api/src/tests/setup.ts @@ -32,6 +32,9 @@ const delegates = [ 'offerVehicle', 'offer', 'vehicleCalendarBlock', + 'vehiclePriceHistory', + 'vehiclePricingRule', + 'vehiclePricingConfiguration', 'maintenanceLog', 'vehicle', 'employee', diff --git a/apps/api/tsconfig.vehicle-pricing.json b/apps/api/tsconfig.vehicle-pricing.json new file mode 100644 index 0000000..b93f1af --- /dev/null +++ b/apps/api/tsconfig.vehicle-pricing.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src/app.ts", + "src/http/**/*.ts", + "src/lib/**/*.ts", + "src/middleware/**/*.ts", + "src/modules/vehicles/**/*.ts", + "src/services/vehicleAvailabilityService.ts", + "src/tests/helpers/**/*.ts", + "src/tests/integration/vehicles.test.ts", + "src/tests/setup.ts", + "src/types/**/*.d.ts" + ], + "exclude": [] +} diff --git a/apps/dashboard/src/app/(dashboard)/fleet/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/fleet/[id]/page.tsx index 6b28b61..b7e1ed7 100644 --- a/apps/dashboard/src/app/(dashboard)/fleet/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/fleet/[id]/page.tsx @@ -1,12 +1,13 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { useParams, useRouter } from 'next/navigation' +import { useParams, useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' -import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react' +import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, DollarSign, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react' import { formatCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' import VehicleCalendar from '@/components/VehicleCalendar' +import VehiclePricingManager from '@/components/VehiclePricingManager' import { useDashboardI18n } from '@/components/I18nProvider' interface VehicleDetail { @@ -23,6 +24,7 @@ interface VehicleDetail { seats: number mileage: number | null licensePlate: string + vin: string | null photos: string[] notes: string | null isPublished: boolean @@ -276,12 +278,13 @@ function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, lo ) } -type Tab = 'details' | 'maintenance' | 'calendar' +type Tab = 'details' | 'maintenance' | 'calendar' | 'pricing' export default function FleetDetailPage() { const params = useParams<{ id: string }>() const router = useRouter() - const { dict } = useDashboardI18n() + const searchParams = useSearchParams() + const { dict, language } = useDashboardI18n() const vd = dict.vehicleDetail const fl = dict.fleet @@ -294,7 +297,7 @@ export default function FleetDetailPage() { const [form, setForm] = useState<{ make: string; model: string; year: number; category: string - dailyRate: string; licensePlate: string; color: string + dailyRate: string; licensePlate: string; vin: string; color: string seats: number; transmission: string; fuelType: string status: string; notes: string; mileage: string pickupLocations: string; allowDifferentDropoff: boolean; dropoffLocations: string @@ -325,6 +328,12 @@ export default function FleetDetailPage() { .catch((err) => setError(err.message)) }, [params.id]) + useEffect(() => { + if (searchParams.get('tab') === 'pricing') { + setActiveTab('pricing') + } + }, [searchParams]) + const startEdit = () => { if (!vehicle) return setForm({ @@ -332,6 +341,7 @@ export default function FleetDetailPage() { category: vehicle.category, dailyRate: (vehicle.dailyRate / 100).toFixed(2), licensePlate: vehicle.licensePlate, + vin: vehicle.vin ?? '', color: vehicle.color ?? '', seats: vehicle.seats, transmission: vehicle.transmission, fuelType: vehicle.fuelType, status: vehicle.status, notes: vehicle.notes ?? '', @@ -376,7 +386,7 @@ export default function FleetDetailPage() { body: JSON.stringify({ make: form.make, model: form.model, year: Number(form.year), category: form.category, dailyRate: Math.round(rate * 100), - licensePlate: form.licensePlate, color: form.color, + licensePlate: form.licensePlate, vin: form.vin.trim() || undefined, color: form.color, seats: Number(form.seats), transmission: form.transmission, fuelType: form.fuelType, status: form.status, notes: form.notes || undefined, @@ -449,6 +459,7 @@ export default function FleetDetailPage() { const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic + const pricingTabLabel = language === 'fr' ? 'Tarifs' : language === 'ar' ? 'التسعير' : 'Pricing' return (
@@ -460,7 +471,12 @@ export default function FleetDetailPage() {

{vehicle.make} {vehicle.model}

-

{vehicle.year} · {vehicle.licensePlate} · {statusLabel}

+

+ {vehicle.year} · {vehicle.licensePlate} + {vehicle.vin ? ` · ${vehicle.vin}` : ''} + {' · '} + {statusLabel} +

{!editing ? ( @@ -486,7 +502,7 @@ export default function FleetDetailPage() { {/* Tab bar */}
- {(['details', 'maintenance', 'calendar'] as Tab[]).map((tab) => ( + {(['details', 'maintenance', 'calendar', 'pricing'] as Tab[]).map((tab) => ( ))}
@@ -578,6 +595,7 @@ export default function FleetDetailPage() {
{vd.labelCategory}
{categoryLabel}
{vd.labelDailyRate}
{formatCurrency(vehicle.dailyRate, 'MAD')}
{vd.labelStatus}
{statusLabel}
+
{vd.vinLabel}
{vehicle.vin || '—'}
{vd.labelSeats}
{vehicle.seats}
{vd.labelTransmission}
{transLabel}
{vd.labelFuelType}
{fuelLabel}
@@ -657,8 +675,8 @@ export default function FleetDetailPage() { - {/* Daily Rate & License Plate */} -
+ {/* Daily Rate, License Plate & VIN */} +
setForm({ ...form, dailyRate: e.target.value })} /> @@ -668,6 +686,10 @@ export default function FleetDetailPage() { setForm({ ...form, licensePlate: e.target.value })} />
+
+ + setForm({ ...form, vin: e.target.value })} /> +
{/* Status */}
@@ -807,6 +829,10 @@ export default function FleetDetailPage() { {activeTab === 'calendar' && ( )} + + {activeTab === 'pricing' && ( + + )}
) } diff --git a/apps/dashboard/src/app/(dashboard)/fleet/page.tsx b/apps/dashboard/src/app/(dashboard)/fleet/page.tsx index 5e3b35f..96f39fa 100644 --- a/apps/dashboard/src/app/(dashboard)/fleet/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/fleet/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Plus, Edit, Eye, Search, Upload, X, ChevronDown } from 'lucide-react' +import { Plus, Edit, Eye, Search, Upload, X, ChevronDown, DollarSign } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { apiFetch } from '@/lib/api' @@ -19,6 +19,7 @@ interface Vehicle { isPublished: boolean primaryPhoto: string | null licensePlate: string + vin?: string | null pickupLocations?: string[] allowDifferentDropoff?: boolean dropoffLocations?: string[] @@ -279,7 +280,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) { const f = dict.fleet const [form, setForm] = useState({ make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', - dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', + licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '', }) const [colorSelect, setColorSelect] = useState('') @@ -311,7 +312,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) { const reset = () => { setForm({ make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', - dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', + licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '', }) setColorSelect('') @@ -330,7 +331,6 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) { if (!form.make) { setError(f.makeMissing); return } if (!form.model) { setError(f.modelMissing); return } if (!form.licensePlate) { setError(f.plateMissing); return } - if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError(f.rateMissing); return } if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) { setError(f.dropoffLocationsRequired) return @@ -342,9 +342,9 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) { method: 'POST', body: JSON.stringify({ ...form, - dailyRate: Math.round(parseFloat(form.dailyRate) * 100), year: Number(form.year), seats: Number(form.seats), + vin: form.vin.trim() || undefined, mileage: form.mileage ? Number(form.mileage) : undefined, pickupLocations: parseLocationInput(form.pickupLocations), allowDifferentDropoff: form.allowDifferentDropoff, @@ -442,16 +442,16 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
- {/* Daily Rate & License Plate */} -
-
- - setForm({ ...form, dailyRate: e.target.value })} /> -
+ {/* License Plate & VIN */} +
setForm({ ...form, licensePlate: e.target.value })} />
+
+ + setForm({ ...form, vin: e.target.value })} /> +
{/* Color, Seats, Transmission */} @@ -649,7 +649,7 @@ export default function FleetPage() { } const filtered = vehicles.filter((v) => { - if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false + if (search && !`${v.make} ${v.model} ${v.licensePlate} ${v.vin ?? ''}`.toLowerCase().includes(search.toLowerCase())) return false if (statusFilter && v.status !== statusFilter) return false if (categoryFilter && v.category !== categoryFilter) return false if (publishedFilter === 'published' && !v.isPublished) return false @@ -729,7 +729,10 @@ export default function FleetPage() {

{vehicle.make} {vehicle.model}

-

{vehicle.year} · {vehicle.licensePlate}

+

+ {vehicle.year} · {vehicle.licensePlate} + {vehicle.vin ? ` · ${vehicle.vin}` : ''} +

@@ -761,6 +764,9 @@ export default function FleetPage() {
+ + + diff --git a/apps/dashboard/src/components/I18nProvider.tsx b/apps/dashboard/src/components/I18nProvider.tsx index fb7d75c..1465685 100644 --- a/apps/dashboard/src/components/I18nProvider.tsx +++ b/apps/dashboard/src/components/I18nProvider.tsx @@ -41,6 +41,7 @@ type FleetDict = { category: string dailyRate: string licensePlate: string + vin: string color: string select: string typeColor: string @@ -253,6 +254,7 @@ type VehicleDetailDict = { yearLabel: string dailyRateLabel: string licensePlateLabel: string + vinLabel: string statusLabel: string colorLabel: string selectColor: string @@ -393,6 +395,7 @@ const dictionaries: Record = { category: 'Category', dailyRate: 'Daily Rate (MAD) *', licensePlate: 'License Plate *', + vin: 'VIN', color: 'Color', select: 'Select…', typeColor: 'Type color…', @@ -487,6 +490,7 @@ const dictionaries: Record = { yearLabel: 'Year *', dailyRateLabel: 'Daily Rate (MAD) *', licensePlateLabel: 'License Plate *', + vinLabel: 'VIN', statusLabel: 'Status', colorLabel: 'Color', selectColor: 'Select color…', @@ -747,6 +751,7 @@ const dictionaries: Record = { category: 'Catégorie', dailyRate: 'Tarif journalier (MAD) *', licensePlate: "Plaque d'immatriculation *", + vin: 'VIN', color: 'Couleur', select: 'Sélectionner…', typeColor: 'Saisir la couleur…', @@ -841,6 +846,7 @@ const dictionaries: Record = { yearLabel: 'Année *', dailyRateLabel: 'Tarif journalier (MAD) *', licensePlateLabel: "Plaque d'immatriculation *", + vinLabel: 'VIN', statusLabel: 'Statut', colorLabel: 'Couleur', selectColor: 'Sélectionner la couleur…', @@ -1101,6 +1107,7 @@ const dictionaries: Record = { category: 'الفئة', dailyRate: 'السعر اليومي (درهم) *', licensePlate: 'رقم اللوحة *', + vin: 'رقم الهيكل', color: 'اللون', select: 'اختر…', typeColor: 'اكتب اللون…', @@ -1195,6 +1202,7 @@ const dictionaries: Record = { yearLabel: 'السنة *', dailyRateLabel: 'السعر اليومي (درهم) *', licensePlateLabel: 'رقم اللوحة *', + vinLabel: 'رقم الهيكل', statusLabel: 'الحالة', colorLabel: 'اللون', selectColor: 'اختر اللون…', diff --git a/apps/dashboard/src/components/VehiclePricingManager.tsx b/apps/dashboard/src/components/VehiclePricingManager.tsx new file mode 100644 index 0000000..2e61e9a --- /dev/null +++ b/apps/dashboard/src/components/VehiclePricingManager.tsx @@ -0,0 +1,848 @@ +'use client' + +import { useEffect, useState } from 'react' +import { CalendarRange, History, PlusCircle, Save, Sparkles, Trash2 } from 'lucide-react' +import { formatCurrency } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' +import { useDashboardI18n } from '@/components/I18nProvider' + +interface PricingConfiguration { + id: string + vehicleId: string + pricingMode: 'MANUAL' | 'AUTOMATIC' + baseDailyRate: number + weeklyRate: number | null + weekendRate: number | null + holidayRate: number | null + monthlyRate: number | null + longTermDailyRate: number | null + minimumDailyRate: number | null + maximumDailyRate: number | null + maxDailyPriceMovementPct: number + automaticPricingEnabled: boolean + approvalRequired: boolean + createdAt: string + updatedAt: string +} + +interface PricingRule { + id: string + name: string + ruleType: 'DATE_RANGE' | 'SEASONAL' | 'HOLIDAY' | 'WEEKEND' | 'SPECIAL_EVENT' | 'MANUAL_OVERRIDE' + startDate: string + endDate: string + dailyRate: number | null + weeklyRate: number | null + weekendRate: number | null + monthlyRate: number | null + minDailyRate: number | null + maxDailyRate: number | null + automaticAdjustmentPct: number | null + priceAdjustment: number | null + isActive: boolean + sortOrder: number + createdAt: string + updatedAt: string +} + +interface PricingHistoryEntry { + id: string + source: 'CONFIG_UPDATE' | 'RULE_CREATED' | 'RULE_UPDATED' | 'RULE_DELETED' | 'MANUAL_OVERRIDE' + changedByEmployeeId: string | null + previousDailyRate: number | null + nextDailyRate: number | null + previousWeeklyRate: number | null + nextWeeklyRate: number | null + note: string | null + effectiveFrom: string | null + createdAt: string +} + +interface PricingPreviewItem { + date: string + dailyRate: number + pricingType: 'MANUAL_BASE' | 'AUTOMATIC_BASE' | 'WEEKEND_RATE' | 'RULE_RATE' | 'AUTOMATIC_RULE' + matchedRuleName: string | null +} + +interface PricingResponse { + configuration: PricingConfiguration + rules: PricingRule[] + history: PricingHistoryEntry[] + preview: { + today: PricingPreviewItem + next14Days: PricingPreviewItem[] + } +} + +interface ConfigForm { + pricingMode: 'MANUAL' | 'AUTOMATIC' + baseDailyRate: string + weeklyRate: string + weekendRate: string + holidayRate: string + monthlyRate: string + longTermDailyRate: string + minimumDailyRate: string + maximumDailyRate: string + maxDailyPriceMovementPct: string + automaticPricingEnabled: boolean + approvalRequired: boolean +} + +interface RuleForm { + id?: string + name: string + ruleType: PricingRule['ruleType'] + startDate: string + endDate: string + dailyRate: string + weeklyRate: string + weekendRate: string + monthlyRate: string + minDailyRate: string + maxDailyRate: string + automaticAdjustmentPct: string + priceAdjustment: string + isActive: boolean + sortOrder: string +} + +function centsToInput(value: number | null | undefined) { + return value == null ? '' : (value / 100).toFixed(2) +} + +function inputToCents(value: string) { + if (!value.trim()) return null + const parsed = Number.parseFloat(value) + if (!Number.isFinite(parsed)) return null + return Math.round(parsed * 100) +} + +function numericInputToInt(value: string) { + if (!value.trim()) return null + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : null +} + +function toDateInput(value: string) { + return value.slice(0, 10) +} + +function buildConfigForm(configuration: PricingConfiguration): ConfigForm { + return { + pricingMode: configuration.pricingMode, + baseDailyRate: centsToInput(configuration.baseDailyRate), + weeklyRate: centsToInput(configuration.weeklyRate), + weekendRate: centsToInput(configuration.weekendRate), + holidayRate: centsToInput(configuration.holidayRate), + monthlyRate: centsToInput(configuration.monthlyRate), + longTermDailyRate: centsToInput(configuration.longTermDailyRate), + minimumDailyRate: centsToInput(configuration.minimumDailyRate), + maximumDailyRate: centsToInput(configuration.maximumDailyRate), + maxDailyPriceMovementPct: String(configuration.maxDailyPriceMovementPct), + automaticPricingEnabled: configuration.automaticPricingEnabled, + approvalRequired: configuration.approvalRequired, + } +} + +function buildRuleForm(rule?: PricingRule): RuleForm { + const today = new Date().toISOString().slice(0, 10) + const nextWeek = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + + return { + id: rule?.id, + name: rule?.name ?? '', + ruleType: rule?.ruleType ?? 'DATE_RANGE', + startDate: rule ? toDateInput(rule.startDate) : today, + endDate: rule ? toDateInput(rule.endDate) : nextWeek, + dailyRate: centsToInput(rule?.dailyRate), + weeklyRate: centsToInput(rule?.weeklyRate), + weekendRate: centsToInput(rule?.weekendRate), + monthlyRate: centsToInput(rule?.monthlyRate), + minDailyRate: centsToInput(rule?.minDailyRate), + maxDailyRate: centsToInput(rule?.maxDailyRate), + automaticAdjustmentPct: rule?.automaticAdjustmentPct != null ? String(rule.automaticAdjustmentPct) : '', + priceAdjustment: centsToInput(rule?.priceAdjustment), + isActive: rule?.isActive ?? true, + sortOrder: rule?.sortOrder != null ? String(rule.sortOrder) : '0', + } +} + +export default function VehiclePricingManager({ vehicleId }: { vehicleId: string }) { + const { language } = useDashboardI18n() + const copy = { + en: { + title: 'Pricing management', + subtitle: 'Manage fixed rates, automatic pricing bounds, seasonal rules, and pricing history for this vehicle.', + savePricing: 'Save pricing', + saving: 'Saving…', + loading: 'Loading pricing settings…', + mode: 'Pricing method', + manual: 'Manual fixed pricing', + automatic: 'Automatic dynamic pricing', + baseDailyRate: 'Base daily rate (MAD)', + weeklyRate: 'Weekly rate (MAD)', + weekendRate: 'Weekend rate (MAD)', + holidayRate: 'Holiday rate (MAD)', + monthlyRate: 'Monthly rate (MAD)', + longTermDailyRate: 'Long-term daily rate (MAD)', + minimumDailyRate: 'Minimum daily rate (MAD)', + maximumDailyRate: 'Maximum daily rate (MAD)', + automaticPricingEnabled: 'Automatic pricing enabled', + approvalRequired: 'Approve automatic price changes before they go live', + maxDailyMovement: 'Max daily price movement (%)', + currentPreview: 'Current pricing preview', + next14Days: 'Next 14 days', + matchedRule: 'Matched rule', + noRule: 'No rule', + rules: 'Seasonal and manual rules', + addRule: 'Add rule', + createRule: 'Create rule', + updateRule: 'Save rule', + deleting: 'Deleting…', + deleteRule: 'Delete', + noRules: 'No vehicle-specific pricing rules yet.', + history: 'Pricing history', + noHistory: 'No pricing changes recorded yet.', + active: 'Active', + inactive: 'Inactive', + startDate: 'Start date', + endDate: 'End date', + name: 'Rule name', + ruleType: 'Rule type', + adjustmentPct: 'Auto adjustment (%)', + priceAdjustment: 'Flat adjustment (MAD)', + sortOrder: 'Priority', + saveErrorBase: 'Base daily rate is required.', + saveErrorBounds: 'Minimum daily rate cannot be greater than maximum daily rate.', + saveErrorRule: 'Rules need a name plus at least one rate, bound, or adjustment.', + configSaved: 'Pricing settings saved.', + ruleSaved: 'Pricing rule saved.', + ruleCreated: 'Pricing rule created.', + ruleDeleted: 'Pricing rule deleted.', + previewLabels: { + MANUAL_BASE: 'Base manual price', + AUTOMATIC_BASE: 'Automatic base price', + WEEKEND_RATE: 'Weekend price', + RULE_RATE: 'Manual rule', + AUTOMATIC_RULE: 'Automatic rule', + } as Record, + ruleTypeLabels: { + DATE_RANGE: 'Date range', + SEASONAL: 'Season', + HOLIDAY: 'Holiday', + WEEKEND: 'Weekend', + SPECIAL_EVENT: 'Special event', + MANUAL_OVERRIDE: 'Manual override', + } as Record, + historyLabels: { + CONFIG_UPDATE: 'Configuration updated', + RULE_CREATED: 'Rule created', + RULE_UPDATED: 'Rule updated', + RULE_DELETED: 'Rule deleted', + MANUAL_OVERRIDE: 'Manual override', + } as Record, + ruleHint: 'Use direct rates for fixed pricing or adjustments and bounds to guide automatic pricing.', + }, + fr: { + title: 'Gestion des tarifs', + subtitle: 'Gérez les tarifs fixes, les limites automatiques, les saisons et l’historique des prix de ce véhicule.', + savePricing: 'Enregistrer les tarifs', + saving: 'Enregistrement…', + loading: 'Chargement des paramètres tarifaires…', + mode: 'Méthode tarifaire', + manual: 'Tarification fixe manuelle', + automatic: 'Tarification dynamique automatique', + baseDailyRate: 'Tarif journalier de base (MAD)', + weeklyRate: 'Tarif hebdomadaire (MAD)', + weekendRate: 'Tarif week-end (MAD)', + holidayRate: 'Tarif jours fériés (MAD)', + monthlyRate: 'Tarif mensuel (MAD)', + longTermDailyRate: 'Tarif long séjour / jour (MAD)', + minimumDailyRate: 'Tarif journalier minimum (MAD)', + maximumDailyRate: 'Tarif journalier maximum (MAD)', + automaticPricingEnabled: 'Tarification automatique activée', + approvalRequired: 'Valider les changements automatiques avant publication', + maxDailyMovement: 'Variation maximale par jour (%)', + currentPreview: 'Aperçu des tarifs', + next14Days: '14 prochains jours', + matchedRule: 'Règle appliquée', + noRule: 'Aucune règle', + rules: 'Règles saisonnières et manuelles', + addRule: 'Ajouter une règle', + createRule: 'Créer la règle', + updateRule: 'Enregistrer la règle', + deleting: 'Suppression…', + deleteRule: 'Supprimer', + noRules: 'Aucune règle spécifique au véhicule pour le moment.', + history: 'Historique tarifaire', + noHistory: 'Aucun changement tarifaire enregistré.', + active: 'Active', + inactive: 'Inactive', + startDate: 'Date de début', + endDate: 'Date de fin', + name: 'Nom de la règle', + ruleType: 'Type de règle', + adjustmentPct: 'Ajustement auto (%)', + priceAdjustment: 'Ajustement fixe (MAD)', + sortOrder: 'Priorité', + saveErrorBase: 'Le tarif journalier de base est requis.', + saveErrorBounds: 'Le tarif minimum ne peut pas dépasser le maximum.', + saveErrorRule: 'Les règles nécessitent un nom et au moins un tarif, une limite ou un ajustement.', + configSaved: 'Paramètres tarifaires enregistrés.', + ruleSaved: 'Règle tarifaire enregistrée.', + ruleCreated: 'Règle tarifaire créée.', + ruleDeleted: 'Règle tarifaire supprimée.', + previewLabels: { + MANUAL_BASE: 'Tarif manuel de base', + AUTOMATIC_BASE: 'Base automatique', + WEEKEND_RATE: 'Tarif week-end', + RULE_RATE: 'Règle manuelle', + AUTOMATIC_RULE: 'Règle automatique', + } as Record, + ruleTypeLabels: { + DATE_RANGE: 'Plage de dates', + SEASONAL: 'Saison', + HOLIDAY: 'Jour férié', + WEEKEND: 'Week-end', + SPECIAL_EVENT: 'Événement spécial', + MANUAL_OVERRIDE: 'Forçage manuel', + } as Record, + historyLabels: { + CONFIG_UPDATE: 'Configuration mise à jour', + RULE_CREATED: 'Règle créée', + RULE_UPDATED: 'Règle mise à jour', + RULE_DELETED: 'Règle supprimée', + MANUAL_OVERRIDE: 'Forçage manuel', + } as Record, + ruleHint: 'Utilisez des tarifs directs pour le fixe ou des ajustements et limites pour guider l’automatique.', + }, + ar: { + title: 'إدارة التسعير', + subtitle: 'إدارة الأسعار الثابتة وحدود التسعير التلقائي والقواعد الموسمية وسجل تغييرات السعر لهذه السيارة.', + savePricing: 'حفظ التسعير', + saving: 'جارٍ الحفظ…', + loading: 'جارٍ تحميل إعدادات التسعير…', + mode: 'طريقة التسعير', + manual: 'تسعير ثابت يدوي', + automatic: 'تسعير ديناميكي تلقائي', + baseDailyRate: 'السعر اليومي الأساسي (درهم)', + weeklyRate: 'السعر الأسبوعي (درهم)', + weekendRate: 'سعر عطلة نهاية الأسبوع (درهم)', + holidayRate: 'سعر العطل (درهم)', + monthlyRate: 'السعر الشهري (درهم)', + longTermDailyRate: 'سعر الإيجار الطويل / يوم (درهم)', + minimumDailyRate: 'الحد الأدنى للسعر اليومي (درهم)', + maximumDailyRate: 'الحد الأقصى للسعر اليومي (درهم)', + automaticPricingEnabled: 'تفعيل التسعير التلقائي', + approvalRequired: 'اعتماد تغييرات السعر التلقائية قبل نشرها', + maxDailyMovement: 'أقصى تغيير يومي (%)', + currentPreview: 'معاينة التسعير الحالية', + next14Days: 'الأيام الـ 14 القادمة', + matchedRule: 'القاعدة المطبقة', + noRule: 'لا توجد قاعدة', + rules: 'القواعد الموسمية واليدوية', + addRule: 'إضافة قاعدة', + createRule: 'إنشاء القاعدة', + updateRule: 'حفظ القاعدة', + deleting: 'جارٍ الحذف…', + deleteRule: 'حذف', + noRules: 'لا توجد قواعد تسعير خاصة بهذه السيارة بعد.', + history: 'سجل التسعير', + noHistory: 'لا توجد تغييرات تسعير مسجلة بعد.', + active: 'نشطة', + inactive: 'غير نشطة', + startDate: 'تاريخ البدء', + endDate: 'تاريخ الانتهاء', + name: 'اسم القاعدة', + ruleType: 'نوع القاعدة', + adjustmentPct: 'تعديل تلقائي (%)', + priceAdjustment: 'تعديل ثابت (درهم)', + sortOrder: 'الأولوية', + saveErrorBase: 'السعر اليومي الأساسي مطلوب.', + saveErrorBounds: 'لا يمكن أن يكون الحد الأدنى أعلى من الحد الأقصى.', + saveErrorRule: 'تحتاج القواعد إلى اسم وإلى سعر أو حد أو تعديل واحد على الأقل.', + configSaved: 'تم حفظ إعدادات التسعير.', + ruleSaved: 'تم حفظ قاعدة التسعير.', + ruleCreated: 'تم إنشاء قاعدة التسعير.', + ruleDeleted: 'تم حذف قاعدة التسعير.', + previewLabels: { + MANUAL_BASE: 'السعر اليدوي الأساسي', + AUTOMATIC_BASE: 'السعر الأساسي التلقائي', + WEEKEND_RATE: 'سعر عطلة نهاية الأسبوع', + RULE_RATE: 'قاعدة يدوية', + AUTOMATIC_RULE: 'قاعدة تلقائية', + } as Record, + ruleTypeLabels: { + DATE_RANGE: 'نطاق تاريخ', + SEASONAL: 'موسم', + HOLIDAY: 'عطلة', + WEEKEND: 'نهاية أسبوع', + SPECIAL_EVENT: 'مناسبة خاصة', + MANUAL_OVERRIDE: 'تجاوز يدوي', + } as Record, + historyLabels: { + CONFIG_UPDATE: 'تم تحديث الإعدادات', + RULE_CREATED: 'تم إنشاء قاعدة', + RULE_UPDATED: 'تم تحديث قاعدة', + RULE_DELETED: 'تم حذف قاعدة', + MANUAL_OVERRIDE: 'تجاوز يدوي', + } as Record, + ruleHint: 'استخدم أسعاراً مباشرة للتسعير الثابت أو تعديلات وحدوداً لتوجيه التسعير التلقائي.', + }, + }[language] + + const [loading, setLoading] = useState(true) + const [savingConfig, setSavingConfig] = useState(false) + const [savingRuleId, setSavingRuleId] = useState(null) + const [deletingRuleId, setDeletingRuleId] = useState(null) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [configForm, setConfigForm] = useState(null) + const [rules, setRules] = useState([]) + const [newRule, setNewRule] = useState(buildRuleForm()) + const [history, setHistory] = useState([]) + const [preview, setPreview] = useState(null) + + function applyResponse(data: PricingResponse) { + setConfigForm(buildConfigForm(data.configuration)) + setRules(data.rules.map((rule) => buildRuleForm(rule))) + setHistory(data.history) + setPreview(data.preview) + } + + async function load() { + setLoading(true) + try { + const data = await apiFetch(`/vehicles/${vehicleId}/pricing`) + applyResponse(data) + setError(null) + } catch (err: any) { + setError(err.message ?? 'Failed to load vehicle pricing') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, [vehicleId]) + + async function saveConfiguration() { + if (!configForm) return + const baseDailyRate = inputToCents(configForm.baseDailyRate) + const minimumDailyRate = inputToCents(configForm.minimumDailyRate) + const maximumDailyRate = inputToCents(configForm.maximumDailyRate) + + if (baseDailyRate == null) { + setError(copy.saveErrorBase) + return + } + if (minimumDailyRate != null && maximumDailyRate != null && minimumDailyRate > maximumDailyRate) { + setError(copy.saveErrorBounds) + return + } + + setSavingConfig(true) + setError(null) + setSuccess(null) + try { + const data = await apiFetch(`/vehicles/${vehicleId}/pricing`, { + method: 'PUT', + body: JSON.stringify({ + pricingMode: configForm.pricingMode, + baseDailyRate, + weeklyRate: inputToCents(configForm.weeklyRate), + weekendRate: inputToCents(configForm.weekendRate), + holidayRate: inputToCents(configForm.holidayRate), + monthlyRate: inputToCents(configForm.monthlyRate), + longTermDailyRate: inputToCents(configForm.longTermDailyRate), + minimumDailyRate, + maximumDailyRate, + maxDailyPriceMovementPct: numericInputToInt(configForm.maxDailyPriceMovementPct) ?? 10, + automaticPricingEnabled: configForm.automaticPricingEnabled, + approvalRequired: configForm.approvalRequired, + note: copy.configSaved, + }), + }) + applyResponse(data) + setSuccess(copy.configSaved) + } catch (err: any) { + setError(err.message ?? copy.saveErrorBase) + } finally { + setSavingConfig(false) + } + } + + async function saveRule(rule: RuleForm, create = false) { + const hasPricingEffect = [ + inputToCents(rule.dailyRate), + inputToCents(rule.weeklyRate), + inputToCents(rule.weekendRate), + inputToCents(rule.monthlyRate), + inputToCents(rule.minDailyRate), + inputToCents(rule.maxDailyRate), + numericInputToInt(rule.automaticAdjustmentPct), + inputToCents(rule.priceAdjustment), + ].some((value) => value != null) + + if (!rule.name.trim() || !hasPricingEffect) { + setError(copy.saveErrorRule) + return + } + + const payload = { + name: rule.name.trim(), + ruleType: rule.ruleType, + startDate: rule.startDate, + endDate: rule.endDate, + dailyRate: inputToCents(rule.dailyRate), + weeklyRate: inputToCents(rule.weeklyRate), + weekendRate: inputToCents(rule.weekendRate), + monthlyRate: inputToCents(rule.monthlyRate), + minDailyRate: inputToCents(rule.minDailyRate), + maxDailyRate: inputToCents(rule.maxDailyRate), + automaticAdjustmentPct: numericInputToInt(rule.automaticAdjustmentPct), + priceAdjustment: inputToCents(rule.priceAdjustment), + isActive: rule.isActive, + sortOrder: numericInputToInt(rule.sortOrder) ?? 0, + } + + setSavingRuleId(rule.id ?? 'new') + setError(null) + setSuccess(null) + try { + const data = create + ? await apiFetch(`/vehicles/${vehicleId}/pricing/rules`, { method: 'POST', body: JSON.stringify(payload) }) + : await apiFetch(`/vehicles/${vehicleId}/pricing/rules/${rule.id}`, { method: 'PATCH', body: JSON.stringify(payload) }) + applyResponse(data) + if (create) { + setNewRule(buildRuleForm()) + setSuccess(copy.ruleCreated) + } else { + setSuccess(copy.ruleSaved) + } + } catch (err: any) { + setError(err.message ?? copy.saveErrorRule) + } finally { + setSavingRuleId(null) + } + } + + async function deleteRule(ruleId: string) { + setDeletingRuleId(ruleId) + setError(null) + setSuccess(null) + try { + const data = await apiFetch(`/vehicles/${vehicleId}/pricing/rules/${ruleId}`, { method: 'DELETE' }) + applyResponse(data) + setSuccess(copy.ruleDeleted) + } catch (err: any) { + setError(err.message ?? copy.ruleDeleted) + } finally { + setDeletingRuleId(null) + } + } + + if (loading || !configForm || !preview) { + return
{copy.loading}
+ } + + return ( +
+
+
+
+

{copy.title}

+

{copy.subtitle}

+
+ +
+ + {error &&
{error}
} + {success &&
{success}
} + +
+
+
+ + +
+
+ + setConfigForm((current) => current ? { ...current, baseDailyRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, weeklyRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, weekendRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, holidayRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, monthlyRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, longTermDailyRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, minimumDailyRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, maximumDailyRate: event.target.value } : current)} /> +
+
+ + setConfigForm((current) => current ? { ...current, maxDailyPriceMovementPct: event.target.value } : current)} /> +
+
+ + +
+
+ +
+
+ +

{copy.currentPreview}

+
+
+

{preview.today.date}

+

{formatCurrency(preview.today.dailyRate, 'MAD')}

+

{copy.previewLabels[preview.today.pricingType]}

+

{copy.matchedRule}: {preview.today.matchedRuleName ?? copy.noRule}

+
+
+
+ +

{copy.next14Days}

+
+
+ {preview.next14Days.map((item) => ( +
+
+

{item.date}

+

{copy.previewLabels[item.pricingType]}

+
+
+

{formatCurrency(item.dailyRate, 'MAD')}

+

{item.matchedRuleName ?? copy.noRule}

+
+
+ ))} +
+
+
+
+
+ +
+

{copy.rules}

+

{copy.ruleHint}

+ +
+ {rules.map((rule, index) => ( +
+
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} /> +
+
+ + +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, startDate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, endDate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, dailyRate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weeklyRate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weekendRate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, monthlyRate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, minDailyRate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, maxDailyRate: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, automaticAdjustmentPct: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, priceAdjustment: event.target.value } : entry))} /> +
+
+ + setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sortOrder: event.target.value } : entry))} /> +
+
+ +
+
+
+ + +
+
+ ))} + + {rules.length === 0 &&
{copy.noRules}
} +
+ +
+
+ +

{copy.addRule}

+
+
+
+ + setNewRule((current) => ({ ...current, name: event.target.value }))} /> +
+
+ + +
+
+ + setNewRule((current) => ({ ...current, startDate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, endDate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, dailyRate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, weeklyRate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, weekendRate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, monthlyRate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, minDailyRate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, maxDailyRate: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, automaticAdjustmentPct: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, priceAdjustment: event.target.value }))} /> +
+
+ + setNewRule((current) => ({ ...current, sortOrder: event.target.value }))} /> +
+
+
+ +
+
+
+ +
+
+ +

{copy.history}

+
+
+ {history.map((entry) => ( +
+
+
+

{copy.historyLabels[entry.source]}

+

{new Date(entry.createdAt).toLocaleString()}

+
+
+ {entry.nextDailyRate != null &&

{formatCurrency(entry.nextDailyRate, 'MAD')}

} + {entry.previousDailyRate != null && entry.nextDailyRate != null && ( +

{formatCurrency(entry.previousDailyRate, 'MAD')} → {formatCurrency(entry.nextDailyRate, 'MAD')}

+ )} +
+
+ {entry.note &&

{entry.note}

} +
+ ))} + {history.length === 0 &&
{copy.noHistory}
} +
+
+
+ ) +} diff --git a/car_management_system.code-workspace b/car_management_system.code-workspace new file mode 100644 index 0000000..80c008e --- /dev/null +++ b/car_management_system.code-workspace @@ -0,0 +1,13 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../car_rental_app" + } + ], + "settings": { + "java.compile.nullAnalysis.mode": "automatic" + } +} \ No newline at end of file diff --git a/docs/rental_car_pricing_management_plan.md b/docs/rental_car_pricing_management_plan.md new file mode 100644 index 0000000..5838553 --- /dev/null +++ b/docs/rental_car_pricing_management_plan.md @@ -0,0 +1,392 @@ +# Rental Car Pricing Management Plan + +## Objective + +Build a rental car pricing system that allows the business to manage vehicle prices using two pricing methods: + +1. **Manual Fixed Pricing**: The user sets daily and/or weekly prices for each car throughout the year. +2. **Automatic Dynamic Pricing**: The user sets minimum and maximum daily prices, and the system automatically adjusts prices based on search activity, demand, availability, seasonality, and booking behavior. + +The system should support both methods so the business can choose between full control and automated optimization. + +--- + +## Method 1: Manual Fixed Pricing + +### Description + +Manual fixed pricing allows the user to set exact prices for each vehicle. Prices can be configured by day, week, date range, season, holiday, or special event. + +This method is useful when the business wants predictable pricing and full control. + +### Core Features + +#### 1. Car-Level Pricing + +Each vehicle should have its own pricing settings. + +| Car | Daily Price | Weekly Price | +|---|---:|---:| +| Toyota Corolla | $45 | $280 | +| Hyundai Elantra | $50 | $310 | +| Ford Mustang | $95 | $600 | +| Chevrolet Suburban | $120 | $750 | + +#### 2. Date-Based Pricing + +The user should be able to set different prices for different date ranges. + +| Date Range | Daily Price | Weekly Price | +|---|---:|---:| +| Jan 1 - Mar 31 | $45 | $280 | +| Apr 1 - Aug 31 | $60 | $380 | +| Sep 1 - Dec 15 | $50 | $320 | +| Dec 16 - Dec 31 | $75 | $475 | + +#### 3. Seasonal Pricing + +The system should support pricing rules for: + +- Low season +- High season +- Holidays +- Weekends +- Special events +- Summer travel season +- End-of-year demand + +#### 4. Daily and Weekly Price Options + +The user should be able to set: + +- Daily rental price +- Weekly rental price +- Weekend price +- Holiday price +- Monthly price, if needed +- Discounted long-term rental price + +#### 5. Bulk Price Updates + +The system should allow the user to update many prices at once. + +Examples: + +- Increase all SUV prices by 15% for summer +- Set all economy cars to $40/day for February +- Apply holiday pricing to all cars from December 20 to January 5 + +### Benefits + +Manual pricing is simple, predictable, and gives the user full control. + +### Risks + +Manual pricing can become outdated quickly. If demand rises and prices stay low, the business loses revenue. If demand drops and prices stay high, cars may remain unused. + +--- + +## Method 2: Automatic Dynamic Pricing + +### Description + +Automatic dynamic pricing allows the user to set a minimum and maximum daily price for each car. The system then calculates the best price within that range based on demand and business rules. + +The system should never price below the minimum or above the maximum unless the user manually overrides it. + +### Core Features + +#### 1. Minimum and Maximum Price Rules + +Each car should have a minimum and maximum daily price. + +| Car | Minimum Daily Price | Maximum Daily Price | +|---|---:|---:| +| Toyota Corolla | $35 | $75 | +| Hyundai Elantra | $40 | $85 | +| Ford Mustang | $75 | $160 | +| Chevrolet Suburban | $90 | $220 | + +#### 2. Demand-Based Pricing + +The system should increase prices when demand is high and lower prices when demand is weak. + +Demand signals may include: + +- Number of searches for a car type +- Number of views on a specific vehicle +- Number of booking attempts +- Number of confirmed reservations +- Number of available cars +- Time remaining before rental date +- Canceled bookings +- Competitor pricing, if available +- Local events or holidays + +#### 3. Availability-Based Pricing + +The system should adjust prices based on available inventory. + +| Availability | Pricing Action | +|---|---| +| Many cars available | Lower price to increase bookings | +| Medium availability | Keep price near normal | +| Few cars available | Increase price | +| One car left in category | Increase toward maximum price | + +#### 4. Search-Based Pricing + +If many users search for a specific car, category, or date range, the system should treat that as demand. + +Example: + +If many users search for SUVs during July 4 weekend, SUV prices should increase automatically within the allowed price range. + +Searches alone should not trigger aggressive increases. The system should compare searches with booking behavior. + +#### 5. Booking Conversion Logic + +The system should compare searches to actual bookings. + +| Search Activity | Booking Activity | Pricing Action | +|---|---|---| +| High searches | High bookings | Raise price | +| High searches | Low bookings | Hold price or reduce slightly | +| Low searches | Low bookings | Reduce price | +| Low searches | High bookings | Raise price carefully | + +#### 6. Time-Based Pricing + +The system should adjust prices based on how close the rental date is. + +| Time Before Rental | Pricing Logic | +|---|---| +| 60+ days before | Keep price moderate | +| 30-60 days before | Adjust based on demand | +| 7-30 days before | Increase if availability is low | +| 1-7 days before | Discount if many cars remain, increase if few cars remain | + +#### 7. Category-Based Pricing + +Automatic pricing should work at both the vehicle level and category level. + +Categories may include: + +- Economy +- Compact +- Sedan +- SUV +- Luxury +- Sports car +- Van +- Truck + +If SUV demand is high, SUV prices may increase while economy car prices remain unchanged. + +#### 8. Manual Override + +The user should always be able to override automatic pricing. + +Manual override options should include: + +- Lock price for a specific car +- Lock price for a date range +- Disable automatic pricing for selected vehicles +- Approve price changes manually before they go live +- Set maximum daily price movement, such as no more than 10% per day + +#### 9. Pricing Safety Rules + +To prevent bad automated pricing, the system should include safety rules: + +- Do not change prices more than once per day unless needed +- Do not increase prices above the user’s maximum price +- Do not decrease prices below the user’s minimum price +- Do not raise prices aggressively based only on searches +- Do not discount high-demand dates too early +- Notify the user when prices change significantly +- Keep a pricing history for review + +#### 10. Pricing Formula Example + +A simple automatic pricing formula: + +```text +Automatic Price = Base Price + + Demand Adjustment + + Availability Adjustment + + Seasonality Adjustment + + Time Adjustment +``` + +Then the system applies price limits: + +```text +If calculated price < minimum price: + final price = minimum price + +If calculated price > maximum price: + final price = maximum price + +Otherwise: + final price = calculated price +``` + +Example: + +| Item | Amount | +|---|---:| +| Base price | $60 | +| Demand adjustment | +$10 | +| Availability adjustment | +$15 | +| Holiday adjustment | +$20 | +| Time adjustment | +$5 | +| Calculated price | $110 | + +If the user’s maximum price is $100, the final price should be **$100**. + +--- + +## Recommended System Design + +### Pricing Dashboard + +The pricing dashboard should allow the user to: + +- View all cars +- See current prices +- Set manual prices +- Set minimum and maximum prices +- Turn automatic pricing on or off +- View pricing history +- Review demand trends +- Approve or reject suggested price changes + +### Car Pricing Page + +Each car should have a pricing page with: + +- Default daily price +- Default weekly price +- Minimum daily price +- Maximum daily price +- Seasonal price rules +- Manual pricing calendar +- Automatic pricing status +- Price change history + +### Calendar Pricing View + +The system should include a calendar view where users can see and edit prices by date. + +| Date | Price | Pricing Type | +|---|---:|---| +| June 1 | $55 | Manual | +| June 2 | $58 | Automatic | +| June 3 | $60 | Automatic | +| July 4 | $95 | Holiday Rule | + +### Reports and Analytics + +The system should generate reports showing: + +- Revenue per car +- Revenue per category +- Occupancy rate +- Average daily rate +- Search demand +- Booking conversion rate +- Lost bookings +- Price change history +- Best-performing price ranges + +--- + +## Suggested Pricing Workflow + +### Step 1: Add Cars + +The user adds each car to the system with details such as: + +- Make +- Model +- Year +- Category +- Location +- Availability +- Default daily price +- Default weekly price + +### Step 2: Choose Pricing Method + +For each car, the user chooses: + +- Manual fixed pricing +- Automatic dynamic pricing + +### Step 3: Set Pricing Rules + +For manual pricing, the user sets exact daily and weekly prices. + +For automatic pricing, the user sets: + +- Minimum daily price +- Maximum daily price +- Base price +- Weekly discount rules +- Seasonal rules +- Safety limits + +### Step 4: Monitor Demand + +The system tracks: + +- Searches +- Views +- Bookings +- Cancellations +- Availability +- Date demand +- Category demand + +### Step 5: Adjust Prices + +Manual prices remain fixed unless the user changes them. + +Automatic prices adjust based on system rules. + +### Step 6: Review Performance + +The user reviews pricing performance and updates rules when needed. + +--- + +## Best Practice Recommendation + +The strongest approach is to support both pricing methods at the same time. + +Some vehicles should use manual pricing, especially rare, luxury, sports, or specialty cars where strict control matters. + +Other vehicles should use automatic pricing, especially common vehicles where demand changes frequently. + +| Vehicle Type | Best Pricing Method | +|---|---| +| Economy cars | Automatic pricing | +| Sedans | Automatic pricing | +| SUVs | Automatic pricing | +| Luxury cars | Manual or automatic with tight limits | +| Sports cars | Manual pricing | +| Vans | Automatic pricing with seasonal rules | +| Specialty cars | Manual pricing | + +--- + +## Final Recommendation + +The rental car pricing system should include both manual and automatic pricing. + +Manual pricing gives the user full control over daily and weekly rates throughout the year. + +Automatic pricing allows the user to set minimum and maximum limits while the system adjusts prices based on demand, searches, bookings, availability, seasonality, and time before rental. + +The best system is a hybrid model: the user controls the boundaries, and the system optimizes prices inside those boundaries. diff --git a/packages/database/prisma/migrations/20260602000000_vehicle_pricing_management/migration.sql b/packages/database/prisma/migrations/20260602000000_vehicle_pricing_management/migration.sql new file mode 100644 index 0000000..c6921c9 --- /dev/null +++ b/packages/database/prisma/migrations/20260602000000_vehicle_pricing_management/migration.sql @@ -0,0 +1,107 @@ +CREATE TYPE "VehiclePricingMode" AS ENUM ('MANUAL', 'AUTOMATIC'); + +CREATE TYPE "VehiclePricingRuleType" AS ENUM ( + 'DATE_RANGE', + 'SEASONAL', + 'HOLIDAY', + 'WEEKEND', + 'SPECIAL_EVENT', + 'MANUAL_OVERRIDE' +); + +CREATE TYPE "VehiclePriceChangeSource" AS ENUM ( + 'CONFIG_UPDATE', + 'RULE_CREATED', + 'RULE_UPDATED', + 'RULE_DELETED', + 'MANUAL_OVERRIDE' +); + +CREATE TABLE "vehicle_pricing_configurations" ( + "id" TEXT NOT NULL, + "vehicleId" TEXT NOT NULL, + "pricingMode" "VehiclePricingMode" NOT NULL DEFAULT 'MANUAL', + "baseDailyRate" INTEGER, + "weeklyRate" INTEGER, + "weekendRate" INTEGER, + "holidayRate" INTEGER, + "monthlyRate" INTEGER, + "longTermDailyRate" INTEGER, + "minimumDailyRate" INTEGER, + "maximumDailyRate" INTEGER, + "maxDailyPriceMovementPct" INTEGER NOT NULL DEFAULT 10, + "automaticPricingEnabled" BOOLEAN NOT NULL DEFAULT false, + "approvalRequired" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "vehicle_pricing_configurations_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "vehicle_pricing_configurations_vehicleId_key" + ON "vehicle_pricing_configurations"("vehicleId"); + +CREATE TABLE "vehicle_pricing_rules" ( + "id" TEXT NOT NULL, + "configurationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ruleType" "VehiclePricingRuleType" NOT NULL DEFAULT 'DATE_RANGE', + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "dailyRate" INTEGER, + "weeklyRate" INTEGER, + "weekendRate" INTEGER, + "monthlyRate" INTEGER, + "minDailyRate" INTEGER, + "maxDailyRate" INTEGER, + "automaticAdjustmentPct" INTEGER, + "priceAdjustment" INTEGER, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "vehicle_pricing_rules_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "vehicle_pricing_rules_configurationId_idx" + ON "vehicle_pricing_rules"("configurationId"); + +CREATE INDEX "vehicle_pricing_rules_configurationId_startDate_endDate_idx" + ON "vehicle_pricing_rules"("configurationId", "startDate", "endDate"); + +CREATE TABLE "vehicle_price_history" ( + "id" TEXT NOT NULL, + "configurationId" TEXT NOT NULL, + "vehicleId" TEXT NOT NULL, + "source" "VehiclePriceChangeSource" NOT NULL DEFAULT 'CONFIG_UPDATE', + "changedByEmployeeId" TEXT, + "previousDailyRate" INTEGER, + "nextDailyRate" INTEGER, + "previousWeeklyRate" INTEGER, + "nextWeeklyRate" INTEGER, + "note" TEXT, + "effectiveFrom" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "vehicle_price_history_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "vehicle_price_history_vehicleId_createdAt_idx" + ON "vehicle_price_history"("vehicleId", "createdAt"); + +CREATE INDEX "vehicle_price_history_configurationId_createdAt_idx" + ON "vehicle_price_history"("configurationId", "createdAt"); + +ALTER TABLE "vehicle_pricing_configurations" + ADD CONSTRAINT "vehicle_pricing_configurations_vehicleId_fkey" + FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "vehicle_pricing_rules" + ADD CONSTRAINT "vehicle_pricing_rules_configurationId_fkey" + FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "vehicle_price_history" + ADD CONSTRAINT "vehicle_price_history_configurationId_fkey" + FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "vehicle_price_history" + ADD CONSTRAINT "vehicle_price_history_vehicleId_fkey" + FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 20094fd..d18e18e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -205,6 +205,28 @@ enum FuelType { HYBRID } +enum VehiclePricingMode { + MANUAL + AUTOMATIC +} + +enum VehiclePricingRuleType { + DATE_RANGE + SEASONAL + HOLIDAY + WEEKEND + SPECIAL_EVENT + MANUAL_OVERRIDE +} + +enum VehiclePriceChangeSource { + CONFIG_UPDATE + RULE_CREATED + RULE_UPDATED + RULE_DELETED + MANUAL_OVERRIDE +} + enum OfferType { PERCENTAGE FIXED_AMOUNT @@ -996,6 +1018,8 @@ model Vehicle { maintenance MaintenanceLog[] offerVehicles OfferVehicle[] calendarBlocks VehicleCalendarBlock[] + pricingConfiguration VehiclePricingConfiguration? + priceHistory VehiclePriceHistory[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1006,6 +1030,80 @@ model Vehicle { @@map("vehicles") } +model VehiclePricingConfiguration { + id String @id @default(cuid()) + vehicleId String @unique + vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) + pricingMode VehiclePricingMode @default(MANUAL) + baseDailyRate Int? + weeklyRate Int? + weekendRate Int? + holidayRate Int? + monthlyRate Int? + longTermDailyRate Int? + minimumDailyRate Int? + maximumDailyRate Int? + maxDailyPriceMovementPct Int @default(10) + automaticPricingEnabled Boolean @default(false) + approvalRequired Boolean @default(false) + + rules VehiclePricingRule[] + history VehiclePriceHistory[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("vehicle_pricing_configurations") +} + +model VehiclePricingRule { + id String @id @default(cuid()) + configurationId String + configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade) + name String + ruleType VehiclePricingRuleType @default(DATE_RANGE) + startDate DateTime + endDate DateTime + dailyRate Int? + weeklyRate Int? + weekendRate Int? + monthlyRate Int? + minDailyRate Int? + maxDailyRate Int? + automaticAdjustmentPct Int? + priceAdjustment Int? + isActive Boolean @default(true) + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([configurationId]) + @@index([configurationId, startDate, endDate]) + @@map("vehicle_pricing_rules") +} + +model VehiclePriceHistory { + id String @id @default(cuid()) + configurationId String + configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade) + vehicleId String + vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) + source VehiclePriceChangeSource @default(CONFIG_UPDATE) + changedByEmployeeId String? + previousDailyRate Int? + nextDailyRate Int? + previousWeeklyRate Int? + nextWeeklyRate Int? + note String? + effectiveFrom DateTime? + createdAt DateTime @default(now()) + + @@index([vehicleId, createdAt]) + @@index([configurationId, createdAt]) + @@map("vehicle_price_history") +} + model Offer { id String @id @default(cuid()) companyId String diff --git a/packages/database/scripts/db-deploy.cjs b/packages/database/scripts/db-deploy.cjs index 021dd13..a9cfe43 100644 --- a/packages/database/scripts/db-deploy.cjs +++ b/packages/database/scripts/db-deploy.cjs @@ -3,7 +3,11 @@ const { spawnSync } = require('node:child_process') const { readdirSync } = require('node:fs') const path = require('node:path') -const { ensureDatabaseUrl } = require('../src/runtime-config') +const { + ensureDatabaseUrl, + loadDefaultEnvFiles, + normalizeDatabaseUrlForExecution, +} = require('../src/runtime-config') const { PrismaClient } = require('../generated') const repoRoot = path.resolve(__dirname, '../../..') @@ -24,7 +28,9 @@ function run(command, args) { } async function main() { + loadDefaultEnvFiles(repoRoot) ensureDatabaseUrl() + normalizeDatabaseUrlForExecution() const prisma = new PrismaClient() try { @@ -103,6 +109,9 @@ async function main() { } main().catch((error) => { + if (error?.message?.includes("Can't reach database server at `localhost:5432`")) { + console.error('[db:deploy] Hint: start the local Postgres service first. In this repo that is usually `docker compose -f docker-compose.dev.yml up -d postgres`.') + } console.error('[db:deploy] Failed:', error) process.exit(1) }) diff --git a/packages/database/src/runtime-config.js b/packages/database/src/runtime-config.js index 18742df..8b67969 100644 --- a/packages/database/src/runtime-config.js +++ b/packages/database/src/runtime-config.js @@ -1,5 +1,96 @@ +const fs = require('node:fs') +const path = require('node:path') + const ENABLED_VALUES = new Set(['1', 'true', 'yes']) +function parseEnvFile(content) { + const env = {} + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + + const normalized = line.startsWith('export ') ? line.slice(7).trim() : line + const separatorIndex = normalized.indexOf('=') + if (separatorIndex === -1) continue + + const key = normalized.slice(0, separatorIndex).trim() + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue + + let value = normalized.slice(separatorIndex + 1) + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + const quote = value[0] + value = value.slice(1, -1) + + if (quote === '"') { + value = value + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') + } + } + + env[key] = value + } + + return env +} + +function loadEnvFile(filePath, env = process.env, { override = false } = {}) { + if (!fs.existsSync(filePath)) return false + const parsed = parseEnvFile(fs.readFileSync(filePath, 'utf8')) + + for (const [key, value] of Object.entries(parsed)) { + if (override || env[key] === undefined) { + env[key] = value + } + } + + return true +} + +function isRunningInContainer() { + return fs.existsSync('/.dockerenv') +} + +function normalizeDatabaseUrlForExecution(env = process.env) { + if (!env.DATABASE_URL) return env.DATABASE_URL + + let parsed + try { + parsed = new URL(env.DATABASE_URL) + } catch { + return env.DATABASE_URL + } + + if (parsed.hostname !== 'postgres' || isRunningInContainer()) { + return env.DATABASE_URL + } + + const fallbackHost = env.POSTGRES_HOST_LOCAL ?? 'localhost' + parsed.hostname = fallbackHost + env.DATABASE_URL = parsed.toString() + return env.DATABASE_URL +} + +function loadDefaultEnvFiles(baseDir, env = process.env) { + const candidates = [ + path.join(baseDir, '.env'), + path.join(baseDir, '.env.local'), + path.join(baseDir, '.env.docker.dev'), + ] + + for (const candidate of candidates) { + loadEnvFile(candidate, env) + } +} + function ensureDatabaseUrl(env = process.env) { const shouldBuildFromPostgres = ENABLED_VALUES.has( env.DATABASE_URL_FROM_POSTGRES?.trim().toLowerCase() ?? '', @@ -26,4 +117,7 @@ function ensureDatabaseUrl(env = process.env) { module.exports = { ensureDatabaseUrl, + loadDefaultEnvFiles, + loadEnvFile, + normalizeDatabaseUrlForExecution, }