DEV Community

Cover image for Kakeizu: a local Gemma reads the gas station's receipts, and check digits catch its mistakes
Mauricio Andrade
Mauricio Andrade

Posted on

Kakeizu: a local Gemma reads the gas station's receipts, and check digits catch its mistakes

Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle Submission 💸

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle.

A friend of mine works at a gas station in the interior of São Paulo. The station has a system, but closing the day still means checking a pile of paper against it by hand: card machine slips, fiscal coupons, Pix receipts, the odd cheque. Amount, date, payment method, one receipt at a time.

Itadori looking sick

Still checking, one receipt at a time.

So I built Kakeizu: you photograph the receipt on your phone, a Gemma model running on the shop's own computer reads it, the domain checks every digit it can, and a person confirms. Nothing is confirmed automatically, and nothing leaves the shop.

The AI suggests, the domain validates, the human decides.

What I Built

Kakeizu reads a photo of a Brazilian payment receipt with Gemma running on the shop's own computer, checks every digit it can, and waits for a person to confirm. The name is Kakeibo (家計簿, the Japanese household ledger) plus Kakuzu, the Akatsuki member who only thinks about money.

It reads the receipts a Brazilian gas station or corner shop sees every day:

Receipt What the domain checks
NFC-e / NF-e (fiscal coupon or invoice) 44-digit access key with a mod-11 check digit; its month and CNPJ must match the other fields
Card machine slip (Stone, Cielo, Getnet) the store's CNPJ check digits, the authorization code
Pix receipt the 32-character end-to-end ID
Cheque the three mod-10 check digits of the magnetic line (CMC-7); a date over 6 months old gets a warning
Any of them amount, date never in the future, the same photo or the same key never twice

Confirmed receipts feed a daily summary and a sales report by hour, weekday, payment method, installments and card brand. An NFC-e and the card slip of the same sale are paired, so the sale counts once.

Who pays for it: the owner of a small shop who closes the cash register by hand. Cloud receipt readers exist, but they charge per document and want the shop's sales, suppliers and customers on someone else's server.

J. Jonah Jameson laughing

The cloud vendor, sending this month's per-document invoice.

Kakeizu costs nothing per document and nothing leaves the shop. The owner pays for the setup and the hour it saves at the end of every day, not for each photo.

Demo

Kakeizu runs on the shop's computer and never on a public server, so there is no live link: the demo is the flow below, recorded with the anonymized sample receipts from the repository.

Demo: a photo goes up, the line reads in the background and prints itself in, then the receipt is reviewed and confirmed

Today, on the phone. The photo goes up and the list comes back at once. The model reads in the background (dashed violet line), and the line prints itself in when it is done. Orange means "needs you", double green means confirmed.

Today's receipts on a phone: one reading, two to review, two confirmed

Review. The photo sits next to everything the model read, and every field that failed a check says why. This template NFC-e has no CNPJ and a zeroed access key, so it cannot be confirmed as read.

Review screen: the receipt photo beside the editable fields, with errors highlighted

Look at the amount, too. The photo says 146,46; the model read 4,00. That number is well formed and no check digit covers it, so only the person comparing it with the photo catches it.

Joey from Friends, disappointed

Sales report. Confirmed sales by hour, weekday, payment method, installments and card brand, with a slip paired to its NFC-e so each sale counts once.

Sales report on a phone

Code

GitHub logo mauricioandrade / kakeizu

Local-first receipt reader for small shops: Gemma (via Ollama) reads NFC-e, card slips, Pix and cheques; check digits validate; a person confirms. Java 25 + Quarkus. Nothing leaves the machine.

🧾 Kakeizu

A receipt reader for small shops that runs 100% locally.
A photo goes in, structured data comes out, and nothing leaves the owner's machine.

Kakeibo (家計簿, the Japanese household ledger) + Kakuzu (the Akatsuki member who only thinks about money)

Java 25 Quarkus 3.40 Ollama Gemma PostgreSQL htmx License: Apache 2.0

🤖 The AI suggests · 🧮 the domain validates · 🧑 the human decides


📑 Contents

😩 The problem

Anime character looking sick and horrified

The owner or manager of a gas station, a corner shop or a small restaurant gets dozens of receipts a day: NFC-e, card machine slips, Pix receipts, the odd cheque. Closing the day means typing amount, date and who paid or got paid into a spreadsheet, one receipt at a time.

Cloud tools do this, but they want the business's financial data on…

Apache 2.0. The README has the setup for both Ollama layouts on Windows + WSL2, the accuracy tables and the backup script.

How I Built It

The whole design is one sentence: the AI suggests, the domain validates, the human decides.

Layer Choice
Model Gemma 4 (gemma4:e4b), vision, thinking on, temperature 0
Inference Ollama on the Windows side, reached from WSL2
Glue Quarkus 3.40 with quarkus-langchain4j-ollama
Language Java 25
Storage PostgreSQL + Flyway
Web Qute templates + htmx, no frontend build

Gemma reads, and nothing it returns is trusted

Gemma gets the photo and a field guide in the system prompt, and returns plain strings: type, amount, date, counterparty, tax ID, unique ID, time, payment method, installments, card brand. Those strings never touch the database as values. They go through the same domain objects a person's correction goes through, and each object checks what it can.

The check digits are the trick. They exist to catch a cashier's typo, and they catch a model's misread just as well. A CNPJ, for instance, carries two mod-11 digits:

public record Cnpj(String value) {
    public Cnpj {
        value = value.replaceAll("[.\\-/\\s]", "").toUpperCase();
        if (!value.matches("[A-Z0-9]{12}\\d{2}")) {
            throw new InvalidValueException("CNPJ must have 14 characters");
        }
        if (checkDigit(value, FIRST_WEIGHTS) != valueAt(value, 12)
                || checkDigit(value, SECOND_WEIGHTS) != valueAt(value, 13)) {
            throw new InvalidValueException("CNPJ check digits do not match: " + value);
        }
    }

    // Each character is worth ASCII - 48, so the new alphanumeric CNPJ
    // (issued since July 2026) and the old all-digit one share one rule
    private static int valueAt(String cnpj, int index) {
        return cnpj.charAt(index) - '0';
    }
}
Enter fullscreen mode Exit fullscreen mode

On a real Cielo slip, Gemma read the CNPJ as 13.438.977/0001-00 three times out of three. The real one is 13.458.397/0001-01. The check digits rejected it every time, and the review screen put the photo next to the red field. It also read the authorization code 375579 as 375759. No check digit covers that code, so nothing caught it. That is exactly why a person confirms every receipt.

How often it is right

Seven receipts, three readings each, run through the app's own validation:

Of 210 fields Count
Right on the first reading 200 (95%)
Wrong, and the domain flags that field 3
Wrong, in a receipt the domain blocks for another field 4
Wrong, and nothing flags it 3

Then a real gas station's day: a Getnet debit slip (3 of 3 ready to confirm as read), an NFC-e paid in cash (2 of 3, the 44-digit access key right every time), a Pix paid on the card machine, and a handwritten cheque. The small sample is the honest caveat: treat these as a sanity check, not a benchmark.

What I learned the hard way

Every field must be required in the schema. Ollama uses the JSON schema LangChain4j sends as a grammar, and by default no field is required. Gemma's reasoning said "installments = 5" and the answer simply left the key out. Marking every field @JsonProperty(required = true) took the share of right fields from 88% to 95%.

The model cannot say null. With every field required and typed as a string, it writes the word "null", or just ", ", when a field is not on the receipt. The adapter turns those into a missing field.

Gintoki spilling his drink

Thinking matters more than speed. With thinking off, a reading takes about 2 seconds instead of 15. It also took an item price as the NFC-e total and the card brand as the store name: wrong values in a valid format, which no check digit can catch. Thinking stayed on.

Vegeta powering up

Temperature 0 is not deterministic. The same photo gave different answers between runs, so every receipt in the accuracy tables was read three times. The NFC-e template's total, 146,46, came back right, then as 46,46, then as 4,00, in different runs.

Naruto in shock

Tell the model where things are printed. It returned the NFC-e note number as the access key until the prompt said the key is all 44 digits, in groups of four, under "Consulte pela Chave de Acesso". A Pix paid on the card machine kept coming back as a card slip until the prompt described it: an "ID/TRANSAÇÃO" and no card brand, no card number, no authorization code.

Some mistakes need the calendar, not a check digit. On a handwritten cheque, Gemma read the year "26" as 2022, three times out of three. A cheque that old can no longer be cashed in Brazil, so a cheque dated more than six months ago now gets a warning on review.

Joey trying hard to think

Nobody waits for the model

A warm reading takes about 15 seconds on a consumer GPU. I measured where it goes: the image takes under a second, and the rest is Gemma writing about 750 tokens of reasoning at 75 tokens a second. Turning the reasoning off cost accuracy, and asking for shorter reasoning changed nothing, so the app stopped waiting for it instead. The upload saves the photo and returns at once, a background worker reads it, and the line on today's list updates itself. If Ollama is down, the receipt still goes to review, empty, to be typed from the photo.

Java 25 doing real work

The receipt is a sealed interface with one record per type, so the mapper and the reports are exhaustive switches with record patterns and no default. Up to five photos can go in one upload: each is saved and queued on its own virtual thread inside a StructuredTaskScope (a preview in Java 25), so one bad photo never stops the others. A correlation id rides a ScopedValue into every log line of an upload, across the forked threads and into the background reader.

Why Does Open Innovation Matter?

Because with a closed API, this product could not be sold to the person it is for.

The data cannot leave the shop. A day of receipts is the business itself: what it sold, to whom, through which bank, from which supplier, and sometimes a customer's name and partial CPF. An open-weight Gemma on the shop's own GPU means the photos, the database and the model all stay on one computer. There is no privacy policy to read because there is no third party.

The cost does not grow with the work. A gas station photographs dozens of receipts a day. A per-call API turns every one of them into a line on a bill, so the busiest day costs the most. Local inference costs the electricity of about 15 seconds of GPU per receipt.

The model is a setting. KAKEIZU_MODEL swaps Gemma for any Ollama vision model, and the same check digits judge the new one. When a better small open model ships, the shop gets it for free.

Open models let me see why they fail. Because I ran Gemma myself, I could turn thinking on and off, read its reasoning, and see it decide "installments = 5" and then drop the key. That is how the required-schema fix was found. A closed endpoint would have shown me only a wrong answer.

My First User

My first user is that friend at the gas station. Midway through the week they sent me photos of a real day's receipts: a Getnet debit slip, a Pix paid on the card machine, an NFC-e paid in cash, and a handwritten cheque. The station stays anonymous here, and none of those receipts are in the repository.

That one batch changed the product more than any test I wrote:

  • A Pix paid on the card machine has no end-to-end ID. The slip prints the machine's own transaction ID, so the Pix ID became optional and the prompt learned to tell this slip from a card slip.
  • Gas stations take cheques. Kakeizu now reads them, checks the magnetic line's three check digits when it is typed, and treats the cheque the other way round: the CNPJ on it is the payer's, so a cheque from another company is a sale. Shops that do not take cheques switch this off.
  • The NFC-e access key is printed far from its label. The prompt now says exactly where to find all 44 digits.

After testing it, they want to use it every day.

Gintoki saying

Prize Categories

Best Use of Gemma. Gemma 4 (gemma4:e4b) runs locally through Ollama and does all the reading: receipt type, amount, date, counterparty, tax ID, access keys and payment details, from phone photos of thermal slips, fiscal coupons and handwritten cheques. Kakeizu is built around both its strengths and its mistakes: thinking stays on because it buys accuracy, and every field it returns goes through deterministic checks before a person confirms it.

Top comments (3)

Collapse
 
phaz profile image
Ilia Ivanov •

Using the mod-11 check digit as the referee is a great call, the model cannot argue with arithmetic. When a check fails, is it usually one misread digit or a bad photo? I wonder if a failed check could trigger a second read of only that field before it goes to the person.

Collapse
 
mauricioandrade profile image
Mauricio Andrade •

Thanks, Ilia! "The model cannot argue with arithmetic" is exactly the spirit of it.

Honestly, I don't have a solid answer yet. My test set was small and most of the receipts were very similar, so I couldn't see a clear pattern between misread digits and bad photos. My intuition is that a bad photo would break several fields at once, while a single misread digit would leave the rest intact, which could be a cheap way to tell them apart. Testing that with a more varied set of receipts is next on my list.

Your idea of re-reading only the failing field is great, and I'm adding it to the roadmap. One detail makes it even more interesting: mod 11 always detects a single wrong digit, but it can't tell which one. So the plan is: on a failed check, crop that region and ask Gemma to read just those digits again. If it still fails, try the usual OCR confusions (8↔3, 1↔7, 0↔8) position by position, and if exactly one candidate passes the check, show it as a suggestion. Either way, it still goes to the person for confirmation. The model suggests, the human decides.

I opened an issue for it here: github.com/mauricioandrade/kakeizu.... Contributions are very welcome!

Either way, I'll look into it and try to solve it. Thanks!

Collapse
 
phaz profile image
Ilia Ivanov •

Crop and re-read, then try the usual OCR confusions and suggest only when exactly one candidate passes, that's a clean design. Glad the idea helped, good luck with the more varied test set.