Files
motovaultpro/backend/src/features/ownership-costs/domain/ownership-costs.service.ts
Eric Gullickson 81b1c3dd70 feat: create ownership_costs backend feature capsule (refs #29)
Milestone 1: Complete backend feature with:
- Migration with CHECK (amount > 0) constraint
- Repository with mapRow() for snake_case -> camelCase
- Service with CRUD and vehicle authorization
- Controller with HTTP handlers
- Routes registered at /api/ownership-costs
- Validation with Zod schemas
- README with endpoint documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:28:43 -06:00

142 lines
4.4 KiB
TypeScript

import { randomUUID } from 'crypto';
import type { Pool } from 'pg';
import type {
CreateOwnershipCostRequest,
UpdateOwnershipCostRequest,
OwnershipCost,
OwnershipCostResponse,
OwnershipCostType,
OwnershipCostStats
} from './ownership-costs.types';
import { OwnershipCostsRepository } from '../data/ownership-costs.repository';
import pool from '../../../core/config/database';
export class OwnershipCostsService {
private readonly repo: OwnershipCostsRepository;
private readonly db: Pool;
constructor(dbPool?: Pool) {
this.db = dbPool || pool;
this.repo = new OwnershipCostsRepository(this.db);
}
async createCost(userId: string, body: CreateOwnershipCostRequest): Promise<OwnershipCost> {
await this.assertVehicleOwnership(userId, body.vehicleId);
const id = randomUUID();
const cost = await this.repo.insert({
id,
userId,
vehicleId: body.vehicleId,
documentId: body.documentId,
costType: body.costType,
amount: body.amount,
description: body.description,
periodStart: body.periodStart,
periodEnd: body.periodEnd,
notes: body.notes,
});
return cost;
}
async getCost(userId: string, id: string): Promise<OwnershipCostResponse | null> {
const cost = await this.repo.findById(id, userId);
if (!cost) return null;
return this.toResponse(cost);
}
async getCosts(userId: string, filters?: { vehicleId?: string; costType?: OwnershipCostType; documentId?: string }): Promise<OwnershipCostResponse[]> {
const costs = await this.repo.findByUserId(userId, filters);
return costs.map(c => this.toResponse(c));
}
async getCostsByVehicle(userId: string, vehicleId: string): Promise<OwnershipCostResponse[]> {
const costs = await this.repo.findByVehicleId(vehicleId, userId);
return costs.map(c => this.toResponse(c));
}
async getVehicleOwnershipCosts(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
const costs = await this.repo.findByVehicleId(vehicleId, userId);
let totalCost = 0;
let insuranceCosts = 0;
let registrationCosts = 0;
let taxCosts = 0;
let otherCosts = 0;
for (const c of costs) {
if (c.amount === null || c.amount === undefined) continue;
const amount = Number(c.amount);
if (isNaN(amount)) {
throw new Error(`Invalid amount value for ownership cost ${c.id}`);
}
totalCost += amount;
// Breakdown by cost type for backward compatibility
switch (c.costType) {
case 'insurance':
insuranceCosts += amount;
break;
case 'registration':
registrationCosts += amount;
break;
case 'tax':
taxCosts += amount;
break;
case 'inspection':
case 'parking':
case 'other':
otherCosts += amount;
break;
}
}
return {
totalCost,
recordCount: costs.length,
insuranceCosts,
registrationCosts,
taxCosts,
otherCosts
};
}
// Alias for backward compatibility with vehicles service
async getVehicleCostStats(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
return this.getVehicleOwnershipCosts(vehicleId, userId);
}
async updateCost(userId: string, id: string, patch: UpdateOwnershipCostRequest): Promise<OwnershipCostResponse | null> {
const existing = await this.repo.findById(id, userId);
if (!existing) return null;
// Convert nulls to undefined for repository compatibility
const cleanPatch = Object.fromEntries(
Object.entries(patch).map(([k, v]) => [k, v === null ? undefined : v])
) as Partial<Pick<OwnershipCost, 'documentId' | 'costType' | 'amount' | 'description' | 'periodStart' | 'periodEnd' | 'notes'>>;
const updated = await this.repo.update(id, userId, cleanPatch);
if (!updated) return null;
return this.toResponse(updated);
}
async deleteCost(userId: string, id: string): Promise<void> {
await this.repo.delete(id, userId);
}
private async assertVehicleOwnership(userId: string, vehicleId: string) {
const res = await this.db.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
if (!res.rows[0]) {
const err: any = new Error('Vehicle not found or not owned by user');
err.statusCode = 403;
throw err;
}
}
private toResponse(cost: OwnershipCost): OwnershipCostResponse {
return cost;
}
}