Auto-categorization is the first thing every bookkeeping app demos and the first thing that quietly goes wrong. A transfer between your own two accounts becomes “income”. A loan disbursement becomes “sales”. A big supplier payment gets split across three mystery categories. Nothing crashes; the totals just drift — until tax season, when someone spends a weekend finding out that the P&L has been fiction since March.

Having built categorization pipelines for real statements — including a statement auto-categorizer with Gemini — this guide covers the five failure modes that actually corrupt books, a Python architecture that catches each one, and how to package the whole thing as a done-for-you service small businesses will pay for monthly.

The five ways it goes wrong

1. Transfers read as income. The classic. “TRF FROM BCA **1234” in your own statement is you moving your own money. A naive model labels it revenue, and suddenly the business “earned” every rupiah it ever shuffled between accounts. Revenue is overstated, and tax estimates built on that P&L are wrong.

2. Loan and capital injections read as income. A disbursed KUR or an owner’s capital deposit looks exactly like cash in. It isn’t income. This is the error that makes a healthy business look like it doubled its sales.

3. Split and recurring payments get memorized wrong. A subscription charged twice in one month because the vendor retried, a supplier invoice paid in two installments — patterns get baked into “rules” learned from a few examples, then applied confidently and wrongly forever.

4. Ambiguous merchant names get confidently miscategorized. “PT SOLUSI DIGITAL NUSANTARA” tells you nothing about whether it’s software, consulting or office rent. Models resolve ambiguity by guessing, and the guess lands in the books with no flag.

5. Context that only the owner knows. The Rp 5,000,000 “transfer to individual” is salary, and only the owner knows it. No amount of model quality fixes missing context — the pipeline has to ask, not decide.

The pattern across all five: the errors are confident, plausible, and invisible in the output. That’s why “the AI categorizes your transactions” as a demo becomes “someone re-does March in April” as a reality. The fixes below are structural, not prompt tweaks.

The architecture that catches them

Rules that produce these fixes:

  1. AI never touches what rules can decide. Transfers, loan patterns and known-counterparties are handled by deterministic Python rules. The model only sees what’s genuinely ambiguous.
  2. The model must cite evidence and can flag instead of guessing. “uncertain” is a first-class output, never a failure.
  3. Amounts and account numbers never go to the API. Only merchant names, dates and reference types. Costs drop, and the sensitive part of the data never leaves the machine.
  4. An owner-review file is a deliverable. Unresolved rows become five questions for the owner, not five wrong categories.
  5. A monthly reconciliation report makes every change visible, so drift gets caught in month one, not at tax time.

What you need

ItemCostNotes
Python 3.10+$0Any computer
Gemini API key$0 to startaistudio.google.com → “Get API key”
google-genai, pydantic$0pip install
Bank statement CSVs$0Every Indonesian bank exports them

Pricing as of October 2026 — verify at ai.google.dev/pricing: Gemini 3.5 Flash-Lite, $0.30/1M input and $2.50/1M output tokens (paid tier). Free tier is fine for testing; real statements go through paid tier only, since free-tier content can be used to improve Google’s products.

Cost: a 400-transaction month where the model sees only the ~120 ambiguous merchant names works out to roughly $0.01–0.05/month. The AI bill is a rounding error; the value is the rules and the review.

Step 1: The rules layer

import re, json
from pathlib import Path

# --- patterns that must NEVER reach the AI as unknowns --------------------
TRANSFER_PATTERNS = [
    r"TRF FROM .*\d{4}",        # transfer from own account (last-4 digits)
    r"TRF TO .*\d{4}",
    r"TRANSFER (IN|OUT)",
    r"BIAYA TRANSFER",
    r"SWITCHING",
]
LOAN_PATTERNS = [
    r"KUR", r"KREDIT USAHA", r"PEMBIAYAAN", r"PINJAMAN", r"ANGSURAN PINJAMAN",
]

def rule_category(desc: str, amount: float, known: dict) -> str | None:
    """Returns a category if a rule decides, else None (goes to the AI)."""
    for pat in TRANSFER_PATTERNS:
        if re.search(pat, desc, re.I):
            return "internal_transfer"
    for pat in LOAN_PATTERNS:
        if re.search(pat, desc, re.I):
            return "loan"
    # known counterparty map, built from past confirmed months
    for key, cat in known.items():
        if key.lower() in desc.lower():
            return cat
    return None

The known counterparty map is the compounding asset: every month, confirmed categories from previous months become rules, and the AI’s share of the work shrinks. By month three, the model only sees genuinely new merchants.

Step 2: The AI layer, with uncertainty as an output

import os, sys, time
from google import genai
from google.genai import errors, types
from pydantic import BaseModel, field_validator

MODEL = os.environ.get("CAT_MODEL", "gemini-3.5-flash-lite")
CATEGORIES = ["sales_revenue", "inventory_purchase", "rent", "utilities",
              "salaries", "marketing", "transport_delivery", "software_tools",
              "tax_fees", "owner_draw", "other"]

class Row(BaseModel):
    row_id: str
    merchant: str
    category: str
    evidence: str          # which word in the merchant name/ref justified this
    uncertain: bool        # True = must go to the owner, never auto-filed

    @field_validator("category")
    @classmethod
    def cat_must_exist(cls, v):
        if v not in CATEGORIES:
            raise ValueError(f"unknown category {v}")
        return v

INSTRUCTIONS = """You categorize business bank transactions from merchant/reference names ONLY.
Rules:
- If the name doesn't clearly indicate the category, set uncertain=true with your best
  guess as category. Uncertain rows are shown to the business owner, so honesty beats confidence.
- "PT ...", "CV ...", "TB ..." names are usually suppliers, but you cannot know what they
  sell. When in doubt between inventory_purchase and other: uncertain=true.
- Never treat loan disbursements, transfers between accounts, or capital injections as income.
- evidence: quote the specific words you relied on. No evidence -> uncertain=true.
"""

def categorize_batch(client, rows: list[dict]) -> list[Row]:
    """One call per batch of names; amounts and account numbers never sent."""
    lines = "\n".join(f'{r["row_id"]}: {r["merchant"]}' for r in rows)
    prompt = f"{INSTRUCTIONS}\n\nTransactions:\n{lines}"
    for attempt in range(3):
        try:
            resp = client.models.generate_content(
                model=MODEL, contents=prompt,
                config=types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=list[Row],
                    thinking_config=types.ThinkingConfig(thinking_level="minimal"),
                    temperature=0,
                ),
            )
        except errors.APIError as e:
            print(f"  API error {e.code} (attempt {attempt+1})", file=sys.stderr)
            time.sleep(5 * (attempt + 1))
            continue
        if resp.parsed:
            return resp.parsed
    return []

def run(statement_csv: str, known: dict, out: str = "categorized.csv"):
    import csv
    with open(statement_csv, newline="", encoding="utf-8") as f:
        stmt = list(csv.DictReader(f))

    results, to_ai = [], []
    for i, r in enumerate(stmt):
        cat = rule_category(r["description"], float(r["amount"]), known)
        if cat:
            results.append({**r, "category": cat, "source": "rule", "uncertain": False})
        else:
            to_ai.append({"row_id": str(i), "merchant": r["description"], **r})

    client = genai.Client()   # reads GEMINI_API_KEY
    for chunk in [to_ai[k:k+40] for k in range(0, len(to_ai), 40)]:
        for ans in categorize_batch(client, chunk):
            orig = stmt[int(ans.row_id)]
            results.append({**orig, "category": ans.category,
                            "source": "ai" if not ans.uncertain else "owner_review",
                            "evidence": ans.evidence, "uncertain": ans.uncertain})

    with open(out, "w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=list(results[0].keys()))
        w.writeheader()
        w.writerows(sorted(results, key=lambda r: int(r["row_id"] if "row_id" in r else 0)))

    review = [r for r in results if r.get("uncertain")]
    print(f"{len(results)} rows: {sum(r['source']=='rule' for r in results)} by rule, "
          f"{sum(r['source']=='ai' for r in results)} by AI, {len(review)} for owner review")
    if review:
        write_review_sheet(review)

def write_review_sheet(review):
    with open("ASK_OWNER.csv", "w", newline="", encoding="utf-8-sig") as f:
        w = csv.writer(f)
        w.writerow(["date", "description", "amount", "ai_guess", "evidence", "owner_category"])
        for r in review:
            w.writerow([r["date"], r["description"], r["amount"],
                        r["category"], r.get("evidence", ""), ""])

if __name__ == "__main__":
    known = json.loads(Path("known_counterparties.json").read_text()) \
            if Path("known_counterparties.json").exists() else {}
    run("statement.csv", known)
python categorize.py

The output has three sources: rule (deterministic, safe), ai (model, cited evidence), and owner_review. The last one becomes ASK_OWNER.csv — five or ten questions like “PT Solusi Digital Nusantara, Rp 2.400.000/month — software subscription or consulting?” The owner answers in two minutes; answers become rules; next month the AI sees less.

Measuring accuracy honestly (and why vendors’ numbers mislead)

If you sell this, you’ll be asked “how accurate is it?”. Answer like this, not with a vendor-style percentage:

  • Accuracy per source, not overall. “Rules: 100% by construction. AI-confident rows: X% confirmed correct over N rows last month. Owner-review rows: answered in minutes.” A blended 97% hides that the 3% errors were all revenue overstatements — the only errors that matter.
  • Track the two errors that bite: revenue overstated (transfers/loans as income) and misfiled expenses that change tax estimates. If those are zero for a month, the books are trustworthy regardless of the blended number.
  • Never claim “fully automatic bookkeeping”. The owner-review loop is the product. Removing it removes the accuracy.

Honest cost breakdown

One client, 400 transactions/month:

ItemMonthly
Gemini API (only ~120 ambiguous names sent, batched)~$0.02
Statement CSVs, Python, your laptop$0
Your time: run, answer-check flags, monthly reconciliation report1–2 h
Client’s time: answer ASK_OWNER.csv~10 min
Total cash costunder $0.10

Client’s alternative: their own hours of categorizing in a spreadsheet, or a bookkeeper’s fee. That’s the gap the service prices into.

How to Sell This as a Service

Done-for-you categorization is an easier sell than any tool, because the buyer never touches the pipeline. They hand you a bank statement export each month; they get back categorized books plus a reconciliation report.

The offer:

  1. Monthly done-for-you: client sends statement CSVs (read-only exports, no bank credentials — never ask for those), you deliver categorized transactions, category totals, and the reconciliation report flagging anything unusual. The ASK_OWNER.csv goes with it.
  2. Cleanup project (entry point): the last 3–6 months of statements, re-categorized and reconciled. Sells fast before tax season, and almost always converts into the monthly plan because the client sees what their books should have looked like.

Pricing. Anchor on the bookkeeping hours replaced, not your $0.02 of API. As a test zone for an Indonesian SME: Rp 500,000–1,500,000/month for one account with a few hundred transactions, or Rp 1,000,000–3,000,000 as a one-off cleanup of 6 months. Benchmark against a part-time bookkeeper’s cost locally. Accountants are a channel here too — one practice with 20 small clients will pay you to run this across all of them.

What to promise: “Categorized books you can defend at tax time, with every uncertain item asked, not guessed.” Never “100% accurate AI bookkeeping” — the accuracy comes from the review loop you’re being paid to run.

Reality check: what does NOT work

  • Categorizing transfers as income. The #1 book-corrupting error. The rules layer exists for this; never let a model “decide” it.
  • AI-assigning amounts to categories without review. Confident miscategorization is worse than no categorization, because nobody checks plausible output.
  • Asking for bank credentials or screen-scraping. You don’t need them — banks export CSVs. Credentials create liability no small service business should carry.
  • Sending full statements to free tools or the free tier. Financial data, paid tier only. Amounts need not leave the machine at all with the architecture above.
  • Skipping the owner questions. Only the owner knows that the transfer to a personal name is salary. A pipeline that guesses instead of asking will be wrong in exactly the rows that matter.
  • One-shot setup with no monthly loop. Merchant names change, new suppliers appear. Without the monthly run and the growing counterparty map, accuracy decays — and so does the retainer.

Final checklist

  • Transfer and loan rules in place and tested before the first AI call
  • Amounts and account numbers never sent to the API
  • Paid-tier key, budget alert set
  • ASK_OWNER.csv answered and converted into rules monthly
  • Revenue-overstatement check (transfers + loans ≠ income) run every month
  • Reconciliation report delivered with the books
  • No bank credentials ever requested or stored

Run it on last month’s statement and compare against your current spreadsheet. Every disagreement is either the AI being wrong — or your spreadsheet being wrong all along. Both are worth knowing, and the second one is the pitch.