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>
142 lines
4.4 KiB
TypeScript
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;
|
|
}
|
|
}
|