From 8517b1ded2e0725e72fb7680bbacd36af226897e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:58:59 -0600 Subject: [PATCH] feat: add TCO types and repository updates (refs #15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../vehicles/api/vehicles.validation.ts | 19 +++++ .../vehicles/data/vehicles.repository.ts | 73 ++++++++++++++++--- .../vehicles/domain/vehicles.types.ts | 57 +++++++++++++++ 3 files changed, 140 insertions(+), 9 deletions(-) diff --git a/backend/src/features/vehicles/api/vehicles.validation.ts b/backend/src/features/vehicles/api/vehicles.validation.ts index e2538c4..6e8755c 100644 --- a/backend/src/features/vehicles/api/vehicles.validation.ts +++ b/backend/src/features/vehicles/api/vehicles.validation.ts @@ -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({ diff --git a/backend/src/features/vehicles/data/vehicles.repository.ts b/backend/src/features/vehicles/data/vehicles.repository.ts index 3f2df8a..7e98bae 100644 --- a/backend/src/features/vehicles/data/vehicles.repository.ts +++ b/backend/src/features/vehicles/data/vehicles.repository.ts @@ -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 { 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, }; } } diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts index 3c14334..8eff5cb 100644 --- a/backend/src/features/vehicles/domain/vehicles.types.ts +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -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 = { + 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 {