feat: add TCO types and repository updates (refs #15)

- Add CostInterval type and PAYMENTS_PER_YEAR constant
- Add 7 TCO fields to Vehicle, CreateVehicleRequest, UpdateVehicleRequest
- Update VehicleResponse and Body types
- Update mapRow() with snake_case to camelCase mapping
- Update create(), update(), batchInsert() for new fields
- Add Zod validation for TCO fields with interval enum

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-12 19:58:59 -06:00
parent b0d79a26ae
commit 8517b1ded2
3 changed files with 140 additions and 9 deletions

View File

@@ -6,6 +6,9 @@
import { z } from 'zod'; import { z } from 'zod';
import { isValidVIN } from '../../../shared-minimal/utils/validators'; 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({ export const createVehicleSchema = z.object({
vin: z.string() vin: z.string()
.length(17, 'VIN must be exactly 17 characters') .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(), color: z.string().min(1).max(50).optional(),
licensePlate: z.string().min(1).max(20).optional(), licensePlate: z.string().min(1).max(20).optional(),
odometerReading: z.number().min(0).max(9999999).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({ export const updateVehicleSchema = z.object({
@@ -30,6 +41,14 @@ export const updateVehicleSchema = z.object({
color: z.string().min(1).max(50).optional(), color: z.string().min(1).max(50).optional(),
licensePlate: z.string().min(1).max(20).optional(), licensePlate: z.string().min(1).max(20).optional(),
odometerReading: z.number().min(0).max(9999999).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({ export const vehicleIdSchema = z.object({

View File

@@ -4,7 +4,7 @@
*/ */
import { Pool } from 'pg'; import { Pool } from 'pg';
import { Vehicle, CreateVehicleRequest, VehicleImageMeta } from '../domain/vehicles.types'; import { Vehicle, CreateVehicleRequest, VehicleImageMeta, CostInterval } from '../domain/vehicles.types';
export class VehiclesRepository { export class VehiclesRepository {
constructor(private pool: Pool) {} constructor(private pool: Pool) {}
@@ -14,9 +14,11 @@ export class VehiclesRepository {
INSERT INTO vehicles ( INSERT INTO vehicles (
user_id, vin, make, model, year, user_id, vin, make, model, year,
engine, transmission, trim_level, drive_type, fuel_type, 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 * RETURNING *
`; `;
@@ -34,7 +36,14 @@ export class VehiclesRepository {
data.nickname, data.nickname,
data.color, data.color,
data.licensePlate, 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); const result = await this.pool.query(query, values);
@@ -142,6 +151,35 @@ export class VehiclesRepository {
fields.push(`odometer_reading = $${paramCount++}`); fields.push(`odometer_reading = $${paramCount++}`);
values.push(data.odometerReading); 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) { if (fields.length === 0) {
return this.findById(id); return this.findById(id);
@@ -193,10 +231,17 @@ export class VehiclesRepository {
vehicle.nickname, vehicle.nickname,
vehicle.color, vehicle.color,
vehicle.licensePlate, 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); placeholders.push(placeholder);
values.push(...vehicleParams); values.push(...vehicleParams);
}); });
@@ -205,7 +250,9 @@ export class VehiclesRepository {
INSERT INTO vehicles ( INSERT INTO vehicles (
user_id, vin, make, model, year, user_id, vin, make, model, year,
engine, transmission, trim_level, drive_type, fuel_type, 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(', ')} VALUES ${placeholders.join(', ')}
RETURNING * RETURNING *
@@ -292,6 +339,14 @@ export class VehiclesRepository {
imageFileName: row.image_file_name, imageFileName: row.image_file_name,
imageContentType: row.image_content_type, imageContentType: row.image_content_type,
imageFileSize: row.image_file_size, 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,
}; };
} }
} }

View File

@@ -3,6 +3,15 @@
* @ai-context Core business types, no external dependencies * @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 { export interface Vehicle {
id: string; id: string;
userId: string; userId: string;
@@ -28,6 +37,14 @@ export interface Vehicle {
imageFileName?: string; imageFileName?: string;
imageContentType?: string; imageContentType?: string;
imageFileSize?: number; imageFileSize?: number;
// TCO fields
purchasePrice?: number;
purchaseDate?: string;
insuranceCost?: number;
insuranceInterval?: CostInterval;
registrationCost?: number;
registrationInterval?: CostInterval;
tcoEnabled?: boolean;
} }
export interface CreateVehicleRequest { export interface CreateVehicleRequest {
@@ -44,6 +61,14 @@ export interface CreateVehicleRequest {
color?: string; color?: string;
licensePlate?: string; licensePlate?: string;
odometerReading?: number; odometerReading?: number;
// TCO fields
purchasePrice?: number;
purchaseDate?: string;
insuranceCost?: number;
insuranceInterval?: CostInterval;
registrationCost?: number;
registrationInterval?: CostInterval;
tcoEnabled?: boolean;
} }
export interface UpdateVehicleRequest { export interface UpdateVehicleRequest {
@@ -60,6 +85,14 @@ export interface UpdateVehicleRequest {
color?: string; color?: string;
licensePlate?: string; licensePlate?: string;
odometerReading?: number; odometerReading?: number;
// TCO fields
purchasePrice?: number;
purchaseDate?: string;
insuranceCost?: number;
insuranceInterval?: CostInterval;
registrationCost?: number;
registrationInterval?: CostInterval;
tcoEnabled?: boolean;
} }
export interface VehicleResponse { export interface VehicleResponse {
@@ -82,6 +115,14 @@ export interface VehicleResponse {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
imageUrl?: string; imageUrl?: string;
// TCO fields
purchasePrice?: number;
purchaseDate?: string;
insuranceCost?: number;
insuranceInterval?: CostInterval;
registrationCost?: number;
registrationInterval?: CostInterval;
tcoEnabled?: boolean;
} }
export interface VehicleImageMeta { export interface VehicleImageMeta {
@@ -116,6 +157,14 @@ export interface CreateVehicleBody {
color?: string; color?: string;
licensePlate?: string; licensePlate?: string;
odometerReading?: number; odometerReading?: number;
// TCO fields
purchasePrice?: number;
purchaseDate?: string;
insuranceCost?: number;
insuranceInterval?: CostInterval;
registrationCost?: number;
registrationInterval?: CostInterval;
tcoEnabled?: boolean;
} }
export interface UpdateVehicleBody { export interface UpdateVehicleBody {
@@ -132,6 +181,14 @@ export interface UpdateVehicleBody {
color?: string; color?: string;
licensePlate?: string; licensePlate?: string;
odometerReading?: number; odometerReading?: number;
// TCO fields
purchasePrice?: number;
purchaseDate?: string;
insuranceCost?: number;
insuranceInterval?: CostInterval;
registrationCost?: number;
registrationInterval?: CostInterval;
tcoEnabled?: boolean;
} }
export interface VehicleParams { export interface VehicleParams {