feat: Total Cost of Ownership (TCO) per Vehicle #28
@@ -6,6 +6,9 @@
|
||||
import { z } from 'zod';
|
||||
import { isValidVIN } from '../../../shared-minimal/utils/validators';
|
||||
|
||||
// Cost interval enum for TCO recurring costs
|
||||
const costIntervalSchema = z.enum(['monthly', 'semi_annual', 'annual']);
|
||||
|
||||
export const createVehicleSchema = z.object({
|
||||
vin: z.string()
|
||||
.length(17, 'VIN must be exactly 17 characters')
|
||||
@@ -14,6 +17,14 @@ export const createVehicleSchema = z.object({
|
||||
color: z.string().min(1).max(50).optional(),
|
||||
licensePlate: z.string().min(1).max(20).optional(),
|
||||
odometerReading: z.number().min(0).max(9999999).optional(),
|
||||
// TCO fields
|
||||
purchasePrice: z.number().min(0).max(99999999.99).optional(),
|
||||
purchaseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
|
||||
insuranceCost: z.number().min(0).max(9999999.99).optional(),
|
||||
insuranceInterval: costIntervalSchema.optional(),
|
||||
registrationCost: z.number().min(0).max(9999999.99).optional(),
|
||||
registrationInterval: costIntervalSchema.optional(),
|
||||
tcoEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateVehicleSchema = z.object({
|
||||
@@ -30,6 +41,14 @@ export const updateVehicleSchema = z.object({
|
||||
color: z.string().min(1).max(50).optional(),
|
||||
licensePlate: z.string().min(1).max(20).optional(),
|
||||
odometerReading: z.number().min(0).max(9999999).optional(),
|
||||
// TCO fields
|
||||
purchasePrice: z.number().min(0).max(99999999.99).optional().nullable(),
|
||||
purchaseDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional().nullable(),
|
||||
insuranceCost: z.number().min(0).max(9999999.99).optional().nullable(),
|
||||
insuranceInterval: costIntervalSchema.optional().nullable(),
|
||||
registrationCost: z.number().min(0).max(9999999.99).optional().nullable(),
|
||||
registrationInterval: costIntervalSchema.optional().nullable(),
|
||||
tcoEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const vehicleIdSchema = z.object({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { Vehicle, CreateVehicleRequest, VehicleImageMeta } from '../domain/vehicles.types';
|
||||
import { Vehicle, CreateVehicleRequest, VehicleImageMeta, CostInterval } from '../domain/vehicles.types';
|
||||
|
||||
export class VehiclesRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
@@ -12,14 +12,16 @@ export class VehiclesRepository {
|
||||
async create(data: CreateVehicleRequest & { userId: string, make?: string, model?: string, year?: number }): Promise<Vehicle> {
|
||||
const query = `
|
||||
INSERT INTO vehicles (
|
||||
user_id, vin, make, model, year,
|
||||
user_id, vin, make, model, year,
|
||||
engine, transmission, trim_level, drive_type, fuel_type,
|
||||
nickname, color, license_plate, odometer_reading
|
||||
nickname, color, license_plate, odometer_reading,
|
||||
purchase_price, purchase_date, insurance_cost, insurance_interval,
|
||||
registration_cost, registration_interval, tco_enabled
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
(data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null,
|
||||
@@ -34,7 +36,14 @@ export class VehiclesRepository {
|
||||
data.nickname,
|
||||
data.color,
|
||||
data.licensePlate,
|
||||
data.odometerReading || 0
|
||||
data.odometerReading || 0,
|
||||
data.purchasePrice ?? null,
|
||||
data.purchaseDate ?? null,
|
||||
data.insuranceCost ?? null,
|
||||
data.insuranceInterval ?? null,
|
||||
data.registrationCost ?? null,
|
||||
data.registrationInterval ?? null,
|
||||
data.tcoEnabled ?? false
|
||||
];
|
||||
|
||||
const result = await this.pool.query(query, values);
|
||||
@@ -142,6 +151,35 @@ export class VehiclesRepository {
|
||||
fields.push(`odometer_reading = $${paramCount++}`);
|
||||
values.push(data.odometerReading);
|
||||
}
|
||||
// TCO fields
|
||||
if (data.purchasePrice !== undefined) {
|
||||
fields.push(`purchase_price = $${paramCount++}`);
|
||||
values.push(data.purchasePrice);
|
||||
}
|
||||
if (data.purchaseDate !== undefined) {
|
||||
fields.push(`purchase_date = $${paramCount++}`);
|
||||
values.push(data.purchaseDate);
|
||||
}
|
||||
if (data.insuranceCost !== undefined) {
|
||||
fields.push(`insurance_cost = $${paramCount++}`);
|
||||
values.push(data.insuranceCost);
|
||||
}
|
||||
if (data.insuranceInterval !== undefined) {
|
||||
fields.push(`insurance_interval = $${paramCount++}`);
|
||||
values.push(data.insuranceInterval);
|
||||
}
|
||||
if (data.registrationCost !== undefined) {
|
||||
fields.push(`registration_cost = $${paramCount++}`);
|
||||
values.push(data.registrationCost);
|
||||
}
|
||||
if (data.registrationInterval !== undefined) {
|
||||
fields.push(`registration_interval = $${paramCount++}`);
|
||||
values.push(data.registrationInterval);
|
||||
}
|
||||
if (data.tcoEnabled !== undefined) {
|
||||
fields.push(`tco_enabled = $${paramCount++}`);
|
||||
values.push(data.tcoEnabled);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findById(id);
|
||||
@@ -193,10 +231,17 @@ export class VehiclesRepository {
|
||||
vehicle.nickname,
|
||||
vehicle.color,
|
||||
vehicle.licensePlate,
|
||||
vehicle.odometerReading || 0
|
||||
vehicle.odometerReading || 0,
|
||||
vehicle.purchasePrice ?? null,
|
||||
vehicle.purchaseDate ?? null,
|
||||
vehicle.insuranceCost ?? null,
|
||||
vehicle.insuranceInterval ?? null,
|
||||
vehicle.registrationCost ?? null,
|
||||
vehicle.registrationInterval ?? null,
|
||||
vehicle.tcoEnabled ?? false
|
||||
];
|
||||
|
||||
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
||||
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
||||
placeholders.push(placeholder);
|
||||
values.push(...vehicleParams);
|
||||
});
|
||||
@@ -205,7 +250,9 @@ export class VehiclesRepository {
|
||||
INSERT INTO vehicles (
|
||||
user_id, vin, make, model, year,
|
||||
engine, transmission, trim_level, drive_type, fuel_type,
|
||||
nickname, color, license_plate, odometer_reading
|
||||
nickname, color, license_plate, odometer_reading,
|
||||
purchase_price, purchase_date, insurance_cost, insurance_interval,
|
||||
registration_cost, registration_interval, tco_enabled
|
||||
)
|
||||
VALUES ${placeholders.join(', ')}
|
||||
RETURNING *
|
||||
@@ -292,6 +339,14 @@ export class VehiclesRepository {
|
||||
imageFileName: row.image_file_name,
|
||||
imageContentType: row.image_content_type,
|
||||
imageFileSize: row.image_file_size,
|
||||
// TCO fields
|
||||
purchasePrice: row.purchase_price ? Number(row.purchase_price) : undefined,
|
||||
purchaseDate: row.purchase_date,
|
||||
insuranceCost: row.insurance_cost ? Number(row.insurance_cost) : undefined,
|
||||
insuranceInterval: row.insurance_interval as CostInterval | undefined,
|
||||
registrationCost: row.registration_cost ? Number(row.registration_cost) : undefined,
|
||||
registrationInterval: row.registration_interval as CostInterval | undefined,
|
||||
tcoEnabled: row.tco_enabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
* @ai-context Core business types, no external dependencies
|
||||
*/
|
||||
|
||||
// TCO cost interval types
|
||||
export type CostInterval = 'monthly' | 'semi_annual' | 'annual';
|
||||
|
||||
export const PAYMENTS_PER_YEAR: Record<CostInterval, number> = {
|
||||
monthly: 12,
|
||||
semi_annual: 2,
|
||||
annual: 1,
|
||||
} as const;
|
||||
|
||||
export interface Vehicle {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -28,6 +37,14 @@ export interface Vehicle {
|
||||
imageFileName?: string;
|
||||
imageContentType?: string;
|
||||
imageFileSize?: number;
|
||||
// TCO fields
|
||||
purchasePrice?: number;
|
||||
purchaseDate?: string;
|
||||
insuranceCost?: number;
|
||||
insuranceInterval?: CostInterval;
|
||||
registrationCost?: number;
|
||||
registrationInterval?: CostInterval;
|
||||
tcoEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
@@ -44,6 +61,14 @@ export interface CreateVehicleRequest {
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
odometerReading?: number;
|
||||
// TCO fields
|
||||
purchasePrice?: number;
|
||||
purchaseDate?: string;
|
||||
insuranceCost?: number;
|
||||
insuranceInterval?: CostInterval;
|
||||
registrationCost?: number;
|
||||
registrationInterval?: CostInterval;
|
||||
tcoEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateVehicleRequest {
|
||||
@@ -60,6 +85,14 @@ export interface UpdateVehicleRequest {
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
odometerReading?: number;
|
||||
// TCO fields
|
||||
purchasePrice?: number;
|
||||
purchaseDate?: string;
|
||||
insuranceCost?: number;
|
||||
insuranceInterval?: CostInterval;
|
||||
registrationCost?: number;
|
||||
registrationInterval?: CostInterval;
|
||||
tcoEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface VehicleResponse {
|
||||
@@ -82,6 +115,14 @@ export interface VehicleResponse {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
imageUrl?: string;
|
||||
// TCO fields
|
||||
purchasePrice?: number;
|
||||
purchaseDate?: string;
|
||||
insuranceCost?: number;
|
||||
insuranceInterval?: CostInterval;
|
||||
registrationCost?: number;
|
||||
registrationInterval?: CostInterval;
|
||||
tcoEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface VehicleImageMeta {
|
||||
@@ -116,6 +157,14 @@ export interface CreateVehicleBody {
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
odometerReading?: number;
|
||||
// TCO fields
|
||||
purchasePrice?: number;
|
||||
purchaseDate?: string;
|
||||
insuranceCost?: number;
|
||||
insuranceInterval?: CostInterval;
|
||||
registrationCost?: number;
|
||||
registrationInterval?: CostInterval;
|
||||
tcoEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateVehicleBody {
|
||||
@@ -132,6 +181,14 @@ export interface UpdateVehicleBody {
|
||||
color?: string;
|
||||
licensePlate?: string;
|
||||
odometerReading?: number;
|
||||
// TCO fields
|
||||
purchasePrice?: number;
|
||||
purchaseDate?: string;
|
||||
insuranceCost?: number;
|
||||
insuranceInterval?: CostInterval;
|
||||
registrationCost?: number;
|
||||
registrationInterval?: CostInterval;
|
||||
tcoEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface VehicleParams {
|
||||
|
||||
Reference in New Issue
Block a user