@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
843 lines (730 loc) • 33.6 kB
JavaScript
import config from './config.js';
import { ACCOUNT_TYPES, DEBIT, CREDIT } from './constants.js';
import COA from './coa.js';
import LedgerEntry from './LedgerEntry.js';
import ErrorLog from './ErrorLog.js';
// Getter to access database instance
function getDb() {
return config.db;
}
/**
* Account class for double-entry accounting system
* Provides CRUD operations and change tracking for accounts
*/
class Account {
key;
label;
balance;
accountType;
hierarchy;
tags;
template;
meta;
openingBalance;
openingBalanceJournalId;
closingBalance;
_doc;
/**
* Creates an Account instance from a CouchDB document
* @param {Object} doc - CouchDB document containing account data
*/
constructor(doc){
this._doc = doc;
this.mapFields();
}
/**
* Maps document fields to instance properties
* @private
*/
mapFields(){
this.key = this._doc.key;
this.label = this._doc.label;
this.balance = this._doc.balance;
this.accountType = this._doc.accountType;
this.hierarchy = this._doc.hierarchy || '';
this.tags = this._doc.tags || [];
this.template = this._doc.template || null;
this.meta = this._doc.meta || {};
this.openingBalance = this._doc.openingBalance || 0;
this.openingBalanceJournalId = this._doc.openingBalanceJournalId || null;
this.closingBalance = this._doc.closingBalance !== undefined ? this._doc.closingBalance : null;
}
/**
* Creates a new account and saves it to the database
* @param {string} key - Account key (unique identifier)
* @param {string} label - Account label (display name)
* @param {string} accountType - Account type (asset, liability, equity, revenue, expense)
* @param {number} balance - Initial balance (default: 0)
* @param {string} hierarchy - Hierarchical path (default: '')
* @param {string[]} tags - Array of tags for categorization (default: [])
* @param {Object} templateInfo - Optional template metadata {id, label, description, createdFrom}
* @param {boolean} skipCOAUpdate - Skip adding account to COA settings (default: false)
* @param {Object} meta - Optional metadata object (default: {})
* @returns {Promise<Account>} The created account instance
*/
static async new(key, label, accountType, balance = 0, hierarchy = '', tags = [], templateInfo = null, skipCOAUpdate = false, meta = {}) {
if (!key || typeof key !== 'string') {
throw new Error('Key is required and must be a string');
}
if (!Object.values(ACCOUNT_TYPES).includes(accountType)) {
throw new Error(`Invalid account type: ${accountType}`);
}
if (!Array.isArray(tags)) {
throw new Error('Tags must be an array of strings');
}
// Validate hierarchy path if provided
if (hierarchy && hierarchy.trim() !== '') {
try {
await COA.validateHierarchyPath(hierarchy);
} catch (error) {
throw new Error(`Hierarchy validation failed: ${error.message}`);
}
}
let doc = {
_id: `account-${key}`,
key,
label,
balance,
accountType,
hierarchy,
tags: tags.filter(tag => typeof tag === 'string'),
meta: meta || {},
openingBalance: balance, // Store the initial balance as opening balance
openingBalanceJournalId: null,
closingBalance: null,
docType: 'account'
};
// Add template metadata if provided
if (templateInfo) {
doc.template = {
key: templateInfo.key,
id: templateInfo.id,
label: templateInfo.label,
description: templateInfo.description
};
}
try {
const response = await getDb().insert(doc);
doc._id = response.id;
doc._rev = response.rev;
// Add account to COA settings (unless skipped for template creation)
if (!skipCOAUpdate) {
try {
await COA.addAccountToCOA({
key,
label,
accountType,
hierarchy,
tags: tags.filter(tag => typeof tag === 'string')
});
} catch (coaError) {
// Log warning but don't fail account creation
console.warn(`Warning: Failed to add account to COA settings: ${coaError.message}`);
ErrorLog.log('warn', 'Account.new.coaSync', coaError, { entityType: 'Account', entityId: key });
}
}
return new Account(doc);
} catch (error) {
throw new Error(`Failed to create account: ${error.message}`);
}
}
/**
* Retrieves an account from the database by ID
* @param {string} accountId - The account ID to retrieve
* @returns {Promise<Account>} The account instance
*/
static async get(accountId) {
try {
const doc = await getDb().get(accountId);
return new Account(doc);
} catch (error) {
throw new Error(`Failed to get account: ${error.message}`);
}
}
/**
* Updates the account balance and saves to database
* @param {number} newBalance - The new balance amount
* @returns {Promise<Account>} The updated account instance
*/
async updateBalance(newBalance) {
this.balance = newBalance;
return this.save();
}
/**
* Checks if the account has unsaved changes
* @returns {boolean} True if there are unsaved changes
*/
isDirty() {
return this.key !== this._doc.key ||
this.label !== this._doc.label ||
this.balance !== this._doc.balance ||
this.accountType !== this._doc.accountType ||
this.hierarchy !== (this._doc.hierarchy || '') ||
JSON.stringify(this.tags) !== JSON.stringify(this._doc.tags || []) ||
this.openingBalance !== (this._doc.openingBalance || 0) ||
this.openingBalanceJournalId !== (this._doc.openingBalanceJournalId || null) ||
this.closingBalance !== (this._doc.closingBalance !== undefined ? this._doc.closingBalance : null);
}
/**
* Returns an object containing only the changed fields
* @returns {Object} Object with changed fields and their new values
*/
getChanges() {
const changes = {};
if (this.key !== this._doc.key) changes.key = this.key;
if (this.label !== this._doc.label) changes.label = this.label;
if (this.balance !== this._doc.balance) changes.balance = this.balance;
if (this.accountType !== this._doc.accountType) changes.accountType = this.accountType;
if (this.hierarchy !== (this._doc.hierarchy || '')) changes.hierarchy = this.hierarchy;
if (JSON.stringify(this.tags) !== JSON.stringify(this._doc.tags || [])) changes.tags = this.tags;
if (this.openingBalance !== (this._doc.openingBalance || 0)) changes.openingBalance = this.openingBalance;
if (this.openingBalanceJournalId !== (this._doc.openingBalanceJournalId || null)) changes.openingBalanceJournalId = this.openingBalanceJournalId;
if (this.closingBalance !== (this._doc.closingBalance !== undefined ? this._doc.closingBalance : null)) changes.closingBalance = this.closingBalance;
return changes;
}
/**
* Reverts all unsaved changes to the last saved state
*/
rollback() {
this.mapFields();
}
/**
* Reloads the account data from the database
* @returns {Promise<Account>} This account instance with fresh data
*/
async reload() {
if (!this._doc || !this._doc._id) {
throw new Error('Cannot reload account without valid document ID');
}
try {
const freshDoc = await getDb().get(this._doc._id);
this._doc = freshDoc;
this.mapFields();
return this;
} catch (error) {
throw new Error(`Failed to reload account: ${error.message}`);
}
}
/**
* Calculates new balance after applying a transaction
* @param {number} currentBalance - Current account balance
* @param {string} type - DEBIT or CREDIT
* @param {number} amount - The amount to apply
* @param {string} activity - 'add' for normal posting, 'reverse' for undoing
* @returns {number} The new balance after applying the transaction
*/
calculateNewBalance(currentBalance, type, amount, activity = 'add') {
if (!type || !['debit', 'credit'].includes(type)) {
throw new Error('Type must be "debit" or "credit"');
}
if (typeof amount !== 'number' || amount <= 0) {
throw new Error('Amount must be a positive number');
}
if (!['add', 'reverse'].includes(activity)) {
throw new Error('Activity must be "add" or "reverse"');
}
let balanceChange = 0;
if (this.isDebitAccount()) {
// For debit balance accounts (assets, expenses)
if (type === 'debit') {
balanceChange = activity === 'add' ? amount : -amount;
} else { // credit
balanceChange = activity === 'add' ? -amount : amount;
}
} else {
// For credit balance accounts (liabilities, equity, revenue)
if (type === 'debit') {
balanceChange = activity === 'add' ? -amount : amount;
} else { // credit
balanceChange = activity === 'add' ? amount : -amount;
}
}
return Math.round((currentBalance + balanceChange) * 100) / 100;
}
/**
* Updates account balance based on ledger entry activity
* @param {string} type - DEBIT or CREDIT
* @param {number} amount - The amount to apply
* @param {string} activity - 'add' for normal posting, 'reverse' for undoing
* @returns {Promise<Account>} The updated account instance
*/
async updateBalance(type, amount, activity = 'add') {
if (!type || !['debit', 'credit'].includes(type)) {
throw new Error('Type must be "debit" or "credit"');
}
if (typeof amount !== 'number' || amount <= 0) {
throw new Error('Amount must be a positive number');
}
if (!['add', 'reverse'].includes(activity)) {
throw new Error('Activity must be "add" or "reverse"');
}
// Calculate balance change based on account type and activity
let balanceChange = 0;
if (this.isDebitAccount()) {
// For debit balance accounts (assets, expenses)
if (type === 'debit') {
balanceChange = activity === 'add' ? amount : -amount;
} else { // credit
balanceChange = activity === 'add' ? -amount : amount;
}
} else {
// For credit balance accounts (liabilities, equity, revenue)
if (type === 'debit') {
balanceChange = activity === 'add' ? -amount : amount;
} else { // credit
balanceChange = activity === 'add' ? amount : -amount;
}
}
this.balance = Math.round((this.balance + balanceChange) * 100) / 100;
// Save the updated balance
return await this.save();
}
/**
* Saves the account to the database (only if dirty)
* @returns {Promise<Account>} The saved account instance
*/
async save() {
if (!this._doc) {
throw new Error('Account must be saved before updating balance');
}
if (!this.isDirty()) return this; // No changes, skip save
// Validate hierarchy if it has changed
if (this.hierarchy !== (this._doc.hierarchy || '')) {
if (this.hierarchy && this.hierarchy.trim() !== '') {
try {
await COA.validateHierarchyPath(this.hierarchy);
} catch (error) {
throw new Error(`Hierarchy validation failed: ${error.message}`);
}
}
}
var doc = { ...this._doc };
doc.key = this.key;
doc.label = this.label;
doc.balance = this.balance;
doc.accountType = this.accountType;
doc.hierarchy = this.hierarchy;
doc.tags = this.tags;
doc.openingBalance = this.openingBalance;
doc.openingBalanceJournalId = this.openingBalanceJournalId;
doc.closingBalance = this.closingBalance;
try {
const response = await getDb().insert(doc);
this._doc = doc;
this._doc._rev = response.rev;
// Update account in COA settings if relevant fields changed
const changes = this.getChanges();
if (changes.label || changes.accountType || changes.hierarchy || changes.tags) {
try {
await COA.updateAccountInCOA(this.key, {
label: this.label,
accountType: this.accountType,
hierarchy: this.hierarchy,
tags: this.tags
});
} catch (coaError) {
// Log warning but don't fail account save
console.warn(`Warning: Failed to update account in COA settings: ${coaError.message}`);
ErrorLog.log('warn', 'Account.save.coaSync', coaError, { entityType: 'Account', entityId: this.key });
}
}
this.mapFields();
return this;
} catch (error) {
throw new Error(`Failed to save account: ${error.message}`);
}
}
/**
* Checks if an account can be safely deleted (no transactions, zero balance, not referenced in templates)
* @returns {Promise<{canDelete: boolean, reason?: string}>} Deletion eligibility status
*/
async canDelete() {
if (!this._doc) {
return { canDelete: false, reason: 'Account must be saved before checking deletion eligibility' };
}
if (this.balance !== 0) {
return { canDelete: false, reason: 'Cannot delete account with non-zero balance' };
}
// Check for existing ledger entries
try {
const entries = await LedgerEntry.listAllByAccount(this._doc._id);
if (entries.length > 0) {
return { canDelete: false, reason: 'Cannot delete account with existing transactions' };
}
} catch (error) {
return { canDelete: false, reason: `Error checking transactions: ${error.message}` };
}
// Check for references in journal templates
try {
const coa = await COA.loadCOAFromDB();
const referencingTemplates = [];
// Check journal templates
if (coa.journelTemplates && coa.journelTemplates.length > 0) {
for (const template of coa.journelTemplates) {
if (template.lines && template.lines.length > 0) {
for (const line of template.lines) {
// Check accountKey field, ignore placeholder references starting with #
if (line.accountKey === this.key && !line.accountKey.startsWith('#')) {
referencingTemplates.push(template.label);
break; // Don't add same template multiple times
}
}
}
}
}
if (referencingTemplates.length > 0) {
return {
canDelete: false,
reason: `Cannot delete account referenced in journal templates: ${referencingTemplates.join(', ')}`
};
}
return { canDelete: true };
} catch (error) {
return { canDelete: false, reason: `Error checking template references: ${error.message}` };
}
}
/**
* Removes the account from the database (only if balance is zero)
* @returns {Promise<boolean>} True if successfully removed
*/
async remove() {
if (!this._doc) {
throw new Error('Account must be saved before removing');
}
// Use the enhanced validation
const deleteCheck = await this.canDelete();
if (!deleteCheck.canDelete) {
throw new Error(deleteCheck.reason);
}
try {
await getDb().destroy(this._doc._id, this._doc._rev);
// Remove account from COA settings
try {
await COA.removeAccountFromCOA(this.key);
} catch (coaError) {
// Log warning but don't fail account removal
console.warn(`Warning: Failed to remove account from COA settings: ${coaError.message}`);
ErrorLog.log('warn', 'Account.remove.coaSync', coaError, { entityType: 'Account', entityId: this.key });
}
this._doc = null;
return true;
} catch (error) {
ErrorLog.log('error', 'Account.remove', error, { entityType: 'Account', entityId: this._doc._id });
throw new Error(`Failed to remove account: ${error.message}`);
}
}
/**
* Determines if this account increases with debits (true) or credits (false)
* @returns {boolean} True for debit accounts (assets, expenses), false for credit accounts
*/
isDebitAccount() {
return this.accountType === ACCOUNT_TYPES.ASSET || this.accountType === ACCOUNT_TYPES.EXPENSE;
}
/**
* Creates an opening balance journal entry for this account
* @param {string} openingBalanceAccountKey - Account key for the offsetting opening balance account
* @param {number} amount - The opening balance amount (can be positive or negative)
* @param {string} [date] - Optional ISO date string for the journal entry
* @returns {Promise<JournalEntry|null>} The created journal entry or null if amount is zero
*/
async createOpeningBalanceJournalEntry(openingBalanceAccountKey, amount, date = null) {
if (!this._doc) {
throw new Error('Account must be saved before creating opening balance journal');
}
if (amount === 0) {
return null; // Skip for zero amount
}
if (!openingBalanceAccountKey) {
throw new Error('Opening balance account key is required');
}
// Validate that the opening balance account exists
const openingBalanceAccount = await Account.findByKey(openingBalanceAccountKey);
if (!openingBalanceAccount) {
throw new Error(`Opening balance account not found: ${openingBalanceAccountKey}`);
}
try {
// Auto-derive fiscal year start date if not provided
if (!date) {
const Book = (await import('./metadata/Book.js')).default;
const { getCurrentBookId } = await import('./config.js');
const book = await Book.get(getCurrentBookId());
const fiscalStart = book._doc?.settings?.fiscalYearStart || '01-01';
const [month, day] = fiscalStart.split('-').map(Number);
const now = new Date();
let year = now.getFullYear();
if (now < new Date(year, month - 1, day)) year--;
date = new Date(year, month - 1, day).toISOString();
}
// Import JournalEntry here to avoid circular dependency
const JournalEntry = (await import('./JournalEntry.js')).default;
// Create journal entry
const journalEntry = await JournalEntry.new(
`Opening Balance - ${this.label}`,
['opening-balance']
);
journalEntry._doc.date = date;
// Determine posting sides based on account type and amount sign
const absAmount = Math.abs(amount);
const isPositiveAmount = amount >= 0;
const postToDebit = isPositiveAmount ? this.isDebitAccount() : !this.isDebitAccount();
if (postToDebit) {
// Post to debit side of this account
journalEntry.addLine(this.key, DEBIT, absAmount, `Opening balance for ${this.label}`);
journalEntry.addLine(openingBalanceAccountKey, CREDIT, absAmount, `Opening balance offset for ${this.label}`);
} else {
// Post to credit side of this account
journalEntry.addLine(this.key, CREDIT, absAmount, `Opening balance for ${this.label}`);
journalEntry.addLine(openingBalanceAccountKey, DEBIT, absAmount, `Opening balance offset for ${this.label}`);
}
// Post the journal entry
await journalEntry.post();
await this.reload();
// Store the journal ID reference in this account
this.openingBalanceJournalId = journalEntry.id;
this.openingBalance = amount;
await this.save();
return journalEntry;
} catch (error) {
console.warn(`Warning: Failed to create opening balance journal for account ${this.key}: ${error.message}`);
ErrorLog.log('warn', 'Account.createOpeningBalanceJE', error, { entityType: 'Account', entityId: this.key });
return null;
}
}
/**
* Updates the opening balance for this account by reversing the existing entry and creating a new one
* @param {string} openingBalanceAccountKey - Account key for the offsetting opening balance account
* @param {number} newAmount - The new opening balance amount (can be positive or negative)
* @param {string} [date] - Optional ISO date string for the journal entry
* @returns {Promise<JournalEntry|null>} The new opening balance journal entry
*/
async updateOpeningBalance(newAmount, openingBalanceAccountKey, date = null, { forceDelete = false } = {}) {
if (!this._doc) {
throw new Error('Account must be saved before updating opening balance');
}
if (!openingBalanceAccountKey) {
throw new Error('Opening balance account key is required');
}
try {
// Reverse existing opening balance journal entry if it exists
if (this.openingBalanceJournalId) {
try {
const JournalEntry = (await import('./JournalEntry.js')).default;
const existingJournal = await JournalEntry.get(this.openingBalanceJournalId);
if (existingJournal && existingJournal.status !== 'reversed' && existingJournal.status !== 'deleted') {
if (forceDelete) {
await existingJournal.forceDelete(true);
console.log(`✅ Force deleted existing opening balance journal: ${this.openingBalanceJournalId}`);
} else {
await existingJournal.reverse('Opening balance updated');
console.log(`✅ Reversed existing opening balance journal: ${this.openingBalanceJournalId}`);
}
}
} catch (error) {
console.warn(`Warning: Failed to reverse existing opening balance journal ${this.openingBalanceJournalId}: ${error.message}`);
ErrorLog.log('warn', 'Account.updateOpeningBalance.reverseExisting', error, { entityType: 'Account', entityId: this.key });
}
}
// Reload account to get latest _rev after JE force delete/reversal
if (this.openingBalanceJournalId) {
await this.reload();
}
// Create new opening balance journal entry if amount is not zero
if (newAmount !== 0) {
const newJournal = await this.createOpeningBalanceJournalEntry(openingBalanceAccountKey, newAmount, date);
console.log(`✅ Created new opening balance journal: ${newJournal?.id} for amount: ${newAmount}`);
return newJournal;
} else {
// If new amount is zero, just clear the journal reference
this.openingBalanceJournalId = null;
this.openingBalance = 0;
await this.save();
console.log(`✅ Cleared opening balance journal reference (amount set to zero)`);
return null;
}
} catch (error) {
throw new Error(`Failed to update opening balance: ${error.message}`);
}
}
/**
* Adds a ledger entry to this account and updates the balance
* Handles backdated entries by recalculating subsequent entry balances
* @param {number} amount - The amount (always positive)
* @param {string} type - DEBIT or CREDIT constant
* @param {string} description - Entry description
* @param {string} journalEntryId - Associated journal entry ID
* @param {string[]} tags - Array of tags for categorization (e.g., template keys)
* @param {string} date - ISO date string (defaults to current timestamp)
* @returns {Promise<LedgerEntry>} The created ledger entry
*/
async addLedgerEntry(amount, type, description = '', journalEntryId = null, tags = [], date = null) {
// Check if backdated (date provided and earlier than latest entry)
const latestEntry = await LedgerEntry.getLatestByAccount(this._doc._id);
const isBackdated = date !== null && latestEntry && date < latestEntry.date;
if (!isBackdated) {
// Not backdated - use current balance
const newBalance = this.calculateNewBalance(this.balance, type, amount, 'add');
const ledgerEntry = await LedgerEntry.new(
this._doc._id,
amount,
type,
description,
journalEntryId,
tags,
date,
newBalance
);
this.balance = newBalance;
await this.save();
return ledgerEntry;
}
// Backdated entry - calculate correct balance and recalculate subsequent entries
const priorEntry = await LedgerEntry.getEntryBeforeDate(this._doc._id, date);
const startingBalance = priorEntry ? priorEntry.balance : 0;
// Calculate new entry's balance
const newEntryBalance = this.calculateNewBalance(startingBalance, type, amount, 'add');
// Create the backdated entry with correct balance
const ledgerEntry = await LedgerEntry.new(
this._doc._id,
amount,
type,
description,
journalEntryId,
tags,
date,
newEntryBalance
);
// Recalculate all entries after this date
await this.recalculateRunningBalances(date, newEntryBalance);
return ledgerEntry;
}
/**
* Recalculates running balances for all ledger entries after a specific date
* @param {string} fromDate - ISO date string to start recalculation from (exclusive)
* @param {number} startingBalance - The balance to start calculation from
* @returns {Promise<void>}
*/
async recalculateRunningBalances(fromDate, startingBalance) {
try {
// Get all ledger entries after the specified date, ordered by date
const subsequentEntries = await LedgerEntry.listAllByAccount(this._doc._id, {
startDate: fromDate,
descending: false // oldest first for proper calculation
});
// Filter out entries with the exact same timestamp to avoid including the deleted entry
const entriesToUpdate = subsequentEntries.filter(entry => entry.date > fromDate);
let runningBalance = startingBalance;
const docsToUpdate = [];
// Recalculate balances and collect changed entries
for (const entry of entriesToUpdate) {
runningBalance = this.calculateNewBalance(runningBalance, entry.type, entry.amount, 'add');
if (entry.balance !== runningBalance) {
entry.balance = runningBalance;
entry._doc.balance = runningBalance;
docsToUpdate.push(entry._doc);
}
}
// Bulk-write all changed entries in a single CouchDB call
if (docsToUpdate.length > 0) {
const bulkResponse = await getDb().bulk({ docs: docsToUpdate });
for (const result of bulkResponse) {
if (result.error) {
throw new Error(`Bulk balance update failed for ${result.id}: ${result.error}`);
}
}
}
// Update the account's final balance
this.balance = runningBalance;
await this.save();
} catch (error) {
throw new Error(`Failed to recalculate running balances: ${error.message}`);
}
}
/**
* Finds an account by key
* @param {string} key - The account key to search for
* @returns {Promise<Account|null>} The account instance or null if not found
*/
static async findByKey(key) {
try {
const doc = await getDb().get(`account-${key}`);
return new Account(doc);
} catch (error) {
if (error.status === 404) return null;
throw new Error(`Failed to find account by key: ${error.message}`);
}
}
/**
* Lists all accounts from the database
* @param {Object} options - Optional filtering options
* @param {string} options.templateKey - Filter accounts by template key
* @returns {Promise<Account[]>} Array of all account instances
*/
static async list(options = {}) {
try {
const response = await getDb().list({
startkey: 'account-',
endkey: 'account-\ufff0',
include_docs: true
});
let accounts = response.rows.map(row => {
return new Account(row.doc);
});
// Apply template filtering if specified
if (options.templateKey) {
accounts = accounts.filter(account => {
return account._doc.template && account._doc.template.key === options.templateKey;
});
}
return accounts;
} catch (error) {
throw new Error(`Failed to list accounts: ${error.message}`);
}
}
/**
* Get all-time debit and credit totals for this account via CouchDB reduce view
* @returns {Promise<{totalDebits: number, totalCredits: number}>}
*/
async getTotals() {
try {
const accountId = this._doc._id;
const result = await getDb().view('ledger', 'account-totals', {
startkey: JSON.stringify([accountId, 'credit']),
endkey: JSON.stringify([accountId, 'debit']),
group: true
});
let totalDebits = 0;
let totalCredits = 0;
for (const row of (result.rows || [])) {
if (row.key[1] === 'debit') totalDebits = row.value;
if (row.key[1] === 'credit') totalCredits = row.value;
}
return { totalDebits, totalCredits };
} catch (error) {
throw new Error(`Failed to get account totals: ${error.message}`);
}
}
/**
* Lists accounts that contain any of the specified tags
* @param {string|string[]} tags - Tag or array of tags to search for
* @returns {Promise<Account[]>} Array of account instances that contain the specified tags
*/
static async listByTag(tags) {
if (!tags) {
throw new Error('Tags parameter is required');
}
const searchTags = Array.isArray(tags) ? tags : [tags];
if (searchTags.length === 0) {
throw new Error('At least one tag must be provided');
}
try {
const selector = searchTags.length === 1
? { docType: 'account', tags: { $elemMatch: { $eq: searchTags[0] } } }
: { docType: 'account', $or: searchTags.map(tag => ({ tags: { $elemMatch: { $eq: tag } } })) };
const result = await getDb().find({ selector, limit: 999999 });
return result.docs.map(doc => new Account(doc));
} catch (error) {
throw new Error(`Failed to list accounts by tag: ${error.message}`);
}
}
}
export default Account;