Harn: a bill-splitting web app on Cloudflare Workers
Upload a receipt, share one link, and everyone ticks what they ate and pays by cash or bank transfer with a slip.
- Period
- Oct 2026
- Role
- Sole designer and developer: product spec, backend, frontend, tests and deployment.
Problem
Splitting a restaurant bill usually means one person doing arithmetic on a receipt photo and chasing payments in a chat. Harn turns the receipt into an itemised list, lets each person claim their own items, spreads tax and service fairly, and tracks who has paid.
Nobody needs an account to take part, which matters for groups that share the link in LINE.
What I built
- A Cloudflare Worker API on D1. Each bill is one JSON document per row with a version counter; every change is applied under optimistic concurrency (write only if the version is unchanged, retry up to 6 times with jittered backoff).
- A receipt reader that sends the photo, plus an optional order-list photo, to the Anthropic API with a strict JSON schema, then checks the result in code: line totals against qty x price, items against the subtotal, and subtotal + tax + service + rounding against the total. Mismatches become warnings and lower the confidence level.
- Split maths that spreads tax, service and rounding in proportion to each item's price, so shares add up to the receipt total, with an equal-split fallback for unclaimed items. A bill locks once everyone on the roster (two or more people) has confirmed.
- A payment flow: cash or bank transfer with an uploaded slip image (downscaled in the browser, size-capped on the server), a PromptPay or bank card, and an admin panel to edit items, rename or remove people, and reopen a locked bill.
- Optional sign-in so bills can be found on another device: LINE Login (OpenID Connect) and a 6-digit email code sent through Resend. Session tokens are stored only as SHA-256 hashes; CSRF is handled with Fetch Metadata and Origin checks.
- A vanilla JS frontend with no build step, in Thai and English (a test asserts both language files have identical keys), polling every 4 seconds for live updates.
- Operations: per-IP daily rate limits in D1, Turnstile on bill creation and receipt parsing, a daily cron that deletes expired bills, sessions and codes (bills expire 90 days after the last update), and HTTP-to-HTTPS enforcement with HSTS.
Engineering highlights
No lost updates, no lock service
Optimistic concurrency on a single JSON row (UPDATE ... WHERE id = ? AND version = ?) lets several people tick items at the same moment without a separate locking service.
Model output is untrusted input
The schema constrains the shape; arithmetic checks in code verify the totals. A misread receipt shows up as a visible warning instead of a silently wrong split.
Cache-friendly prompt layout
The system prompt is the static prefix with a cache_control breakpoint; per-request images and language come after it, so the cached prefix stays stable.
Sized for the platform
Image storage goes through one module, so moving from D1 blobs to object storage later touches no callers. Handlers stay within a tight 10 ms per-request CPU budget by leaving image re-encoding to the browser.
Hardened over several review rounds
__Host- cookie prefix, single-use OAuth state bound to a cookie, open-redirect-safe return paths, per-IP limits sized for mobile networks where many users share one address, and tests that fail if development-only flags appear in the production config.
Codebase at a glance
- 108 automated tests, all passing on a local run on 8 Nov 2026 (node --test)
- About 7,600 lines of JavaScript, SQL and tests across source, frontend, migrations and tests
- 3 database migrations
Stack
- Cloudflare Workers
- Cloudflare D1 (SQLite)
- JavaScript (ES modules)
- Anthropic API (vision, structured output)
- LINE Login (OIDC)
- Resend (email)
- Cloudflare Turnstile
- Vanilla JS / HTML / CSS
- Wrangler
- Node.js test runner