From 381f602e9ffede9d7351e6cfd7f36d1426731da5 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:01:24 -0600 Subject: [PATCH] feat: add TCO calculation service (refs #15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TCOResponse interface - Add getTCO() method aggregating all cost sources - Add normalizeRecurringCost() with division-by-zero guard - Integrate FuelLogsService and MaintenanceService for cost data - Respect user preferences for distance unit and currency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vehicles/domain/vehicles.service.ts | 111 +++++++++++++++++- .../vehicles/domain/vehicles.types.ts | 14 +++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index cde91b5..ca8fcaa 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -10,7 +10,10 @@ import { CreateVehicleRequest, UpdateVehicleRequest, VehicleResponse, - VehicleImageMeta + VehicleImageMeta, + TCOResponse, + CostInterval, + PAYMENTS_PER_YEAR } from './vehicles.types'; import { logger } from '../../../core/logging/logger'; import { cacheService } from '../../../core/config/redis'; @@ -25,6 +28,10 @@ import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } fr import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; +import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service'; +import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository'; +import { MaintenanceService } from '../../maintenance/domain/maintenance.service'; +import { UserSettingsService } from '../../fuel-logs/external/user-settings.service'; export class VehicleLimitExceededError extends Error { constructor( @@ -378,6 +385,108 @@ export class VehiclesService { ).catch(err => logger.error('Failed to log vehicle delete audit event', { error: err })); } + async getTCO(id: string, userId: string): Promise { + // Get vehicle and verify ownership + const vehicle = await this.repository.findById(id); + if (!vehicle) { + const err: any = new Error('Vehicle not found'); + err.statusCode = 404; + throw err; + } + if (vehicle.userId !== userId) { + const err: any = new Error('Unauthorized'); + err.statusCode = 403; + throw err; + } + + // Get user preferences for units + const userSettings = await UserSettingsService.getUserSettings(userId); + const distanceUnit = userSettings.unitSystem === 'metric' ? 'km' : 'mi'; + const currencyCode = userSettings.currencyCode || 'USD'; + + // Get fuel costs from fuel-logs service + const fuelLogsRepository = new FuelLogsRepository(this.pool); + const fuelLogsService = new FuelLogsService(fuelLogsRepository); + let fuelCosts = 0; + try { + const fuelStats = await fuelLogsService.getVehicleStats(id, userId); + fuelCosts = fuelStats.totalCost || 0; + } catch { + // Vehicle may have no fuel logs + fuelCosts = 0; + } + + // Get maintenance costs from maintenance service + const maintenanceService = new MaintenanceService(); + let maintenanceCosts = 0; + try { + const maintenanceStats = await maintenanceService.getVehicleMaintenanceCosts(id, userId); + maintenanceCosts = maintenanceStats.totalCost || 0; + } catch { + // Vehicle may have no maintenance records + maintenanceCosts = 0; + } + + // Get fixed costs from vehicle record + const purchasePrice = vehicle.purchasePrice || 0; + + // Normalize recurring costs based on purchase date + const insuranceCosts = this.normalizeRecurringCost( + vehicle.insuranceCost, + vehicle.insuranceInterval, + vehicle.purchaseDate + ); + const registrationCosts = this.normalizeRecurringCost( + vehicle.registrationCost, + vehicle.registrationInterval, + vehicle.purchaseDate + ); + + // Calculate lifetime total + const lifetimeTotal = purchasePrice + insuranceCosts + registrationCosts + fuelCosts + maintenanceCosts; + + // Calculate cost per distance + const odometerReading = vehicle.odometerReading || 0; + const costPerDistance = odometerReading > 0 ? lifetimeTotal / odometerReading : 0; + + return { + vehicleId: id, + purchasePrice, + insuranceCosts, + registrationCosts, + fuelCosts, + maintenanceCosts, + lifetimeTotal, + costPerDistance, + distanceUnit, + currencyCode + }; + } + + private normalizeRecurringCost( + cost: number | null | undefined, + interval: CostInterval | null | undefined, + purchaseDate: string | null | undefined + ): number { + if (!cost || !interval || !purchaseDate) return 0; + + const monthsOwned = Math.max(1, this.calculateMonthsOwned(purchaseDate)); + const paymentsPerYear = PAYMENTS_PER_YEAR[interval]; + if (!paymentsPerYear) { + throw new Error(`Invalid cost interval: ${interval}`); + } + const totalPayments = (monthsOwned / 12) * paymentsPerYear; + return cost * totalPayments; + } + + private calculateMonthsOwned(purchaseDate: string): number { + const purchase = new Date(purchaseDate); + const now = new Date(); + const yearDiff = now.getFullYear() - purchase.getFullYear(); + const monthDiff = now.getMonth() - purchase.getMonth(); + return yearDiff * 12 + monthDiff; + } + async getVehicleRaw(id: string, userId: string): Promise { const vehicle = await this.repository.findById(id); if (!vehicle || vehicle.userId !== userId) { diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts index 8eff5cb..b0377a6 100644 --- a/backend/src/features/vehicles/domain/vehicles.types.ts +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -194,3 +194,17 @@ export interface UpdateVehicleBody { export interface VehicleParams { id: string; } + +// TCO (Total Cost of Ownership) response +export interface TCOResponse { + vehicleId: string; + purchasePrice: number; + insuranceCosts: number; + registrationCosts: number; + fuelCosts: number; + maintenanceCosts: number; + lifetimeTotal: number; + costPerDistance: number; + distanceUnit: string; + currencyCode: string; +}