DEV Community

Arnulfo
Arnulfo

Posted on

Cuadre: turning Panama's paper receipts into a tax-ready spreadsheet, with Gemma on my laptop

I started Cuadre for the Hacktoberfest Open-Source AI Challenge (Week 2: Side Hustle), but I didn't publish it before the deadline. It isn't a challenge entry. I'm sharing it anyway because what I learned building it is worth writing down.

What I Built

In Panama, almost every purchase a freelancer or a small business makes comes with a paper receipt. It has the shop's tax ID (the RUC) and a line for ITBMS, our sales tax: 7% on most things, 10% on hotels, 15% on tobacco. The ITBMS you pay on work purchases can often be credited against the ITBMS you owe each month (your accountant decides which purchases qualify). To get it, someone has to sit down at the end of the month and type every receipt into a spreadsheet. A shoebox of thermal paper becomes rows of merchant, RUC, date, subtotal, tax and total.

That someone is usually the freelancer at 11 p.m., or their accountant, who gets the receipts as blurry WhatsApp photos from 30 different clients.

Cuadre (from cuadrar, "to make the books balance") does the typing. You put the receipt photos in a folder and run one command. You get:

  • recibos.csv, one row per receipt, which opens cleanly in Excel;
  • resumen.md, with totals by month and category and the ITBMS paid;
  • a short list of the receipts a human should double-check, each with the reason.

The photos are read by Gemma, an open-weight model, running on my own laptop. They never leave the machine.

Who it's for:

  • Accountants with small-business clients. This is the strongest case: they transcribe receipts by hand every month, and they can't send their clients' data to a cloud service they don't control.
  • Freelancers who file ITBMS every month and want back the tax they paid on their own purchases.
  • Small shops, like a fonda (a small home-style restaurant), a hardware store or a repair shop, that keep receipts in a box until the accountant asks for them.

Demo

Six made-up Panamanian receipts, each one with a trap: a supermarket ticket with tax-exempt groceries, a fonda lunch where the tax rounds, a stationery store, a hotel taxed at 10%, the same stationery receipt photographed twice, and a handwritten kiosk receipt with no RUC and no tax line. Every merchant, RUC and amount below is fictional.

This is the actual output from Gemma on my laptop:

┌────────────────────────────────────────────────────────────────────────┐
│ Cuadre · 6 recibos                                                     │
│ Compras: B/. 146.43 · ITBMS pagado: B/. 10.98                          │
│ Listos: 2 · Para revisar: 4                                            │
│   ! 01-super.jpg: El ITBMS es 3,5 % del subtotal (lo normal: 7 %);     │
│     puede haber productos exentos                                      │
│   ! 04-hotel.jpg: El RUC «155612233-2» no tiene forma de RUC panameño  │
│   ! 05-papeleria-otra-foto.jpg: Posible duplicado de 03-papeleria.jpg: │
│     no se suma                                                         │
│   ! 06-kiosko.jpg: Sin ITBMS desglosado                                │
│ Leyó: gemma4:e2b, en este equipo                                       │
└────────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The interface is in Spanish because the people it's for speak Spanish. In short:

  • ITBMS paid: B/. 10.98, exactly right (0.83 + 0.60 + 1.05 + 8.50). The balboa (B/.) is pegged one-to-one to the US dollar.
  • Supermarket: the tax is only 3.5% of the subtotal. That's not an error, because some groceries are exempt, but it's worth a look.
  • Hotel: Gemma cut off the end of the RUC. The code noticed that the result doesn't look like a Panamanian RUC and flagged it. I left this one in on purpose: it shows the checks doing their job.
  • Duplicate photo: detected and not counted twice.
  • Kiosk: no tax line and no RUC, as expected for a handwritten receipt.

And this is the CSV an accountant would receive (first rows):

archivo,fecha,comercio,ruc,dv,factura,categoria,subtotal,itbms,total,estado,avisos
02-fonda.jpg,2026-10-05,FONDA DONA MERY,8-745-1290,12,000231,alimentación,8.50,0.60,9.10,ok,
03-papeleria.jpg,2026-10-07,PAPELERIA CALIDONIA,2556-112-251344,08,0012877,oficina,15.00,1.05,16.05,ok,
04-hotel.jpg,2026-10-09,HOTEL BRISAS DE BOQUETE,155612233-2,57,H-00981,hospedaje,85.00,8.50,93.50,revisar,El RUC «155612233-2» no tiene forma de RUC panameño
Enter fullscreen mode Exit fullscreen mode

How I Built It

Python standard library only at runtime, 42 tests, and a cache keyed by the photo's hash, so when you add next week's receipts and run it again, only the new photos are read.

The rule: the model transcribes, the code calculates. A small vision model reads printed text well, but it's not reliable at arithmetic, and a tax report can't contain invented numbers. So Gemma has exactly one job, which is to copy what is printed. Everything else is plain code:

Step Who does it Why
Read the photo Gemma 4 E2B, locally through Ollama The only step that needs a vision model
Shape of the answer A JSON Schema in Ollama's format Gemma can't return extra fields or invent a category
Amounts monto_de_linea() Gemma copies the whole line; the code separates the rate (7%) from the amount (0.83)
Dates fecha() Day first, the way Panama writes them
Sum, rate, RUC, date, duplicates validar.py Fixed rules. A doubtful receipt goes to "review" instead of being added silently
Totals informe.py Decimal math, so no rounding drift; duplicates are excluded

Gemma runs with temperature: 0 and thinking turned off: transcription doesn't need reasoning, and the same photo always gets the same reading. On a laptop with 16 GB of RAM and no GPU, a receipt takes between 6 and 27 seconds.

What testing taught me. The first version looked fine on paper. Then I ran it on real images, and Gemma taught me three things:

What I saw What I changed
In the itbms field, Gemma copied the label "ITBMS 7%" instead of the amount, on all six receipts. The code doesn't make up numbers, so all six came out "doesn't balance" Ask Gemma for the complete printed line (ITBMS 7% 0.83) and let the code pull out the last amount, ignoring percentages
On the hotel receipt, Gemma dropped the last part of the RUC Nothing to fix in the model: the RUC shape check caught it. That's the point of having checks
On the handwritten kiosk receipt, it put the date 10/10/26 in the subtotal field, and the code read it as B/. 26.00 A line shaped like a date is never an amount. If the date is missing and turns up in another field, the code recovers it from there

After those changes, the run is stable: the exact ITBMS total, the duplicate excluded, the exempt items detected, and every doubtful receipt marked for review.

One limit I kept on purpose: Cuadre checks the shape of a RUC, not its check digit, because I haven't verified the tax authority's algorithm. It says so instead of pretending.

Why Open Models Matter Here

A receipt is personal data. It tells you where someone shopped, what they bought, when and how much. A freelancer's receipts also reveal who they work for. For an accountant, uploading those photos to a cloud API means handing their clients' data to a third party. With Gemma running in Ollama, the only connection Cuadre makes is to localhost:11434. The code doesn't accept any other address. Nothing leaves the office.

The business only works if a receipt costs close to nothing. An accountant with 30 clients and about 100 receipts per client reads around 3,000 receipts a month. With a local model, that costs the laptop's electricity. There's no per-image fee that grows with every new client and no quota that runs out at the end of the month, which is exactly when everyone files.

I can't be locked out. If a provider raises prices or retires a model, Cuadre keeps working: the model is on the disk, and swapping it is one flag (--modelo gemma4:e4b on a machine with more memory).

I could shape the model around the job. Because the runtime is open, I could constrain the output with a schema, turn off thinking, fix the temperature, and see exactly how Gemma failed. Then I could build a check around each failure. With open weights, the next step is possible too: fine-tuning Gemma on anonymized Panamanian receipts so it cuts fewer RUCs.

To be honest about the trade-off: a large hosted model would probably make fewer reading mistakes. But for this job, privacy and a near-zero cost per receipt matter more, and the small model's mistakes are the kind the code can catch.

What's Next

So far I've only tested Cuadre on the six fictional receipts above. The next steps are running it on real receipts (without publishing them), putting it in front of an accountant to see whether they'd actually pay for it, and opening the repository.

AI assistance disclosure: I built Cuadre with Claude Code (Claude Opus 5.5) as my coding agent. It implemented the code, tests and documentation, generated the demo receipts, and drafted this post from the recorded test runs. I chose the idea and the Panamanian tax rules to check, and reviewed the results.

Top comments (1)

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

The cache detail made me wonder about rephotographed receipts: a new photo hash would bypass exact-image deduplication even when the paper is the same.