Files
motovaultpro/backend/src/features/documents/domain/documents.service.ts
Eric Gullickson 7928b87ef5 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>
2026-01-13 21:30:02 -06:00

191 lines
6.5 KiB
TypeScript

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();
const doc = await this.repo.insert({
id,
userId,
vehicleId: body.vehicleId,
documentType: body.documentType as DocumentType,
title: body.title,
notes: body.notes ?? null,
details: body.details ?? null,
issuedDate: body.issuedDate ?? null,
expirationDate: body.expirationDate ?? null,
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> {
return this.repo.findById(id, userId);
}
async listDocuments(userId: string, filters?: { vehicleId?: string; type?: DocumentType; expiresBefore?: string }) {
return this.repo.listByUser(userId, filters);
}
async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) {
const existing = await this.repo.findById(id, userId);
if (!existing) return null;
if (patch && typeof patch === 'object') {
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);
}
private async assertVehicleOwnership(userId: string, vehicleId: string) {
const res = await pool.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;
}
}
}