QuickBooks API investigation

Rendered from docs/qbo-investigation.md at build time.

Started 2026-09-26. Owner: Stephen. Findings are appended to the bottom as each stage completes.

Why

Phase 1 of Initiative B (the account/class classifier + review loop in src/) is blocked on ground truth: data/feedback.db has zero rows because nobody has produced the historical-bills export that src.ingest_seed needs. Stephen has now accepted the invites to all 73 QuickBooks companies (records/qbo_invites_2026-09-25.tsv), so that ground truth is reachable through the API instead of a manual CSV export per company.

Until now the repo has never talked to QuickBooks at all: no QBO_* values in .env, no OAuth token cache, and src/config.py refuses anything but QBO_ENV=sandbox.

Decision (2026-09-26)

Read-only production access is in scope. The sandbox is Intuit's demo company, so it can only prove plumbing; every question that matters (what the 73 charts of accounts look like, whether they share a template, pulling real bills) needs production. The "sandbox-only" rule in src/config.py is replaced by two independent guardrails:

  1. an explicit env gate (QBO_ENV=production must be set deliberately), and
  2. a read-only client wrapper enforced in code — Intuit has no read-only OAuth scope (com.intuit.quickbooks.accounting is read+write), so read-only has to be our discipline: .all() / .query() / .get() only, never .save() / .delete() / batch.

Writing anything to production QuickBooks stays out of scope.

Where the data lives

The discovery output (records/qbo_discovery/) is not committed — it holds vendor names and dollar totals. It lives in the project's private S3 bucket (see infra/README.md) and syncs with scripts/data_sync.py:

uv run python scripts/data_sync.py pull     # get the latest dumps (needs the cnu profile or a collaborator key)
uv run python scripts/data_sync.py push     # after re-running discovery

Anyone with access to the QuickBooks companies can also regenerate it from scratch in a few minutes:

uv sync
cp .env.example .env            # fill in QBO_PROD_CLIENT_ID / _SECRET / _REDIRECT_URI
QBO_ENV=production uv run python -m src.qbo_client authorize   # once per company
QBO_ENV=production uv run python scripts/qbo_discover.py       # ~5 s per company

Tokens land in data/qbo_tokens/ (gitignored, one file per company). records/qbo_realms.tsv (realm ID ↔ company name, no financial data) is committed so people can tell which companies have been surveyed.

Questions to answer

  1. Consistency across companies. Are the chart of accounts, class list and department list identical (or template-derived) across the 73 companies? This decides whether Phase 1 is one classifier or 73.
  2. Realm ↔ campus mapping. CompanyInfo (legal name, address, ZIP) for each realm, cross-checked against alpha_locations.json. Also gives a clean fix for the 75010 Sports Academy / Alpha Carrolton ZIP collision the campus classifier hits.
  3. Bill volume and shape. Bills per company per month, line-item structure (AccountBasedExpenseLineDetail vs ItemBasedExpenseLineDetail), how consistently ClassRef is filled in. Purchase-order counts (is PO matching even viable later?).
  4. Attachments. Do Attachable records on bills correspond to the invoice PDFs in mail_export/? If so, that is ground truth for the campus classifier as well as the account/class one.
  5. Operational limits. Rate limits (500 req/min/realm, 10 concurrent), refresh-token expiry (100 days idle), and how painful 73 one-time consents are in practice.

Stages

Stage 0 — Plumbing on sandbox

Stage 1 — Client refactor (multi-company, read-only)

Stage 2 — Discovery

scripts/qbo_discover.py dumps, per realm, to records/qbo_discovery/<env>/<realmId>/: company_info.json, accounts.csv, classes.csv, departments.csv, vendors.csv, summary.json (bill counts by month, line-detail types, ClassRef fill rate, lines/$ by class, top accounts and vendors, PO count). Then records/qbo_discovery/<env>/REPORT.md diffs the account and class sets across every company on disk. ~5 s per company; pages bills 1000 at a time and backs off on 429.

Stage 3 — Ground-truth export

Wrap-up

Findings

Stage 0 — sandbox plumbing (2026-09-27)

Works end to end with the dev keys: consent → code exchange → token cache → proactive refresh on the next call → Account.all / Class.all / CompanyInfo.all / Bill.all / Department.count.

Stage 2 — first production company (2026-09-27)

Production consent works via https://alpha-billing-classifier.aicost.fit/qbo/callback: Cloudflare Access preserves the query string through its login, so the code/state/realmId land in the address bar as needed. First realm: 9341452781248173 = Alpha School 33155, LLC (Miami). Read-only survey:

Mail ↔ bill matching, Miami company (2026-09-27)

QBO_ENV=production uv run python scripts/qbo_match_mail.py 9341452781248173 → records/qbo_discovery/production/9341452781248173/mail_match.{csv,md} (in S3, not git). 100 messages (Sep 23–25) vs 95 bills since 2026-07-01.

The mailbox is the AP workflow (2026-09-27)

While checking whether the emails behind the Sep-26 bills had arrived, a read-only folder scan (scripts/mailbox_census.py) showed that INBOX is a small slice of billing@alpha.school. The mailbox has ~1,466 Gmail labels and tens of thousands of label-messages, and the labels are the AP team's workflow:

Consequences:

  1. The vendor emails behind the Miami bills aren't in INBOX because they were processed and filed. Zero INBOX hits for Mason, Nicolas Management, Designlab, Miami Ice; Mason shows up under Approved (6), Pending from BU (1), Posted (1). So "pull more mail" means pulling the labelled archive, not a longer INBOX window. export_alpha_mail.py takes --mailbox, so it can already do this one label at a time; a --since and a multi-label mode would make it one command.
  2. The labels are human ground truth at scale — vendor, entity, and whether a message became a bill — independent of QuickBooks. Joined with QuickBooks (account + class via DocNumber), that's the training set. scripts/dataset_stats.py totals it up and the site's Datasets page shows it.
  3. The campus classifier's taxonomy should probably be the entity labels the AP team actually uses, not the 73 QuickBooks companies — or at least be evaluated against them, since that's what the humans assign.
  4. Gmail labels are many-to-many (a message can carry Vendor/X, Approved and Alpha 33155 at once), so the census counts label-messages, not distinct messages; a real export should key on Message-ID and record the label set per message.

Census output: records/mailbox/census.json (S3, not git).

Origin detection, classifier v3, and the comparison page (2026-09-27)

Cross-company: four companies (2026-09-27)

Brownsville (78521), Orange County (92610) and Alpha High School (78701) authorized and surveyed alongside Miami. Answers Q1: