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.
Top comments (0)