medici
Double-entry accounting system for nodejs + mongoose
npm i medici
Basics
To use Medici you will need a working knowledge of JavaScript, Node.js, and Mongoose.
Medici divides itself into "books", each of which store journal entries and their child transactions. The cardinal rule of double-entry accounting is that "for every debit entry, there must be a corresponding credit entry" which means "everything must balance out to zero", and that rule is applied to every journal entry written to the book. If the transactions for a journal entry do not balance out to zero, the system will throw a new error with the message INVALID JOURNAL
.
Books simply represent the physical book in which you would record your transactions - on a technical level, the "book" attribute simply is added as a key-value pair to both the Medici_Transactions
and Medici_Journals
collection to allow you to have multiple books if you want to.
Each transaction in Medici is for one account. Additionally, sub accounts can be created, and are separated by a colon. Transactions to the Assets:Cash account will appear in a query for transactions in the Assets account, but will not appear in a query for transactions in the Assets:Property account. This allows you to query, for example, all expenses, or just "office overhead" expenses (Expenses:Office Overhead).
In theory, the account names are entirely arbitrary, but you will likely want to use traditional accounting sections and subsections like assets, expenses, income, accounts receivable, accounts payable, etc. But, in the end, how you structure the accounts is entirely up to you.
Limitations:
- You can safely add values up to 9007199254740991 (Number.MAX_SAFE_INTEGER) and by default down to 0.000001 (precision: 7).
- Anything more than 9007199254740991 or less than 0.000001 (precision: 7) is not guaranteed to be handled properly.
You can set the floating point precision as follows:
const myBook = new Book("MyBook", { precision: 8 });
Writing journal entries
Writing a journal entry is very simple. First you need a book
object:
const { Book } = require("medici");
const myBook = new Book("MyBook");
Now write an entry:
const journal = await myBook
.entry("Received payment")
.debit("Assets:Cash", 1000)
.credit("Income", 1000, { client: "Joe Blow" })
.commit();
You can continue to chain debits and credits to the journal object until you are finished. The entry.debit()
and entry.credit()
methods both have the same arguments: (account, amount, meta).
You can use the "meta" field which you can use to store any additional information about the transaction that your application needs. In the example above, the client
attribute is added to the transaction in the Income
account, so you can later use it in a balance or transaction query to limit transactions to those for Joe Blow.
Querying Account Balance
To query account balance, just use the book.balance()
method:
const { balance } = await myBook.balance({
account: "Assets:Accounts Receivable",
client: "Joe Blow",
});
console.log("Joe Blow owes me", balance);
Note that the meta
query parameters are on the same level as the default query parameters (account, _journal, start_date, end_date). Medici parses the query and automatically turns any values that do not match top-level schema properties into meta parameters.
Retrieving Transactions
To retrieve transactions, use the book.ledger()
method (here I'm using moment.js for dates):
const startDate = moment().subtract("months", 1).toDate();
const endDate = new Date();
const { results, total } = await myBook.ledger({
account: "Income",
start_date: startDate,
end_date: endDate,
}, null, { lean: true });
Voiding Journal Entries
Sometimes you will make an entry that turns out to be inaccurate or that otherwise needs to be voided. Keeping with traditional double-entry accounting, instead of simply deleting that journal entry, Medici instead will mark the entry as "voided", and then add an equal, opposite journal entry to offset the transactions in the original. This gives you a clear picture of all actions taken with your book.
To void a journal entry, you can either call the void(void_reason)
method on a Medici_Journal document, or use the book.void(journal_id, void_reason)
method if you know the journal document's ID.
await myBook.void("5eadfd84d7d587fb794eaacb", "I made a mistake");
If you do not specify a void reason, the system will set the memo of the new journal to the original journal's memo prepended with "[VOID]".
ACID checks of an account balance
Sometimes you need to guarantee that an account balance never goes negative. You can employ MongoDB ACID transactions for that. As of 2022 the recommended way is to use special Medici writelock mechanism. See comments in the code example below.
import { Book, mongoTransaction } from "medici";
const mainLedger = new Book("mainLedger");
async function withdraw(walletId: string, amount: number) {
return mongoTransaction(async session => {
await mainLedger
.entry("Withdraw by User")
.credit("Assets", amount)
.debit(`Accounts:${walletId}`, amount)
.commit({ session });
const balanceAfter = await mainLedger.balance(
{
account: `Accounts:${walletId}`,
},
{ session }
);
if (balanceAfter.balance < 0) {
throw new Error("Not enough balance in wallet.");
}
await mainLedger.writelockAccounts([`Accounts:${walletId}`], { session });
});
}
Document Schema
Journals are schemed in Mongoose as follows:
JournalSchema = {
datetime: Date,
memo: {
type: String,
default: "",
},
_transactions: [
{
type: Schema.Types.ObjectId,
ref: "Medici_Transaction",
},
],
book: String,
voided: {
type: Boolean,
default: false,
},
void_reason: String,
approved: {
type: Boolean,
default: true,
},
};
Transactions are schemed as follows:
TransactionSchema = {
credit: Number,
debit: Number,
meta: Schema.Types.Mixed,
datetime: Date,
account_path: [String],
accounts: String,
book: String,
memo: String,
_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
timestamp: Date,
voided: {
type: Boolean,
default: false,
},
void_reason: String,
_original_journal: Schema.Types.ObjectId,
approved: {
type: Boolean,
default: true,
},
};
Note that the book
, datetime
, memo
, voided
, and void_reason
attributes are duplicates of their counterparts on the Journal document. These attributes will pretty much be needed on every transaction search, so they are added to the Transaction document to avoid having to populate the associated Journal every time.
Customizing the Transaction document schema
If you need to have related documents for Transactions and want to use Mongoose's populate
method, or if you need to add additional fields to the schema that the meta
won't satisfy, you can define your own schema for Medici_Transaction
and use the setJournalSchema
and setTransactionSchema
to use those schemas. When you specify meta values when querying or writing transactions, the system will check the Transaction schema to see if those values correspond to actual top-level fields, and if so will set those instead of the corresponding meta
field.
For example, if you want transactions to have a related "person" document, you can define the transaction schema like so and use setTransactionSchema to register it:
MyTransactionSchema = {
_person: {
type: Schema.Types.ObjectId,
ref: "Person",
},
credit: Number,
debit: Number,
meta: Schema.Types.Mixed,
datetime: Date,
account_path: [String],
accounts: String,
book: String,
memo: String,
_journal: {
type: Schema.Types.ObjectId,
ref: "Medici_Journal",
},
timestamp: Date,
voided: {
type: Boolean,
default: false,
},
void_reason: String,
approved: {
type: Boolean,
default: true,
},
};
MyTransactionSchema.index({ void: 1, void_reason: 1 });
setTransactionSchema(MyTransactionSchema, undefined, { defaultIndexes: true });
await syncIndexes({ background: false });
Then when you query transactions using the book.ledger()
method, you can specify the related documents to populate as the second argument. E.g., book.ledger({account:'Assets:Accounts Receivable'}, ['_person']).then()...
Performance
Medici v2 was slow when number of records reach 30k. Starting from v3.0 the following indexes are auto generated on the medici_transactions
collection:
"_journal": 1
"accounts": 1,
"book": 1,
"approved": 1,
"datetime": -1,
"timestamp": -1
"account_path.0": 1,
"book": 1,
"approved": 1
"account_path.0": 1,
"account_path.1": 1,
"book": 1,
"approved": 1
"account_path.0": 1,
"account_path.1": 1,
"account_path.2": 1,
"book": 1,
"approved": 1
Added in version 5:
"datetime": -1,
"timestamp": -1
However, if you are doing lots of queries using the meta
data (which is a typical scenario) you probably would want to add the following index(es):
"meta.myCustomProperty": 1,
"book": 1,
"approved": 1,
"datetime": -1,
"timestamp": -1
and/or
"meta.myCustomProperty": 1,
"account_path.0": 1,
"book": 1,
"approved": 1
and/or
"meta.myCustomProperty": 1,
"account_path.0": 1,
"account_path.1": 1,
"book": 1,
"approved": 1
and/or
"meta.myCustomProperty": 1,
"account_path.0": 1,
"account_path.1": 1,
"account_path.2": 1,
"book": 1,
"approved": 1
Here is how to add an index manually via MongoDB CLI or other tool:
db = db.getSiblingDB("my_db_name")
db.getCollection("medici_transactions").createIndex({
"meta.myCustomProperty": 1,
"book": 1,
"approved": 1,
"datetime": -1,
"timestamp": -1
}, {background: true})
Keep in mind, that the order of the fields in the Index is important. Always sort them by cardinality. E.g. If your Accounts are like "Expenses:Salary:Employee1","Expenses:Salary:Employee2" etc. then the cardinality of the last account-path is bigger than from the first part. So you would order the fields in the indexes like this:
"account_path.2": 1,
"account_path.1": 1,
"account_path.0": 1,
"book": 1,
"approved": 1
But if your Accounts are like "Employee1:Expenses:Salary", "Employee2:Expenses:Salary" than the cardinality of the first part is bigger. So you would order the fields in the indexes like this (=default Indexes):
"account_path.0": 1,
"account_path.1": 1,
"account_path.2": 1,
"book": 1,
"approved": 1
For more information, see Performance Best Practices: Indexing
Index memory consumption example
For medici_transactions
collection with 50000 documents:
- the mandatory
_id
index takes about 600 KB, - each of the medici default indexes take from 300 to 600 KB.
- your custom indexes containing
meta.*
properties would take 600 to 1200 KB.
Changelog