@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
321 lines (287 loc) âĸ 10.5 kB
JavaScript
import Account from './Account.js';
import LedgerEntry from './LedgerEntry.js';
import JournalEntry from './JournalEntry.js';
import COA from './coa.js';
import TrialBalance from './reports/TrialBalance.js';
import LedgerTemplate from './helpers/LedgerTemplate.js';
import JournalTemplate from './helpers/JournalTemplate.js';
import config, { getDB, getMetaDB, initMetadataConnection, switchToBook, setUserProfile, getUserProfile } from './config.js';
import Book from './metadata/Book.js';
import User from './metadata/User.js';
import COATemplate from './COATemplate.js';
import Check from './Check.js';
import Upload, { UPLOAD_STATUS } from './Upload.js';
import { CHECK_STATUS } from './constants.js';
import ErrorLog from './ErrorLog.js';
/**
* Verify user has access to a book via user_book relationship (for hippo UI)
* @param {string} userId - User identifier
* @param {string} bookId - Book identifier
* @returns {Promise<Object>} User-book relationship object
*/
async function verifyUserBookAccess(userId, bookId) {
const metaDB = getMetaDB();
try {
// Query for 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(`Access denied: User ${userId} does not have access to book ${bookId}`);
}
return result.docs[0];
} catch (error) {
if (error.message.includes('Access denied')) {
throw error;
}
throw new Error(`Failed to verify book access: ${error.message}`);
}
}
/**
* Verify organization-level access to a book (for backend apps like VM, CF)
* Checks that the book's organizationId matches the caller's organizationId
* @param {string} bookId - Book identifier
* @returns {Promise<Object>} Synthetic access object with editor role
*/
async function verifyOrgBookAccess(bookId) {
const metaDB = getMetaDB();
const profile = getUserProfile();
if (!profile?.organizationId) {
throw new Error('Organization access requires organizationId in userProfile');
}
try {
const bookDoc = await metaDB.get(bookId);
if (!bookDoc.organizationId) {
throw new Error(`Book ${bookId} is not organization-bound`);
}
if (bookDoc.organizationId !== profile.organizationId) {
throw new Error(`Access denied: Organization mismatch for book ${bookId}`);
}
// Return synthetic access object matching user_book shape
return {
type: 'org_access',
userId: profile.userId,
bookId: bookId,
organizationId: bookDoc.organizationId,
role: 'editor',
permissions: ['read', 'write']
};
} catch (error) {
if (error.message.includes('Access denied') || error.message.includes('not organization-bound') || error.message.includes('Organization access requires')) {
throw error;
}
if (error.status === 404) {
throw new Error(`Book ${bookId} not found`);
}
throw new Error(`Failed to verify org book access: ${error.message}`);
}
}
/**
* Initialize metadata database indexes
* @param {CouchDBDriver} metaDB - Metadata database driver instance
*/
async function initializeMetadataIndexes(metaDB) {
console.log('đ§ Initializing metadata indexes...');
try {
const indexes = [
{
index: { fields: ['type', 'userId'] },
name: 'user-books-by-user'
},
{
index: { fields: ['type', 'bookId'] },
name: 'user-books-by-book'
},
{
index: { fields: ['type', 'userId', 'joinedAt'] },
name: 'user-books-by-user-date'
},
{
index: { fields: ['type', 'name'] },
name: 'coa-templates-by-name'
},
{
index: { fields: ['type', 'bookId', 'role', 'joinedAt'] },
name: 'user-books-by-book-role-date'
},
{
index: { fields: ['type', 'userId', 'bookId'] },
name: 'user-books-by-user-book'
}
];
for (const indexDef of indexes) {
try {
await metaDB.createIndex({
...indexDef,
type: 'json'
});
console.log(`â
Created metadata index: ${indexDef.name}`);
} catch (error) {
if (error.error === 'file_exists') {
console.log(`âšī¸ Metadata index already exists: ${indexDef.name}`);
} else {
throw error;
}
}
}
console.log('đ Metadata indexes initialization completed!');
} catch (error) {
console.error('â Metadata indexes initialization failed:', error.message);
throw error;
}
}
/**
* Switch to a different book
* @param {string} bookId - Book identifier
* @param {string} userId - User identifier (for access verification)
* @param {Object} [options] - Options
* @param {string} [options.accessMode] - 'user' for explicit user_book access (hippo UI). Otherwise auto-detects: org access if organizationId present, user_book fallback if not.
* @returns {Promise<void>}
*/
async function switchBook(bookId, userId, options = {}) {
if (!bookId) {
throw new Error('switchBook() requires bookId');
}
if (!userId) {
throw new Error('switchBook() requires userId for access verification');
}
// Verify access based on mode
// If accessMode is 'user' â explicit user_book check (hippo UI)
// If organizationId present (and not 'user' mode) â org access (default for backend apps)
// Otherwise â fall back to user_book check
if (options.accessMode === 'user') {
await verifyUserBookAccess(userId, bookId);
} else if (getUserProfile()?.organizationId) {
await verifyOrgBookAccess(bookId);
} else {
await verifyUserBookAccess(userId, bookId);
}
// Switch to book database
switchToBook(bookId);
// Check if book database exists
const bookDB = getDB();
try {
await bookDB.databaseInfo();
} catch (error) {
if (error.status === 404) {
throw new Error(`Book database '${bookId}' does not exist. Books should be created through User.createBook() method.`);
} else {
throw error;
}
}
console.log(`â
Switched to book: ${bookId}`);
}
/**
* Initialize hippo-lib
* @param {Object} userProfile - User profile
* @param {string} userProfile.userId - User identifier
* @param {string} [userProfile.organizationId] - Organization identifier (for org-bound access)
* @param {Object} serverProfile - Server connection details
* @param {string} serverProfile.username - CouchDB username
* @param {string} serverProfile.password - CouchDB password
* @param {string} serverProfile.url - CouchDB server URL
* @param {string} [bookId] - Optional book identifier
* @param {Object} [options] - Options
* @param {string} [options.accessMode] - 'user' for explicit user_book access (hippo UI). Otherwise auto-detects from userProfile.organizationId.
* @returns {Promise<void>}
*/
async function init(userProfile, serverProfile, bookId, options = {}) {
if (!userProfile?.userId) {
throw new Error('hippo-lib.init() requires userProfile.userId');
}
if (!serverProfile?.username || !serverProfile?.password || !serverProfile?.url) {
throw new Error('hippo-lib.init() requires serverProfile with username, password, and url');
}
console.log(`đ hippo-lib.init() userProfile:`, JSON.stringify(userProfile), `bookId: ${bookId || '(none)'}`, `options:`, JSON.stringify(options));
// Store user profile globally
setUserProfile(userProfile);
// Initialize metadata database connection
initMetadataConnection(serverProfile);
// Check if metadata database exists, create if it doesn't
const metaDB = getMetaDB();
try {
await metaDB.databaseInfo();
} catch (error) {
if (error.status === 404) {
console.log('đ Creating metadata database: hippo-meta');
await metaDB.createDatabase('hippo-meta');
} else {
throw error;
}
}
// Rebuild indexes if requested
if (options.rebuildIndexes) {
await initializeMetadataIndexes(metaDB);
}
console.log('â
hippo-lib metadata initialized');
// Switch to book if bookId provided, or auto-discover org book
if (bookId) {
await switchBook(bookId, userProfile.userId, options);
} else if (userProfile.organizationId && options.accessMode !== 'user') {
// Auto-discover org book (prefer default book)
const orgBooks = await Book.listOrgBooks(userProfile.organizationId);
if (orgBooks.length > 0) {
const defaultBook = orgBooks.find(b => b.isDefault) || orgBooks[0];
console.log(`đ Auto-discovered org book: ${defaultBook._id} (default: ${!!defaultBook.isDefault})`);
await switchBook(defaultBook._id, userProfile.userId, options);
} else {
console.log('âšī¸ No org book found, metadata-only mode');
}
} else {
console.log('âšī¸ No book selected. Use switchBook() to select a book for accounting operations.');
}
}
/**
* Get database information
* @returns {Promise<Object>} Database info from CouchDB
*/
async function getDatabaseInfo() {
const db = getDB();
return await db.databaseInfo();
}
export {
init,
switchBook,
getDatabaseInfo,
User,
Book,
Account,
LedgerEntry,
JournalEntry,
Check,
CHECK_STATUS,
Upload,
UPLOAD_STATUS,
COA,
COATemplate,
TrialBalance,
LedgerTemplate,
JournalTemplate,
ErrorLog
};
// Default export for convenience
export default {
init,
switchBook,
getDatabaseInfo,
User,
Book,
Account,
LedgerEntry,
JournalEntry,
Check,
CHECK_STATUS,
Upload,
UPLOAD_STATUS,
COA,
COATemplate,
TrialBalance,
LedgerTemplate,
JournalTemplate,
ErrorLog
};