UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

654 lines (565 loc) â€ĸ 21.6 kB
import { getMetaDB, getDB, switchToBook } from '../config.js'; import COA from '../coa.js'; import { v4 as uuidv4 } from 'uuid'; /** * Book class for managing accounting books */ class Book { _id; name; description; created; owner; organizationId; isDefault; settings; _doc; /** * Creates a Book instance from a CouchDB document * @param {Object} doc - CouchDB document containing book data */ constructor(doc) { this._doc = doc; this.mapFields(); } /** * Map document fields to instance properties */ mapFields() { this._id = this._doc._id; this.name = this._doc.name; this.description = this._doc.description; this.created = this._doc.created; this.owner = this._doc.owner; this.organizationId = this._doc.organizationId; this.isDefault = this._doc.isDefault || false; this.settings = this._doc.settings; } /** * Create a new book (ownerless by default) * @param {string} name - Book display name * @param {Object} options - Book creation options * @param {string} [options.template='base'] - COA template to use * @param {string} [options.description=''] - Book description * @param {string} [options.currency='USD'] - Default currency * @param {string} [options.fiscalYearStart='01-01'] - Fiscal year start (MM-DD) * @param {string} [options.organizationId=null] - Organization identifier * @param {boolean} [options.isDefault=false] - Whether this is the default book for the org * @returns {Promise<Book>} Created book instance */ static async create(name, options = {}) { if (!name) { throw new Error('Book name is required'); } const { template = 'base', description = '', currency = 'USD', fiscalYearStart = '01-01', organizationId = null, isDefault = false } = options; const bookUuid = uuidv4(); const bookId = `book-${bookUuid}`; const bookDoc = { _id: bookId, type: 'book', name: name, description: description, created: new Date().toISOString(), owner: null, settings: { currency: currency, fiscalYearStart: fiscalYearStart, template: template } }; if (organizationId) { bookDoc.organizationId = organizationId; } if (isDefault) { bookDoc.isDefault = true; } const metaDB = getMetaDB(); // Insert book metadata const result = await metaDB.insert(bookDoc, bookId); bookDoc._rev = result.rev; // Create book database switchToBook(bookId); const bookDB = getDB(); try { await bookDB.databaseInfo(); } catch (error) { if (error.status === 404) { console.log(`🆕 Creating book database: ${bookId}`); await bookDB.createDatabase(bookId); } else { throw error; } } // Initialize book database with indexes await Book._initializeBookDatabase(bookDB, bookId); // Load and initialize COA template await COA.loadTemplate(template); console.log(`✅ Initialized COA from template: ${template}`); return new Book(bookDoc); } /** * Initialize book database with indexes * @private * @param {Object} db - Database driver instance * @param {string} bookId - Book identifier */ static async _initializeBookDatabase(db, bookId) { console.log(`🔧 Initializing book database: ${bookId}...`); try { const indexes = [ { index: { fields: ['docType', 'date'] }, name: 'ledger-by-date' }, { index: { fields: ['docType', 'accountId'] }, name: 'ledger-by-account' }, { index: { fields: ['docType', 'journalEntryId'] }, name: 'ledger-by-journal' }, { index: { fields: ['docType', 'name'] }, name: 'account-by-name' }, { index: { fields: ['docType', 'key'] }, name: 'account-by-key' }, { index: { fields: ['docType', 'tags'] }, name: 'account-by-tags' }, { index: { fields: ['docType', 'date'] }, name: 'journal-by-date' }, { index: { fields: ['docType', 'templateInfo.key', 'date'] }, name: 'journal-by-template-and-date' }, { index: { fields: ['docType', 'status', 'date'] }, name: 'journal-by-status-and-date' }, { index: { fields: ['docType', 'externalRefId'] }, name: 'journal-by-external-ref' }, { index: { fields: ['docType', 'accountId', 'date'] }, name: 'ledger-by-account-date' }, { index: { fields: ['docType', 'number'] }, name: 'check-by-number' }, { index: { fields: ['docType', 'status', 'date'] }, name: 'check-by-status-date' }, { index: { fields: ['docType', 'timestamp'] }, name: 'error-log-by-timestamp' }, { index: { fields: ['docType', 'operation'] }, name: 'error-log-by-operation' }, { index: { fields: ['docType', 'entityId'] }, name: 'error-log-by-entity' }, { index: { fields: ['docType', 'createdAt'] }, name: 'upload-by-date' } ]; for (const indexDef of indexes) { try { await db.createIndex({ ...indexDef, type: 'json' }); console.log(`✅ Created index: ${indexDef.name} for ${bookId}`); } catch (error) { if (error.error === 'file_exists') { console.log(`â„šī¸ Index already exists: ${indexDef.name} for ${bookId}`); } else { throw error; } } } // Install design document for ledger views const designDoc = { _id: '_design/ledger', views: { 'account-totals': { map: `function (doc) { if (doc.docType === 'ledger_entry') { emit([doc.accountId, doc.type], doc.amount); } }`, reduce: '_sum' }, 'entry-before-date': { map: `function (doc) { if (doc.docType === 'ledger_entry') { emit([doc.accountId, doc.date], null); } }` } } }; try { await db.insert(designDoc); console.log(`✅ Created design doc: _design/ledger for ${bookId}`); } catch (ddocError) { if (ddocError.status === 409) { console.log(`â„šī¸ Design doc already exists: _design/ledger for ${bookId}`); } else { throw ddocError; } } // Install design document for error log views const errorLogDesignDoc = { _id: '_design/error_log', views: { 'by-timestamp': { map: `function (doc) { if (doc.docType === 'error_log') emit(doc.timestamp, null); }` }, 'count': { map: `function (doc) { if (doc.docType === 'error_log') emit(null, 1); }`, reduce: '_count' }, 'by-operation': { map: `function (doc) { if (doc.docType === 'error_log') emit(doc.operation, 1); }`, reduce: '_count' }, 'by-severity': { map: `function (doc) { if (doc.docType === 'error_log') emit(doc.severity, 1); }`, reduce: '_count' } } }; try { await db.insert(errorLogDesignDoc); console.log(`✅ Created design doc: _design/error_log for ${bookId}`); } catch (ddocError) { if (ddocError.status === 409) { console.log(`â„šī¸ Design doc already exists: _design/error_log for ${bookId}`); } else { throw ddocError; } } console.log(`🎉 Book database initialization completed: ${bookId}!`); } catch (error) { console.error(`❌ Book database initialization failed for ${bookId}:`, error.message); throw error; } } /** * Get a book by ID * @param {string} bookId - Book identifier * @returns {Promise<Book>} Book instance */ static async get(bookId) { const metaDB = getMetaDB(); const doc = await metaDB.get(bookId); return new Book(doc); } /** * List all books belonging to an organization * @param {string} organizationId - Organization identifier * @returns {Promise<Book[]>} Array of book instances */ static async listOrgBooks(organizationId) { const metaDB = getMetaDB(); const result = await metaDB.find({ selector: { type: 'book', organizationId: organizationId }, limit: 999999 }); return result.docs.map(doc => { const book = new Book(doc); book.role = 'editor'; book.permissions = ['read', 'write']; return book; }); } /** * List all books for a user * @param {string} userId - User identifier * @returns {Promise<Book[]>} Array of book instances */ static async listUserBooks(userId) { const metaDB = getMetaDB(); // Query user-book relationships const result = await metaDB.find({ selector: { type: 'user_book', userId: userId }, sort: [{ 'joinedAt': 'desc' }], limit: 999999 }); const books = []; for (const userBook of result.docs) { try { const bookDoc = await metaDB.get(userBook.bookId); const book = new Book(bookDoc); // Add role info from user-book relationship book.role = userBook.role; book.permissions = userBook.permissions; books.push(book); } catch (error) { console.warn(`Warning: Could not load book ${userBook.bookId}:`, error.message); } } return books; } /** * List unclaimed (ownerless) books belonging to an organization * @param {string} organizationId - Organization identifier * @returns {Promise<Book[]>} Array of unclaimed book instances */ static async listUnclaimedOrgBooks(organizationId) { const metaDB = getMetaDB(); const result = await metaDB.find({ selector: { type: 'book', organizationId: organizationId, owner: null }, limit: 999999 }); return result.docs.map(doc => new Book(doc)); } /** * Claim an ownerless book — sets owner and creates user_book relationship * @param {string} bookId - Book identifier * @param {string} userId - User claiming the book * @returns {Promise<Book>} Claimed book instance */ static async claim(bookId, userId) { if (!bookId || !userId) { throw new Error('bookId and userId are required'); } const metaDB = getMetaDB(); // Get the book const bookDoc = await metaDB.get(bookId); if (bookDoc.owner) { throw new Error('Book already has an owner'); } // Set owner bookDoc.owner = userId; bookDoc.updated = new Date().toISOString(); const result = await metaDB.insert(bookDoc, bookDoc._id); bookDoc._rev = result.rev; // Create user_book relationship const userBookId = `userbook-${uuidv4()}`; const userBookDoc = { _id: userBookId, type: 'user_book', userId: userId, bookId: bookId, role: 'owner', permissions: ['read', 'write', 'admin'], joinedAt: new Date().toISOString() }; await metaDB.insert(userBookDoc, userBookId); return new Book(bookDoc); } /** * Delete a book (owner only) * @param {string} bookId - Book identifier * @param {string} userId - User identifier (must be owner) * @returns {Promise<void>} */ static async delete(bookId, userId) { const metaDB = getMetaDB(); // Verify ownership const bookDoc = await metaDB.get(bookId); if (bookDoc.owner !== userId) { throw new Error('Only the book owner can delete the book'); } // Delete all user-book relationships const userBooks = await metaDB.find({ selector: { type: 'user_book', bookId: bookId } }); for (const userBook of userBooks.docs) { await metaDB.destroy(userBook._id, userBook._rev); } // Delete book metadata await metaDB.destroy(bookId, bookDoc._rev); // Delete book database await metaDB.deleteDatabase(bookId); console.log(`✅ Deleted book: ${bookId}`); } /** * Delete all books belonging to an organization * Called when an org is deleted. * TODO: Introduce permission-based authorization for this call * @param {string} organizationId - Organization identifier * @returns {Promise<Object>} Deletion results {deleted: [], errors: []} */ static async deleteOrgBooks(organizationId) { if (!organizationId) { throw new Error('organizationId is required'); } const metaDB = getMetaDB(); const books = await this.listOrgBooks(organizationId); const results = { deleted: [], errors: [] }; for (const book of books) { try { // Delete all user-book relationships const userBooks = await metaDB.find({ selector: { type: 'user_book', bookId: book._id } }); for (const userBook of userBooks.docs) { await metaDB.destroy(userBook._id, userBook._rev); } // Delete book metadata await metaDB.destroy(book._id, book._doc._rev); // Delete book database try { await metaDB.deleteDatabase(book._id); } catch (dbError) { console.warn(`Could not delete database ${book._id}: ${dbError.message}`); } results.deleted.push(book._id); } catch (error) { results.errors.push({ bookId: book._id, error: error.message }); } } return results; } /** * Update book metadata * @param {Object} updates - Fields to update * @returns {Promise<Book>} Updated book instance */ async update(updates) { const metaDB = getMetaDB(); // Update document Object.assign(this._doc, updates); this._doc.updated = new Date().toISOString(); const result = await metaDB.insert(this._doc, this._doc._id); this._doc._rev = result.rev; // Update instance properties this.mapFields(); return this; } /** * Invite a user to this book * @param {string} bookId - Book identifier * @param {string} userId - User to invite * @param {string} role - Role to assign ('owner', 'editor', 'viewer') * @returns {Promise<Object>} Created user-book relationship */ static async inviteUser(bookId, userId, role) { if (!bookId || !userId || !role) { throw new Error('bookId, userId, and role are required'); } // Set permissions based on role let permissions; switch (role) { case 'owner': permissions = ['read', 'write', 'admin']; break; case 'editor': permissions = ['read', 'write']; break; case 'viewer': permissions = ['read']; break; default: throw new Error(`Invalid role: ${role}. Must be 'owner', 'editor', or 'viewer'`); } const metaDB = getMetaDB(); // Check if relationship already exists const existingResult = await metaDB.find({ selector: { type: 'user_book', userId: userId, bookId: bookId }, limit: 1 }); if (existingResult.docs.length > 0) { throw new Error(`User ${userId} already has access to book ${bookId}`); } // Create new user-book relationship with UUID const { v4: uuidv4 } = await import('uuid'); const userBookUuid = uuidv4(); const userBookId = `userbook-${userBookUuid}`; const userBookDoc = { _id: userBookId, type: 'user_book', userId: userId, bookId: bookId, role: role, permissions: permissions, joinedAt: new Date().toISOString() }; await metaDB.insert(userBookDoc, userBookId); return userBookDoc; } /** * Remove user access from this book * @param {string} bookId - Book identifier * @param {string} userId - User to remove * @returns {Promise<void>} */ static async removeUser(bookId, userId) { const metaDB = getMetaDB(); // Find user-book relationship by userId and bookId const result = await metaDB.find({ selector: { type: 'user_book', userId: userId, bookId: bookId }, limit: 1 }); if (result.docs.length === 0) { throw new Error(`User ${userId} does not have access to book ${bookId}`); } const userBook = result.docs[0]; // Prevent removing the owner (must transfer ownership first) if (userBook.role === 'owner') { throw new Error('Cannot remove owner access. Transfer ownership first.'); } await metaDB.destroy(userBook._id, userBook._rev); } /** * List all users with access to this book * @param {string} bookId - Book identifier * @returns {Promise<Object[]>} Array of user-book relationships */ static async listUsers(bookId) { const metaDB = getMetaDB(); const result = await metaDB.find({ selector: { type: 'user_book', bookId: bookId }, sort: [{ 'role': 'asc' }, { 'joinedAt': 'asc' }] }); return result.docs; } /** * Update user role for this book * @param {string} bookId - Book identifier * @param {string} userId - User to update * @param {string} role - New role * @returns {Promise<Object>} Updated user-book relationship */ static async updateUserRole(bookId, userId, role) { const metaDB = getMetaDB(); // Find user-book relationship by userId and bookId const result = await metaDB.find({ selector: { type: 'user_book', userId: userId, bookId: bookId }, limit: 1 }); if (result.docs.length === 0) { throw new Error(`User ${userId} does not have access to book ${bookId}`); } const userBook = result.docs[0]; // Set permissions based on role let permissions; switch (role) { case 'owner': permissions = ['read', 'write', 'admin']; break; case 'editor': permissions = ['read', 'write']; break; case 'viewer': permissions = ['read']; break; default: throw new Error(`Invalid role: ${role}. Must be 'owner', 'editor', or 'viewer'`); } userBook.role = role; userBook.permissions = permissions; userBook.updated = new Date().toISOString(); await metaDB.insert(userBook, userBook._id); return userBook; } } export default Book;