Skip to content
Dylan San GabrielDylan
Get in touch

← All work

GET /api/work/receipt-splitter

Split the check from a photo

In progress · · solo · side project

Photograph the dinner receipt, say who ate what, and it tells everyone what they owe. It checks its own answer against the receipt's printed total and won't show a split that doesn't match.Photograph a receipt, tap who had what, get the split. Runs on your phone with no server, and refuses to show a split that doesn't add up.

  • TypeScript
  • Preact
  • Tesseract.js
  • Vite
Network calls after load
0
Wrong splits on real receipts
0
Money arithmetic
integer cents
The splitter after reading the example receipt. On the left, the receipt it was pointed at and, below it, what each person owes: You $28.86, Friend $40.40, summing to the printed $69.26. On the right, the green "Adds up" verdict and the five line items with a name tapped onto each.
The splitter after reading the example receipt. On the left, the receipt it was pointed at and, below it, what each person owes: You $28.86, Friend $40.40, summing to the printed $69.26. On the right, the green "Adds up" verdict and the five line items with a name tapped onto each.
GET/api/work/{id}

Parameters

Request

curl "https://portfolio.dylansg0318.workers.dev/api/work/receipt-splitter"
source: src/content/projects/{id}

Response200 OK · 3.6 KB

{
  "id": "receipt-splitter",
  "title": "Split the check from a photo",
  "status": "wip",
  "year": 2026,
  "category": "tool",
  "role": "solo · side project",
  "summary": "Photograph a receipt, tap who had what, get the split. Runs on your phone with no server, and refuses to show a split that doesn't add up.",
  "summary_plain": "Photograph the dinner receipt, say who ate what, and it tells everyone what they owe. It checks its own answer against the receipt's printed total and won't show a split that doesn't match.",
  "stack": [
    "TypeScript",
    "Preact",
    "Tesseract.js",
    "Vite"
  ],
  "metrics": {
    "network_calls_after_load": "0",
    "wrong_splits_on_real_receipts": "0",
    "money_arithmetic": "integer cents"
  },
  "links": {
    "source": "https://github.com/Dylansg318/SplitApp",
    "play": "https://portfolio.dylansg0318.workers.dev/play/receipt-splitter"
  },
  "problem": "Group dinners end with one person reading line items aloud while everyone does arithmetic on their phones. The first version (February 2025, Java) photographed the receipt, OCR'd it, and asked GPT-3.5 to split it. It worked on clean receipts and produced confident, wrong splits on real ones, with no way to tell which was which.",
  "unique": "Confidence comes from arithmetic, not from a model. A receipt prints its own answer key: the line items have to sum to the subtotal, and subtotal plus tax plus tip has to equal the total, so a reading that satisfies both is proved rather than believed, and one that doesn't is refused. The language model is gone entirely. The camera reads several frames and only trusts values that repeat, a rule lifted from the barcode scanner I built at work, where a drifting hand can't repeat a wrong read.",
  "ai": "Two eras. v1 (2025) used a model at runtime to do the splitting, which is what made it unauditable. v2 (2026) removed that model and was built with Claude Code from a change record I wrote first: the invariants (nothing leaves the device, never show a split that doesn't reconcile, allocations sum exactly) were mine, and every slice had to print a measurement before the next one started. The agent wrote most of the code; I supplied the real receipts, caught a benchmark that re-jittered one photo instead of re-observing it, and decided which numbers were allowed to move.",
  "learned": [
    "Self-reported confidence isn't evidence. Tesseract read \"TAX 1.69\" as \"19.169\" at healthy confidence. The receipt's own arithmetic caught it; the confidence score never would have.",
    "Synthetic test data lies. The generated receipts scored 30 of 40. The first 13 real photographs scored 1. The gap, not the score, was the finding, and it moved cropping and multi-frame capture from \"later\" to \"now\".",
    "A rule measured in one project transfers to another with its numbers attached. The scanner's \"two frames must agree, a blank frame breaks the streak\" came with 220 sessions of evidence, and I kept the measurement discipline, not just the rule.",
    "Refusing is a feature. On real receipts the tool has produced zero wrong splits. It has also refused most of them. That ordering of priorities is the whole design."
  ],
  "cover": {
    "url": "https://portfolio.dylansg0318.workers.dev/_astro/cover.DEhD44so.png",
    "width": 1920,
    "height": 1241,
    "alt": "The splitter after reading the example receipt. On the left, the receipt it was pointed at and, below it, what each person owes: You $28.86, Friend $40.40, summing to the printed $69.26. On the right, the green \"Adds up\" verdict and the five line items with a name tapped onto each."
  },
  "page": "https://portfolio.dylansg0318.workers.dev/projects/receipt-splitter"
}

Live: this is the answer from this site’s API, not a mock. Send re-runs the request with your parameters; if the answer names a page, it opens.

The problem

Group dinners end with one person reading line items aloud while everyone does arithmetic on their phones. The first version (February 2025, Java) photographed the receipt, OCR'd it, and asked GPT-3.5 to split it. It worked on clean receipts and produced confident, wrong splits on real ones, with no way to tell which was which.

What was unique

Confidence comes from arithmetic, not from a model. A receipt prints its own answer key: the line items have to sum to the subtotal, and subtotal plus tax plus tip has to equal the total, so a reading that satisfies both is proved rather than believed, and one that doesn't is refused. The language model is gone entirely. The camera reads several frames and only trusts values that repeat, a rule lifted from the barcode scanner I built at work, where a drifting hand can't repeat a wrong read.

Where AI fit in

Two eras. v1 (2025) used a model at runtime to do the splitting, which is what made it unauditable. v2 (2026) removed that model and was built with Claude Code from a change record I wrote first: the invariants (nothing leaves the device, never show a split that doesn't reconcile, allocations sum exactly) were mine, and every slice had to print a measurement before the next one started. The agent wrote most of the code; I supplied the real receipts, caught a benchmark that re-jittered one photo instead of re-observing it, and decided which numbers were allowed to move.

GET/api/work/{id}

Parameters

Request

curl "https://portfolio.dylansg0318.workers.dev/api/work/receipt-splitter?fields=problem%2Cunique%2Cai"
source: src/content/projects/{id}

Response200 OK · 1.5 KB

{
  "problem": "Group dinners end with one person reading line items aloud while everyone does arithmetic on their phones. The first version (February 2025, Java) photographed the receipt, OCR'd it, and asked GPT-3.5 to split it. It worked on clean receipts and produced confident, wrong splits on real ones, with no way to tell which was which.",
  "unique": "Confidence comes from arithmetic, not from a model. A receipt prints its own answer key: the line items have to sum to the subtotal, and subtotal plus tax plus tip has to equal the total, so a reading that satisfies both is proved rather than believed, and one that doesn't is refused. The language model is gone entirely. The camera reads several frames and only trusts values that repeat, a rule lifted from the barcode scanner I built at work, where a drifting hand can't repeat a wrong read.",
  "ai": "Two eras. v1 (2025) used a model at runtime to do the splitting, which is what made it unauditable. v2 (2026) removed that model and was built with Claude Code from a change record I wrote first: the invariants (nothing leaves the device, never show a split that doesn't reconcile, allocations sum exactly) were mine, and every slice had to print a measurement before the next one started. The agent wrote most of the code; I supplied the real receipts, caught a benchmark that re-jittered one photo instead of re-observing it, and decided which numbers were allowed to move.",
  "page": "https://portfolio.dylansg0318.workers.dev/projects/receipt-splitter"
}

Live: this is the answer from this site’s API, not a mock. Send re-runs the request with your parameters; if the answer names a page, it opens.

Runs small here. Open fullscreen

The story

Every group dinner ends the same way: one person holding the receipt, reading line items out loud, while everyone does phone math. I wanted the lazy version. Take a photo, say who had what, get the split.

The first attempt, in early 2025, had an AI do the splitting. It looked great on a clean receipt and quietly got real ones wrong, with no way to tell the good answers from the bad. That’s the lesson this one is built on: a confident wrong answer is worse than no answer.

So the new version doesn’t trust anything it reads until the numbers prove themselves. A receipt states its own subtotal, tax, tip and total. If the items add to the subtotal and subtotal plus tax plus tip equals the total, the reading is right. If not, it says so and refuses instead of guessing. The camera also reads the receipt several times as your hand moves and only believes numbers it sees twice in a row, a trick I borrowed from the barcode scanner I built for the warehouse at work.

Everything happens on your phone. The photo never leaves it.

To try it, point it at a real receipt or choose a photo of one. The best test is on a phone with the live camera.

What it does

Camera or photo, on-device Tesseract.js, a geometric parse (cluster words into rows by y, find the right-aligned price column by x), reconciliation against the receipt’s own printed totals, then a tap-to-assign split with tax and tip apportioned by what each person ate, in integer cents, with largest-remainder rounding so the shares always sum to the printed total.

Why the model went

v1 sent OCR text to GPT-3.5 and displayed whatever came back. The fragile step was always the one before the model, reading thermal paper, and the model couldn’t tell a misread from a good read any better than I could. Removing it took away the cost, the API key, the privacy leak and the unauditable step in one move. What replaced it is two equations, and they’re stronger evidence than any confidence score: when exactly one field is misread, the other three determine its true value, and the app proposes that correction for you to confirm instead of applying it.

The camera, and what came from the ERP

Real photographs broke the synthetic results (30 of 40 settled synthetically, 1 of the first 13 real photos). Two things fixed most of that. Cropping to a guide frame, so the table edge and the card reader stop being OCR’d as line items. And multi-frame confirmation: each field keeps a streak and is trusted only after it repeats in consecutive frames, a blank frame breaks the streak, and a page dense with money tokens has to confirm one more time. Those rules are a port of the <CameraScanner> in my warehouse system, where they took wrong barcode reads from 9 in 220 sessions to 0.

One ordering was measured rather than assumed: a frame that reconciles on its own is accepted immediately and the streak is the fallback, because gating on the streak started refusing receipts that had read perfectly. Consensus is there to delete noise, not fill gaps: junk read off the table edge can’t survive the hand moving.

Where it stands

The reader, parser, reconciler, split model and camera loop are built and tested. On real receipts it has produced zero wrong splits and settled about half of the ones it was pointed at, so the honest description is “safe, and still learning to read”. Live camera testing on phones is the current work. Offline install and sharing the split by link are next.

The demo above is the real app, not a mock. It serves its own copy of the OCR engine (about 6 MB, fetched once from this site) and makes no other network requests.

What I learned

  1. Self-reported confidence isn't evidence. Tesseract read "TAX 1.69" as "19.169" at healthy confidence. The receipt's own arithmetic caught it; the confidence score never would have.
  2. Synthetic test data lies. The generated receipts scored 30 of 40. The first 13 real photographs scored 1. The gap, not the score, was the finding, and it moved cropping and multi-frame capture from "later" to "now".
  3. A rule measured in one project transfers to another with its numbers attached. The scanner's "two frames must agree, a blank frame breaks the streak" came with 220 sessions of evidence, and I kept the measurement discipline, not just the rule.
  4. Refusing is a feature. On real receipts the tool has produced zero wrong splits. It has also refused most of them. That ordering of priorities is the whole design.
GET/api/work/{id}

Parameters

Request

curl "https://portfolio.dylansg0318.workers.dev/api/work/receipt-splitter?fields=learned"
source: src/content/projects/{id}

Response200 OK · 945 B

{
  "learned": [
    "Self-reported confidence isn't evidence. Tesseract read \"TAX 1.69\" as \"19.169\" at healthy confidence. The receipt's own arithmetic caught it; the confidence score never would have.",
    "Synthetic test data lies. The generated receipts scored 30 of 40. The first 13 real photographs scored 1. The gap, not the score, was the finding, and it moved cropping and multi-frame capture from \"later\" to \"now\".",
    "A rule measured in one project transfers to another with its numbers attached. The scanner's \"two frames must agree, a blank frame breaks the streak\" came with 220 sessions of evidence, and I kept the measurement discipline, not just the rule.",
    "Refusing is a feature. On real receipts the tool has produced zero wrong splits. It has also refused most of them. That ordering of priorities is the whole design."
  ],
  "page": "https://portfolio.dylansg0318.workers.dev/projects/receipt-splitter"
}

Live: this is the answer from this site’s API, not a mock. Send re-runs the request with your parameters; if the answer names a page, it opens.