Bookkeeping Ledger
One running record of every dollar in and out — editable, backfillable, and built to export to QuickBooks.
The bookkeeping ledger is the single place that answers "what money came in, what went out, and
where did it go." Until it existed, those figures were scattered across payments, refunds, order
columns, per-show cash and card fields, and fundraiser rollups, with nothing joining them. The
LedgerEntry model is that join.
What it is — and is not
It is a reporting and backup ledger, not a new system of record. Orders, refunds, and shows keep their own tables and remain the truth for their domain. Every ledger row is one of two things:
- Derived from a source record (an order, a refund, an archived show), written idempotently and
keyed by a
dedupeKeyso re-running the backfill updates rather than duplicates. - Manual — typed in by staff (an expense, a correction, a cash sale), carrying no dedupe key so the backfill never touches it.
The one rule: each dollar from exactly one source
The ledger's totals are only trustworthy because nothing is counted twice. Money is recorded from a single source per era and channel:
Order+Refund— every online, manual, phone, wholesale, fundraiser, and event sale, plus its refunds. This is the transactional truth for the database years.ArchivedShowSale— the pre-database paper years of festival and market sales, which do not overlap the orders.
Rollup tables that merely re-summarise those same sales — Fundraiser.totalRevenue, the per-show
cash/card fields on FeaturedEvent — are not backfilled. Adding them on top of the orders they
summarise would double-count the money.
How an order becomes ledger rows
An order is split into its parts, each a separate categorised row:
| Row | Category | Side |
|---|---|---|
| Goods total | PRODUCT_SALES | Income |
| Shipping charged | SHIPPING_INCOME | Income |
| Sales tax collected | SALES_TAX_COLLECTED | Income |
| Discount given | DISCOUNTS | Expense (contra) |
| Cost of goods (from item cost snapshots) | COGS | Expense |
| Processor fee | PROCESSOR_FEES | Expense |
A refund is a single REFUNDS row. Refunds and discounts are stored as positive amounts under
expense-side categories so that a plain sum(income) − sum(expense) nets correctly — they are
contra-income, not literal costs. Every amount is stored in integer cents so a column of entries
sums without rounding drift.
Keeping it current
- Live: a domain-event handler writes each order and refund into the ledger the moment its money
settles (
payment.completed,payment.refunded). No nightly job required. - Backfill:
npm run ledger:backfillreconstructs the whole ledger from existing settled orders, their refunds, and the archived shows. It is idempotent — safe to run as often as you like. - Statement import: the expenses nothing else records — fuel, booth fees, supplier payments, bank charges — come in from a bank or card statement. See below.
Importing a bank or card statement
Financials → Ledger → Import statement reads a CSV export from a bank or card account.
A statement is mostly money the ledger already has
This is the thing to understand before using it. Every card deposit on a bank statement is money already recorded, from the orders that produced it. Importing a statement wholesale would book that revenue a second time and double every figure the ledger reports.
So the importer is built around detection, not ingestion. Rows that look like money already recorded are excluded by default — you opt a suspected duplicate back in, rather than having to remember to opt it out.
Two exclusions, both stated on the row:
- The ledger already holds this amount. Same amount, within three days — the window exists because card settlement lands a day or three after the sale, so matching on the exact date would miss almost every real duplicate.
- It looks like a processor payout. A deposit from Stripe, PayPal or Square is many orders netted together with fees taken out. No rule can split that back into the orders it contains, so it is not matched approximately and reported as reconciled — it is excluded on the strength of its description, and the reason is said out loud.
What is left is the genuinely new material, with a suggested category you can change. The category picker only offers categories on the same side as the money moved: a withdrawal filed under an income category would flip the sign of a figure in every report built on the ledger, so it is refused rather than quietly corrected.
The steps
- Choose the CSV and name the account it came from ("Chase business checking"). The name is recorded on every row and is part of how a repeated line is recognised — the same amount on the same day is a different transaction on a different account.
- Columns are detected automatically. If the file is unusual you are asked to pick them; a date, a description, and either a signed amount column or a debit/credit pair are enough.
- Review the marked-up list, tick what belongs, and import.
Re-importing an overlapping export is safe: each row carries a content hash, so a line that appears in two statement downloads updates the row it already created instead of duplicating it. Rows that have been imported before are labelled as such.
Some rows are read and some are not, and the ones that are not are listed with a reason rather than
dropped quietly. Amounts in accounting parentheses ((12.34)) are read as negative; European
decimal notation (1.234,56) is rejected, because it is ambiguous against US thousands
separators and guessing would mis-scale the amount by a thousand without ever failing.
Each import is recorded as a LedgerImportBatch with what it read, wrote, and skipped, and every
row it created points back to it.
The admin page
/admin/financials/ledger (under Financials → Ledger) shows money-in, money-out, and net over
any date range, with filters for direction, category, and free-text search. Staff with
financials:write can add, edit, and delete manual entries — the place to record an expense or
correct a figure. Derived rows can have a note added but are otherwise kept in sync automatically and
cannot be deleted (the backfill would recreate them); to change one, edit the underlying order,
refund, or show.
Getting it into QuickBooks
QuickBooks Online remains the source of truth for accounting. The ledger is how everything reaches it accurately — by file, by automatic posting, or both.
Downloading a file
Financials → Ledger → Export downloads exactly the rows your filters are showing, oldest first, in one of three shapes:
| Format | What it is for |
|---|---|
| Full detail | Every column of every row. A spreadsheet and a backup — not an import file. |
| QuickBooks bank upload | The four-column Date, Description, Credit, Debit CSV that QuickBooks Online reads under Banking → Upload from file. |
| QuickBooks journal entries | The journal-entry import in QuickBooks Online Advanced. Full double-entry: every row becomes a balanced debit and credit. |
Two options sit alongside the format:
- Only rows not yet sent to QuickBooks — everything since the last export you marked.
- Mark these rows as exported — stamps
exportedAt, which is what makes the filter above mean anything. It is deliberately opt-in, so taking a second copy of last month's file does not make rows look filed when they are not.
The bank upload leaves two categories out
A bank upload is a list of money that actually entered or left an account. Cost of goods and discounts did not: the cash for stock left when the ingredients were bought, and a discount is money that never arrived. Including them would invent bank transactions that never happened, so they are omitted. They are present in the detail and journal formats, where they belong.
Why IIF is not offered
IIF is a QuickBooks Desktop format; QuickBooks Online cannot import it. The original plan listed it, and it was dropped once that was checked rather than shipped as a file nobody here can use.
Posting automatically
The hourly QuickBooks sync already pushes paid orders as sales receipts and refunds as refund receipts. It now also posts ledger rows whose money never became an order — historical show takings, hand-entered expenses, imported statement rows — as journal entries.
The rule that keeps this from doubling the books: rows sourced from an ORDER or a REFUND are
already in QuickBooks as receipts, so they are never journalled. The mapper refuses them outright
rather than relying on the sweep to filter them out.
A journal entry needs an account on both sides, so Settings → Integrations → QuickBooks now carries a ledger account mapping: one cash/clearing account, one inventory account, and the account each category is booked to. Two details there are worth knowing, because getting either wrong misstates a set of books quietly:
- Sales tax collected is a liability, not income. The ledger files it under money-in because that describes the direction it moved, but it is owed to the state. It must be credited to Sales Tax Payable, or revenue is overstated by every dollar of tax ever collected.
- Cost of goods offsets against inventory, not cash. That payment already left the bank when the stock was bought.
Where a category has no account mapped, the row is held for a person to look at rather than posted to a guessed account — the same refuse-rather-than-guess contract the order sync uses. The downloadable file is more forgiving: it falls back to a suggested standard QuickBooks account name, because a person reads that file before importing it.
How is this guide?
Last updated on