From 7928b87ef5900e71b9742c94a10a6ad1a0b705ae Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:30:02 -0600 Subject: [PATCH] feat: integrate DocumentsService with ownership_costs (refs #29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 2: Auto-create ownership_cost when insurance/registration document is created with cost data (premium or cost field). - Add OwnershipCostsService integration - Auto-create cost on document create when amount > 0 - Sync cost changes on document update - mapDocumentTypeToCostType() validation - extractCostAmount() for premium/cost field extraction - CASCADE delete handled by FK constraint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../documents/domain/documents.service.ts | 137 +++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/backend/src/features/documents/domain/documents.service.ts b/backend/src/features/documents/domain/documents.service.ts index eeff00b..72fe408 100644 --- a/backend/src/features/documents/domain/documents.service.ts +++ b/backend/src/features/documents/domain/documents.service.ts @@ -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 { 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 { + 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 = { + '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 { @@ -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 { + 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 | 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 { + // Note: Linked ownership_cost records are CASCADE deleted via FK await this.repo.softDelete(id, userId); }