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,
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user