npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

double-entry-ledger

v0.1.0

Published

A double-entry ledger for Node and MongoDB. Balanced by construction, append-only, idempotent, and reconcilable. Zero dependencies.

Readme

double-entry-ledger

A double-entry ledger for Node and MongoDB. Balanced by construction, append-only, idempotent, and reconcilable — so the books cannot quietly go wrong.

CI npm license zero dependencies

await ledger.post({
  description: 'Card deposit',
  idempotencyKey: 'deposit-001',
  postings: [
    { account: 'platform:bank',     debit: 5000 },   // an asset rises
    { account: 'user:alice:wallet', credit: 5000 }   // and so does what you owe
  ]
})

Debits must equal credits. If they don't, nothing is written.


The problem

Almost every application that handles money starts the same way:

await users.updateOne({ _id }, { $inc: { balance: amount } })

It works. It keeps working. Then one day the number is wrong and there is no way to find out why, because the only record of what the balance should be is the balance itself.

The specific ways it goes wrong:

  • A retry doubles a credit. The HTTP request timed out, the client retried, the handler ran twice.
  • A half-finished transfer. One $inc succeeded, the other threw, and now money exists in one place and not the other. Nothing records that this happened.
  • Floating point. 0.1 + 0.2 is 0.30000000000000004. Accumulate that across a million rows.
  • A negative balance nobody bounded. Two withdrawals raced, both read a sufficient balance, both proceeded.
  • "Where did this £40 come from?" There is no answer. There is only a number.

Double-entry bookkeeping is the 500-year-old fix. Every movement touches at least two accounts and the two sides must agree, so the books carry their own proof. This library is that discipline, enforced by code rather than by remembering.


Install

npm install double-entry-ledger

Zero runtime dependencies. Node 18+. MongoDB is optional — the in-memory store is a first-class citizen and the whole test suite runs against it.


Quick start

const { createLedger, createMemoryStore } = require('double-entry-ledger')

const ledger = createLedger({ store: createMemoryStore() })

// A customer's balance is a LIABILITY: it is money you owe them.
// Your bank account is an ASSET. Getting that pairing right is what makes a
// deposit balance — it increases both sides of the equation at once.
await ledger.createAccount({ id: 'platform:bank',     type: 'asset',     currency: 'USD' })
await ledger.createAccount({ id: 'user:alice:wallet', type: 'liability', currency: 'USD' })

await ledger.post({
  postings: [
    { account: 'platform:bank',     debit: 5000 },
    { account: 'user:alice:wallet', credit: 5000 }
  ]
})

await ledger.balance('user:alice:wallet')
// { account: 'user:alice:wallet', currency: 'USD', balance: 5000, formatted: '50.00 USD', ... }

Run the full worked example — deposit, retry, split payment with a fee, refused overdraft, chargeback, trial balance, reconciliation:

npm run example

What it guarantees

| | | |---|---| | Every entry balances | Debits equal credits exactly, in integer minor units. An unbalanced entry is refused, never adjusted into shape. | | Nothing is ever edited | Entries are append-only. A mistake is corrected with a reversing entry, so both the error and the correction stay visible. | | A retry is not a double-spend | idempotencyKey is claimed atomically. Post the same key twice and the second call returns the first entry, having written nothing. | | A guarded account cannot go negative | Enforced by an atomic conditional update, not by reading the balance and hoping. Concurrent withdrawals cannot both pass. | | Money is never a float | Amounts are integer minor units. A float is rejected at the boundary rather than silently rounded. | | One currency per entry | Cross-currency movement needs explicit FX postings, because an implicit conversion is a rate nobody recorded. | | The balances are checkable | reconcile() recomputes every balance from the entries and reports drift. Balances are a cache; entries are the truth. |


Debits and credits, briefly

The part that trips up programmers: a debit is not "money in".

Debits and credits are just the two sides of one equation:

assets + expenses  =  liabilities + equity + revenue

A debit increases the left side and decreases the right. So a debit increases your bank account (an asset) and decreases what you owe a customer (a liability).

| Type | Increased by | Typical use | |---|---|---| | asset | debit | your bank account, cash, receivables | | expense | debit | fees you pay, costs | | liability | credit | customer balances, payables | | equity | credit | owner capital, retained earnings | | revenue | credit | your fees, sales |

The library records which direction is normal for each type, so balance() returns a number that reads the way you expect. A customer's wallet shows 5000 when they have $50 — not -5000 that everyone then remembers to flip.

A customer's wallet is a liability, not an asset. It is the single most common modelling mistake, and getting it right is what makes deposits balance.


Guarded accounts

Liability accounts are guarded by default — a customer cannot spend money they do not have:

await ledger.post({
  postings: [
    { account: 'user:alice:wallet', debit: 10000 },
    { account: 'platform:bank',    credit: 10000 }
  ]
})
// InsufficientFundsError: Account "user:alice:wallet" has 3750 available
// but 10000 was requested (USD, minor units).

Everything else is unguarded, deliberately: a bank account can be overdrawn, revenue goes negative when refunds exceed sales, and equity goes negative as an accumulated deficit. Guarding those would reject correct bookkeeping. Override per account with allowNegative.

The guard is an atomic conditional update — the store only moves the balance when the result would stay at or above zero. Reading the balance and then deciding is the bug this avoids: two simultaneous withdrawals both read a sufficient balance and both proceed. There is a test for exactly that, and it asserts that ten concurrent withdrawals against five withdrawals of funds result in precisely five.


Correcting mistakes

There is no update and no delete. A wrong entry is corrected by reversing it:

await ledger.reverse(entry.id, { description: 'Chargeback' })

That writes the mirror image and leaves the original untouched. Both are permanently visible.

This is not squeamishness about deletion. An audit that cannot see the mistake cannot see that it was caught, and "the number changed and nobody knows why" is the failure being traded away. reverse() is idempotent — calling it twice returns the same reversal.


Reconciliation

Balances are a cache over the entries. This is what makes that claim checkable rather than merely stated:

const report = await ledger.reconcile()
// { entries: 41_233, accounts: 512, balanced: true, drift: [], totals: { USD: {...} } }

await ledger.reconcile({ repair: true })   // rebuild the cache from the entries

It recomputes every balance from every entry, re-checks that each entry balances on the way past, and reports any account whose cached balance disagrees. Run it on a schedule. A cache nobody verifies is a cache that is eventually wrong without anyone noticing — and finding out from a customer is the expensive way.

trialBalance() gives the classic report: every active account in its normal column, with the two totals that must agree.


Stores

Memory (default)

createLedger({ store: createMemoryStore() })

Real implementation, not a mock — it enforces the same guarded commit, and the whole test suite runs against it. Single process only, and nothing survives a restart.

MongoDB

const { MongoClient } = require('mongodb')
const client = new MongoClient(process.env.MONGO_URL)
await client.connect()

createLedger({
  store: createMongoStore({ db: client.db('app'), client })
})

Three collections: accounts, entries, balances. Indexes are created on first use.

An entry's postings live inside one document. That is the central design choice: a single-document write is atomic in MongoDB with no transaction and no replica set, so "all of an entry's postings land, or none do" is true by construction. Splitting postings into their own collection would make the core invariant depend on a transaction being available.

Pass client if you can. With it, the store uses a transaction to move the balance cache and write the entry together. Without it — or on a standalone mongod, where transactions are unavailable — the store moves the guarded balances first, writes the entry, and compensates if the write fails. If the process dies between the two, the cache is ahead of the entries; the ledger is still correct, and reconcile() finds and repairs it. That is the honest cost of running without a replica set, and it is why reconciliation is a first-class feature rather than optional hygiene.

Your own

Implement the store interface (see src/stores/memory.js — it is short). The one hard requirement is that commit be atomic against concurrent callers; every guarantee above rests on it.


What this is not

  • Not an accounting system. No chart of accounts, no periods, no tax, no reporting beyond a trial balance. It is the substrate those are built on.
  • Not multi-currency. One currency per entry, by design. Cross-currency movement goes through explicit FX postings against a conversion account, so the rate you used is recorded rather than implied.
  • Not a payments integration. It records what happened; talking to Stripe is your job.
  • Not a replacement for your database's durability. Use MongoDB with a replica set and tested backups. A ledger on one unbacked box is a ledger you will lose.

Testing

npm test                  # 68 tests, no services required

No test framework, no build step, no service dependencies. The store contract runs against both the memory store and a MongoDB stand-in faithful enough to enforce unique indexes and honour the $gte filter the balance guard depends on — because the guarded commit is the most delicate code here and shipping it unexercised was not an option.

The same contract runs against a real mongod in CI on every push:

docker compose up -d
MONGO_URL=mongodb://127.0.0.1:27017 npm run test:integration

Support this project

Free, MIT-licensed, maintained in spare time. If it saved you a reconciliation nightmare:

❤️ Sponsor · ☕ Ko-fi · crypto and other ways

Never expected, never a condition of support — issues and PRs get the same attention from everyone.

I also take on retained development and platform ownership work for teams building systems where this class of problem matters.


License

MIT © Milon Patowary