A Chat Cashier That Isn't Allowed to Count Money
The n8n agent I built for Kelas Beta: a keripik shop cashier on DOKU's MCP server, and why its real guardrails live in Postgres instead of the system prompt.
For the demo at Kelas Beta I wanted something the room could try on their own phones instead of watching me type. So I built a cashier for an imaginary small business, a shop selling keripik (cassava chips, banana chips, tempe chips, eight products in total), and put its chat behind a QR code on a slide. You order in plain Indonesian, the agent creates a real DOKU sandbox checkout, you pay it in DOKU’s public payment simulator, and then you tell the agent you’ve paid.
It’s built in n8n, uses a Gemini chat model, and reaches DOKU through the MCP client tool covered in part 2. This post is about how it’s put together, and mostly about where its guardrails turned out to be.

The shape of the workflow
Reading the canvas left to right:
- Chat trigger. Each phone gets its own session, so forty people ordering at once don’t share a conversation.
- “Di luar topik?” (off topic?). A filter that runs before the model sees anything. Obvious injection attempts (“ignore your instructions”, “act as”, “jailbreak”, “write me a Python script”) go to a polite refusal and never reach the agent.
- The agent, with memory, a structured output parser, and five tools: the DOKU MCP client (limited to four of its 35 tools), a Supabase read of the product catalogue, an HTTP call that totals an order, and a status update.
- “Pesanan sah?” (is the order valid?). After the agent says it has created a checkout, this gate checks three fields at once:
has_orderis true,invoice_numberisn’t empty,checkout_urlisn’t empty. If any one is missing, the order is never saved. - Save the order, reply, and if a payment is confirmed and the customer gave an email, send them a receipt.
- On error, email the admin and tell the customer something honest about a disruption.
I should be upfront about step 2, since a regex sitting in front of the model can look like an AI feature. It isn’t. It’s a blunt first line that catches sentences that sound like attacks, and it won’t catch an attack phrased as a snack order. Everything after it is built on the assumption that something will get through.
Two kinds of guardrail
The system prompt has rules, and one of them is in capitals: you may not calculate money. No subtotals, discounts, tax, shipping or change. The reason is written into the prompt too: the numbers that reach a payment system have to come from something auditable, and a language model isn’t that. Another rule says never to invent an invoice number, a payment status or an amount, and to call a tool instead.
Those rules help. I’d still describe them as a polite request, because a prompt can be argued with and a determined user will eventually find the phrasing that wins. The guardrails I trust sit a layer further down, in the database, where there’s nothing to argue with:
| Rule in Postgres | Why it’s there |
|---|---|
Money is numeric(19,2), never float |
Float sums drift, and drift never reconciles |
| Totals are computed by a SQL function | The model passes SKU and quantity; it never produces a price |
simpan_pesanan is atomic |
Stock is decremented and the order saved in one transaction, and one failing item rolls back the whole order |
invoice_number is UNIQUE |
Idempotency: if the agent retries, one invoice is still one row |
amount is NOT NULL |
Explained below, because it earned its place |
The catalogue view omits description |
Tool results go into the model’s context every turn, so an unused column is a token cost on every message |
The last one isn’t a safety rule, strictly speaking. I included it because it’s the kind of detail that only shows up once you’ve watched a tool result eat a few thousand tokens per turn.
amount NOT NULL, and the agent that said it had done it
While I was building this, the agent more than once told a customer it had created their checkout when it hadn’t. No tool call, no invoice, just a confident sentence. What stopped it was that the order row requires an amount, the amount only comes from a real checkout, and so the claimed-but-imaginary order couldn’t be saved. The “Pesanan sah?” gate catches the same lie one step earlier, by refusing to proceed without an invoice number and a checkout URL.
I think this is the most useful idea in the whole series, so I’ll say it plainly: design the system so that an agent’s false claim can’t be stored. Reviewing the agent’s output is good. Making its lies unrepresentable in your schema is much better, because it works at three in the morning when nobody is reviewing anything.
Failures that aren’t lies happen too. Here’s a real exchange from testing, where checkout creation failed, the agent said so honestly, and a retry worked:

Notice what it does after the link: it asks the customer to come back and type “cek status pesanan” with the invoice number. When they say they’ve paid, the agent doesn’t take their word for it. It calls get_transaction_by_invoice_number and repeats what DOKU says. DOKU is the only source of truth for whether money arrived.
Running out of stock is a feature
With eight products and a room full of people ordering at once, stock runs out quickly. That’s fine. When someone asks for 27 packs of tempe chips and there are 25, this is what comes back:

It looks like a careful assistant. It isn’t, really. The model knows nothing about stock. The SQL function refused, inside a transaction, and the agent relayed the refusal. If I reset the stock, I reset the database, not the agent, because the agent doesn’t hold any state worth resetting.
What it costs
This design has a price, and it’s mostly in flexibility. The agent can’t offer a discount, waive shipping or round a total, even when that would be the friendly thing to do, because none of those exist in the price table and the model is forbidden from doing arithmetic. Every new pricing behaviour means a schema change and a new SQL function rather than a line in the prompt. For a demo that’s mildly annoying. For a system that moves real money I think it’s exactly the trade you want, though I can see a product team finding it slow.
The other honest caveat: this is a demo for eight products and one shop. I haven’t run this pattern at the scale of a real marketplace, and I’d expect new problems there (concurrency on hot SKUs, for one) that a room of forty people doesn’t create.
The idea behind all three posts
Across the series the pattern repeats. In the build layer, what holds is a human gate and a document in git. In the operate layer, it’s the tools that were never put on the list. In this demo, it’s a NOT NULL column and a SQL function. None of them depend on the model behaving well, which is the only kind of guardrail I’d put in front of money.
Series from my Kelas Beta session, 23 September 2026: 1 · AI-assisted development · 2 · What DOKU’s MCP server is for · 3 · A chat cashier that can’t count money