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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user