@accounter/server
Version:
Accounter GraphQL server
160 lines • 7.17 kB
JavaScript
/**
* Document Aggregator
*
* Aggregates multiple documents from a single charge into a unified representation
* for matching purposes. Handles type priority filtering, business extraction,
* amount normalization, currency validation, date selection, and description concatenation.
*/
import { DocumentType } from '../../../shared/enums.js';
import { isAccountingDocument } from '../../documents/helpers/common.helper.js';
import { normalizeDocumentAmount } from '../helpers/document-amount.helper.js';
import { extractDocumentBusiness } from '../helpers/document-business.helper.js';
/**
* Accounting document types (used for type priority filtering)
*/
const INVOICE_TYPES = [DocumentType.Invoice, DocumentType.CreditInvoice];
const RECEIPT_TYPES = [DocumentType.Receipt, DocumentType.InvoiceReceipt];
/**
* Check if document type is an invoice or credit invoice
*/
function isInvoiceType(type) {
return INVOICE_TYPES.includes(type);
}
/**
* Check if document type is a receipt or invoice-receipt
*/
function isReceiptType(type) {
return RECEIPT_TYPES.includes(type);
}
/**
* Aggregate multiple documents into a single representation
*
* Per specification (section 4.2):
* 1. Filter by type priority: if both invoices AND receipts exist, use only invoices
* 2. Extract business ID from each document (creditor_id/debtor_id)
* 3. Normalize each amount based on business role and document type
* 4. Validate non-empty array
* 5. Check for mixed currencies → throw error
* 6. Check for multiple non-null business IDs → throw error
* 7. Sum all normalized amounts
* 8. Select latest date
* 9. Concatenate serial_number or file names with line breaks
* 10. Determine document type for result (use first after filtering)
*
* @param documents - Array of documents from a charge (use charge_id field for FK)
* @param adminBusinessId - Current admin business UUID for business extraction
* @returns Aggregated document data
*
* @throws {Error} If documents array is empty
* @throws {Error} If multiple different currencies exist
* @throws {Error} If multiple different non-null business IDs exist
* @throws {Error} If business extraction fails (propagates from extractDocumentBusiness)
* @throws {Error} If no valid date found in any document
*
* @example
* const aggregated = aggregateDocuments([
* { total_amount: 100, currency_code: 'USD', creditor_id: 'b1', debtor_id: 'u1', type: DocumentType.Invoice, ... },
* { total_amount: 50, currency_code: 'USD', creditor_id: 'b1', debtor_id: 'u1', type: DocumentType.Invoice, ... }
* ], 'u1');
* // Returns aggregated with normalized amounts summed
*/
export function aggregateDocuments(documents, adminBusinessId) {
const accountingDocuments = documents?.filter(doc => isAccountingDocument(doc.type));
// Validate non-empty input
if (!documents || accountingDocuments.length === 0) {
throw new Error('Cannot aggregate documents: array is empty');
}
// Apply type priority filtering
const hasInvoices = accountingDocuments.some(d => isInvoiceType(d.type));
const hasReceipts = accountingDocuments.some(d => isReceiptType(d.type));
let filteredDocuments = accountingDocuments;
if (hasReceipts) {
// If receipts exist, use only receipts
filteredDocuments = accountingDocuments.filter(d => isReceiptType(d.type));
}
else if (hasInvoices) {
// If no receipts and invoices exist, use only invoices
filteredDocuments = accountingDocuments.filter(d => isInvoiceType(d.type));
}
// Validate we still have documents after filtering
if (filteredDocuments.length === 0) {
throw new Error('Cannot aggregate documents: no valid documents after type priority filtering');
}
// Extract business info and normalize amounts for each document
const processedDocuments = filteredDocuments.map(doc => {
const businessInfo = extractDocumentBusiness(doc.creditor_id, doc.debtor_id, adminBusinessId);
const normalizedAmount = normalizeDocumentAmount(doc.total_amount ?? 0, businessInfo.isBusinessCreditor, doc.type);
return {
document: doc,
businessInfo,
normalizedAmount,
};
});
// Validate single currency
const currencies = new Set(processedDocuments
.map(p => p.document.currency_code)
.filter((code) => code !== null && code !== undefined));
if (currencies.size === 0) {
throw new Error('Cannot aggregate documents: all documents have null currency_code');
}
if (currencies.size > 1) {
throw new Error(`Cannot aggregate documents: multiple currencies found (${Array.from(currencies).join(', ')})`);
}
// Validate single non-null business ID
const businessIds = processedDocuments
.map(p => p.businessInfo.businessId)
.filter((id) => id !== null && id !== undefined);
const uniqueBusinessIds = new Set(businessIds);
if (uniqueBusinessIds.size > 1) {
throw new Error(`Cannot aggregate documents: multiple business IDs found (${Array.from(uniqueBusinessIds).join(', ')})`);
}
// Sum normalized amounts
const totalAmount = processedDocuments.reduce((sum, p) => sum + p.normalizedAmount, 0);
// Get common currency (safe since we validated single currency)
const currency = Array.from(currencies)[0];
// Get business ID (single non-null or null if all null)
const businessId = uniqueBusinessIds.size === 1 ? Array.from(uniqueBusinessIds)[0] : null;
// Get latest date
const dates = filteredDocuments
.map(d => d.date)
.filter((date) => date !== null && date !== undefined);
if (dates.length === 0) {
throw new Error('Cannot aggregate documents: all documents have null date');
}
const latestDate = dates.reduce((latest, d) => {
return d > latest ? d : latest;
}, dates[0]);
// Concatenate descriptions (serial numbers, file names, or IDs)
const descriptions = filteredDocuments
.map(d => {
// Prefer serial_number, fallback to file_url or image_url name, or document ID
if (d.serial_number && d.serial_number.trim() !== '') {
return d.serial_number.trim();
}
if (d.file_url) {
// Extract filename from URL
const fileName = d.file_url.split('/').pop() || d.file_url;
return fileName;
}
if (d.image_url) {
// Extract filename from URL
const fileName = d.image_url.split('/').pop() || d.image_url;
return fileName;
}
// Fallback to document ID
return `Doc-${d.id.substring(0, 8)}`;
})
.filter(desc => desc !== null && desc !== undefined && desc.trim() !== '');
const description = descriptions.length > 0 ? descriptions.join('\n') : '';
// Determine document type (use first document after filtering)
const type = filteredDocuments[0].type;
return {
amount: totalAmount,
currency,
businessId,
date: latestDate,
type,
description,
};
}
//# sourceMappingURL=document-aggregator.js.map