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:
@@ -10,7 +10,10 @@ import {
|
|||||||
CreateVehicleRequest,
|
CreateVehicleRequest,
|
||||||
UpdateVehicleRequest,
|
UpdateVehicleRequest,
|
||||||
VehicleResponse,
|
VehicleResponse,
|
||||||
VehicleImageMeta
|
VehicleImageMeta,
|
||||||
|
TCOResponse,
|
||||||
|
CostInterval,
|
||||||
|
PAYMENTS_PER_YEAR
|
||||||
} from './vehicles.types';
|
} from './vehicles.types';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import { cacheService } from '../../../core/config/redis';
|
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 { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
|
||||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||||
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
|
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 {
|
export class VehicleLimitExceededError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -378,6 +385,108 @@ export class VehiclesService {
|
|||||||
).catch(err => logger.error('Failed to log vehicle delete audit event', { error: err }));
|
).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> {
|
async getVehicleRaw(id: string, userId: string): Promise<Vehicle | null> {
|
||||||
const vehicle = await this.repository.findById(id);
|
const vehicle = await this.repository.findById(id);
|
||||||
if (!vehicle || vehicle.userId !== userId) {
|
if (!vehicle || vehicle.userId !== userId) {
|
||||||
|
|||||||
@@ -194,3 +194,17 @@ export interface UpdateVehicleBody {
|
|||||||
export interface VehicleParams {
|
export interface VehicleParams {
|
||||||
id: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user