feat: add TCO calculation service (refs #15)

- 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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-12 20:01:24 -06:00
parent 35fd1782b4
commit 381f602e9f
2 changed files with 124 additions and 1 deletions

View File

@@ -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<TCOResponse> {
// 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<Vehicle | null> {
const vehicle = await this.repository.findById(id);
if (!vehicle || vehicle.userId !== userId) {

View File

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