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; +}