@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
654 lines (565 loc) âĸ 21.6 kB
JavaScript
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;