add pricing feature

This commit is contained in:
root
2026-06-02 01:15:04 -04:00
parent 05d7b1bf8c
commit 0df950f2b1
18 changed files with 2364 additions and 29 deletions
@@ -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 })
}
@@ -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
@@ -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() })
@@ -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<string, number> = {
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<string>()
@@ -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)
}
@@ -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', () => {
@@ -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)
})
})
})
+3
View File
@@ -32,6 +32,9 @@ const delegates = [
'offerVehicle',
'offer',
'vehicleCalendarBlock',
'vehiclePriceHistory',
'vehiclePricingRule',
'vehiclePricingConfiguration',
'maintenanceLog',
'vehicle',
'employee',
+19
View File
@@ -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": []
}
@@ -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 (
<div className="space-y-6">
@@ -460,7 +471,12 @@ export default function FleetDetailPage() {
</button>
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {statusLabel}</p>
<p className="text-sm text-slate-500 mt-0.5">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
{' · '}
{statusLabel}
</p>
</div>
</div>
{!editing ? (
@@ -486,7 +502,7 @@ export default function FleetDetailPage() {
{/* Tab bar */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
{(['details', 'maintenance', 'calendar'] as Tab[]).map((tab) => (
{(['details', 'maintenance', 'calendar', 'pricing'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -505,6 +521,7 @@ export default function FleetDetailPage() {
</>
)}
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
{tab === 'pricing' && <><DollarSign className="w-4 h-4" /> {pricingTabLabel}</>}
</button>
))}
</div>
@@ -578,6 +595,7 @@ export default function FleetDetailPage() {
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
<div><dt className="text-slate-500">{vd.vinLabel}</dt><dd className="text-slate-900">{vehicle.vin || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
@@ -657,8 +675,8 @@ export default function FleetDetailPage() {
</div>
</div>
{/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-3">
{/* Daily Rate, License Plate & VIN */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
@@ -668,6 +686,10 @@ export default function FleetDetailPage() {
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.vinLabel}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
{/* Status */}
<div>
@@ -807,6 +829,10 @@ export default function FleetDetailPage() {
{activeTab === 'calendar' && (
<VehicleCalendar vehicleId={params.id} />
)}
{activeTab === 'pricing' && (
<VehiclePricingManager vehicleId={params.id} />
)}
</div>
)
}
@@ -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) {
</div>
</div>
{/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dailyRate}</label>
<input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div>
{/* License Plate & VIN */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.vin}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
</div>
{/* 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() {
<td className="px-6 py-4">
<div>
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
<p className="text-xs text-slate-400">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
</p>
</div>
</td>
<td className="px-6 py-4">
@@ -761,6 +764,9 @@ export default function FleetPage() {
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2 rtl:justify-start">
<Link href={`/fleet/${vehicle.id}?tab=pricing`} title="Manage pricing" aria-label="Manage pricing" className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<DollarSign className="w-4 h-4" />
</Link>
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" />
</Link>
@@ -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<DashboardLanguage, DashboardDictionary> = {
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<DashboardLanguage, DashboardDictionary> = {
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<DashboardLanguage, DashboardDictionary> = {
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<DashboardLanguage, DashboardDictionary> = {
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<DashboardLanguage, DashboardDictionary> = {
category: 'الفئة',
dailyRate: 'السعر اليومي (درهم) *',
licensePlate: 'رقم اللوحة *',
vin: 'رقم الهيكل',
color: 'اللون',
select: 'اختر…',
typeColor: 'اكتب اللون…',
@@ -1195,6 +1202,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
yearLabel: 'السنة *',
dailyRateLabel: 'السعر اليومي (درهم) *',
licensePlateLabel: 'رقم اللوحة *',
vinLabel: 'رقم الهيكل',
statusLabel: 'الحالة',
colorLabel: 'اللون',
selectColor: 'اختر اللون…',
@@ -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<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'Date range',
SEASONAL: 'Season',
HOLIDAY: 'Holiday',
WEEKEND: 'Weekend',
SPECIAL_EVENT: 'Special event',
MANUAL_OVERRIDE: 'Manual override',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'Configuration updated',
RULE_CREATED: 'Rule created',
RULE_UPDATED: 'Rule updated',
RULE_DELETED: 'Rule deleted',
MANUAL_OVERRIDE: 'Manual override',
} as Record<PricingHistoryEntry['source'], string>,
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 lhistorique 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<PricingPreviewItem['pricingType'], string>,
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<PricingRule['ruleType'], string>,
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<PricingHistoryEntry['source'], string>,
ruleHint: 'Utilisez des tarifs directs pour le fixe ou des ajustements et limites pour guider lautomatique.',
},
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<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'نطاق تاريخ',
SEASONAL: 'موسم',
HOLIDAY: 'عطلة',
WEEKEND: 'نهاية أسبوع',
SPECIAL_EVENT: 'مناسبة خاصة',
MANUAL_OVERRIDE: 'تجاوز يدوي',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'تم تحديث الإعدادات',
RULE_CREATED: 'تم إنشاء قاعدة',
RULE_UPDATED: 'تم تحديث قاعدة',
RULE_DELETED: 'تم حذف قاعدة',
MANUAL_OVERRIDE: 'تجاوز يدوي',
} as Record<PricingHistoryEntry['source'], string>,
ruleHint: 'استخدم أسعاراً مباشرة للتسعير الثابت أو تعديلات وحدوداً لتوجيه التسعير التلقائي.',
},
}[language]
const [loading, setLoading] = useState(true)
const [savingConfig, setSavingConfig] = useState(false)
const [savingRuleId, setSavingRuleId] = useState<string | null>(null)
const [deletingRuleId, setDeletingRuleId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [configForm, setConfigForm] = useState<ConfigForm | null>(null)
const [rules, setRules] = useState<RuleForm[]>([])
const [newRule, setNewRule] = useState<RuleForm>(buildRuleForm())
const [history, setHistory] = useState<PricingHistoryEntry[]>([])
const [preview, setPreview] = useState<PricingResponse['preview'] | null>(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<PricingResponse>(`/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<PricingResponse>(`/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<PricingResponse>(`/vehicles/${vehicleId}/pricing/rules`, { method: 'POST', body: JSON.stringify(payload) })
: await apiFetch<PricingResponse>(`/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<PricingResponse>(`/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 <div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
}
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.title}</h3>
<p className="mt-1 max-w-3xl text-sm text-slate-500">{copy.subtitle}</p>
</div>
<button onClick={saveConfiguration} disabled={savingConfig} className="btn-primary">
<Save className="h-4 w-4" />
{savingConfig ? copy.saving : copy.savePricing}
</button>
</div>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{success && <div className="mt-4 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{success}</div>}
<div className="mt-6 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="grid gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 md:grid-cols-2">
<div className="md:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.mode}</label>
<select className="input-field" value={configForm.pricingMode} onChange={(event) => setConfigForm((current) => current ? { ...current, pricingMode: event.target.value as ConfigForm['pricingMode'] } : current)}>
<option value="MANUAL">{copy.manual}</option>
<option value="AUTOMATIC">{copy.automatic}</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.baseDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, baseDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.weeklyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, weeklyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.weekendRate} onChange={(event) => setConfigForm((current) => current ? { ...current, weekendRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.holidayRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.holidayRate} onChange={(event) => setConfigForm((current) => current ? { ...current, holidayRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.monthlyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, monthlyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.longTermDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.longTermDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, longTermDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.minimumDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, minimumDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.maximumDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, maximumDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maxDailyMovement}</label>
<input className="input-field" type="number" min="0" max="100" step="1" value={configForm.maxDailyPriceMovementPct} onChange={(event) => setConfigForm((current) => current ? { ...current, maxDailyPriceMovementPct: event.target.value } : current)} />
</div>
<div className="md:col-span-2 flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={configForm.automaticPricingEnabled} onChange={(event) => setConfigForm((current) => current ? { ...current, automaticPricingEnabled: event.target.checked } : current)} />
{copy.automaticPricingEnabled}
</label>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={configForm.approvalRequired} onChange={(event) => setConfigForm((current) => current ? { ...current, approvalRequired: event.target.checked } : current)} />
{copy.approvalRequired}
</label>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600" />
<h4 className="text-sm font-semibold text-slate-900">{copy.currentPreview}</h4>
</div>
<div className="mt-4 rounded-2xl bg-slate-50 p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">{preview.today.date}</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{formatCurrency(preview.today.dailyRate, 'MAD')}</p>
<p className="mt-1 text-sm text-slate-500">{copy.previewLabels[preview.today.pricingType]}</p>
<p className="mt-2 text-xs text-slate-400">{copy.matchedRule}: {preview.today.matchedRuleName ?? copy.noRule}</p>
</div>
<div className="mt-4">
<div className="mb-3 flex items-center gap-2">
<CalendarRange className="h-4 w-4 text-slate-400" />
<p className="text-sm font-semibold text-slate-900">{copy.next14Days}</p>
</div>
<div className="space-y-2">
{preview.next14Days.map((item) => (
<div key={item.date} className="flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2 text-sm">
<div>
<p className="font-medium text-slate-900">{item.date}</p>
<p className="text-xs text-slate-500">{copy.previewLabels[item.pricingType]}</p>
</div>
<div className="text-right">
<p className="font-semibold text-slate-900">{formatCurrency(item.dailyRate, 'MAD')}</p>
<p className="text-xs text-slate-400">{item.matchedRuleName ?? copy.noRule}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.rules}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.ruleHint}</p>
<div className="mt-5 space-y-4">
{rules.map((rule, index) => (
<div key={rule.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.name}</label>
<input className="input-field" value={rule.name} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.ruleType}</label>
<select className="input-field" value={rule.ruleType} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, ruleType: event.target.value as RuleForm['ruleType'] } : entry))}>
{Object.entries(copy.ruleTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startDate}</label>
<input className="input-field" type="date" value={rule.startDate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, startDate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endDate}</label>
<input className="input-field" type="date" value={rule.endDate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, endDate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.dailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, dailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.weeklyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weeklyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.weekendRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weekendRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.monthlyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, monthlyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.minDailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, minDailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.maxDailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, maxDailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.adjustmentPct}</label>
<input className="input-field" type="number" step="1" value={rule.automaticAdjustmentPct} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, automaticAdjustmentPct: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.priceAdjustment}</label>
<input className="input-field" type="number" step="0.01" value={rule.priceAdjustment} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, priceAdjustment: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.sortOrder}</label>
<input className="input-field" type="number" min="0" step="1" value={rule.sortOrder} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sortOrder: event.target.value } : entry))} />
</div>
<div className="flex items-end">
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={rule.isActive} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, isActive: event.target.checked } : entry))} />
{rule.isActive ? copy.active : copy.inactive}
</label>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<button onClick={() => deleteRule(rule.id!)} disabled={deletingRuleId === rule.id} className="btn-secondary text-red-600">
<Trash2 className="h-4 w-4" />
{deletingRuleId === rule.id ? copy.deleting : copy.deleteRule}
</button>
<button onClick={() => saveRule(rule)} disabled={savingRuleId === rule.id} className="btn-primary">
<Save className="h-4 w-4" />
{savingRuleId === rule.id ? copy.saving : copy.updateRule}
</button>
</div>
</div>
))}
{rules.length === 0 && <div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400">{copy.noRules}</div>}
</div>
<div className="mt-6 rounded-2xl border border-blue-100 bg-blue-50/60 p-4">
<div className="mb-4 flex items-center gap-2">
<PlusCircle className="h-4 w-4 text-blue-600" />
<h4 className="text-sm font-semibold text-slate-900">{copy.addRule}</h4>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.name}</label>
<input className="input-field" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.ruleType}</label>
<select className="input-field" value={newRule.ruleType} onChange={(event) => setNewRule((current) => ({ ...current, ruleType: event.target.value as RuleForm['ruleType'] }))}>
{Object.entries(copy.ruleTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startDate}</label>
<input className="input-field" type="date" value={newRule.startDate} onChange={(event) => setNewRule((current) => ({ ...current, startDate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endDate}</label>
<input className="input-field" type="date" value={newRule.endDate} onChange={(event) => setNewRule((current) => ({ ...current, endDate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.dailyRate} onChange={(event) => setNewRule((current) => ({ ...current, dailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.weeklyRate} onChange={(event) => setNewRule((current) => ({ ...current, weeklyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.weekendRate} onChange={(event) => setNewRule((current) => ({ ...current, weekendRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.monthlyRate} onChange={(event) => setNewRule((current) => ({ ...current, monthlyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.minDailyRate} onChange={(event) => setNewRule((current) => ({ ...current, minDailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.maxDailyRate} onChange={(event) => setNewRule((current) => ({ ...current, maxDailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.adjustmentPct}</label>
<input className="input-field" type="number" step="1" value={newRule.automaticAdjustmentPct} onChange={(event) => setNewRule((current) => ({ ...current, automaticAdjustmentPct: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.priceAdjustment}</label>
<input className="input-field" type="number" step="0.01" value={newRule.priceAdjustment} onChange={(event) => setNewRule((current) => ({ ...current, priceAdjustment: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.sortOrder}</label>
<input className="input-field" type="number" min="0" step="1" value={newRule.sortOrder} onChange={(event) => setNewRule((current) => ({ ...current, sortOrder: event.target.value }))} />
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={() => saveRule(newRule, true)} disabled={savingRuleId === 'new'} className="btn-primary">
<PlusCircle className="h-4 w-4" />
{savingRuleId === 'new' ? copy.saving : copy.createRule}
</button>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-slate-400" />
<h3 className="text-base font-semibold text-slate-900">{copy.history}</h3>
</div>
<div className="mt-5 space-y-3">
{history.map((entry) => (
<div key={entry.id} className="rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{copy.historyLabels[entry.source]}</p>
<p className="text-slate-500">{new Date(entry.createdAt).toLocaleString()}</p>
</div>
<div className="text-right">
{entry.nextDailyRate != null && <p className="font-semibold text-slate-900">{formatCurrency(entry.nextDailyRate, 'MAD')}</p>}
{entry.previousDailyRate != null && entry.nextDailyRate != null && (
<p className="text-xs text-slate-400">{formatCurrency(entry.previousDailyRate, 'MAD')} {formatCurrency(entry.nextDailyRate, 'MAD')}</p>
)}
</div>
</div>
{entry.note && <p className="mt-2 text-sm text-slate-600">{entry.note}</p>}
</div>
))}
{history.length === 0 && <div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400">{copy.noHistory}</div>}
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
{
"folders": [
{
"path": "."
},
{
"path": "../car_rental_app"
}
],
"settings": {
"java.compile.nullAnalysis.mode": "automatic"
}
}
+392
View File
@@ -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 users maximum price
- Do not decrease prices below the users 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 users 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.
@@ -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;
+98
View File
@@ -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
+10 -1
View File
@@ -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)
})
+94
View File
@@ -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,
}