A chart of accounts model that keeps reports and imports stable needs only a few rules: every account has a type, every type owns a fixed number range that decides which report it appears on, a sub-account inherits its parent's type, numbers are assigned inside the type's range when none is given, and accounts are deactivated rather than deleted. Put those into the data model and most of the mess you see in small business books can't happen.
I'm Damin and I run ZeraBooks, an AI-native general ledger. The ranges and the import columns below are the ones ZeraBooks uses. The code is a sketch of the rules, not our implementation, and it runs as-is on Node 24.
Why number ranges at all?
Names change. Numbers shouldn't. A range per type gives you a lot for free: reports sort into the right order without any configuration, and an import can be validated before it touches the ledger.
ZeraBooks uses five-digit ranges:
| Type | Numbers from | Report |
|---|---|---|
| Assets (and Bank) | 10000 | Balance sheet |
| Liabilities (and Credit Card) | 20000 | Balance sheet |
| Equity | 30000 | Balance sheet |
| Revenue | 40000 | Profit and loss |
| Cost of Revenue | 50000 | Profit and loss |
| Operating Expenses | 60000 | Profit and loss |
| Other Income | 80000 | Profit and loss |
| Other Expenses | 90000 | Profit and loss |
Splitting revenue from cost of revenue, and operating expenses from the "other" lines, is what lets the profit and loss show gross profit and keep one-off items like interest apart.
Bank and Credit Card are two more import Types on top of the eight. They sit in the asset and liability ranges on the reports, but they're the accounts statements get uploaded and reconciled against, so the model marks them separately.
What does an import row look like?
The CSV the ZeraBooks import reads directly has seven columns:
Category Name,Category Number,Parent Name,Parent Category Number,Type,Status,Description
Professional Fees,60700,,,Operating Expenses,Active,Outside advisers.
Accounting and Bookkeeping,60710,Professional Fees,60700,Operating Expenses,Active,Your accountant and bookkeeper.
Legal Fees,60720,Professional Fees,60700,Operating Expenses,Active,Your lawyer.
Only Category Name and Type are required. The parent columns are what make a row a sub-account.
The model
// The ten Type values an import row can carry. Bank and Credit Card are
// asset and liability accounts that also get statements uploaded against them.
type AccountType =
| 'Bank' | 'Assets' | 'Credit Card' | 'Liabilities' | 'Equity'
| 'Revenue' | 'Cost of Revenue' | 'Operating Expenses' | 'Other Income' | 'Other Expenses'
type Range = { from: number; to: number; report: 'Balance sheet' | 'Profit and loss' }
const RANGES: Record<AccountType, Range> = {
'Bank': { from: 10000, to: 19999, report: 'Balance sheet' },
'Assets': { from: 10000, to: 19999, report: 'Balance sheet' },
'Credit Card': { from: 20000, to: 29999, report: 'Balance sheet' },
'Liabilities': { from: 20000, to: 29999, report: 'Balance sheet' },
'Equity': { from: 30000, to: 39999, report: 'Balance sheet' },
'Revenue': { from: 40000, to: 49999, report: 'Profit and loss' },
'Cost of Revenue': { from: 50000, to: 59999, report: 'Profit and loss' },
'Operating Expenses': { from: 60000, to: 79999, report: 'Profit and loss' }, // sketch: room to grow into the 70000s
'Other Income': { from: 80000, to: 89999, report: 'Profit and loss' },
'Other Expenses': { from: 90000, to: 99999, report: 'Profit and loss' },
}
type Account = {
name: string
number: number
type: AccountType
parent: number | null // parent account number
active: boolean // deactivate, never delete: history stays on old reports
}
// One import row, as read from the CSV. Only name and type are required.
type Row = { name: string; number?: number; type: AccountType; parentNumber?: number }
function nextFree(chart: Account[], type: AccountType, parent: Account | null): number {
const step = parent ? 10 : 100
const base = parent ? parent.number : RANGES[type].from
const used = new Set(chart.map(a => a.number))
for (let n = base + step; n <= RANGES[type].to; n += step) {
if (!used.has(n) && n % 1000 !== 0) return n // keep round thousands free
}
throw new Error(`no free number for ${type}`)
}
function importRow(chart: Account[], row: Row): string {
const parent = row.parentNumber ? chart.find(a => a.number === row.parentNumber) ?? null : null
if (row.parentNumber && !parent) return `skip ${row.name}: parent ${row.parentNumber} not found`
// a sub-account takes its parent's type, so it can't land in the wrong section
const type = parent ? parent.type : row.type
if (parent && row.type !== parent.type) return `skip ${row.name}: type ${row.type} differs from parent's ${parent.type}`
const number = row.number ?? nextFree(chart, type, parent)
const r = RANGES[type]
if (number < r.from || number > r.to) return `skip ${row.name}: ${number} is outside ${type} (${r.from} to ${r.to})`
if (chart.some(a => a.number === number)) return `skip ${row.name}: ${number} already used`
chart.push({ name: row.name, number, type, parent: parent?.number ?? null, active: true })
return `added ${number} ${row.name} (${type}, ${r.report})`
}
const chart: Account[] = []
const rows: Row[] = [
{ name: 'Business Checking', number: 10100, type: 'Bank' },
{ name: 'Professional Fees', number: 60700, type: 'Operating Expenses' },
{ name: 'Accounting and Bookkeeping', number: 60710, type: 'Operating Expenses', parentNumber: 60700 },
{ name: 'Legal Fees', type: 'Operating Expenses', parentNumber: 60700 }, // no number given
{ name: 'Owner Draws', number: 60900, type: 'Equity' }, // wrong range
{ name: 'Consulting Fees', type: 'Revenue', parentNumber: 60700 }, // wrong type for parent
{ name: 'Software and Subscriptions', type: 'Operating Expenses' }, // no number given
]
for (const row of rows) console.log(importRow(chart, row))
What does the import do with awkward rows?
The sample rows include the usual problems. Running it:
$ node coa.ts
added 10100 Business Checking (Bank, Balance sheet)
added 60700 Professional Fees (Operating Expenses, Profit and loss)
added 60710 Accounting and Bookkeeping (Operating Expenses, Profit and loss)
added 60720 Legal Fees (Operating Expenses, Profit and loss)
skip Owner Draws: 60900 is outside Equity (30000 to 39999)
skip Consulting Fees: type Revenue differs from parent's Operating Expenses
added 60100 Software and Subscriptions (Operating Expenses, Profit and loss)
The interesting ones:
Legal Fees has no number. It has a parent, 60700, so it gets the next free sub-account slot, 60720, ten above Accounting and Bookkeeping. A row without a number is a normal import, not an error.
Owner Draws is numbered 60900 but typed Equity. The number says operating expense and the type says equity. One of them is wrong, and the model refuses to guess. In real charts this is one of the most expensive single mistakes, because draws booked as an expense understate profit on the tax return.
Consulting Fees claims Revenue under an expense parent. A sub-account takes its parent's type, so this row would land in the wrong section of the profit and loss. Rejecting it is kinder than accepting it.
Software and Subscriptions has no number and no parent. It gets the first free top-level slot in its range. The code skips round thousands so that numbers like 60000 stay free, a habit worth copying. ZeraBooks creates Accounts Receivable, Accounts Payable, Undeposited Funds, Opening Balance Equity and Retained Earnings itself, and its template keeps clear of round thousands for the same reason.
Which design choices are worth keeping?
Steps of 100 and 10. Top-level accounts go up by 100, sub-accounts by 10 under their parent. That leaves room to insert later without renumbering, which matters because renumbering breaks every export and saved report that used the old numbers.
Deactivate, don't delete. The active flag exists so an account you've stopped using keeps its history on past reports and takes no new postings. Deleting an account with history either orphans transactions or rewrites old reports, and neither is acceptable in a ledger.
Import only adds. Re-running an import should never rename or remove existing accounts. The already used check is the simplest version of that rule. A fuller one matches on names too and lists what it would change for review.
Validate before saving. Every rule above runs per row and returns a reason. A person reviews the reasons before anything is written. That's the difference between an import and a migration accident.
What's not here
Multi-currency accounts, departments or classes, and the mapping from an old system's chart into this one. That last one is where AI earns its keep in practice: reading a messy export and proposing types and numbers for a person to approve.
Disclosure: I'm Damin and I run ZeraBooks, so weigh this part accordingly. In the ZeraBooks general ledger, a file in the template's format is read directly, and any other chart, including exports from QuickBooks, Xero or Sage, a spreadsheet or a PDF, is read by AI. Or Generate with AI drafts a numbered chart from a description of the business. Nothing is saved until you've reviewed the rows, importing only adds, blank numbers get the next free one in the type's range, and categories are deactivated rather than deleted. It's $79 a month billed annually or $149 monthly, with a 7-day trial.
The 44-account template that uses these ranges, and its CSV, are on our chart of accounts template page.
If you model accounts yourself, write the range check first. It's the rule every other rule leans on.











