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": []
}