UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

394 lines (341 loc) 13.3 kB
import config from './config.js'; import { DEBIT, CREDIT, ACCOUNT_TYPES } from './constants.js'; import JournalEntry from './JournalEntry.js'; import Account from './Account.js'; import ErrorLog from './ErrorLog.js'; const MAX_ROWS = 500; const META_UPDATE_INTERVAL = 10; // Upload statuses const UPLOAD_STATUS = { PENDING: 'pending', PROCESSING: 'processing', COMPLETED: 'completed', PARTIAL: 'partial', FAILED: 'failed', ROLLED_BACK: 'rolled_back' }; function getDb() { return config.db; } /** * Upload class for managing spreadsheet imports * Each upload creates two documents: * - upload_data: immutable payload with all entries to process * - upload_meta: lightweight status tracker updated periodically */ class Upload { /** * Create a new upload (data doc + meta doc) * @param {string} type - Upload type: 'ledger', 'journal', 'general' * @param {string} contextAccountKey - The account key for context (e.g., the ledger being imported into) * @param {string} contextAccountLabel - Display label for the context account * @param {Array} entries - Array of entry objects (already validated and resolved by client) * @param {string} userId - ID of the user creating the upload * @returns {Promise<string>} The uploadId */ static async create(type, contextAccountKey, contextAccountLabel, entries, userId) { if (!entries || !Array.isArray(entries) || entries.length === 0) { throw new Error('Entries array is required and must not be empty'); } if (entries.length > MAX_ROWS) { throw new Error(`Maximum ${MAX_ROWS} rows per upload. Got ${entries.length}`); } // Check for active imports in this book if (await Upload.hasActiveImport()) { throw new Error('Another import is currently being processed. Please wait for it to complete.'); } const timestamp = Date.now(); const rand = Math.random().toString(36).substr(2, 9); const uploadId = `upload_${timestamp}_${rand}`; // Sort entries by date chronologically for optimal balance recalculation const sortedEntries = entries .map((entry, index) => ({ ...entry, id: index + 1 })) .sort((a, b) => new Date(a.date) - new Date(b.date)); const dataDoc = { _id: `upload-data_${timestamp}_${rand}`, docType: 'upload_data', uploadId, type, contextAccountKey, entries: sortedEntries, createdAt: new Date().toISOString(), createdBy: userId }; const metaDoc = { _id: `upload-meta_${timestamp}_${rand}`, docType: 'upload_meta', uploadId, type, contextAccountKey, contextAccountLabel, status: UPLOAD_STATUS.PENDING, totalRows: sortedEntries.length, lastProcessedId: 0, succeeded: 0, failed: [], createdAt: new Date().toISOString(), createdBy: userId, startedAt: null, completedAt: null }; try { const bulkResponse = await getDb().bulk({ docs: [dataDoc, metaDoc] }); for (const result of bulkResponse) { if (result.error) { throw new Error(`Failed to create upload docs: ${result.error} - ${result.reason}`); } } return uploadId; } catch (error) { ErrorLog.log('error', 'Upload.create', error, { uploadId }); throw new Error(`Failed to create upload: ${error.message}`); } } /** * Process an upload - create journal entries for each row * @param {string} uploadId - The upload to process * @returns {Promise<Object>} Processing results */ static async process(uploadId) { const meta = await Upload.getMeta(uploadId); if (!meta) throw new Error(`Upload not found: ${uploadId}`); if (meta.status !== UPLOAD_STATUS.PENDING && meta.status !== UPLOAD_STATUS.PROCESSING) { throw new Error(`Upload ${uploadId} cannot be processed (status: ${meta.status})`); } const dataDoc = await Upload.getDataDoc(uploadId); if (!dataDoc) throw new Error(`Upload data not found: ${uploadId}`); // Update meta to processing meta.status = UPLOAD_STATUS.PROCESSING; meta.startedAt = meta.startedAt || new Date().toISOString(); await Upload._saveMeta(meta); const startFromId = meta.lastProcessedId || 0; let succeeded = meta.succeeded || 0; const failed = meta.failed || []; let lastProcessedId = startFromId; for (const entry of dataDoc.entries) { // Skip already-processed entries (for resume) if (entry.id <= startFromId) continue; try { await Upload._processEntry(entry, dataDoc.type, dataDoc.contextAccountKey, uploadId); succeeded++; } catch (err) { failed.push({ id: entry.id, error: err.message }); } lastProcessedId = entry.id; // Periodic meta update if ((lastProcessedId - startFromId) % META_UPDATE_INTERVAL === 0) { meta.lastProcessedId = lastProcessedId; meta.succeeded = succeeded; meta.failed = failed; await Upload._saveMeta(meta); } } // Final meta update meta.lastProcessedId = lastProcessedId; meta.succeeded = succeeded; meta.failed = failed; meta.completedAt = new Date().toISOString(); if (failed.length === 0) { meta.status = UPLOAD_STATUS.COMPLETED; } else if (succeeded === 0) { meta.status = UPLOAD_STATUS.FAILED; } else { meta.status = UPLOAD_STATUS.PARTIAL; } await Upload._saveMeta(meta); return { uploadId, status: meta.status, total: dataDoc.entries.length, succeeded, failed: failed.length, errors: failed }; } /** * Process a single entry - create a 2-line journal entry * @param {Object} entry - The entry to process * @param {string} type - Upload type * @param {string} contextAccountKey - Context account key * @param {string} uploadId - Upload ID for tagging * @private */ static async _processEntry(entry, type, contextAccountKey, uploadId) { const externalRefId = `${uploadId}_row_${entry.id}`; // Server-side validation const contextAccount = await Account.findByKey(contextAccountKey); if (!contextAccount) { throw new Error(`Context account not found: ${contextAccountKey}`); } const compensatingAccount = await Account.findByKey(entry.accountKey); if (!compensatingAccount) { throw new Error(`Compensating account not found: ${entry.accountKey}`); } if (typeof entry.amount !== 'number' || entry.amount <= 0) { throw new Error(`Amount must be a positive number, got: ${entry.amount}`); } if (![DEBIT, CREDIT].includes(entry.debitCredit)) { throw new Error(`debitCredit must be 'debit' or 'credit', got: ${entry.debitCredit}`); } const description = entry.narration || `${contextAccount.label} / ${compensatingAccount.label}`; const oppositeType = entry.debitCredit === DEBIT ? CREDIT : DEBIT; // Create journal entry const je = await JournalEntry.new(description, [uploadId], externalRefId, entry.date); je.addLine(contextAccountKey, entry.debitCredit, entry.amount, description); je.addLine(entry.accountKey, oppositeType, entry.amount, description); if (!je.validate()) { throw new Error('Journal entry does not balance'); } await je.post(); } /** * Resume a previously interrupted upload * @param {string} uploadId - The upload to resume * @returns {Promise<Object>} Processing results */ static async resume(uploadId) { const meta = await Upload.getMeta(uploadId); if (!meta) throw new Error(`Upload not found: ${uploadId}`); if (meta.status !== UPLOAD_STATUS.PROCESSING) { throw new Error(`Upload ${uploadId} is not in processing state (status: ${meta.status})`); } return await Upload.process(uploadId); } /** * Get upload meta document * @param {string} uploadId - The upload ID * @returns {Promise<Object|null>} Meta document or null */ static async getMeta(uploadId) { try { const result = await getDb().find({ selector: { docType: 'upload_meta', uploadId }, limit: 1 }); return result.docs.length > 0 ? result.docs[0] : null; } catch (error) { throw new Error(`Failed to get upload meta: ${error.message}`); } } /** * List all upload meta documents * @returns {Promise<Array>} Array of meta documents */ static async listAll() { try { const result = await getDb().find({ selector: { docType: 'upload_meta' }, sort: [{ createdAt: 'desc' }], limit: 100 }); return result.docs; } catch (error) { throw new Error(`Failed to list uploads: ${error.message}`); } } /** * Get upload data document * @param {string} uploadId - The upload ID * @returns {Promise<Object|null>} Data document or null */ static async getDataDoc(uploadId) { try { const result = await getDb().find({ selector: { docType: 'upload_data', uploadId }, limit: 1 }); return result.docs.length > 0 ? result.docs[0] : null; } catch (error) { throw new Error(`Failed to get upload data: ${error.message}`); } } /** * Rollback an upload - force delete all journal entries created by this upload * @param {string} uploadId - The upload to rollback * @returns {Promise<Object>} Rollback results */ static async rollback(uploadId) { const meta = await Upload.getMeta(uploadId); if (!meta) throw new Error(`Upload not found: ${uploadId}`); if (meta.status === UPLOAD_STATUS.ROLLED_BACK) { throw new Error('Upload has already been rolled back'); } if (meta.status === UPLOAD_STATUS.PROCESSING) { throw new Error('Cannot rollback an upload that is still processing'); } try { // Find all journal entries tagged with this uploadId const result = await getDb().find({ selector: { docType: 'journal_entry', tags: { $elemMatch: { $eq: uploadId } } }, limit: 999999 }); let deleted = 0; let errors = []; for (const doc of result.docs) { try { const je = new JournalEntry(doc); await je.forceDelete(true); deleted++; } catch (err) { errors.push({ journalId: doc._id, error: err.message }); } } // Update meta meta.status = UPLOAD_STATUS.ROLLED_BACK; meta.completedAt = new Date().toISOString(); await Upload._saveMeta(meta); return { uploadId, deleted, errors }; } catch (error) { ErrorLog.log('error', 'Upload.rollback', error, { uploadId }); throw new Error(`Failed to rollback upload: ${error.message}`); } } /** * Check if there are any active (processing) imports in the current book * @returns {Promise<boolean>} */ static async hasActiveImport() { try { const result = await getDb().find({ selector: { docType: 'upload_meta', status: UPLOAD_STATUS.PROCESSING }, limit: 1 }); return result.docs.length > 0; } catch (error) { return false; } } /** * Save/update meta document * @param {Object} meta - Meta document to save * @private */ static async _saveMeta(meta) { try { const response = await getDb().insert(meta); meta._rev = response.rev; } catch (error) { ErrorLog.log('error', 'Upload._saveMeta', error, { uploadId: meta.uploadId }); throw new Error(`Failed to update upload meta: ${error.message}`); } } } export { UPLOAD_STATUS }; export default Upload;