DEV Community

Cover image for Nonprofit books as a data model: two extra dimensions on every ledger line
Damin Mutti
Damin Mutti

Posted on Originally published at zerabooks.com

Nonprofit books as a data model: two extra dimensions on every ledger line

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)