A for-profit ledger line needs an account, a date and an amount. A nonprofit's line needs two more attributes: whether money coming in carried a donor restriction, and which function money going out served. Store both consistently and the board report, the funder report and Form 990 Part IX all become group-by queries over the same rows.

This post treats nonprofit bookkeeping as a schema problem. I run ZeraBooks, which keeps books this way, and every number below is invented.

The two dimensions

Restriction applies to revenue. Under ASU 2016-14 it has two values, with_donor_restrictions and without_donor_restrictions. Restricted money also needs a purpose key, usually one per grant.

Function applies to expenses. Form 990 Part IX reports three columns: program, management_general and fundraising. Shared costs need a temporary fourth value, to_allocate, which must be empty once the month is closed.

from dataclasses import dataclass from decimal import Decimal from typing import Literal, Optional Restriction = Literal["with", "without"] Function = Literal["program", "management_general", "fundraising", "to_allocate"] @dataclass(frozen=True) class Line: date: str # "2026-05-31"  account: str # "Contributions", "Salaries", ...  amount: Decimal # positive = credit to revenue / debit to expense  restriction: Optional[Restriction] = None purpose: Optional[str] = None # grant key on a restricted gift and on the costs that spend it  function: Optional[Function] = None 

When the ledger has no tag field

Many small-business ledgers have no class or department field on statement lines. The portable workaround is to encode the dimension in the category path, so the chart of accounts carries the data:

Revenue Gifts › Unrestricted Gifts › Restricted › Riverside Foundation 2026 Gifts › Restricted › Food pantry van fund Donated goods and services Expense Staff pay › Program | › Admin | › Fundraising | › Unallocated Building › Program | › Admin | › Fundraising | › Unallocated 

Parsing is a split on ›. The leaf is the function for expenses and the purpose for restricted revenue. The cost is growth: six expense types times four function leaves is 24 categories before you add a single grant. Past a dozen funders, fund accounting software that stores the fund as a real column is the better design, and I'd say so to anyone asking.

Restricted balances as a roll-forward

Each restricted grant is a running balance. Spending on its purpose releases it:

def roll_forward(lines: list[Line], purpose: str, through: str) -> Decimal: received = sum((l.amount for l in lines if l.purpose == purpose and l.account.startswith("Gifts") and l.date <= through), Decimal("0")) released = sum((l.amount for l in lines if l.purpose == purpose and l.function == "program" and l.date <= through), Decimal("0")) return received - released # A $9,000 grant in February, $2,400 and $1,175 of program spending tagged to it # in March and April leaves $5,425.00 restricted at April 30. 

If released ever exceeds received, either an expense was tagged to the wrong grant or the books are spending money the grant letter didn't give.

Four invariants for the month-end close

EXPENSE_ACCOUNTS = {"Staff pay", "Building", "Food supplies", "Platform fees"} def check_month(lines, statements, grants, month_end): problems = [] # 1. every statement adds up from its printed opening to its printed closing  for s in statements: if s.opening + sum(s.lines, Decimal("0")) != s.closing: problems.append(f"{s.account}: statement does not add up") # 2. holding lines are empty after allocation  unallocated = sum((l.amount for l in lines if l.function == "to_allocate" and l.date <= month_end), Decimal("0")) if unallocated != 0: problems.append(f"{unallocated} of shared cost never split") # 3. every expense line carries a function  untagged = [l for l in lines if l.account in EXPENSE_ACCOUNTS and l.function is None and l.date <= month_end] if untagged: problems.append(f"{len(untagged)} expense lines with no function") # 4. no restricted balance below zero  for g in grants: if roll_forward(lines, g, month_end) < 0: problems.append(f"{g}: more released than received") return problems 

The first check is the one people skip. A transactions export from online banking has no printed opening or closing balance, so it can't prove that nothing is missing. Start from the statement.

Events with no bank row

Some things never appear on a statement and have to arrive as journal entries:

Event Debit Credit Amount
Donated canned goods, fair value Food supplies › Program Donated goods and services 1,350.00
Giving platform kept its fees Platform fees › Fundraising Gifts › Unrestricted 96.40
Coordinator pay split by timesheet Staff pay › Program 2,520.00, › Admin 630.00, › Fundraising 350.00 Staff pay › Unallocated 3,500.00

The platform row is the one that hides. A payout of $3,113.60 recorded as gifts understates gifts and fees by the same $96.40, so net income is right and both lines are wrong. Gross it up from the payout report.

Batched deposits are the other trap. A single check deposit can hold gifts of both restriction types, so one bank row needs to fan out into several revenue rows before matching. Otherwise the deposit lands in one bucket, or gets counted twice when someone enters the gifts separately.

Where this runs

ZeraBooks is an AI-native general ledger, bank statement converter and done-for-you bookkeeping service for small businesses and accounting firms. The ledger handles both dimensions as sub-categories, and every uploaded statement is checked against its printed opening and closing balance, which is invariant 1 before anyone starts categorizing. The guide to bookkeeping for nonprofits has a full chart of accounts and a worked month in plain English. For organizations that want the whole thing maintained for them, ZeraBooks bookkeeping services start from $150 a month in U.S. dollars, priced after a free call. ZeraBooks doesn't prepare Form 990 or store donor records.

Two columns, enforced every month, and the 990 stops being a reconstruction project.