DEV Community

Bowen Rui
Bowen Rui

Posted on Fully Autonomous

The steak weighed less than we ordered: settling a shared grocery order with Gemma

This is a submission for the Hacktoberfest Weekend Challenge: Build for a Friend

What I Built

My friend and I share Walmart grocery orders. In September my friend sent me 13 product links, I added 8 things of my own, and the order went on my card. When it arrived, we had to settle up from the receipt. It was more fiddly than it should have been:

  • The sirloin steak is sold by weight. I ordered it at $13.45; it weighed in at $11.79.
  • Three items were on Rollback (the detergent, the tuna and the chicken wings), so they cost less than their listed prices.
  • The tuna shipped separately from the rest of the order.
  • New Jersey doesn't tax groceries, so all $1.19 of tax came from one item: the Tide detergent ($17.94 × 6.625%), which was my friend's.
  • We split the $5 tip evenly.

The order total was $130.94. To get from that number to "who owes what", I had to match my friend's links to lines on the receipt, find the one item the tax came from, and remember that the steak's price had changed. The answer:

My friend Me
Items 13 items, $90.58 8 items, $34.17
Tax $1.19 (the Tide) $0.00
Tip $2.50 $2.50
Share $94.27 $36.67

So my friend owed me $94.27. Working that out was fiddly, and we'll be doing it again after the next order, so I built splitcart. You paste the group chat where we said what we wanted, paste the receipt (or a screenshot of it), check what Gemma read, and get a plain message to send back to the chat.

I built it for my friend and me, and for the way we actually order: links in a chat, one card, one receipt. It can handle more people, but that's who it's for.

Before writing this, I ran it once more on our September order, the way we'd actually use it: paste the chat, paste the receipt, press the button. Gemma took 37 seconds and cost $0.0011. All four checks came up green: items match the subtotal, the total adds up, the steak's weighed price, the tax on the Tide. All 21 items went to the right person without a single edit, and the message at the end said the same thing we settled on in September: my friend owes me $94.27.

Demo

There's no public deployment. It's a small server you run on your own laptop with your own API key, because the receipt and the chat should stay on your machine until the personal details are stripped out. Here is the real app running on a synthetic copy of our September order (same 21 items and prices, fake address, card and order number):

splitcart demo: paste, cover, check, review, settle

The wait while Gemma reads the screenshot is sped up 8x in the GIF; in reality it takes about 40 seconds. Full video.

The flow:

  1. Paste. The chat goes on the left. The receipt goes on the right, as pasted text or a screenshot. On a screenshot, you drag black boxes over your address, card digits and order number.
  2. Check what gets sent. Names become Person A and Person B. Code removes the address, card digits, order number, phone numbers and link tracking. This screen shows exactly what will leave the laptop.
  3. Review. You see Gemma's reading of every line, with the weighed steak and the Rollback prices flagged. Code checks that the items add up to the subtotal, and that subtotal + tax + tip equals the total. Every owner, price and "taxed" box can be changed.
  4. Settle up. You get each person's share with a short explanation, and a message ready to paste:
Walmart order, Sep 6: total $130.94, paid by Me.
Friend owes Me $94.27.

Friend: $94.27
  - 13 items $90.58
  - 3 Rollback prices already included (saved $6.76)
  - tax $1.19 (on Tide Ultra Odor Defense Plus)
  - half the tip $2.50
Me: $36.67
  - 8 items $34.17
  - Sirloin Tender Steak was sold by weight: estimated $13.45, charged $11.79
  - half the tip $2.50

Tax $1.19 is on Tide Ultra Odor Defense Plus (the only taxed item, $17.94): $17.94 x 6.625% = $1.19, which matches the receipt.
Tip $5.00, split evenly.
Enter fullscreen mode Exit fullscreen mode

What gets sent: names replaced, screenshot covered and cut into tiles

Review: weighed steak and Rollback prices flagged, the words Gemma matched on, owners editable

Code

GitHub logo Bowen1314 / splitcart

Settle a shared grocery order from the group chat and the receipt. Gemma reads, code does the math.

splitcart

Settle a shared grocery order from the group chat and the receipt.

My friend and I split Walmart orders. After the September order we had to work out who owed what from the receipt a steak sold by weight that came in cheaper than ordered, three Rollback prices, tuna that shipped separately sales tax on exactly one item (detergent; groceries aren't taxed in New Jersey), and a $5 tip split evenly. splitcart does that settle-up: paste the chat where people said what they wanted, paste (or screenshot) the receipt, check the result, copy the message.

demo

How it works

 chat text ─┐                       ┌─> Gemma 3 27B: receipt -> JSON items      ┐
 receipt  ──┼─> redact + alias  ────┤   (text, or screenshot tiles)             ├─> validate -> checks -> review -> settle
 screenshot ┘   (local, in code)    └─> Gemma 3 27B: item -> who asked for it   ┘   (local)     (local)            (local, integer cents)
  1. …

The server uses only the Python standard library, the UI is plain JavaScript, and Pillow cuts up screenshots. MIT licensed.

How I Built It

The model: Gemma 3 27B, open weights. splitcart calls google/gemma-3-27b-it on Nebius Token Factory through the standard OpenAI-compatible chat API. I picked it because it reads images, it follows a JSON schema, and it's an open-weight model that can run anywhere, not only behind one company's API.

Gemma reads, code does the math. The model has exactly two jobs, and both answers must match a JSON schema.

Job 1: transcribe the receipt. For each line, Gemma returns the name, quantity, final price, the estimate for weighed items, the "was" price, and flags for weighed, discounted, substituted, out of stock and taxable. This is its actual answer for the steak:

{"name": "Sirloin Tender Steak, Choice Angus Beef, 3-4 per Tray, Fresh, 0.6 - 1.62 lb",
 "qty": 1, "price": "11.79", "estimated_price": "13.45", "was_price": null,
 "weighed": true, "discounted": false, "substituted": false,
 "out_of_stock": false, "taxable": false}
Enter fullscreen mode Exit fullscreen mode

Job 2: match each item to the person who asked for it. Gemma must quote the words in the chat it relied on, or answer unknown instead of guessing. It only ever sees Person A and Person B. Its answer for the steak was {"item": 14, "person": "Person B", "evidence": "a sirloin"}, which the app maps back to "Me" locally.

Everything with a dollar sign happens in Python, in whole cents. Each item goes to its owner, and the tax goes to whoever owns the taxed items. The tip can be split evenly, by subtotal, or by custom amounts. Every split uses the largest-remainder method, so the shares always add up to the receipt total, to the cent:

def allocate(total: int, weights: list[int]) -> list[int]:
    """Split `total` cents in proportion to `weights`; the parts always sum to `total`."""
    whole = sum(weights)
    shares = [total * w // whole for w in weights]
    remainders = [total * w % whole for w in weights]
    leftover = total - sum(shares)
    for i in sorted(range(len(weights)), key=lambda i: (-remainders[i], i))[:leftover]:
        shares[i] += 1
    return shares
Enter fullscreen mode Exit fullscreen mode

The model never adds anything up, not even the fees: it lists each fee line, and the code sums them. A model that's right about arithmetic 99% of the time is still wrong about money.

Nothing from the model is trusted until it's validated. Prices must be clean two-decimal amounts, so "1.785" is rejected, not rounded. Every field must be present, and every owner must be one of the people in the chat. If the JSON fails, Gemma gets one retry listing the exact problems (items[4].price: more than two decimals). Then the code checks the receipt against itself: do the items add up to the subtotal, and does subtotal + tax + tip + fees equal the total? If you enter the tax rate, it checks the tax too.

Working around how Gemma 3 sees images. Gemma 3's vision encoder takes a fixed 896×896 image. A phone screenshot of a 21-item order is about 1170×7600 pixels, and squashed into one square it's unreadable. So splitcart cuts the screenshot into square tiles. It cuts only through blank rows, so no line of text is split, and pads each tile so nothing is stretched. The September screenshot becomes 7 tiles, and reading them costs about 2,400 input tokens.

Privacy before the model call. A receipt carries your home address, the last four digits of your card and an order number. None of them are needed to split a bill. Before anything is sent, redact.py removes them, plus phone numbers, emails and tracking parameters on links. Chat names are replaced with Person A, Person B. Screenshots can't be scanned without sending them somewhere, so you cover them yourself, and the boxes are flattened into the pixels before upload.

How well it works. I rebuilt our September order as a synthetic test case: the same 21 items and prices, with a fake address, card and order number. I also wrote a small made-up stress case with three people, a shared item, a substitution, an out-of-stock item, a quantity of 2, two fees, and one item nobody asked for. Then I scored every field Gemma returned against the right answers (final prompt, temperature 0):

What Gemma read Runs Items, prices, flags, owners Receipt totals Settlement
Our order, pasted as text 3 21/21, every field right 6/6 right: my friend owes me $94.27
Screenshot with my details covered (7 tiles) 3 21/21, every field right 6/6 right
Blurred, tilted, low-quality copy of that screenshot (6 tiles) 2 21/21, every field right 5/6 (delivery fee) $5.00 off until the flagged fee is fixed
Made-up stress case, 3 people, pasted text 2 10/10, every field right 6/6 right

The blurred copy is the interesting failure. Both runs read all 21 items, prices and owners correctly, but read the delivery fee, printed as "Free", as $5.00 (the $5.00 tip is on the next line). The arithmetic check catches it: the review screen shows the total check in red, "$135.94; receipt total $130.94. Without the $5.00 fees it adds up", and fixing it is one edit. This is exactly why the code checks the model. The stress case caught a different problem in an earlier version of the prompt: Gemma marked "Ground Beef, 1 lb" as sold by weight. One added sentence (a size in the product name is a package size, not weighing) fixed it.

A settlement from pasted text is two model calls, about 2,600 input and 3,000 output tokens. At Nebius's published prices for Gemma 3 27B ($0.10 and $0.30 per million tokens), that's about $0.0011 per settlement; from a screenshot it's about $0.0012. Everything I ran while building and testing this project, every eval run and the demo recordings, cost $0.0275 in model calls, under three cents.

There are 67 tests. They cover the arithmetic (2,000 random splits and 300 random orders, all of which must sum exactly), the September numbers, JSON validation, redaction, and the full pipeline with a scripted model, which checks that no personal detail ends up in the request.

Why Does Open Innovation Matter?

For a two-person grocery app, "open" comes down to four concrete things:

  • No lock-in. splitcart speaks the plain OpenAI-compatible API and points at Gemma 3 27B, whose weights are published. If Nebius raised its prices or disappeared, I'd change one setting, SPLITCART_BASE_URL, and point it at another provider or at a machine running the same weights. Today it calls a hosted copy instead of running the model on my laptop. That's a choice I can revisit, not a contract.
  • It costs almost nothing. About a tenth of a cent per settlement. If my friend and I settled an order every week for a year, that would be about six cents. Splitting groceries shouldn't need a subscription.
  • I can look inside it and change it. If Walmart changes its order page, or we start shopping somewhere else, I can fine-tune the same open model on our own receipts and keep the rest of the app as it is. With a closed model, I'd be waiting on someone else's roadmap.
  • Honest privacy. A hosted model means data leaves my laptop, so the app sends as little as possible: personal details are stripped in code, names are swapped for labels, and you see exactly what's sent before you press the button. Open weights also keep the fully local option real. On a machine with enough memory, the same app could run with nothing leaving it at all.

My Agent Session

I built this with an AI coding assistant (Claude Code). I described the problem and the rules: the model reads, the code does the math, nothing personal gets sent. I gave it our real receipt numbers, and it wrote the code, the tests and the evaluation script. Two of my calls shaped the build. Its first plan was to run a model on my laptop; I told it not to, so splitcart calls hosted Gemma, and that is why the redaction step exists. And at one point it had two different versions of our split in its notes, one with the tip divided by spend; I set it straight (we split the tip evenly), which is why "Split tip" is an explicit setting on the review screen instead of a hidden assumption.

AI disclosure: this project was built with an AI coding assistant, which wrote the code and drafted this post. The problem, the receipt, the numbers and the decisions are mine.

Prize Categories

  • Best Use of Gemma. Gemma 3 27B (open weights) reads the receipt, as text or as tiled screenshots, and matches items to people through schema-constrained JSON. Code does all the money math.

Top comments (0)