What you're building
A personal double-entry bookkeeping system that runs entirely on Cloudflare's free/cheap tier. Your AI coding agent (Claude Code, Cursor, etc.) handles data entry — you describe a transaction or hand it a receipt photo, and it figures out the debits, credits, and account mapping. The API handles writes. The UI (if you build one) is read-only.
Total infrastructure cost: under $5/month. One Worker, one D1 database, one R2 bucket. No framework, no ORM, no build pipeline beyond what wrangler provides.
Why GnuCash's schema? GnuCash has been around since 1998. Its data model handles accounts, transactions, splits, budgets, and scheduled transactions. The SQL backend stores these in straightforward tables with GUID primary keys. We take that proven schema and deploy it to D1 with two modifications.
Prerequisites
- A Cloudflare account (free tier works)
wranglerCLI installed and authenticated (npm install -g wrangler && wrangler login)- An AI coding agent — Claude Code, Cursor, Windsurf, or anything that can run shell commands
- Basic understanding of double-entry bookkeeping (every transaction has two sides that balance)
Design the schema
Start from GnuCash's SQL backend. The core tables are defined in C++ files that map to SQL columns. Here's how they translate:
GnuCash tables you need
| GnuCash table | Your table | What it stores |
|---|---|---|
| accounts | accounts | Chart of accounts (tree structure via parent_guid) |
| transactions | transactions | Ledger entries (date, description, status) |
| splits | splits | The actual money movement (account + amount per line) |
GnuCash also has budgets, budget_amounts, and schedxactions tables. You can add these later if you need budgeting or recurring transactions — the schema maps directly. We skip them here to keep the initial deployment minimal.
GnuCash gives you the table structure. The business logic — balanced splits, the pending/posted/void lifecycle, account validation — you build in the Worker. These are standard double-entry accounting rules, not GnuCash-specific.
Two changes from GnuCash
1. Replace rational numbers with integer cents.
GnuCash stores amounts as a numerator/denominator pair (e.g. 1999/100 for $19.99). This supports any currency's decimal places. If you only work in USD, you don't need this — store integer cents in a single INTEGER column. $19.99 = 1999.
2. Add a business_guid column.
GnuCash assumes one file = one set of books. If you run multiple businesses (LLCs, side projects), add a business_guid foreign key to accounts, transactions, budgets, and schedxactions. One database serves all your businesses. API routes are prefixed with /:slug to scope everything.
The migration
sql-- 0001_initial.sql
CREATE TABLE accounts (
guid TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('ASSET','LIABILITY','EQUITY','INCOME','EXPENSE')),
parent_guid TEXT REFERENCES accounts(guid),
code TEXT,
description TEXT,
is_hidden INTEGER NOT NULL DEFAULT 0,
is_placeholder INTEGER NOT NULL DEFAULT 0,
meta TEXT
);
CREATE TABLE transactions (
guid TEXT PRIMARY KEY,
posted_at TEXT,
entered_at TEXT NOT NULL,
description TEXT,
num TEXT,
notes TEXT,
source_url TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','posted','void'))
);
CREATE TABLE splits (
guid TEXT PRIMARY KEY,
tx_guid TEXT NOT NULL REFERENCES transactions(guid),
account_guid TEXT NOT NULL REFERENCES accounts(guid),
amount INTEGER NOT NULL, -- cents: positive = debit, negative = credit
memo TEXT,
reconcile_state TEXT NOT NULL DEFAULT 'n' CHECK(reconcile_state IN ('n','c','y')),
reconciled_at TEXT
);
CREATE TABLE businesses (
guid TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE INDEX idx_splits_tx ON splits(tx_guid);
CREATE INDEX idx_splits_account ON splits(account_guid);
CREATE INDEX idx_tx_status ON transactions(status);
CREATE INDEX idx_tx_posted_at ON transactions(posted_at);
CREATE INDEX idx_accounts_parent ON accounts(parent_guid);
Splits must sum to zero. This is the core invariant. Every transaction has 2+ splits. Positive amounts are debits, negative amounts are credits. If you buy $50 of paint with a credit card: debit Paint & Materials +5000, credit Credit Card -5000. The API must enforce this on every write.
Create the infrastructure
bash# Create a new Worker project
npm create cloudflare@latest my-books -- --type worker-typescript
cd my-books
# Create the D1 database
npx wrangler d1 create my-books
# Copy the database_id from the output
# Create the R2 bucket (for receipts)
npx wrangler r2 bucket create my-books-docs
wrangler.toml
tomlname = "my-books"
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "my-books"
database_id = "YOUR_DATABASE_ID"
[[r2_buckets]]
binding = "DOCS"
bucket_name = "my-books-docs"
Apply the schema migration:
bashnpx wrangler d1 migrations apply my-books --remote
Then seed your chart of accounts. A solopreneur typically needs:
- Assets: Checking, Savings, Accounts Receivable, Equipment
- Liabilities: Credit Card, Accounts Payable, Sales Tax Payable
- Equity: Owner's Equity, Owner's Draws, Retained Earnings
- Income: Service revenue accounts for what you sell
- Expenses: Whatever you spend on — rent, software, fuel, materials, insurance, etc.
Build the API
The Worker is a single fetch() handler that parses URL segments and dispatches to route handlers. No framework needed.
ts// src/index.ts — simplified
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const segments = new URL(request.url).pathname
.replace(/^\//, '').split('/').filter(Boolean);
const slug = segments[0];
const resource = segments[1];
const rest = segments.slice(2);
// Resolve slug to business
const business = await env.DB.prepare(
'SELECT * FROM businesses WHERE slug = ?'
).bind(slug).first();
if (!business) return err('Not found', 404);
switch (resource) {
case 'accounts': return handleAccounts(request, env, rest, business.guid);
case 'transactions': return handleTransactions(request, env, rest, business.guid);
case 'pending': return handlePending(request, env, rest, business.guid);
case 'docs': return handleDocs(request, env, rest, business.guid);
case 'agent': return handleAgent(request, env, rest, business.guid);
case 'context': return handleContext(request, env, business);
default: return err('Not found', 404);
}
},
};
Routes you need
| Endpoint | What it does |
|---|---|
GET /:slug/accounts | List accounts with computed balances (sum of posted splits) |
POST /:slug/transactions | Create transaction. Validate: 2+ splits, sum to zero, accounts exist, not placeholders. Enters as pending. |
POST /:slug/pending/:guid/post | Approve a pending transaction (changes status to posted) |
POST /:slug/pending/:guid/void | Void a transaction (posted entries can never be deleted) |
POST /:slug/docs | Upload a receipt. Deduplicate by SHA-256 hash of file content. |
POST /:slug/agent/query | Readonly SQL proxy. Accept SELECT, reject everything else. |
GET /:slug/context | Return full schema + account tree + business rules as markdown. How the AI agent bootstraps itself. |
Transaction validation (the important part)
This is the code that enforces double-entry integrity. Every transaction POST must pass these checks:
ts// Reject if fewer than 2 splits
if (!body.splits || body.splits.length < 2)
return err('At least 2 splits required');
// Reject if splits don't balance
const total = body.splits.reduce((sum, s) => sum + s.amount, 0);
if (total !== 0)
return err(`Splits must sum to 0 (got ${total})`);
// Reject if any account doesn't exist, belongs to another business, or is a placeholder
for (const split of body.splits) {
const acct = await env.DB.prepare(
'SELECT is_placeholder FROM accounts WHERE guid = ? AND business_guid = ?'
).bind(split.account_guid, businessGuid).first();
if (!acct) return err('Account not found in this business');
if (acct.is_placeholder) return err('Placeholder accounts cannot receive splits');
}
The agent/query endpoint
This is the most powerful endpoint. It lets your AI agent run any SELECT statement against D1. The Worker rejects writes and warns if the query doesn't scope by business_guid.
tsconst SELECT_PATTERN = /^\s*SELECT\b/i;
const FORBIDDEN_PATTERN = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|DETACH|PRAGMA)\b/i;
if (!SELECT_PATTERN.test(sql)) return err('Only SELECT statements are allowed');
if (FORBIDDEN_PATTERN.test(sql)) return err('Statement contains forbidden keywords');
// Warn (don't block) if query might leak cross-business data
const scopeWarning = !sql.includes('business_guid') && !sql.includes(businessGuid)
? 'Warning: query does not filter by business_guid.'
: undefined;
Lock it down
Two layers of authentication protect the API.
Layer 1: Cloudflare Access (perimeter)
- Go to Cloudflare Zero Trust > Access > Applications
- Create a self-hosted application pointing to your Worker's URL
- Add a policy (e.g., allow your email)
- Go to Service Auth > Service Tokens, create a token
- Save the Client ID and Client Secret — these are what your AI agent will use
Humans get a login page in the browser. The AI agent sends two headers instead:
CF-Access-Client-Id: <your client id> CF-Access-Client-Secret: <your client secret>
Cloudflare Access validates either method, then injects a CF-Access-Jwt-Assertion header before the request reaches your Worker.
Layer 2: Worker JWT verification
The Worker doesn't trust the request just because it arrived. It verifies the JWT:
- Fetches JWKS from
https://<your-team>.cloudflareaccess.com/cdn-cgi/access/certs - Finds the signing key by
kid - Verifies the RSA signature
- Checks the audience matches your Access application
- Checks the token hasn't expired
Store your Access audience tag and team domain as Worker secrets:
bashnpx wrangler secret put CF_ACCESS_AUD
npx wrangler secret put CF_ACCESS_TEAM
Exception: the context endpoint.
GET /:slug/contextskips JWT verification at the Worker level. CF Access still authenticates the request at the perimeter, but the Worker doesn't double-check. This endpoint returns the schema and account tree — no sensitive data, and the AI agent needs it to bootstrap.
Connect your AI agent
This is the part that makes the system useful. Your AI agent needs three things:
A. Environment variables
Set these in your shell profile so your agent can access them:
bash# ~/.zshrc or ~/.bashrc
export BOOKS_API_URL="your-worker.your-subdomain.workers.dev"
export BOOKS_CF_CLIENT_ID="your-service-token-client-id"
export BOOKS_CF_CLIENT_SECRET="your-service-token-client-secret"
B. The context endpoint
Build a GET /:slug/context endpoint that returns everything the AI needs in one request: schema, account tree, business rules, API routes, and example operations. Return it as markdown.
This is how the agent orients itself. Instead of you explaining the schema every session, the agent fetches the context document and knows what accounts exist, what the API endpoints are, and what the validation rules are.
bash# The agent can bootstrap itself with one call:
curl -s "https://${BOOKS_API_URL}/my-business/context" \
-H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}"
C. A skill file
If you're using Claude Code, create a skill file so the agent remembers how to use the system across sessions:
markdown# .claude/skills/books/SKILL.md
---
name: books
description: Access and manage the bookkeeping system — enter transactions,
upload receipts, check balances, query the ledger.
allowed-tools: Bash, Read, Glob, Grep
---
# How to use the books API
Source shell vars first: `source ~/.zshrc 2>/dev/null`
All API calls:
curl -s "https://${BOOKS_API_URL}/<slug>/<resource>" \
-H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}" \
-H "Content-Type: application/json"
## Workflow
1. Create transaction via POST (lands as pending)
2. Post it via POST /:slug/pending/:guid/post
3. Upload receipt via POST /:slug/docs if applicable
## Common Patterns
- Expense paid by credit card: debit expense +amount, credit CC -amount
- Owner funds business: debit Checking +amount, credit Owner's Equity -amount
- Client pays invoice: debit Checking +amount, credit Income -amount
For other agents (Cursor, Windsurf, etc.), put the same information in whatever project-level instruction file they support (.cursorrules, .windsurfrules, etc.).
The pattern: Context endpoint for live state (accounts, schema). Skill file for static instructions (auth, workflow, common patterns). The agent fetches context on demand and has the skill loaded at session start.
Enter your first transaction
With everything deployed, here's what a real interaction looks like. You drop a few receipt PDFs into a folder and tell your agent:
"Process the receipts in ~/receipts"
The agent reads each file, extracts the details (vendor, date, amount, items, payment method), maps them to your chart of accounts, and for each receipt:
1. Uploads the receipt to R2:
bashcurl -s "https://${BOOKS_API_URL}/my-business/docs" \
-H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}" \
-F "file=@/Users/you/receipts/home-depot-03-16.pdf"
# Returns: { "key": "a1b2c3...pdf", "size": 155000, "duplicate": false }
2. Creates the transaction with the receipt linked:
The agent reads the PDF — Home Depot, Crescent 12" Measuring Wheel, $74.97 + $6.19 tax = $81.16, paid with Mastercard. It maps Equipment as the debit account, Credit Card as the credit, and builds the API call:
bashcurl -s "https://${BOOKS_API_URL}/my-business/transactions" \
-H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}" \
-H "Content-Type: application/json" \
-d '{
"description": "Home Depot — Crescent 12\" Measuring Wheel (Mastercard ...0413)",
"posted_at": "2026-03-16T00:00:00Z",
"source_url": "a1b2c3...pdf",
"splits": [
{ "account_guid": "sc-equipment", "amount": 8116, "memo": "Measuring wheel $74.97 + tax $6.19" },
{ "account_guid": "sc-cc", "amount": -8116, "memo": "Mastercard ...0413" }
]
}'
3. Posts it:
bashcurl -s -X POST \
"https://${BOOKS_API_URL}/my-business/pending/<guid>/post" \
-H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
-H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}"
The agent repeats this for every receipt in the folder. The Worker validates each one — 2 splits, they sum to zero, accounts exist and aren't placeholders. You end up with a clean ledger and every receipt stored in R2, linked to its transaction.
That's the workflow. You hand the agent a stack of receipts. It reads them, does the accounting, uploads the documents, and enters the transactions. You review the pending queue if you want, or let them post automatically.
What's next
- Receipt compression. Phone photos are huge. Add WASM-based image compression in the Worker (
@jsquash/jpeg) to shrink JPEG/PNG uploads before storing in R2. - Bank statement import. Parse CSV exports from your bank, match them against existing transactions, create entries for anything new.
- Scheduled transactions. Add GnuCash's
schedxactionstable and build a Cron Trigger that auto-creates recurring entries (rent, subscriptions). - Reports. Use the agent/query endpoint to build P&L statements, balance sheets, expense breakdowns. The agent can write the SQL.
- Read-only UI. A static page served from the Worker that shows accounts, balances, and recent transactions. No writes — that's the agent's job.
