This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Every freelancer I know has the same January. You download twelve months of bank statements, open a spreadsheet, and spend a weekend deciding whether SQ *PHILZ COFFEE SAN FRANCISCO was a client meeting or just coffee. Then you hand your accountant a CSV and hope.
People pay real money to skip that weekend. Apps like Keeper and QuickBooks charge a monthly subscription for it, and the deal includes connecting your bank account to their servers so their models can read every transaction you've ever made.
Shoebox does the same job without the second part.
- Drop in a CSV from any bank or card. Shoebox guesses which column is which (one amount column or separate debit/credit, day-first dates, cards that list purchases as positive numbers) and shows a preview so you can fix it.
- It sorts every transaction into US Schedule C categories: meals, travel, software, contract labor, personal, transfers and so on. Lines it's confident about are filed automatically.
- It asks about the rest, one merchant at a time. The "Needs review" list is grouped by merchant, so one answer covers all 13 trips to the same coffee shop. Every answer becomes a rule, so next month's statement needs fewer.
- It adds it all up: gross income, deductible expenses by Schedule C line (meals at 50%), net profit, a rough self-employment tax estimate, and quarter-by-quarter numbers for estimated payments. Export a CSV, a Markdown worksheet for your accountant, or print it.
The AI runs inside your browser tab. There's no server, no account, no bank login and no analytics. After the first visit it works with Wi-Fi off.
Who would pay for it: freelancers and sole proprietors who do their own books, or who pay an accountant by the hour to do it. A one-time $29 licence is an easy sell against a subscription that renews every month, and since inference runs on the customer's laptop, a new customer costs me nothing to serve. That's the part of the business that only works because the model is open.
Demo
Try it: shoebox-hcdp.onrender.com. It works in any modern browser; the first visit downloads a 34 MB model, and after that it runs offline.
Click Try a sample freelancer's statement to load nine months of a fictional designer's checking account (321 transactions). No real data needed.
Run it yourself:
git clone https://github.com/Eternity0207/shoebox
cd shoebox && npm install && npm run dev
Code
Eternity0207
/
shoebox
Private bookkeeping for freelancers: sorts bank statements into Schedule C categories with an open model running in your browser.
Shoebox
Private bookkeeping for freelancers. Drop in a bank or credit card CSV and Shoebox sorts every transaction into US Schedule C tax categories, learns from your corrections, and adds it all up for tax time.
The AI runs in your browser. Your bank statement is never uploaded: there is no server, no account and no analytics. After the first visit it works offline.
What it does
- Imports any bank's CSV. It guesses which column is the date, description and amount (or separate debit/credit columns, day-first dates, cards that list purchases as positive), shows you a preview, and lets you fix the mapping. Re-importing an overlapping statement doesn't create duplicates.
- Sorts transactions into tax categories with a small open embedding model. Lines it's sure about are filed automatically; the rest go into a short "Needs review" list, grouped by merchant.
- Learns from you. Every correction becomes a rule for thatβ¦
How I Built It
Shoebox is static files: Vite and vanilla JavaScript, no framework. The model runs in a Web Worker through Transformers.js and ONNX Runtime Web, on the CPU. Transactions and rules live in IndexedDB. The plumbing for offline use (an Origin Private File System model cache, a service worker, ONNX Runtime served from the app's own origin) is reused from my Week 1 project, Pocket Field Guide, which saved me a day.
The pipeline
A bank line goes through five steps:
-
Clean the name.
POS PURCHASE SQ *PHILZ COFFEE #12 SAN FRANCISCO CAβphilz coffee san francisco. This is boring regex work (processor prefixes, store numbers, phone numbers, state codes,FACEBKβfacebook ads), and it matters more than any model choice. - Your rules. If you've told Shoebox about this merchant before, that wins.
- Your corrections, generalized. If the line is very close in embedding space to one you corrected, it copies your answer.
- Zero-shot ranking. Each tax category has a plain-English hint listing the kinds of merchants that belong in it ("gas station fuel, parking garages, highway tolls, oil change, auto repair shop..."). The merchant name is embedded and scored against each hint. The sign of the amount rules out impossible answers (money coming in can't be an expense).
- A confidence gate. If the top category's probability is at least 0.5, it's filed. Otherwise you're asked.
Picking the model by measuring
Before writing any UI, I hand-labelled 145 bank lines written the way real exports look, with processor prefixes, truncated names and store numbers. Then I ran small open embedding models over them:
| Model | Top-1 | Top-3 | Download |
|---|---|---|---|
| all-MiniLM-L6-v2 | 84.8% | 93.8% | 23 MB |
| bge-small-en-v1.5 | 86.2% | 97.9% | 34 MB |
| gte-small | 90.3% | 97.2% | 34 MB |
| EmbeddingGemma-300M | 87.6% | 97.9% | 309 MB |
gte-small won, at a tenth of EmbeddingGemma's size. One trick helped every model: score each merchant against the whole category hint and against each example phrase in it, then average. Whole-hint alone gave gte-small 80%.
Then I calibrated the gate: at a confidence of 0.5 or above, gte-small files 106 of the 145 lines by itself and gets 99.1% of those right. Everything below that goes to a human. For tax records, I'd rather ask than guess.
The number that matters: clicks
Accuracy on a test set isn't what a freelancer feels. What they feel is how many times they have to click. So I scripted a user who works through "Needs review" biggest group first, and ran it on the sample statement, both in Node and in the real app in the browser:
- 321 transactions from 65 merchants, sorted in about 3.5 seconds on a laptop CPU
- 72 lines from 17 merchants flagged for review
- 17 clicks to clear them (10 changes, 7 "β looks right")
- 0 lines wrong at the end
One honest caveat: I wrote both the sample statement and the category hints, so this is a best case. Real statements will have weirder merchants. But the shape holds, because every click teaches Shoebox a whole merchant, not just one line.
The bug that only showed up in the browser
The browser flagged 90 lines where my Node script flagged 77. Same model, same code. It turned out that with the 8-bit model, padding a string into a batch shifts its embedding enough to flip near-ties: "hyatt regency sacramento" scored Travel 0.29 / Meals 0.28 on its own, and the opposite inside a batch. In other words, a merchant's category depended on what else was in the file.
The fix was to embed strings one at a time. It's still fast, because a statement has far fewer merchants than lines. Then I precomputed the category vectors at build time, so the browser and my eval scripts use identical numbers. Now both report exactly 72 lines from 17 merchants, and the app is ready a second or two after it opens instead of 7.5.
I tried Gemma, and cut it
My plan was a two-model setup: the small embedding model for the easy lines, and Gemma 3 1B on WebGPU for a second opinion on the uncertain ones. I built it, then measured it on the lines it would actually see (the ones below the confidence gate):
| On the 39 uncertain lines | Correct |
|---|---|
| gte-small alone | 66.7% |
| Gemma picks from the top 3 (with category hints) | 51.3% |
| Gemma picks from the top 3 (names only) | 43.6% |
| Gemma describes the business, gte-small ranks that | 66.7% |
The first version scored below chance. That was my bug: I'd prefilled the answer with Answer: (trailing space), so the model's most likely next token was a newline, and I was reading noise from the letter logits. Fixed, multiple choice was still worse than the small model alone. With a list of category hints in front of it, the 1B model pattern-matched the hint text ("Logitech" β Software) instead of using what it knows about the merchant.
Playing to Gemma's strength did better. Asking "what kind of business is this?" and letting the embedding model categorize the answer exactly tied the small model alone: it fixed some lines and broke the same number of others. It also invented things: "nintendo" β auto repair shop, "bart clipper reload" β mobile phone retailer.
An ~800 MB download that's sometimes confidently wrong has no place in a tax tool. So Shoebox ships with one 34 MB model and the person who knows their own business. The experiment stays in the repo (scripts/eval-gemma-variants.mjs) so anyone can rerun it with a bigger model.
Hosting
Shoebox is hosted on Render as a free static site, configured by a render.yaml Blueprint. The one setting that matters is the cross-origin isolation headers (COOP: same-origin, COEP: credentialless), which let ONNX Runtime use every CPU core.
Why Does Open Innovation Matter?
The data is the whole problem. A bank statement is a complete map of someone's life: where they eat, who they pay, what they earn, which doctor they see. The closed version of this product starts with "connect your bank account" and ends with that map on someone else's server. With an open model running in the browser, there's nothing to connect and nothing to leak. You can check it yourself: open DevTools β Network, import a statement, and watch nothing go out.
The economics only work because inference is free. A closed API charges per call, so a bookkeeping app built on one has to meter usage, raise prices or both. Shoebox's marginal cost per customer is zero, because every customer brings their own CPU. That's what makes a one-time price possible.
Open let me measure and choose. I compared four embedding models and a 1B LLM on my own data in an afternoon, picked the one that was best for this job, and threw away the one that wasn't. With a closed API I'd have been stuck with whatever the endpoint gave me, and I'd never have found the batching bug, because I couldn't have run the same weights in Node and in the browser and compared the numbers.
It's yours to change. The categories are a plain JavaScript file. A freelancer in another country can rewrite them for their own tax forms, run npm run embed, and the model understands the new categories immediately, with no fine-tuning and no permission needed.
Prize Categories
- Best Use of Render: the app is hosted on Render as a static site, with a Blueprint that sets the cross-origin isolation headers multithreaded in-browser inference needs.
Top comments (0)