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>
191 lines
6.5 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|
|
|