feat: add ownership-costs feature capsule (refs #15)

- Create ownership_costs table for recurring vehicle costs
- Add backend feature capsule with types, repository, service, routes
- Update TCO calculation to use ownership_costs (with fallback to legacy vehicle fields)
- Add taxCosts and otherCosts to TCO response
- Create frontend ownership-costs feature with form, list, API, hooks
- Update TCODisplay to show all cost types

This implements a more flexible approach to tracking recurring ownership costs
(insurance, registration, tax, other) with explicit date ranges and optional
document association.

🤖 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 21:28:25 -06:00
parent 5c93150a58
commit a8c4eba8d1
19 changed files with 1644 additions and 15 deletions

View File

@@ -0,0 +1,210 @@
/**
* @ai-summary Data access layer for ownership costs
* @ai-context Handles database operations for vehicle ownership costs
*/
import { Pool } from 'pg';
import {
OwnershipCost,
CreateOwnershipCostRequest,
UpdateOwnershipCostRequest,
CostInterval,
OwnershipCostType
} from '../domain/ownership-costs.types';
export class OwnershipCostsRepository {
constructor(private pool: Pool) {}
async create(data: CreateOwnershipCostRequest & { userId: string }): Promise<OwnershipCost> {
const query = `
INSERT INTO ownership_costs (
user_id, vehicle_id, document_id, cost_type, description,
amount, interval, start_date, end_date
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`;
const values = [
data.userId,
data.vehicleId,
data.documentId ?? null,
data.costType,
data.description ?? null,
data.amount,
data.interval,
data.startDate,
data.endDate ?? null
];
const result = await this.pool.query(query, values);
return this.mapRow(result.rows[0]);
}
async findByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCost[]> {
const query = `
SELECT * FROM ownership_costs
WHERE vehicle_id = $1 AND user_id = $2
ORDER BY start_date DESC, created_at DESC
`;
const result = await this.pool.query(query, [vehicleId, userId]);
return result.rows.map(row => this.mapRow(row));
}
async findByUserId(userId: string): Promise<OwnershipCost[]> {
const query = `
SELECT * FROM ownership_costs
WHERE user_id = $1
ORDER BY start_date DESC, created_at DESC
`;
const result = await this.pool.query(query, [userId]);
return result.rows.map(row => this.mapRow(row));
}
async findById(id: string): Promise<OwnershipCost | null> {
const query = 'SELECT * FROM ownership_costs WHERE id = $1';
const result = await this.pool.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return this.mapRow(result.rows[0]);
}
async findByDocumentId(documentId: string): Promise<OwnershipCost[]> {
const query = `
SELECT * FROM ownership_costs
WHERE document_id = $1
ORDER BY start_date DESC
`;
const result = await this.pool.query(query, [documentId]);
return result.rows.map(row => this.mapRow(row));
}
async update(id: string, data: UpdateOwnershipCostRequest): Promise<OwnershipCost | null> {
const fields = [];
const values = [];
let paramCount = 1;
// Build dynamic update query
if (data.documentId !== undefined) {
fields.push(`document_id = $${paramCount++}`);
values.push(data.documentId);
}
if (data.costType !== undefined) {
fields.push(`cost_type = $${paramCount++}`);
values.push(data.costType);
}
if (data.description !== undefined) {
fields.push(`description = $${paramCount++}`);
values.push(data.description);
}
if (data.amount !== undefined) {
fields.push(`amount = $${paramCount++}`);
values.push(data.amount);
}
if (data.interval !== undefined) {
fields.push(`interval = $${paramCount++}`);
values.push(data.interval);
}
if (data.startDate !== undefined) {
fields.push(`start_date = $${paramCount++}`);
values.push(data.startDate);
}
if (data.endDate !== undefined) {
fields.push(`end_date = $${paramCount++}`);
values.push(data.endDate);
}
if (fields.length === 0) {
return this.findById(id);
}
values.push(id);
const query = `
UPDATE ownership_costs
SET ${fields.join(', ')}
WHERE id = $${paramCount}
RETURNING *
`;
const result = await this.pool.query(query, values);
if (result.rows.length === 0) {
return null;
}
return this.mapRow(result.rows[0]);
}
async delete(id: string): Promise<boolean> {
const query = 'DELETE FROM ownership_costs WHERE id = $1';
const result = await this.pool.query(query, [id]);
return (result.rowCount ?? 0) > 0;
}
async batchInsert(
costs: Array<CreateOwnershipCostRequest & { userId: string }>,
client?: Pool
): Promise<OwnershipCost[]> {
if (costs.length === 0) {
return [];
}
const queryClient = client || this.pool;
const placeholders: string[] = [];
const values: unknown[] = [];
let paramCount = 1;
costs.forEach((cost) => {
const costParams = [
cost.userId,
cost.vehicleId,
cost.documentId ?? null,
cost.costType,
cost.description ?? null,
cost.amount,
cost.interval,
cost.startDate,
cost.endDate ?? null
];
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
placeholders.push(placeholder);
values.push(...costParams);
});
const query = `
INSERT INTO ownership_costs (
user_id, vehicle_id, document_id, cost_type, description,
amount, interval, start_date, end_date
)
VALUES ${placeholders.join(', ')}
RETURNING *
`;
const result = await queryClient.query(query, values);
return result.rows.map((row: Record<string, unknown>) => this.mapRow(row));
}
private mapRow(row: Record<string, unknown>): OwnershipCost {
return {
id: row.id as string,
userId: row.user_id as string,
vehicleId: row.vehicle_id as string,
documentId: row.document_id as string | undefined,
costType: row.cost_type as OwnershipCostType,
description: row.description as string | undefined,
amount: Number(row.amount),
interval: row.interval as CostInterval,
startDate: row.start_date as Date,
endDate: row.end_date as Date | undefined,
createdAt: row.created_at as Date,
updatedAt: row.updated_at as Date,
};
}
}