feat: integrate DocumentsService with ownership_costs (refs #29)

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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-13 21:30:02 -06:00
parent 81b1c3dd70
commit 7928b87ef5

View File

@@ -1,15 +1,18 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types'; import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types';
import { DocumentsRepository } from '../data/documents.repository'; 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'; import pool from '../../../core/config/database';
export class DocumentsService { export class DocumentsService {
private readonly repo = new DocumentsRepository(pool); private readonly repo = new DocumentsRepository(pool);
private readonly ownershipCostsService = new OwnershipCostsService(pool);
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> { async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
await this.assertVehicleOwnership(userId, body.vehicleId); await this.assertVehicleOwnership(userId, body.vehicleId);
const id = randomUUID(); const id = randomUUID();
return this.repo.insert({ const doc = await this.repo.insert({
id, id,
userId, userId,
vehicleId: body.vehicleId, vehicleId: body.vehicleId,
@@ -22,6 +25,70 @@ export class DocumentsService {
emailNotifications: body.emailNotifications ?? false, emailNotifications: body.emailNotifications ?? false,
scanForMaintenance: body.scanForMaintenance ?? 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> { async getDocument(userId: string, id: string): Promise<DocumentRecord | null> {
@@ -36,12 +103,78 @@ export class DocumentsService {
const existing = await this.repo.findById(id, userId); const existing = await this.repo.findById(id, userId);
if (!existing) return null; if (!existing) return null;
if (patch && typeof patch === 'object') { 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; 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> { async deleteDocument(userId: string, id: string): Promise<void> {
// Note: Linked ownership_cost records are CASCADE deleted via FK
await this.repo.softDelete(id, userId); await this.repo.softDelete(id, userId);
} }