diff --git a/backend/src/features/ownership-costs/README.md b/backend/src/features/ownership-costs/README.md new file mode 100644 index 0000000..f5a184c --- /dev/null +++ b/backend/src/features/ownership-costs/README.md @@ -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 diff --git a/backend/src/features/ownership-costs/api/ownership-costs.controller.ts b/backend/src/features/ownership-costs/api/ownership-costs.controller.ts index ca3326f..05f57f9 100644 --- a/backend/src/features/ownership-costs/api/ownership-costs.controller.ts +++ b/backend/src/features/ownership-costs/api/ownership-costs.controller.ts @@ -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(); } } diff --git a/backend/src/features/ownership-costs/api/ownership-costs.routes.ts b/backend/src/features/ownership-costs/api/ownership-costs.routes.ts index 4a357c7..3742bc5 100644 --- a/backend/src/features/ownership-costs/api/ownership-costs.routes.ts +++ b/backend/src/features/ownership-costs/api/ownership-costs.routes.ts @@ -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'); -} diff --git a/backend/src/features/ownership-costs/api/ownership-costs.validation.ts b/backend/src/features/ownership-costs/api/ownership-costs.validation.ts new file mode 100644 index 0000000..654bd08 --- /dev/null +++ b/backend/src/features/ownership-costs/api/ownership-costs.validation.ts @@ -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; +export type IdParams = z.infer; +export type CreateBody = z.infer; +export type UpdateBody = z.infer; diff --git a/backend/src/features/ownership-costs/data/ownership-costs.repository.ts b/backend/src/features/ownership-costs/data/ownership-costs.repository.ts index 71aaa7d..6096d06 100644 --- a/backend/src/features/ownership-costs/data/ownership-costs.repository.ts +++ b/backend/src/features/ownership-costs/data/ownership-costs.repository.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - client?: Pool - ): Promise { - 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 { + 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) => 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): 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 { + 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 { + 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 { + 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> + ): Promise { + 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 { + await this.db.query( + `DELETE FROM ownership_costs WHERE id = $1 AND user_id = $2`, + [id, userId] + ); } } diff --git a/backend/src/features/ownership-costs/domain/ownership-costs.service.ts b/backend/src/features/ownership-costs/domain/ownership-costs.service.ts index 4acf5df..56f6979 100644 --- a/backend/src/features/ownership-costs/domain/ownership-costs.service.ts +++ b/backend/src/features/ownership-costs/domain/ownership-costs.service.ts @@ -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 { - logger.info('Creating ownership cost', { userId, vehicleId: data.vehicleId, costType: data.costType }); + async createCost(userId: string, body: CreateOwnershipCostRequest): Promise { + 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 { + const cost = await this.repo.findById(id, userId); + if (!cost) return null; return this.toResponse(cost); } - async getByVehicleId(vehicleId: string, userId: string): Promise { - // 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 { + const costs = await this.repo.findByUserId(userId, filters); + return costs.map(c => this.toResponse(c)); } - async getById(id: string, userId: string): Promise { - 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 { + const costs = await this.repo.findByVehicleId(vehicleId, userId); + return costs.map(c => this.toResponse(c)); } - async update(id: string, data: UpdateOwnershipCostRequest, userId: string): Promise { - // 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 { + 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 { - // 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 { - 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> = { - 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 { + 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 { + 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>; - // 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 { + 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; } } diff --git a/backend/src/features/ownership-costs/domain/ownership-costs.types.ts b/backend/src/features/ownership-costs/domain/ownership-costs.types.ts index f367cc7..3108d8d 100644 --- a/backend/src/features/ownership-costs/domain/ownership-costs.types.ts +++ b/backend/src/features/ownership-costs/domain/ownership-costs.types.ts @@ -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 = { - 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; + +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; + +// Response types +export interface OwnershipCostResponse extends 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; } diff --git a/backend/src/features/ownership-costs/index.ts b/backend/src/features/ownership-costs/index.ts index 7b97d0e..4fefbc0 100644 --- a/backend/src/features/ownership-costs/index.ts +++ b/backend/src/features/ownership-costs/index.ts @@ -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'; diff --git a/backend/src/features/ownership-costs/migrations/001_create_ownership_costs_table.sql b/backend/src/features/ownership-costs/migrations/001_create_ownership_costs_table.sql index 5cc9749..111a5c0 100644 --- a/backend/src/features/ownership-costs/migrations/001_create_ownership_costs_table.sql +++ b/backend/src/features/ownership-costs/migrations/001_create_ownership_costs_table.sql @@ -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 diff --git a/backend/src/features/ownership-costs/migrations/002_migrate_vehicle_tco_data.sql b/backend/src/features/ownership-costs/migrations/002_migrate_vehicle_tco_data.sql deleted file mode 100644 index 9206040..0000000 --- a/backend/src/features/ownership-costs/migrations/002_migrate_vehicle_tco_data.sql +++ /dev/null @@ -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' - );