refactor: Link ownership-costs to documents feature (#29) #30

Merged
egullickson merged 5 commits from issue-29-link-ownership-costs into main 2026-01-15 01:23:57 +00:00
18 changed files with 1259 additions and 1300 deletions

View File

@@ -1,15 +1,18 @@
import { randomUUID } from 'crypto';
import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types';
import { DocumentsRepository } from '../data/documents.repository';
import { OwnershipCostsService } from '../../ownership-costs/domain/ownership-costs.service';
import type { OwnershipCostType } from '../../ownership-costs/domain/ownership-costs.types';
import pool from '../../../core/config/database';
export class DocumentsService {
private readonly repo = new DocumentsRepository(pool);
private readonly ownershipCostsService = new OwnershipCostsService(pool);
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
await this.assertVehicleOwnership(userId, body.vehicleId);
const id = randomUUID();
return this.repo.insert({
const doc = await this.repo.insert({
id,
userId,
vehicleId: body.vehicleId,
@@ -22,6 +25,70 @@ export class DocumentsService {
emailNotifications: body.emailNotifications ?? false,
scanForMaintenance: body.scanForMaintenance ?? false,
});
// Auto-create ownership_cost when insurance/registration has cost data
await this.autoCreateOwnershipCost(userId, doc, body);
return doc;
}
/**
* Auto-creates an ownership_cost record when an insurance or registration
* document is created with cost data (premium or cost field in details).
*/
private async autoCreateOwnershipCost(
userId: string,
doc: DocumentRecord,
body: CreateDocumentBody
): Promise<void> {
const costType = this.mapDocumentTypeToCostType(body.documentType);
if (!costType) return; // Not a cost-linkable document type
const costAmount = this.extractCostAmount(body);
if (!costAmount || costAmount <= 0) return; // No valid cost data
try {
await this.ownershipCostsService.createCost(userId, {
vehicleId: body.vehicleId,
documentId: doc.id,
costType,
amount: costAmount,
description: doc.title,
periodStart: body.issuedDate,
periodEnd: body.expirationDate,
});
} catch (err) {
// Log but don't fail document creation if cost creation fails
console.error('Failed to auto-create ownership cost for document:', doc.id, err);
}
}
/**
* Maps document types to ownership cost types.
* Returns null for document types that don't auto-create costs.
*/
private mapDocumentTypeToCostType(documentType: string): OwnershipCostType | null {
const typeMap: Record<string, OwnershipCostType> = {
'insurance': 'insurance',
'registration': 'registration',
};
return typeMap[documentType] || null;
}
/**
* Extracts cost amount from document details.
* Insurance uses 'premium', registration uses 'cost'.
*/
private extractCostAmount(body: CreateDocumentBody): number | null {
if (!body.details) return null;
const premium = body.details.premium;
const cost = body.details.cost;
if (typeof premium === 'number' && premium > 0) return premium;
if (typeof cost === 'number' && cost > 0) return cost;
return null;
}
async getDocument(userId: string, id: string): Promise<DocumentRecord | null> {
@@ -36,12 +103,78 @@ export class DocumentsService {
const existing = await this.repo.findById(id, userId);
if (!existing) return null;
if (patch && typeof patch === 'object') {
return this.repo.updateMetadata(id, userId, patch as any);
const updated = await this.repo.updateMetadata(id, userId, patch as any);
// Sync cost changes to linked ownership_cost if applicable
if (updated && patch.details) {
await this.syncOwnershipCost(userId, updated, patch);
}
return updated;
}
return existing;
}
/**
* Syncs cost data changes to linked ownership_cost record.
* If document has linked cost and details.premium/cost changed, update it.
*/
private async syncOwnershipCost(
userId: string,
doc: DocumentRecord,
patch: UpdateDocumentBody
): Promise<void> {
const costType = this.mapDocumentTypeToCostType(doc.documentType);
if (!costType) return;
const newCostAmount = this.extractCostAmountFromDetails(patch.details);
if (newCostAmount === null) return; // No cost in update
try {
// Find existing linked cost
const linkedCosts = await this.ownershipCostsService.getCosts(userId, { documentId: doc.id });
if (linkedCosts.length > 0 && newCostAmount > 0) {
// Update existing linked cost
await this.ownershipCostsService.updateCost(userId, linkedCosts[0].id, {
amount: newCostAmount,
periodStart: patch.issuedDate ?? undefined,
periodEnd: patch.expirationDate ?? undefined,
});
} else if (linkedCosts.length === 0 && newCostAmount > 0) {
// Create new cost if none exists
await this.ownershipCostsService.createCost(userId, {
vehicleId: doc.vehicleId,
documentId: doc.id,
costType,
amount: newCostAmount,
description: doc.title,
periodStart: patch.issuedDate ?? doc.issuedDate ?? undefined,
periodEnd: patch.expirationDate ?? doc.expirationDate ?? undefined,
});
}
} catch (err) {
console.error('Failed to sync ownership cost for document:', doc.id, err);
}
}
/**
* Extracts cost amount from details object (for updates).
*/
private extractCostAmountFromDetails(details?: Record<string, any> | null): number | null {
if (!details) return null;
const premium = details.premium;
const cost = details.cost;
if (typeof premium === 'number') return premium;
if (typeof cost === 'number') return cost;
return null;
}
async deleteDocument(userId: string, id: string): Promise<void> {
// Note: Linked ownership_cost records are CASCADE deleted via FK
await this.repo.softDelete(id, userId);
}

View File

@@ -0,0 +1,153 @@
# Ownership Costs Feature
Tracks vehicle ownership costs including insurance, registration, tax, inspection, parking, and other costs.
## Database Schema
```sql
ownership_costs (
id UUID PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE,
document_id UUID NULL REFERENCES documents(id) ON DELETE CASCADE,
cost_type VARCHAR(32) NOT NULL CHECK (cost_type IN ('insurance', 'registration', 'tax', 'inspection', 'parking', 'other')),
amount DECIMAL(10, 2) NOT NULL CHECK (amount > 0),
description VARCHAR(200),
period_start DATE,
period_end DATE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
```
## API Endpoints
### POST /ownership-costs
Create a new ownership cost record.
**Auth**: Required (JWT)
**Request Body**:
```json
{
"vehicleId": "uuid",
"documentId": "uuid (optional)",
"costType": "insurance | registration | tax | inspection | parking | other",
"amount": 150.00,
"description": "Auto insurance premium (optional)",
"periodStart": "2024-01-01 (optional)",
"periodEnd": "2024-12-31 (optional)",
"notes": "Additional notes (optional)"
}
```
**Response**: 201 Created
```json
{
"id": "uuid",
"userId": "string",
"vehicleId": "uuid",
"documentId": "uuid",
"costType": "insurance",
"amount": 150.00,
"description": "Auto insurance premium",
"periodStart": "2024-01-01",
"periodEnd": "2024-12-31",
"notes": "Additional notes",
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
```
### GET /ownership-costs
List all ownership costs for the authenticated user.
**Auth**: Required (JWT)
**Query Parameters**:
- `vehicleId` (optional): Filter by vehicle UUID
- `costType` (optional): Filter by cost type
- `documentId` (optional): Filter by document UUID
**Response**: 200 OK
```json
[
{
"id": "uuid",
"userId": "string",
"vehicleId": "uuid",
"costType": "insurance",
"amount": 150.00,
"description": "Auto insurance premium",
"periodStart": "2024-01-01",
"periodEnd": "2024-12-31",
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
]
```
### GET /ownership-costs/:id
Get a specific ownership cost record.
**Auth**: Required (JWT)
**Response**: 200 OK (same as POST response)
**Errors**:
- 404 Not Found: Cost not found or not owned by user
### PUT /ownership-costs/:id
Update an ownership cost record.
**Auth**: Required (JWT)
**Request Body**: (all fields optional)
```json
{
"documentId": "uuid",
"costType": "insurance",
"amount": 160.00,
"description": "Updated description",
"periodStart": "2024-01-01",
"periodEnd": "2024-12-31",
"notes": "Updated notes"
}
```
**Response**: 200 OK (same as POST response)
**Errors**:
- 404 Not Found: Cost not found or not owned by user
### DELETE /ownership-costs/:id
Delete an ownership cost record.
**Auth**: Required (JWT)
**Response**: 204 No Content
**Errors**:
- 404 Not Found: Cost not found or not owned by user
## Cost Types
| Type | Description |
|------|-------------|
| insurance | Auto insurance premiums |
| registration | Vehicle registration fees |
| tax | Property or excise taxes |
| inspection | State inspection fees |
| parking | Parking permits, monthly fees |
| other | Other ownership costs |
## Authorization
All endpoints enforce user scoping - users can only access their own ownership cost records. Vehicle ownership is verified before creating records.
## Integration with Other Features
- **Documents**: Optional document_id links costs to supporting documents
- **Vehicles**: vehicle_id foreign key with CASCADE delete
- **TCO Calculation**: Service provides `getVehicleOwnershipCosts()` for total cost of ownership aggregation

View File

@@ -1,225 +1,145 @@
/**
* @ai-summary Fastify route handlers for ownership costs API
* @ai-context HTTP request/response handling with Fastify reply methods
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { FastifyReply, FastifyRequest } from 'fastify';
import { OwnershipCostsService } from '../domain/ownership-costs.service';
import { pool } from '../../../core/config/database';
import type { CreateBody, UpdateBody, IdParams, ListQuery } from './ownership-costs.validation';
import { logger } from '../../../core/logging/logger';
import {
OwnershipCostParams,
VehicleParams,
CreateOwnershipCostBody,
UpdateOwnershipCostBody
} from '../domain/ownership-costs.types';
export class OwnershipCostsController {
private service: OwnershipCostsService;
private readonly service = new OwnershipCostsService();
constructor() {
this.service = new OwnershipCostsService(pool);
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
logger.info('Ownership costs list requested', {
operation: 'ownership-costs.list',
userId,
filters: {
vehicleId: request.query.vehicleId,
costType: request.query.costType,
documentId: request.query.documentId,
},
});
const costs = await this.service.getCosts(userId, {
vehicleId: request.query.vehicleId,
costType: request.query.costType,
documentId: request.query.documentId,
});
logger.info('Ownership costs list retrieved', {
operation: 'ownership-costs.list.success',
userId,
costCount: costs.length,
});
return reply.code(200).send(costs);
}
async create(request: FastifyRequest<{ Body: CreateOwnershipCostBody }>, reply: FastifyReply) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userId = (request as any).user.sub;
const cost = await this.service.create(request.body, userId);
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const costId = request.params.id;
return reply.code(201).send(cost);
} catch (error: unknown) {
const err = error as Error;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger.error('Error creating ownership cost', { error: err, userId: (request as any).user?.sub });
logger.info('Ownership cost get requested', {
operation: 'ownership-costs.get',
userId,
costId,
});
if (err.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: err.message
});
}
if (err.message.includes('Unauthorized')) {
return reply.code(403).send({
error: 'Forbidden',
message: err.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to create ownership cost'
const cost = await this.service.getCost(userId, costId);
if (!cost) {
logger.warn('Ownership cost not found', {
operation: 'ownership-costs.get.not_found',
userId,
costId,
});
return reply.code(404).send({ error: 'Not Found' });
}
logger.info('Ownership cost retrieved', {
operation: 'ownership-costs.get.success',
userId,
costId,
vehicleId: cost.vehicleId,
costType: cost.costType,
});
return reply.code(200).send(cost);
}
async getByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userId = (request as any).user.sub;
const { vehicleId } = request.params;
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const costs = await this.service.getByVehicleId(vehicleId, userId);
logger.info('Ownership cost create requested', {
operation: 'ownership-costs.create',
userId,
vehicleId: request.body.vehicleId,
costType: request.body.costType,
amount: request.body.amount,
});
return reply.code(200).send(costs);
} catch (error: unknown) {
const err = error as Error;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger.error('Error listing ownership costs', { error: err, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
const created = await this.service.createCost(userId, request.body);
if (err.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: err.message
});
}
if (err.message.includes('Unauthorized')) {
return reply.code(403).send({
error: 'Forbidden',
message: err.message
});
}
logger.info('Ownership cost created', {
operation: 'ownership-costs.create.success',
userId,
costId: created.id,
vehicleId: created.vehicleId,
costType: created.costType,
amount: created.amount,
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get ownership costs'
});
}
return reply.code(201).send(created);
}
async getById(request: FastifyRequest<{ Params: OwnershipCostParams }>, reply: FastifyReply) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userId = (request as any).user.sub;
const { id } = request.params;
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const costId = request.params.id;
const cost = await this.service.getById(id, userId);
logger.info('Ownership cost update requested', {
operation: 'ownership-costs.update',
userId,
costId,
updateFields: Object.keys(request.body),
});
return reply.code(200).send(cost);
} catch (error: unknown) {
const err = error as Error;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger.error('Error getting ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
if (err.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: err.message
});
}
if (err.message.includes('Unauthorized')) {
return reply.code(403).send({
error: 'Forbidden',
message: err.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get ownership cost'
const updated = await this.service.updateCost(userId, costId, request.body);
if (!updated) {
logger.warn('Ownership cost not found for update', {
operation: 'ownership-costs.update.not_found',
userId,
costId,
});
return reply.code(404).send({ error: 'Not Found' });
}
logger.info('Ownership cost updated', {
operation: 'ownership-costs.update.success',
userId,
costId,
vehicleId: updated.vehicleId,
costType: updated.costType,
});
return reply.code(200).send(updated);
}
async update(request: FastifyRequest<{ Params: OwnershipCostParams; Body: UpdateOwnershipCostBody }>, reply: FastifyReply) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userId = (request as any).user.sub;
const { id } = request.params;
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const costId = request.params.id;
const updatedCost = await this.service.update(id, request.body, userId);
logger.info('Ownership cost delete requested', {
operation: 'ownership-costs.delete',
userId,
costId,
});
return reply.code(200).send(updatedCost);
} catch (error: unknown) {
const err = error as Error;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger.error('Error updating ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
await this.service.deleteCost(userId, costId);
if (err.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: err.message
});
}
if (err.message.includes('Unauthorized')) {
return reply.code(403).send({
error: 'Forbidden',
message: err.message
});
}
logger.info('Ownership cost deleted', {
operation: 'ownership-costs.delete.success',
userId,
costId,
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to update ownership cost'
});
}
}
async delete(request: FastifyRequest<{ Params: OwnershipCostParams }>, reply: FastifyReply) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userId = (request as any).user.sub;
const { id } = request.params;
await this.service.delete(id, userId);
return reply.code(204).send();
} catch (error: unknown) {
const err = error as Error;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger.error('Error deleting ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
if (err.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: err.message
});
}
if (err.message.includes('Unauthorized')) {
return reply.code(403).send({
error: 'Forbidden',
message: err.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to delete ownership cost'
});
}
}
async getVehicleStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const userId = (request as any).user.sub;
const { vehicleId } = request.params;
const stats = await this.service.getVehicleCostStats(vehicleId, userId);
return reply.code(200).send(stats);
} catch (error: unknown) {
const err = error as Error;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger.error('Error getting ownership cost stats', { error: err, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
if (err.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: err.message
});
}
if (err.message.includes('Unauthorized')) {
return reply.code(403).send({
error: 'Forbidden',
message: err.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get ownership cost stats'
});
}
return reply.code(204).send();
}
}

View File

@@ -1,56 +1,38 @@
/**
* @ai-summary Fastify routes for ownership costs API
* @ai-context Route definitions with Fastify plugin pattern and authentication
*/
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import { FastifyPluginAsync } from 'fastify';
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
import { OwnershipCostsController } from './ownership-costs.controller';
export const ownershipCostsRoutes: FastifyPluginAsync = async (
fastify: FastifyInstance,
_opts: FastifyPluginOptions
) => {
const controller = new OwnershipCostsController();
const ctrl = new OwnershipCostsController();
const requireAuth = fastify.authenticate.bind(fastify);
// POST /api/ownership-costs - Create new ownership cost
fastify.post('/ownership-costs', {
preHandler: [fastify.authenticate],
handler: controller.create.bind(controller)
fastify.get('/ownership-costs', {
preHandler: [requireAuth],
handler: ctrl.list.bind(ctrl)
});
// GET /api/ownership-costs/:id - Get specific ownership cost
fastify.get('/ownership-costs/:id', {
preHandler: [fastify.authenticate],
handler: controller.getById.bind(controller)
fastify.get<{ Params: any }>('/ownership-costs/:id', {
preHandler: [requireAuth],
handler: ctrl.get.bind(ctrl)
});
// PUT /api/ownership-costs/:id - Update ownership cost
fastify.put('/ownership-costs/:id', {
preHandler: [fastify.authenticate],
handler: controller.update.bind(controller)
fastify.post<{ Body: any }>('/ownership-costs', {
preHandler: [requireAuth],
handler: ctrl.create.bind(ctrl)
});
// DELETE /api/ownership-costs/:id - Delete ownership cost
fastify.delete('/ownership-costs/:id', {
preHandler: [fastify.authenticate],
handler: controller.delete.bind(controller)
fastify.put<{ Params: any; Body: any }>('/ownership-costs/:id', {
preHandler: [requireAuth],
handler: ctrl.update.bind(ctrl)
});
// GET /api/ownership-costs/vehicle/:vehicleId - Get costs for a vehicle
fastify.get('/ownership-costs/vehicle/:vehicleId', {
preHandler: [fastify.authenticate],
handler: controller.getByVehicle.bind(controller)
});
// GET /api/ownership-costs/vehicle/:vehicleId/stats - Get aggregated cost stats
fastify.get('/ownership-costs/vehicle/:vehicleId/stats', {
preHandler: [fastify.authenticate],
handler: controller.getVehicleStats.bind(controller)
fastify.delete<{ Params: any }>('/ownership-costs/:id', {
preHandler: [requireAuth],
handler: ctrl.remove.bind(ctrl)
});
};
// For backward compatibility during migration
export function registerOwnershipCostsRoutes() {
throw new Error('registerOwnershipCostsRoutes is deprecated - use ownershipCostsRoutes Fastify plugin instead');
}

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
import { CreateOwnershipCostSchema, UpdateOwnershipCostSchema, OwnershipCostTypeSchema } from '../domain/ownership-costs.types';
export const ListQuerySchema = z.object({
vehicleId: z.string().uuid().optional(),
costType: OwnershipCostTypeSchema.optional(),
documentId: z.string().uuid().optional(),
});
export const IdParamsSchema = z.object({ id: z.string().uuid() });
export const CreateBodySchema = CreateOwnershipCostSchema;
export const UpdateBodySchema = UpdateOwnershipCostSchema;
export type ListQuery = z.infer<typeof ListQuerySchema>;
export type IdParams = z.infer<typeof IdParamsSchema>;
export type CreateBody = z.infer<typeof CreateBodySchema>;
export type UpdateBody = z.infer<typeof UpdateBodySchema>;

View File

@@ -1,210 +1,160 @@
/**
* @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';
import pool from '../../../core/config/database';
import type { OwnershipCost, OwnershipCostType } from '../domain/ownership-costs.types';
export class OwnershipCostsRepository {
constructor(private pool: Pool) {}
constructor(private readonly db: 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 *
`;
// ========================
// Row Mappers
// ========================
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]);
private mapRow(row: any): OwnershipCost {
return {
id: row.id,
userId: row.user_id,
vehicleId: row.vehicle_id,
documentId: row.document_id,
costType: row.cost_type,
amount: row.amount,
description: row.description,
periodStart: row.period_start,
periodEnd: row.period_end,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
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
`;
// ========================
// CRUD Operations
// ========================
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 = [
async insert(cost: {
id: string;
userId: string;
vehicleId: string;
documentId?: string | null;
costType: OwnershipCostType;
amount: number;
description?: string | null;
periodStart?: string | null;
periodEnd?: string | null;
notes?: string | null;
}): Promise<OwnershipCost> {
const res = await this.db.query(
`INSERT INTO ownership_costs (
id, user_id, vehicle_id, document_id, cost_type, amount, description, period_start, period_end, notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
cost.id,
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));
cost.description ?? null,
cost.periodStart ?? null,
cost.periodEnd ?? null,
cost.notes ?? null,
]
);
return this.mapRow(res.rows[0]);
}
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,
};
async findById(id: string, userId: string): Promise<OwnershipCost | null> {
const res = await this.db.query(
`SELECT * FROM ownership_costs WHERE id = $1 AND user_id = $2`,
[id, userId]
);
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
}
async findByUserId(
userId: string,
filters?: { vehicleId?: string; costType?: OwnershipCostType; documentId?: string }
): Promise<OwnershipCost[]> {
const conds: string[] = ['user_id = $1'];
const params: any[] = [userId];
let i = 2;
if (filters?.vehicleId) {
conds.push(`vehicle_id = $${i++}`);
params.push(filters.vehicleId);
}
if (filters?.costType) {
conds.push(`cost_type = $${i++}`);
params.push(filters.costType);
}
if (filters?.documentId) {
conds.push(`document_id = $${i++}`);
params.push(filters.documentId);
}
const sql = `SELECT * FROM ownership_costs WHERE ${conds.join(' AND ')} ORDER BY period_start DESC, created_at DESC`;
const res = await this.db.query(sql, params);
return res.rows.map(row => this.mapRow(row));
}
async findByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCost[]> {
const res = await this.db.query(
`SELECT * FROM ownership_costs WHERE vehicle_id = $1 AND user_id = $2 ORDER BY period_start DESC, created_at DESC`,
[vehicleId, userId]
);
return res.rows.map(row => this.mapRow(row));
}
async update(
id: string,
userId: string,
patch: Partial<Pick<OwnershipCost, 'documentId' | 'costType' | 'amount' | 'description' | 'periodStart' | 'periodEnd' | 'notes'>>
): Promise<OwnershipCost | null> {
const fields: string[] = [];
const params: any[] = [];
let i = 1;
if (patch.documentId !== undefined) {
fields.push(`document_id = $${i++}`);
params.push(patch.documentId);
}
if (patch.costType !== undefined) {
fields.push(`cost_type = $${i++}`);
params.push(patch.costType);
}
if (patch.amount !== undefined) {
fields.push(`amount = $${i++}`);
params.push(patch.amount);
}
if (patch.description !== undefined) {
fields.push(`description = $${i++}`);
params.push(patch.description);
}
if (patch.periodStart !== undefined) {
fields.push(`period_start = $${i++}`);
params.push(patch.periodStart);
}
if (patch.periodEnd !== undefined) {
fields.push(`period_end = $${i++}`);
params.push(patch.periodEnd);
}
if (patch.notes !== undefined) {
fields.push(`notes = $${i++}`);
params.push(patch.notes);
}
if (!fields.length) return this.findById(id, userId);
params.push(id, userId);
const sql = `UPDATE ownership_costs SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
const res = await this.db.query(sql, params);
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
}
async delete(id: string, userId: string): Promise<void> {
await this.db.query(
`DELETE FROM ownership_costs WHERE id = $1 AND user_id = $2`,
[id, userId]
);
}
}

View File

@@ -1,199 +1,141 @@
/**
* @ai-summary Business logic for ownership costs feature
* @ai-context Handles ownership cost operations and TCO aggregation
*/
import { Pool } from 'pg';
import { OwnershipCostsRepository } from '../data/ownership-costs.repository';
import {
OwnershipCost,
import { randomUUID } from 'crypto';
import type { Pool } from 'pg';
import type {
CreateOwnershipCostRequest,
UpdateOwnershipCostRequest,
OwnershipCost,
OwnershipCostResponse,
OwnershipCostStats,
PAYMENTS_PER_YEAR,
OwnershipCostType
OwnershipCostType,
OwnershipCostStats
} from './ownership-costs.types';
import { logger } from '../../../core/logging/logger';
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
import { OwnershipCostsRepository } from '../data/ownership-costs.repository';
import pool from '../../../core/config/database';
export class OwnershipCostsService {
private repository: OwnershipCostsRepository;
private vehiclesRepository: VehiclesRepository;
private readonly repo: OwnershipCostsRepository;
private readonly db: Pool;
constructor(pool: Pool) {
this.repository = new OwnershipCostsRepository(pool);
this.vehiclesRepository = new VehiclesRepository(pool);
constructor(dbPool?: Pool) {
this.db = dbPool || pool;
this.repo = new OwnershipCostsRepository(this.db);
}
async create(data: CreateOwnershipCostRequest, userId: string): Promise<OwnershipCostResponse> {
logger.info('Creating ownership cost', { userId, vehicleId: data.vehicleId, costType: data.costType });
async createCost(userId: string, body: CreateOwnershipCostRequest): Promise<OwnershipCost> {
await this.assertVehicleOwnership(userId, body.vehicleId);
// Verify vehicle ownership
const vehicle = await this.vehiclesRepository.findById(data.vehicleId);
if (!vehicle) {
throw new Error('Vehicle not found');
}
if (vehicle.userId !== userId) {
throw new Error('Unauthorized');
}
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,
});
const cost = await this.repository.create({ ...data, userId });
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 getByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCostResponse[]> {
// Verify vehicle ownership
const vehicle = await this.vehiclesRepository.findById(vehicleId);
if (!vehicle) {
throw new Error('Vehicle not found');
}
if (vehicle.userId !== userId) {
throw new Error('Unauthorized');
}
const costs = await this.repository.findByVehicleId(vehicleId, userId);
return costs.map(cost => 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 getById(id: string, userId: string): Promise<OwnershipCostResponse> {
const cost = await this.repository.findById(id);
if (!cost) {
throw new Error('Ownership cost not found');
}
if (cost.userId !== userId) {
throw new Error('Unauthorized');
}
return this.toResponse(cost);
async getCostsByVehicle(userId: string, vehicleId: string): Promise<OwnershipCostResponse[]> {
const costs = await this.repo.findByVehicleId(vehicleId, userId);
return costs.map(c => this.toResponse(c));
}
async update(id: string, data: UpdateOwnershipCostRequest, userId: string): Promise<OwnershipCostResponse> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Ownership cost not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
async getVehicleOwnershipCosts(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
const costs = await this.repo.findByVehicleId(vehicleId, userId);
const updated = await this.repository.update(id, data);
if (!updated) {
throw new Error('Update failed');
}
let totalCost = 0;
let insuranceCosts = 0;
let registrationCosts = 0;
let taxCosts = 0;
let otherCosts = 0;
return this.toResponse(updated);
}
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}`);
}
async delete(id: string, userId: string): Promise<void> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Ownership cost not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
totalCost += amount;
await this.repository.delete(id);
logger.info('Ownership cost deleted', { id, userId });
}
/**
* Get aggregated cost statistics for a vehicle
* Used by TCO calculation in vehicles service
*/
async getVehicleCostStats(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
const costs = await this.repository.findByVehicleId(vehicleId, userId);
const now = new Date();
const stats: OwnershipCostStats = {
insuranceCosts: 0,
registrationCosts: 0,
taxCosts: 0,
otherCosts: 0,
totalCosts: 0
};
for (const cost of costs) {
const startDate = new Date(cost.startDate);
const endDate = cost.endDate ? new Date(cost.endDate) : now;
// Skip future costs
if (startDate > now) continue;
// Calculate effective end date (either specified end, or now for ongoing costs)
const effectiveEnd = endDate < now ? endDate : now;
const monthsCovered = this.calculateMonthsBetween(startDate, effectiveEnd);
const normalizedCost = this.normalizeToTotal(cost.amount, cost.interval, monthsCovered);
// Type-safe key access
const keyMap: Record<OwnershipCostType, keyof Omit<OwnershipCostStats, 'totalCosts'>> = {
insurance: 'insuranceCosts',
registration: 'registrationCosts',
tax: 'taxCosts',
other: 'otherCosts'
};
const key = keyMap[cost.costType];
if (key) {
stats[key] += normalizedCost;
} else {
logger.warn('Unknown cost type in aggregation', { costType: cost.costType, costId: cost.id });
// 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;
}
}
stats.totalCosts = stats.insuranceCosts + stats.registrationCosts + stats.taxCosts + stats.otherCosts;
return stats;
return {
totalCost,
recordCount: costs.length,
insuranceCosts,
registrationCosts,
taxCosts,
otherCosts
};
}
/**
* Calculate months between two dates
*/
private calculateMonthsBetween(startDate: Date, endDate: Date): number {
const yearDiff = endDate.getFullYear() - startDate.getFullYear();
const monthDiff = endDate.getMonth() - startDate.getMonth();
return Math.max(1, yearDiff * 12 + monthDiff);
// Alias for backward compatibility with vehicles service
async getVehicleCostStats(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
return this.getVehicleOwnershipCosts(vehicleId, userId);
}
/**
* Normalize recurring cost to total based on interval and months covered
*/
private normalizeToTotal(amount: number, interval: string, monthsCovered: number): number {
// One-time costs are just the amount
if (interval === 'one_time') {
return amount;
}
async updateCost(userId: string, id: string, patch: UpdateOwnershipCostRequest): Promise<OwnershipCostResponse | null> {
const existing = await this.repo.findById(id, userId);
if (!existing) return null;
const paymentsPerYear = PAYMENTS_PER_YEAR[interval as keyof typeof PAYMENTS_PER_YEAR];
if (!paymentsPerYear) {
logger.warn('Invalid cost interval', { interval });
return 0;
}
// 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'>>;
// Calculate total payments over the covered period
const yearsOwned = monthsCovered / 12;
const totalPayments = yearsOwned * paymentsPerYear;
return amount * totalPayments;
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 {
id: cost.id,
userId: cost.userId,
vehicleId: cost.vehicleId,
documentId: cost.documentId,
costType: cost.costType,
description: cost.description,
amount: cost.amount,
interval: cost.interval,
startDate: cost.startDate instanceof Date ? cost.startDate.toISOString().split('T')[0] : cost.startDate as unknown as string,
endDate: cost.endDate ? (cost.endDate instanceof Date ? cost.endDate.toISOString().split('T')[0] : cost.endDate as unknown as string) : undefined,
createdAt: cost.createdAt instanceof Date ? cost.createdAt.toISOString() : cost.createdAt as unknown as string,
updatedAt: cost.updatedAt instanceof Date ? cost.updatedAt.toISOString() : cost.updatedAt as unknown as string,
};
return cost;
}
}

View File

@@ -1,108 +1,69 @@
/**
* @ai-summary Type definitions for ownership-costs feature
* @ai-context Tracks vehicle ownership costs (insurance, registration, tax, other)
* @ai-context Tracks vehicle ownership costs (insurance, registration, tax, inspection, parking, other)
*/
// Cost types supported by ownership-costs feature
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'other';
import { z } from 'zod';
// Cost interval types (one_time added for things like purchase price)
export type CostInterval = 'monthly' | 'semi_annual' | 'annual' | 'one_time';
// Payments per year for each interval type
export const PAYMENTS_PER_YEAR: Record<CostInterval, number> = {
monthly: 12,
semi_annual: 2,
annual: 1,
one_time: 0, // Special case: calculated differently
} as const;
// Ownership cost types
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'inspection' | 'parking' | 'other';
// Database record type (camelCase for TypeScript)
export interface OwnershipCost {
id: string;
userId: string;
vehicleId: string;
documentId?: string;
costType: OwnershipCostType;
description?: string;
amount: number;
interval: CostInterval;
startDate: Date;
endDate?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface CreateOwnershipCostRequest {
vehicleId: string;
documentId?: string;
costType: OwnershipCostType;
description?: string;
amount: number;
interval: CostInterval;
startDate: string; // ISO date string
endDate?: string; // ISO date string
}
export interface UpdateOwnershipCostRequest {
documentId?: string | null;
costType?: OwnershipCostType;
description?: string | null;
amount?: number;
interval?: CostInterval;
startDate?: string;
endDate?: string | null;
}
export interface OwnershipCostResponse {
id: string;
userId: string;
vehicleId: string;
documentId?: string;
costType: OwnershipCostType;
description?: string;
amount: number;
interval: CostInterval;
startDate: string;
endDate?: string;
periodStart?: string;
periodEnd?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
// Aggregated cost statistics for TCO calculation
// Zod schemas for validation (camelCase for API)
export const OwnershipCostTypeSchema = z.enum(['insurance', 'registration', 'tax', 'inspection', 'parking', 'other']);
export const CreateOwnershipCostSchema = z.object({
vehicleId: z.string().uuid(),
documentId: z.string().uuid().optional(),
costType: OwnershipCostTypeSchema,
amount: z.number().positive(),
description: z.string().max(200).optional(),
periodStart: z.string().optional(),
periodEnd: z.string().optional(),
notes: z.string().max(10000).optional(),
});
export type CreateOwnershipCostRequest = z.infer<typeof CreateOwnershipCostSchema>;
export const UpdateOwnershipCostSchema = z.object({
documentId: z.string().uuid().nullable().optional(),
costType: OwnershipCostTypeSchema.optional(),
amount: z.number().positive().optional(),
description: z.string().max(200).nullable().optional(),
periodStart: z.string().nullable().optional(),
periodEnd: z.string().nullable().optional(),
notes: z.string().max(10000).nullable().optional(),
});
export type UpdateOwnershipCostRequest = z.infer<typeof UpdateOwnershipCostSchema>;
// Response type (alias to OwnershipCost)
export type OwnershipCostResponse = OwnershipCost;
// TCO aggregation stats
// NOTE: Extended for backward compatibility with vehicles service
// The spec calls for totalCost and recordCount only, but the vehicles service
// expects breakdown by cost type. This will be cleaned up when vehicles service
// is refactored to use the new ownership-costs table directly.
export interface OwnershipCostStats {
insuranceCosts: number;
registrationCosts: number;
taxCosts: number;
otherCosts: number;
totalCosts: number;
}
// Fastify-specific types for HTTP handling
export interface CreateOwnershipCostBody {
vehicleId: string;
documentId?: string;
costType: OwnershipCostType;
description?: string;
amount: number;
interval: CostInterval;
startDate: string;
endDate?: string;
}
export interface UpdateOwnershipCostBody {
documentId?: string | null;
costType?: OwnershipCostType;
description?: string | null;
amount?: number;
interval?: CostInterval;
startDate?: string;
endDate?: string | null;
}
export interface OwnershipCostParams {
id: string;
}
export interface VehicleParams {
vehicleId: string;
totalCost: number;
recordCount: number;
// Backward compatibility fields for vehicles service
insuranceCosts?: number;
registrationCosts?: number;
taxCosts?: number;
otherCosts?: number;
}

View File

@@ -12,11 +12,8 @@ export type {
UpdateOwnershipCostRequest,
OwnershipCostResponse,
OwnershipCostStats,
OwnershipCostType,
CostInterval
OwnershipCostType
} from './domain/ownership-costs.types';
export { PAYMENTS_PER_YEAR } from './domain/ownership-costs.types';
// Internal: Register routes with Fastify app
export { ownershipCostsRoutes, registerOwnershipCostsRoutes } from './api/ownership-costs.routes';
export { ownershipCostsRoutes } from './api/ownership-costs.routes';

View File

@@ -1,60 +1,27 @@
-- Migration: Create ownership_costs table
-- Issue: #15
-- Description: Store vehicle ownership costs (insurance, registration, tax, other)
-- with explicit date ranges and optional document association
-- Issue: #29
-- Description: Create ownership_costs table for tracking insurance, registration, tax, inspection, parking, and other costs
CREATE TABLE IF NOT EXISTS ownership_costs (
CREATE TABLE ownership_costs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
document_id UUID,
cost_type VARCHAR(50) NOT NULL,
description TEXT,
amount DECIMAL(12, 2) NOT NULL,
interval VARCHAR(20) NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
vehicle_id UUID NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE,
document_id UUID NULL REFERENCES documents(id) ON DELETE CASCADE,
cost_type VARCHAR(32) NOT NULL CHECK (cost_type IN ('insurance', 'registration', 'tax', 'inspection', 'parking', 'other')),
amount DECIMAL(10, 2) NOT NULL CHECK (amount > 0),
description VARCHAR(200),
period_start DATE,
period_end DATE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Foreign key to vehicles (cascade delete)
CONSTRAINT fk_ownership_costs_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE,
-- Foreign key to documents (set null on delete)
CONSTRAINT fk_ownership_costs_document
FOREIGN KEY (document_id)
REFERENCES documents(id)
ON DELETE SET NULL,
-- Enforce valid cost types
CONSTRAINT chk_ownership_costs_type
CHECK (cost_type IN ('insurance', 'registration', 'tax', 'other')),
-- Enforce valid intervals
CONSTRAINT chk_ownership_costs_interval
CHECK (interval IN ('monthly', 'semi_annual', 'annual', 'one_time')),
-- Enforce non-negative amounts
CONSTRAINT chk_ownership_costs_amount_non_negative
CHECK (amount >= 0),
-- Enforce end_date >= start_date when end_date is provided
CONSTRAINT chk_ownership_costs_date_range
CHECK (end_date IS NULL OR end_date >= start_date)
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for common queries
CREATE INDEX IF NOT EXISTS idx_ownership_costs_user_id ON ownership_costs(user_id);
CREATE INDEX IF NOT EXISTS idx_ownership_costs_vehicle_id ON ownership_costs(vehicle_id);
CREATE INDEX IF NOT EXISTS idx_ownership_costs_cost_type ON ownership_costs(cost_type);
CREATE INDEX IF NOT EXISTS idx_ownership_costs_start_date ON ownership_costs(start_date DESC);
CREATE INDEX IF NOT EXISTS idx_ownership_costs_document_id ON ownership_costs(document_id) WHERE document_id IS NOT NULL;
CREATE INDEX idx_ownership_costs_user_id ON ownership_costs(user_id);
CREATE INDEX idx_ownership_costs_vehicle_id ON ownership_costs(vehicle_id);
CREATE INDEX idx_ownership_costs_document_id ON ownership_costs(document_id);
CREATE INDEX idx_ownership_costs_cost_type ON ownership_costs(cost_type);
-- Add updated_at trigger
DROP TRIGGER IF EXISTS update_ownership_costs_updated_at ON ownership_costs;
CREATE TRIGGER update_ownership_costs_updated_at
BEFORE UPDATE ON ownership_costs
FOR EACH ROW

View File

@@ -1,73 +0,0 @@
-- Migration: Migrate existing vehicle TCO data to ownership_costs table
-- Issue: #15
-- Description: Copy insurance and registration costs from vehicles table to ownership_costs
-- Insert insurance costs from vehicles that have insurance data
INSERT INTO ownership_costs (
user_id,
vehicle_id,
document_id,
cost_type,
description,
amount,
interval,
start_date,
end_date
)
SELECT
v.user_id,
v.id AS vehicle_id,
NULL AS document_id,
'insurance' AS cost_type,
'Migrated from vehicle record' AS description,
v.insurance_cost AS amount,
v.insurance_interval AS interval,
COALESCE(v.purchase_date, v.created_at::date) AS start_date,
NULL AS end_date
FROM vehicles v
WHERE v.insurance_cost IS NOT NULL
AND v.insurance_cost > 0
AND v.insurance_interval IS NOT NULL
AND v.is_active = true
AND NOT EXISTS (
-- Only migrate if no insurance cost already exists for this vehicle
SELECT 1 FROM ownership_costs oc
WHERE oc.vehicle_id = v.id
AND oc.cost_type = 'insurance'
AND oc.description = 'Migrated from vehicle record'
);
-- Insert registration costs from vehicles that have registration data
INSERT INTO ownership_costs (
user_id,
vehicle_id,
document_id,
cost_type,
description,
amount,
interval,
start_date,
end_date
)
SELECT
v.user_id,
v.id AS vehicle_id,
NULL AS document_id,
'registration' AS cost_type,
'Migrated from vehicle record' AS description,
v.registration_cost AS amount,
v.registration_interval AS interval,
COALESCE(v.purchase_date, v.created_at::date) AS start_date,
NULL AS end_date
FROM vehicles v
WHERE v.registration_cost IS NOT NULL
AND v.registration_cost > 0
AND v.registration_interval IS NOT NULL
AND v.is_active = true
AND NOT EXISTS (
-- Only migrate if no registration cost already exists for this vehicle
SELECT 1 FROM ownership_costs oc
WHERE oc.vehicle_id = v.id
AND oc.cost_type = 'registration'
AND oc.description = 'Migrated from vehicle record'
);

View File

@@ -0,0 +1,33 @@
-- Migration: Alter ownership_costs table to match updated schema
-- Issue: #29
-- Description: Fix schema mismatch - rename columns, add notes, update constraints
-- Rename columns to match code expectations
ALTER TABLE ownership_costs RENAME COLUMN start_date TO period_start;
ALTER TABLE ownership_costs RENAME COLUMN end_date TO period_end;
-- Make period_start nullable (was NOT NULL as start_date)
ALTER TABLE ownership_costs ALTER COLUMN period_start DROP NOT NULL;
-- Drop interval column (no longer used in new schema)
ALTER TABLE ownership_costs DROP COLUMN IF EXISTS interval;
-- Add notes column
ALTER TABLE ownership_costs ADD COLUMN IF NOT EXISTS notes TEXT;
-- Update description column type to match new schema
ALTER TABLE ownership_costs ALTER COLUMN description TYPE VARCHAR(200);
-- Drop old check constraints
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_type;
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_amount_non_negative;
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_date_range;
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_interval;
-- Update cost_type column to VARCHAR(32) and add new constraint with additional types
ALTER TABLE ownership_costs ALTER COLUMN cost_type TYPE VARCHAR(32);
ALTER TABLE ownership_costs ADD CONSTRAINT ownership_costs_cost_type_check
CHECK (cost_type IN ('insurance', 'registration', 'tax', 'inspection', 'parking', 'other'));
-- Add amount constraint (amount > 0)
ALTER TABLE ownership_costs ADD CONSTRAINT ownership_costs_amount_check CHECK (amount > 0);

View File

@@ -1,60 +1,28 @@
/**
* @ai-summary API calls for ownership-costs feature
* @ai-summary API client for ownership-costs feature
*/
import { apiClient } from '../../../core/api/client';
import {
OwnershipCost,
CreateOwnershipCostRequest,
UpdateOwnershipCostRequest,
OwnershipCostStats
} from '../types/ownership-costs.types';
import type { CreateOwnershipCostRequest, OwnershipCost, UpdateOwnershipCostRequest } from '../types/ownership-costs.types';
export const ownershipCostsApi = {
/**
* Get all ownership costs for a vehicle
*/
getByVehicle: async (vehicleId: string): Promise<OwnershipCost[]> => {
const response = await apiClient.get(`/ownership-costs/vehicle/${vehicleId}`);
return response.data;
async list(filters?: { vehicleId?: string }) {
const res = await apiClient.get<OwnershipCost[]>('/ownership-costs', { params: filters });
return res.data;
},
/**
* Get a single ownership cost by ID
*/
getById: async (id: string): Promise<OwnershipCost> => {
const response = await apiClient.get(`/ownership-costs/${id}`);
return response.data;
async get(id: string) {
const res = await apiClient.get<OwnershipCost>(`/ownership-costs/${id}`);
return res.data;
},
/**
* Create a new ownership cost
*/
create: async (data: CreateOwnershipCostRequest): Promise<OwnershipCost> => {
const response = await apiClient.post('/ownership-costs', data);
return response.data;
async create(payload: CreateOwnershipCostRequest) {
const res = await apiClient.post<OwnershipCost>('/ownership-costs', payload);
return res.data;
},
/**
* Update an existing ownership cost
*/
update: async (id: string, data: UpdateOwnershipCostRequest): Promise<OwnershipCost> => {
const response = await apiClient.put(`/ownership-costs/${id}`, data);
return response.data;
async update(id: string, payload: UpdateOwnershipCostRequest) {
const res = await apiClient.put<OwnershipCost>(`/ownership-costs/${id}`, payload);
return res.data;
},
/**
* Delete an ownership cost
*/
delete: async (id: string): Promise<void> => {
async remove(id: string) {
await apiClient.delete(`/ownership-costs/${id}`);
},
/**
* Get aggregated cost stats for a vehicle
*/
getVehicleStats: async (vehicleId: string): Promise<OwnershipCostStats> => {
const response = await apiClient.get(`/ownership-costs/vehicle/${vehicleId}/stats`);
return response.data;
},
};

View File

@@ -1,214 +1,235 @@
/**
* @ai-summary Form component for adding/editing ownership costs
* @ai-summary Form component for creating/editing ownership costs
*/
import React, { useState, useEffect } from 'react';
import React from 'react';
import { Button } from '../../../shared-minimal/components/Button';
import {
OwnershipCost,
OwnershipCostType,
CostInterval,
COST_TYPE_LABELS,
INTERVAL_LABELS
} from '../types/ownership-costs.types';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import dayjs from 'dayjs';
import { useCreateOwnershipCost } from '../hooks/useOwnershipCosts';
import type { OwnershipCostType } from '../types/ownership-costs.types';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import type { Vehicle } from '../../vehicles/types/vehicles.types';
import { useDocumentsList } from '../../documents/hooks/useDocuments';
import type { DocumentRecord } from '../../documents/types/documents.types';
interface OwnershipCostFormProps {
vehicleId: string;
initialData?: OwnershipCost;
onSubmit: (data: {
costType: OwnershipCostType;
description?: string;
amount: number;
interval: CostInterval;
startDate: string;
endDate?: string;
}) => Promise<void>;
onCancel: () => void;
loading?: boolean;
onSuccess?: () => void;
onCancel?: () => void;
}
export const OwnershipCostForm: React.FC<OwnershipCostFormProps> = ({
initialData,
onSubmit,
onCancel,
loading,
}) => {
const [costType, setCostType] = useState<OwnershipCostType>(initialData?.costType || 'insurance');
const [description, setDescription] = useState(initialData?.description || '');
const [amount, setAmount] = useState(initialData?.amount?.toString() || '');
const [interval, setInterval] = useState<CostInterval>(initialData?.interval || 'monthly');
const [startDate, setStartDate] = useState(initialData?.startDate || new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(initialData?.endDate || '');
const [error, setError] = useState<string | null>(null);
export const OwnershipCostForm: React.FC<OwnershipCostFormProps> = ({ onSuccess, onCancel }) => {
const [vehicleID, setVehicleID] = React.useState<string>('');
const [costType, setCostType] = React.useState<OwnershipCostType>('insurance');
const [amount, setAmount] = React.useState<string>('');
const [description, setDescription] = React.useState<string>('');
const [periodStart, setPeriodStart] = React.useState<string>('');
const [periodEnd, setPeriodEnd] = React.useState<string>('');
const [notes, setNotes] = React.useState<string>('');
const [documentID, setDocumentID] = React.useState<string>('');
const [error, setError] = React.useState<string | null>(null);
useEffect(() => {
if (initialData) {
setCostType(initialData.costType);
setDescription(initialData.description || '');
setAmount(initialData.amount.toString());
setInterval(initialData.interval);
setStartDate(initialData.startDate);
setEndDate(initialData.endDate || '');
}
}, [initialData]);
const { data: vehicles } = useVehicles();
const { data: documents } = useDocumentsList({ vehicleId: vehicleID });
const create = useCreateOwnershipCost();
const resetForm = () => {
setVehicleID('');
setCostType('insurance');
setAmount('');
setDescription('');
setPeriodStart('');
setPeriodEnd('');
setNotes('');
setDocumentID('');
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate amount
if (!vehicleID) {
setError('Please select a vehicle.');
return;
}
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount < 0) {
setError('Please enter a valid amount');
return;
}
// Validate dates
if (!startDate) {
setError('Start date is required');
return;
}
if (endDate && new Date(endDate) < new Date(startDate)) {
setError('End date must be after start date');
if (isNaN(parsedAmount) || parsedAmount <= 0) {
setError('Please enter a valid positive amount.');
return;
}
try {
await onSubmit({
costType,
description: description.trim() || undefined,
await create.mutateAsync({
vehicleId: vehicleID,
documentId: documentID || undefined,
costType: costType,
amount: parsedAmount,
interval,
startDate,
endDate: endDate || undefined,
description: description.trim() || undefined,
periodStart: periodStart || undefined,
periodEnd: periodEnd || undefined,
notes: notes.trim() || undefined,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save cost';
setError(message);
resetForm();
onSuccess?.();
} catch (err: any) {
setError(err?.message || 'Failed to create ownership cost');
}
};
const isEditMode = !!initialData;
const vehicleLabel = (v: Vehicle) => {
if (v.nickname && v.nickname.trim().length > 0) return v.nickname.trim();
const parts = [v.year, v.make, v.model, v.trimLevel].filter(Boolean);
const primary = parts.join(' ').trim();
if (primary.length > 0) return primary;
if (v.vin && v.vin.length > 0) return v.vin;
return v.id.slice(0, 8) + '...';
};
const documentLabel = (doc: DocumentRecord) => {
return doc.title || `Document ${doc.id.slice(0, 8)}`;
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-md text-sm">
{error}
</div>
)}
<LocalizationProvider dateAdapter={AdapterDayjs}>
<form onSubmit={handleSubmit} className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Vehicle</label>
<select
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={vehicleID}
onChange={(e) => setVehicleID(e.target.value)}
required
>
<option value="">Select vehicle...</option>
{(vehicles || []).map((v: Vehicle) => (
<option key={v.id} value={v.id}>{vehicleLabel(v)}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Cost Type
</label>
<select
value={costType}
onChange={(e) => setCostType(e.target.value as OwnershipCostType)}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi"
style={{ fontSize: '16px' }}
>
{(Object.entries(COST_TYPE_LABELS) as [OwnershipCostType, string][]).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Cost Type</label>
<select
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={costType}
onChange={(e) => setCostType(e.target.value as OwnershipCostType)}
>
<option value="insurance">Insurance</option>
<option value="registration">Registration</option>
<option value="tax">Tax</option>
<option value="inspection">Inspection</option>
<option value="parking">Parking</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Description (optional)
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g., Geico Full Coverage"
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna"
style={{ fontSize: '16px' }}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Amount</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-titanio">$</span>
<input
className="h-11 min-h-[44px] w-full rounded-lg border pl-7 pr-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="number"
step="0.01"
min="0.01"
value={amount}
placeholder="0.00"
onChange={(e) => setAmount(e.target.value)}
required
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Amount
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
inputMode="decimal"
step="0.01"
min="0"
required
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna"
style={{ fontSize: '16px' }}
/>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Description (optional)</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
value={description}
placeholder="e.g., State Farm Full Coverage"
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex flex-col">
<DatePicker
label="Period Start (optional)"
value={periodStart ? dayjs(periodStart) : null}
onChange={(newValue) => setPeriodStart(newValue?.format('YYYY-MM-DD') || '')}
format="MM/DD/YYYY"
slotProps={{
textField: {
fullWidth: true,
sx: {
'& .MuiOutlinedInput-root': {
minHeight: 44,
},
},
},
}}
/>
</div>
<div className="flex flex-col">
<DatePicker
label="Period End (optional)"
value={periodEnd ? dayjs(periodEnd) : null}
onChange={(newValue) => setPeriodEnd(newValue?.format('YYYY-MM-DD') || '')}
format="MM/DD/YYYY"
slotProps={{
textField: {
fullWidth: true,
sx: {
'& .MuiOutlinedInput-root': {
minHeight: 44,
},
},
},
}}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Notes (optional)</label>
<textarea
className="min-h-[88px] rounded-lg border px-3 py-2 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Link to Document (optional)</label>
<select
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={documentID}
onChange={(e) => setDocumentID(e.target.value)}
disabled={!vehicleID}
>
<option value="">No document</option>
{(documents || []).map((doc: DocumentRecord) => (
<option key={doc.id} value={doc.id}>{documentLabel(doc)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Payment Interval
</label>
<select
value={interval}
onChange={(e) => setInterval(e.target.value as CostInterval)}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi"
style={{ fontSize: '16px' }}
>
{(Object.entries(INTERVAL_LABELS) as [CostInterval, string][]).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
{error && (
<div className="text-red-600 dark:text-red-400 text-sm mt-3">{error}</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Start Date
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone"
style={{ fontSize: '16px' }}
/>
<div className="flex flex-col sm:flex-row gap-2 mt-4">
<Button type="submit" className="min-h-[44px]">Create Ownership Cost</Button>
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
End Date (optional)
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone"
style={{ fontSize: '16px' }}
/>
<p className="text-xs text-gray-500 dark:text-titanio mt-1">
Leave blank for ongoing costs
</p>
</div>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button variant="secondary" onClick={onCancel} type="button">
Cancel
</Button>
<Button type="submit" loading={loading}>
{isEditMode ? 'Update Cost' : 'Add Cost'}
</Button>
</div>
</form>
</form>
</LocalizationProvider>
);
};
export default OwnershipCostForm;

View File

@@ -2,65 +2,38 @@
* @ai-summary List component for displaying ownership costs
*/
import React, { useState } from 'react';
import {
OwnershipCost,
CreateOwnershipCostRequest,
COST_TYPE_LABELS,
INTERVAL_LABELS
} from '../types/ownership-costs.types';
import { OwnershipCostForm } from './OwnershipCostForm';
import { useOwnershipCosts } from '../hooks/useOwnershipCosts';
import { Button } from '../../../shared-minimal/components/Button';
import React from 'react';
import type { OwnershipCost, OwnershipCostType } from '../types/ownership-costs.types';
import { useOwnershipCostsList, useDeleteOwnershipCost } from '../hooks/useOwnershipCosts';
interface OwnershipCostsListProps {
vehicleId: string;
vehicleId?: string;
onEdit?: (cost: OwnershipCost) => void;
}
export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({
vehicleId,
}) => {
const { costs, isLoading, error, createCost, updateCost, deleteCost } = useOwnershipCosts(vehicleId);
const [showForm, setShowForm] = useState(false);
const [editingCost, setEditingCost] = useState<OwnershipCost | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const COST_TYPE_LABELS: Record<OwnershipCostType, string> = {
insurance: 'Insurance',
registration: 'Registration',
tax: 'Tax',
inspection: 'Inspection',
parking: 'Parking',
other: 'Other',
};
const handleSubmit = async (data: Omit<CreateOwnershipCostRequest, 'vehicleId'>) => {
setIsSubmitting(true);
try {
if (editingCost) {
await updateCost(editingCost.id, data);
} else {
await createCost({ ...data, vehicleId });
}
setShowForm(false);
setEditingCost(null);
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (cost: OwnershipCost) => {
setEditingCost(cost);
setShowForm(true);
};
export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({ vehicleId, onEdit }) => {
const { data: costs, isLoading, error } = useOwnershipCostsList(vehicleId ? { vehicleId } : undefined);
const deleteMutation = useDeleteOwnershipCost();
const [deleteConfirm, setDeleteConfirm] = React.useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
await deleteCost(id);
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
} catch (err) {
console.error('Failed to delete cost:', err);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingCost(null);
};
// Format currency
const formatCurrency = (value: number): string => {
return value.toLocaleString(undefined, {
minimumFractionDigits: 2,
@@ -68,7 +41,6 @@ export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({
});
};
// Format date
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString();
};
@@ -84,127 +56,95 @@ export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({
}
if (error) {
// Show a subtle message if the feature isn't set up yet, don't block the page
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900 dark:text-avus">
Recurring Costs
</h3>
</div>
<div className="text-sm text-gray-500 dark:text-titanio p-4 bg-gray-50 dark:bg-scuro rounded-lg border border-gray-200 dark:border-silverstone">
<p>Recurring costs tracking is being set up.</p>
<p className="text-xs mt-1 text-gray-400 dark:text-canna">Run migrations to enable this feature.</p>
</div>
<div className="text-sm text-gray-500 dark:text-titanio p-4 bg-gray-50 dark:bg-scuro rounded-lg border border-gray-200 dark:border-silverstone">
<p>Unable to load ownership costs.</p>
</div>
);
}
if (!costs || costs.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-titanio">
<p>No ownership costs recorded yet.</p>
<p className="text-sm mt-1">Track insurance, registration, taxes, and other recurring vehicle costs.</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900 dark:text-avus">
Recurring Costs
</h3>
{!showForm && (
<Button
variant="secondary"
onClick={() => setShowForm(true)}
className="text-sm"
>
Add Cost
</Button>
)}
</div>
{showForm && (
<div className="p-4 bg-gray-50 dark:bg-scuro rounded-lg border border-gray-200 dark:border-silverstone">
<h4 className="text-md font-medium text-gray-900 dark:text-avus mb-4">
{editingCost ? 'Edit Cost' : 'Add New Cost'}
</h4>
<OwnershipCostForm
vehicleId={vehicleId}
initialData={editingCost || undefined}
onSubmit={handleSubmit}
onCancel={handleCancel}
loading={isSubmitting}
/>
</div>
)}
{costs.length === 0 && !showForm ? (
<div className="text-center py-8 text-gray-500 dark:text-titanio">
<p>No recurring costs added yet.</p>
<p className="text-sm mt-1">Track insurance, registration, and other recurring vehicle costs.</p>
</div>
) : (
<div className="space-y-3">
{costs.map((cost) => (
<div
key={cost.id}
className="p-4 bg-white dark:bg-jet rounded-lg border border-gray-200 dark:border-silverstone"
>
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-avus">
{COST_TYPE_LABELS[cost.costType]}
</span>
<span className="text-xs px-2 py-0.5 bg-gray-100 dark:bg-silverstone text-gray-600 dark:text-titanio rounded">
{INTERVAL_LABELS[cost.interval]}
</span>
</div>
{cost.description && (
<p className="text-sm text-gray-500 dark:text-titanio mt-1">
{cost.description}
</p>
)}
<p className="text-xs text-gray-400 dark:text-canna mt-2">
{formatDate(cost.startDate)}
{cost.endDate ? ` - ${formatDate(cost.endDate)}` : ' - Ongoing'}
</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-gray-900 dark:text-avus">
${formatCurrency(cost.amount)}
</div>
<div className="flex gap-2 mt-2">
<div className="space-y-3">
{costs.map((cost) => (
<div
key={cost.id}
className="p-4 bg-white dark:bg-jet rounded-lg border border-gray-200 dark:border-silverstone"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-avus">
{COST_TYPE_LABELS[cost.costType]}
</span>
</div>
{cost.description && (
<p className="text-sm text-gray-500 dark:text-titanio mt-1">
{cost.description}
</p>
)}
{(cost.periodStart || cost.periodEnd) && (
<p className="text-xs text-gray-400 dark:text-canna mt-2">
{cost.periodStart && formatDate(cost.periodStart)}
{cost.periodStart && cost.periodEnd && ' - '}
{cost.periodEnd && formatDate(cost.periodEnd)}
</p>
)}
{cost.notes && (
<p className="text-xs text-gray-500 dark:text-titanio mt-2">
{cost.notes}
</p>
)}
</div>
<div className="text-right ml-4">
<div className="text-lg font-semibold text-gray-900 dark:text-avus">
${formatCurrency(cost.amount)}
</div>
<div className="flex gap-2 mt-2">
{onEdit && (
<button
onClick={() => onEdit(cost)}
className="text-sm text-primary-600 dark:text-abudhabi hover:underline min-h-[44px] px-2"
>
Edit
</button>
)}
{deleteConfirm === cost.id ? (
<div className="flex gap-1">
<button
onClick={() => handleEdit(cost)}
className="text-sm text-primary-600 dark:text-abudhabi hover:underline"
onClick={() => handleDelete(cost.id)}
className="text-sm text-red-600 hover:underline min-h-[44px] px-2"
>
Edit
Confirm
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="text-sm text-gray-500 hover:underline min-h-[44px] px-2"
>
Cancel
</button>
{deleteConfirm === cost.id ? (
<div className="flex gap-1">
<button
onClick={() => handleDelete(cost.id)}
className="text-sm text-red-600 hover:underline"
>
Confirm
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="text-sm text-gray-500 hover:underline"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(cost.id)}
className="text-sm text-red-600 hover:underline"
>
Delete
</button>
)}
</div>
</div>
) : (
<button
onClick={() => setDeleteConfirm(cost.id)}
className="text-sm text-red-600 hover:underline min-h-[44px] px-2"
>
Delete
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
))}
</div>
);
};

View File

@@ -1,75 +1,150 @@
/**
* @ai-summary React hook for ownership costs management
* @ai-summary React Query hooks for ownership-costs feature
*/
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ownershipCostsApi } from '../api/ownership-costs.api';
import {
OwnershipCost,
CreateOwnershipCostRequest,
UpdateOwnershipCostRequest
} from '../types/ownership-costs.types';
import type { CreateOwnershipCostRequest, UpdateOwnershipCostRequest, OwnershipCost } from '../types/ownership-costs.types';
interface UseOwnershipCostsResult {
costs: OwnershipCost[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
createCost: (data: CreateOwnershipCostRequest) => Promise<OwnershipCost>;
updateCost: (id: string, data: UpdateOwnershipCostRequest) => Promise<OwnershipCost>;
deleteCost: (id: string) => Promise<void>;
export function useOwnershipCostsList(filters?: { vehicleId?: string }) {
const queryKey = ['ownership-costs', filters];
const query = useQuery({
queryKey,
queryFn: () => ownershipCostsApi.list(filters),
networkMode: 'offlineFirst',
});
return query;
}
export function useOwnershipCosts(vehicleId: string): UseOwnershipCostsResult {
const [costs, setCosts] = useState<OwnershipCost[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchCosts = useCallback(async () => {
if (!vehicleId) return;
setIsLoading(true);
setError(null);
try {
const data = await ownershipCostsApi.getByVehicle(vehicleId);
setCosts(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load ownership costs';
setError(message);
console.error('Failed to fetch ownership costs:', err);
} finally {
setIsLoading(false);
}
}, [vehicleId]);
useEffect(() => {
fetchCosts();
}, [fetchCosts]);
const createCost = useCallback(async (data: CreateOwnershipCostRequest): Promise<OwnershipCost> => {
const newCost = await ownershipCostsApi.create(data);
setCosts(prev => [newCost, ...prev]);
return newCost;
}, []);
const updateCost = useCallback(async (id: string, data: UpdateOwnershipCostRequest): Promise<OwnershipCost> => {
const updated = await ownershipCostsApi.update(id, data);
setCosts(prev => prev.map(cost => cost.id === id ? updated : cost));
return updated;
}, []);
const deleteCost = useCallback(async (id: string): Promise<void> => {
await ownershipCostsApi.delete(id);
setCosts(prev => prev.filter(cost => cost.id !== id));
}, []);
return {
costs,
isLoading,
error,
refresh: fetchCosts,
createCost,
updateCost,
deleteCost,
};
export function useOwnershipCost(id?: string) {
const query = useQuery({
queryKey: ['ownership-cost', id],
queryFn: () => ownershipCostsApi.get(id!),
enabled: !!id,
networkMode: 'offlineFirst',
});
return query;
}
export function useCreateOwnershipCost() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateOwnershipCostRequest) => ownershipCostsApi.create(payload),
onMutate: async (newCost) => {
await qc.cancelQueries({ queryKey: ['ownership-costs'] });
const previousCosts = qc.getQueryData(['ownership-costs']);
const optimisticCost: OwnershipCost = {
id: `temp-${Date.now()}`,
vehicleId: newCost.vehicleId,
documentId: newCost.documentId || null,
costType: newCost.costType,
amount: newCost.amount,
description: newCost.description || null,
periodStart: newCost.periodStart || null,
periodEnd: newCost.periodEnd || null,
notes: newCost.notes || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
qc.setQueryData(['ownership-costs'], (old: OwnershipCost[] | undefined) => {
return old ? [optimisticCost, ...old] : [optimisticCost];
});
return { previousCosts };
},
onError: (_err, _newCost, context) => {
if (context?.previousCosts) {
qc.setQueryData(['ownership-costs'], context.previousCosts);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['ownership-costs'] });
},
networkMode: 'offlineFirst',
});
}
export function useUpdateOwnershipCost(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateOwnershipCostRequest) => ownershipCostsApi.update(id, payload),
onMutate: async (updateData) => {
await qc.cancelQueries({ queryKey: ['ownership-cost', id] });
await qc.cancelQueries({ queryKey: ['ownership-costs'] });
const previousCost = qc.getQueryData(['ownership-cost', id]);
const previousCosts = qc.getQueryData(['ownership-costs']);
qc.setQueryData(['ownership-cost', id], (old: OwnershipCost | undefined) => {
if (!old) return old;
return {
...old,
...updateData,
updatedAt: new Date().toISOString(),
};
});
qc.setQueryData(['ownership-costs'], (old: OwnershipCost[] | undefined) => {
if (!old) return old;
return old.map(cost =>
cost.id === id
? { ...cost, ...updateData, updatedAt: new Date().toISOString() }
: cost
);
});
return { previousCost, previousCosts };
},
onError: (_err, _updateData, context) => {
if (context?.previousCost) {
qc.setQueryData(['ownership-cost', id], context.previousCost);
}
if (context?.previousCosts) {
qc.setQueryData(['ownership-costs'], context.previousCosts);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['ownership-cost', id] });
qc.invalidateQueries({ queryKey: ['ownership-costs'] });
},
networkMode: 'offlineFirst',
});
}
export function useDeleteOwnershipCost() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => ownershipCostsApi.remove(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ['ownership-costs'] });
await qc.cancelQueries({ queryKey: ['ownership-cost', id] });
const previousCosts = qc.getQueryData(['ownership-costs']);
const previousCost = qc.getQueryData(['ownership-cost', id]);
qc.setQueryData(['ownership-costs'], (old: OwnershipCost[] | undefined) => {
if (!old) return old;
return old.filter(cost => cost.id !== id);
});
qc.removeQueries({ queryKey: ['ownership-cost', id] });
return { previousCosts, previousCost, deletedId: id };
},
onError: (_err, _id, context) => {
if (context?.previousCosts) {
qc.setQueryData(['ownership-costs'], context.previousCosts);
}
if (context?.previousCost && context.deletedId) {
qc.setQueryData(['ownership-cost', context.deletedId], context.previousCost);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['ownership-costs'] });
},
networkMode: 'offlineFirst',
});
}

View File

@@ -1,5 +1,5 @@
/**
* @ai-summary Public API for ownership-costs frontend feature
* @ai-summary Public API for ownership-costs feature
*/
// Export components
@@ -7,7 +7,13 @@ export { OwnershipCostForm } from './components/OwnershipCostForm';
export { OwnershipCostsList } from './components/OwnershipCostsList';
// Export hooks
export { useOwnershipCosts } from './hooks/useOwnershipCosts';
export {
useOwnershipCostsList,
useOwnershipCost,
useCreateOwnershipCost,
useUpdateOwnershipCost,
useDeleteOwnershipCost,
} from './hooks/useOwnershipCosts';
// Export API
export { ownershipCostsApi } from './api/ownership-costs.api';
@@ -17,9 +23,5 @@ export type {
OwnershipCost,
CreateOwnershipCostRequest,
UpdateOwnershipCostRequest,
OwnershipCostStats,
OwnershipCostType,
CostInterval
} from './types/ownership-costs.types';
export { COST_TYPE_LABELS, INTERVAL_LABELS } from './types/ownership-costs.types';

View File

@@ -2,23 +2,18 @@
* @ai-summary Type definitions for ownership-costs feature
*/
// Cost types supported by ownership-costs feature
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'other';
// Cost interval types
export type CostInterval = 'monthly' | 'semi_annual' | 'annual' | 'one_time';
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'inspection' | 'parking' | 'other';
export interface OwnershipCost {
id: string;
userId: string;
vehicleId: string;
documentId?: string;
documentId?: string | null;
costType: OwnershipCostType;
description?: string;
amount: number;
interval: CostInterval;
startDate: string;
endDate?: string;
description?: string | null;
periodStart?: string | null;
periodEnd?: string | null;
notes?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -27,44 +22,19 @@ export interface CreateOwnershipCostRequest {
vehicleId: string;
documentId?: string;
costType: OwnershipCostType;
description?: string;
amount: number;
interval: CostInterval;
startDate: string;
endDate?: string;
description?: string;
periodStart?: string;
periodEnd?: string;
notes?: string;
}
export interface UpdateOwnershipCostRequest {
documentId?: string | null;
costType?: OwnershipCostType;
description?: string | null;
amount?: number;
interval?: CostInterval;
startDate?: string;
endDate?: string | null;
description?: string | null;
periodStart?: string | null;
periodEnd?: string | null;
notes?: string | null;
}
// Aggregated cost statistics
export interface OwnershipCostStats {
insuranceCosts: number;
registrationCosts: number;
taxCosts: number;
otherCosts: number;
totalCosts: number;
}
// Display labels for cost types
export const COST_TYPE_LABELS: Record<OwnershipCostType, string> = {
insurance: 'Insurance',
registration: 'Registration',
tax: 'Tax',
other: 'Other',
};
// Display labels for intervals
export const INTERVAL_LABELS: Record<CostInterval, string> = {
monthly: 'Monthly',
semi_annual: 'Semi-Annual (6 months)',
annual: 'Annual',
one_time: 'One-Time',
};