UNPKG

medici

Version:

Double-entry accounting ledger for Node + Mongoose

279 lines (278 loc) 14.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Book = void 0; const mongoose_1 = require("mongoose"); const errors_1 = require("./errors"); const handleVoidMemo_1 = require("./helper/handleVoidMemo"); const addReversedTransactions_1 = require("./helper/addReversedTransactions"); const parseFilterQuery_1 = require("./helper/parse/parseFilterQuery"); const parseBalanceQuery_1 = require("./helper/parse/parseBalanceQuery"); const Entry_1 = require("./Entry"); const journal_1 = require("./models/journal"); const transaction_1 = require("./models/transaction"); const lock_1 = require("./models/lock"); const balance_1 = require("./models/balance"); const GROUP = { $group: { _id: null, balance: { $sum: { $subtract: ["$credit", "$debit"] } }, notes: { $sum: 1 }, lastTransactionId: { $max: "$_id" }, }, }; class Book { constructor(name, options = {}) { this.name = name; this.precision = options.precision != null ? options.precision : 8; this.maxAccountPath = options.maxAccountPath != null ? options.maxAccountPath : 3; this.balanceSnapshotSec = options.balanceSnapshotSec != null ? options.balanceSnapshotSec : 24 * 60 * 60; this.expireBalanceSnapshotSec = options.expireBalanceSnapshotSec != null ? options.expireBalanceSnapshotSec : 2 * this.balanceSnapshotSec; if (typeof this.name !== "string" || this.name.trim().length === 0) { throw new errors_1.BookConstructorError("Invalid value for name provided."); } if (typeof this.precision !== "number" || !Number.isInteger(this.precision) || this.precision < 0) { throw new errors_1.BookConstructorError("Invalid value for precision provided."); } if (typeof this.maxAccountPath !== "number" || !Number.isInteger(this.maxAccountPath) || this.maxAccountPath < 0) { throw new errors_1.BookConstructorError("Invalid value for maxAccountPath provided."); } if (typeof this.balanceSnapshotSec !== "number" || this.balanceSnapshotSec < 0) { throw new errors_1.BookConstructorError("Invalid value for balanceSnapshotSec provided."); } if (typeof this.expireBalanceSnapshotSec !== "number" || this.expireBalanceSnapshotSec < 0) { throw new errors_1.BookConstructorError("Invalid value for expireBalanceSnapshotSec provided."); } } entry(memo, date = null, original_journal) { return Entry_1.Entry.write(this, memo, date, original_journal); } async balance(query, options = {}) { // If there is a session, we must NOT set any readPreference (as per mongo v5 and v6). // https://www.mongodb.com/docs/v6.0/core/transactions/#read-concern-write-concern-read-preference // Otherwise, we are free to use any readPreference. if (options && !options.session && !options.readPreference) { // Let's try reading from the secondary node, if available. options.readPreference = "secondaryPreferred"; } const parsedQuery = (0, parseBalanceQuery_1.parseBalanceQuery)(query, this); const meta = parsedQuery.meta; delete parsedQuery.meta; let balanceSnapshot = null; let accountForBalanceSnapshot; if (this.balanceSnapshotSec) { accountForBalanceSnapshot = query.account ? [].concat(query.account).join() : undefined; balanceSnapshot = await (0, balance_1.getBestBalanceSnapshot)({ book: parsedQuery.book, account: accountForBalanceSnapshot, meta, }, options); if (balanceSnapshot) { // Use cached balance parsedQuery._id = { $gt: balanceSnapshot.transaction }; } } const match = { $match: parsedQuery }; const partialBalanceOptions = { ...options }; // If using a balance snapshot then make sure to use the appropriate (default "_id_") index for the additional balance calc. if (parsedQuery._id && balanceSnapshot) { const lastTransactionDate = balanceSnapshot.transaction.getTimestamp(); if (lastTransactionDate.getTime() + this.expireBalanceSnapshotSec * 1000 > Date.now()) { // last transaction for this balance was just recently, then let's use the "_id" index as it will likely be faster than any other. partialBalanceOptions.hint = { _id: 1 }; } } const result = (await transaction_1.transactionModel.collection.aggregate([match, GROUP], partialBalanceOptions).toArray())[0]; let balance = 0; let notes = 0; if (balanceSnapshot) { balance += balanceSnapshot.balance; notes += balanceSnapshot.notes; } if (result) { balance += parseFloat(result.balance.toFixed(this.precision)); notes += result.notes; // We can do snapshots only if there is at least one entry for this balance if (this.balanceSnapshotSec && result.lastTransactionId) { // It's the first (ever?) snapshot for this balance. We just need to save whatever we've just aggregated // so that the very next balance query would use cached snapshot. if (!balanceSnapshot) { await (0, balance_1.snapshotBalance)({ book: this.name, account: accountForBalanceSnapshot, meta, transaction: result.lastTransactionId, balance, notes, expireInSec: this.expireBalanceSnapshotSec, }, options); } else { // There is a snapshot already. But let's check if it's too old. const isSnapshotObsolete = Date.now() > balanceSnapshot.createdAt.getTime() + this.balanceSnapshotSec * 1000; // If it's too old we would need to cache another snapshot. if (isSnapshotObsolete) { delete parsedQuery._id; const match = { $match: parsedQuery }; // Important! We are going to recalculate the entire balance from the day one. // Since this operation can take seconds (if you have millions of documents) // we better run this query IN THE BACKGROUND. // If this exact balance query would be executed multiple times at the same second we might end up with // multiple snapshots in the database. Which is fine. The chance of this happening is low. // Our main goal here is not to delay this .balance() method call. The tradeoff is that // database will use 100% CPU for few (milli)seconds, which is fine. It's all fine (C) transaction_1.transactionModel.collection .aggregate([match, GROUP], options) .toArray() .then((results) => { const resultFull = results[0]; return (0, balance_1.snapshotBalance)({ book: this.name, account: accountForBalanceSnapshot, meta, transaction: resultFull.lastTransactionId, balance: parseFloat(resultFull.balance.toFixed(this.precision)), notes: resultFull.notes, expireInSec: this.expireBalanceSnapshotSec, }, options); }) .catch((error) => { console.error("medici: Couldn't do background balance snapshot.", error); }); } } } } return { balance, notes }; } async ledger(query, options = {}) { // Pagination const { perPage, page, ...restOfQuery } = query; const paginationOptions = {}; if (typeof perPage === "number" && Number.isSafeInteger(perPage)) { paginationOptions.skip = (Number.isSafeInteger(page) ? page - 1 : 0) * perPage; paginationOptions.limit = perPage; } const filterQuery = (0, parseFilterQuery_1.parseFilterQuery)(restOfQuery, this); const findPromise = transaction_1.transactionModel.collection .find(filterQuery, { ...paginationOptions, sort: { datetime: -1, timestamp: -1, }, session: options.session, readPreference: options.readPreference, readConcern: options.readConcern, }) .toArray(); let countPromise = Promise.resolve(0); if (paginationOptions.limit) { countPromise = transaction_1.transactionModel.collection.countDocuments(filterQuery, { session: options.session, readPreference: options.readPreference, readConcern: options.readConcern, }); } const results = (await findPromise); return { results, total: (await countPromise) || results.length, }; } async void(journal_id, reason, options = {}, use_original_date = false) { journal_id = typeof journal_id === "string" ? new mongoose_1.Types.ObjectId(journal_id) : journal_id; const journal = await journal_1.journalModel.collection.findOne({ _id: journal_id, book: this.name, }, { session: options.session, readPreference: options.readPreference, readConcern: options.readConcern, projection: { _id: true, _transactions: true, memo: true, void_reason: true, voided: true, datetime: true, }, }); if (journal === null) { throw new errors_1.JournalNotFoundError(); } if (journal.voided) { throw new errors_1.JournalAlreadyVoidedError(); } reason = (0, handleVoidMemo_1.handleVoidMemo)(reason, journal.memo); // Not using options.session here as this read operation is not necessary to be in the ACID session. const transactions = await transaction_1.transactionModel.collection .find({ _journal: journal._id }, { session: options.session, readPreference: options.readPreference, readConcern: options.readConcern, }) .toArray(); if (transactions.length !== journal._transactions.length) { throw new errors_1.MediciError(`Transactions for journal ${journal._id} not found on book ${journal.book}`); } const entry = this.entry(reason, use_original_date ? journal.datetime : null, journal_id); (0, addReversedTransactions_1.addReversedTransactions)(entry, transactions); // Set this journal to void with reason and also set all associated transactions const resultOne = await journal_1.journalModel.collection.updateOne({ _id: journal._id }, { $set: { voided: true, void_reason: reason } }, { session: options.session, writeConcern: options.session ? undefined : { w: 1, j: true }, // Ensure at least ONE node wrote to JOURNAL (disk) }); // This can happen if someone read a journal, then deleted it from DB, then tried voiding. Full stop. if (resultOne.matchedCount === 0) throw new errors_1.ConsistencyError(`Failed to void ${journal.memo} ${journal._id} journal on book ${journal.book}`); // Someone else voided! Is it two simultaneous voidings? Let's stop our void action altogether. if (resultOne.modifiedCount === 0) throw new errors_1.ConsistencyError(`Already voided ${journal.memo} ${journal._id} journal on book ${journal.book}`); const resultMany = await transaction_1.transactionModel.collection.updateMany({ _journal: journal._id }, { $set: { voided: true, void_reason: reason } }, { session: options.session, writeConcern: options.session ? undefined : { w: 1, j: true }, // Ensure at least ONE node wrote to JOURNAL (disk) }); // At this stage we have to make sure the `commit()` is executed. // Let's not make the DB even more inconsistent if something wild happens. Let's not throw, instead log to stderr. if (resultMany.matchedCount !== transactions.length) throw new errors_1.ConsistencyError(`Failed to void all ${journal.memo} ${journal._id} journal transactions on book ${journal.book}`); if (resultMany.modifiedCount === 0) throw new errors_1.ConsistencyError(`Already voided ${journal.memo} ${journal._id} journal transactions on book ${journal.book}`); return entry.commit(options); } async writelockAccounts(accounts, options) { accounts = Array.from(new Set(accounts)); // ISBN: 978-1-4842-6879-7. MongoDB Performance Tuning (2021), p. 217 // Reduce the Chance of Transient Transaction Errors by moving the // contentious statement to the end of the transaction. for (const account of accounts) { await lock_1.lockModel.collection.updateOne({ account, book: this.name }, { $set: { updatedAt: new Date() }, $setOnInsert: { book: this.name, account }, $inc: { __v: 1 }, }, { upsert: true, session: options.session }); } return this; } async listAccounts(options = {}) { const distinctResult = await transaction_1.transactionModel.collection.distinct("accounts", { book: this.name }, { session: options.session, readPreference: options.readPreference, readConcern: options.readConcern, }); const accountsSet = new Set(); for (const fullAccountName of distinctResult) { const paths = fullAccountName.split(":"); let path = paths[0]; accountsSet.add(path); for (let i = 1; i < paths.length; ++i) { path += ":" + paths[i]; accountsSet.add(path); } } return Array.from(accountsSet).sort(); } } exports.Book = Book; exports.default = Book;