K-2 — an autonomous office of the CFO

Inspiration

My family's civil contracting company in Gujarat has about forty-five suppliers, almost all on credit. There is no purchase order system and there never has been.

A cost begins its life as paper a truck driver carries — a royalty pass proving the material was legally mined, or a delivery challan from the quarry. The supplier's invoice arrives roughly three weeks later. Payment goes out about three weeks after that.

For those three weeks, that money is invisible. The material is on site, the liability is real, and nothing in the company knows about it. At month end the owner is guessing what he owes, and he is guessing against a stack of thermal-printed weighbridge slips in three scripts.

Every "AI accounting" tool I looked at assumed a clean ERP, English documents, and a purchase order to reconcile against. None of those exist here. K-2 exists to close that three-week hole using the only input that arrives on time: the paper itself.

What it does

Scanned paper becomes a booked liability the day it arrives, gets matched against the supplier's invoice weeks later, has tax and retention deducted, and becomes a payout that a human approves from their phone.

scan -> render -> classify -> segment -> extract
     -> accrue -> match invoice -> deduct TDS & retention
     -> prepared payout -> a human's thumb -> RazorpayX

Anything uncertain becomes a human decision rather than a silent default.

The rule that shaped everything

The model reads paper. Arithmetic is deterministic. No figure in the ledger is ever produced by a model call.

The vision model's only job is to read printed characters — "13460 kg" off a weighbridge stamp. Deterministic code turns that into money:

amount_paise = floor(quantity_thousandths * rate_paise_per_tonne / 1000)

On a real royalty pass from our own files:

floor(13460 * 96000 / 1000) = 1292160 paise = Rs 12,921.60

Money is stored as an integer count of paise, quantity as integer thousandths, rates in basis points. No float touches money anywhere — and that isn't fastidiousness. We deduct tax at source and retention per line, sum lines into a vendor total, and sum vendors into a run total that leaves the bank:

tds       = floor(gross * 200  / 10000)   # 194C, 200 bps
retention = floor(gross * 500  / 10000)   # 500 bps
net       = gross - tds - retention

One float anywhere in that chain and the total stops reconciling to the lines. A real accountant then rejects the whole batch, and the system is worthless.

A prepared line from a live run:

Gross Rs 16,463.00
TDS (194C) Rs 0.00 — correctly below the Rs 30,000 threshold
Retention (5%) Rs 823.15
Net payable Rs 15,639.85

The full loop, working

The whole rail runs end to end in RazorpayX test mode. All seven vendors exist as contacts with fund accounts. A prepared payout is pushed to the owner's phone as a Telegram message in Hindi and English:

मंज़ूर / Approve · सुधार / Correct · अस्वीकार / Reject

He presses approve. The payout appears in RazorpayX and moves queued -> processing -> processed:

pout_TYtDmp2s6oWEM0   processed   7700000 paise   IMPS   vendor bill
                      Rs 77,000.00 - GANESH STONE QUARRY

Paper photographed at a quarry gate, to money in a supplier's account, with exactly one human decision in between.

The one thing guarding it

The original code took the beneficiary account number from model-extracted invoice data. A forged invoice PDF could redirect a real payment — a vision model, reading a supplier's paper, deciding where money went.

K-2 now has a vendor_payment_methods table that only a human can write to, through a script that refuses to run unless stdin is an interactive terminal and makes you retype the full account number to confirm. A payout is built only when the instruction's account number and IFSC exactly match a human-verified row. No pipeline, batch command, model or agent can create a payee.

Approving twice cannot pay twice. The idempotency key is content-derived:

key = SHA256([ runReference, vendorId, fundAccountId, amountPaise ])

An identical request is the same key; a one-paise change is a different one. A line with no verified beneficiary is marked held, and approving the run does not release it.

How we built it

TypeScript on Bun, SQLite with STRICT tables throughout, and zero runtime dependencies — the only devDependency is bun-types.

The unusual part: I did not write the product code. Every line in src/ was written by autonomous coding agents dispatched through Agent Orchestrator, across more than thirty merged pull requests.

  1. A planning agent reads a build specification and writes a stage brief naming the capability and the rules that bind it — deliberately never file names or function names.
  2. The brief goes to a worker session in its own git worktree. It implements, tests, opens a PR.
  3. An independent reviewer re-runs the suite and applies its own mutation tests — deliberately breaking one line to confirm a test catches it.

That third step is the whole thing. A surviving mutation is a finding about the test, not the code. It caught defects a green suite had happily approved: an untested state != 'decided' filter that would have shown resolved exceptions as open forever; an untested integer guard — the "no float touches money" invariant with nothing behind it; a missing fund-account check that would have shipped a payout with a null destination.

Final state: 188 tests, 0 failures, typecheck clean.

Challenges we ran into

A JSON schema that forbade every answer. The extraction schema was {type: "object", additionalProperties: false, properties: {}} — structurally permitting the model to return only {}. Every run produced zero accruals. The model was fine; the contract made a correct answer impossible.

A date parser that rejected reality. Extraction started returning real values and accruals were still zero. The pipeline required YYYY-MM-DD; every piece of paper in Gujarat prints 18/07/2026.

A narration guard that refused every real payment. The RazorpayX adapter built its own narration as Payment run {runId} for {vendorId}, then validated it against ^[A-Za-z0-9 ]+$. Every identifier in the system contains a hyphen. It rejected a string it had just generated. The tests passed because their fixtures used SIMRUN and syntheticvendor — the only hyphen-free identifiers in the entire codebase. The test had been shaped around the bug.

Three writing systems. Vendors are stored exactly as printed — જય ખોડિયાર મેટલ alongside JAY KHODIYAR METALS, sometimes the same supplier. An operator types "jay khodiyar" and matches nothing. The fix wasn't transliteration in code; it was showing the query planner the real vendor list and letting the model map the spelling, while the SQL stayed an exact match.

A filename 380 characters long. The classifier's descriptive label was being used verbatim as a temp filename. ENAMETOOLONG, at 2am, an hour before the deadline.

Rate limits, twice. Both the orchestration model and the extraction model ran out of quota mid-sprint. We killed 76 stranded worker processes and switched providers on the fly.

What we learned

A green test suite proves nothing about the tests. Mutation testing was the highest-value practice in the build. Three of the defects above were found by breaking working code and watching the suite stay green.

Constraints belong in types and schemas, not comments. "Money is integer paise" as a comment is a suggestion. As CHECK (typeof(amount_paise) = 'integer') on a STRICT table, it is a law.

Fixtures drift toward whatever makes the test pass. The hyphen bug is the clearest lesson of the build: if your fixtures don't look like production data, your tests are measuring your imagination.

The hard part of finance automation is knowing when to stop. The most important line in K-2 is the one that says held.

What's next

  • Multi-site cost allocation, so a delivery attributes to a tender line item and not only to a vendor.
  • Supplier-facing email intake with per-sender allowlisting.
  • Moving from RazorpayX test mode to production, which changes no code — only which key is in the environment.

Built With

Share this project:

Updates

Submission history