How I built an order bot that refuses to trust payment screenshots
Fake GCash receipts are easy to make. My chat order demo marks an order paid only when the gateway confirms the money, and code, not AI, does the math.
A fake GCash receipt is an image. Anyone can make one, and GCash itself warns about AI-generated fake receipts and tells people to always check their own Transactions tab. If you take orders in chat and confirm payment by looking at a screenshot, that is exactly the gap.
So when I built a chat order assistant as a public demo, the one rule I cared about was simple. The chat can take the order. It cannot decide the order is paid.
You can try it at demos.sitsit.dev/sales. It's a sample bakery with test payments through Xendit, so no real money moves. The workflows run on n8n, and the replies come from Claude Haiku.
The screenshot never enters the flow
There is no "upload your receipt" step at all. When a customer confirms an order, code prices it from the menu and asks Xendit for a payment link. The customer pays on Xendit's page with GCash or Maya. Then Xendit calls my webhook.
Even that call isn't trusted on its own. The webhook rejects any request without Xendit's callback token, and then the workflow goes back to Xendit and asks for the invoice directly before it changes anything. This is the check, excerpted from the workflow's Verify payment step:
const checks = [
['Callback token matched', true],
['Xendit confirms the invoice is paid', ['PAID', 'SETTLED'].includes(inv.status)],
['Invoice belongs to this order', !!o.order_id && inv.external_id === o.order_id && inv.id === o.invoice_id],
['Amount and currency match (₱' + o.total + ')', inv.currency === 'PHP' && Number(inv.paid_amount ?? inv.amount) === Number(o.total)],
['Not already marked paid (no double count)', o.status === 'awaiting_payment'],
];
const verified = checks.every(c => c[1]);
inv is Xendit's own answer, not the callback body. o is the order I saved when the link was made. Only if every line passes does the order become PAID. If any line fails, the order isn't touched at all. On the demo page a paid order lists every check it passed, with a tick, because I think showing the receipt of the check is more convincing to a shop owner than telling them it's safe.
The first line is true because the token check already happened at the door. Writing it as a check anyway keeps the trace complete about what was verified.
The AI doesn't do the math either
The second way an order bot lies about money is quieter. A language model can say "₱450 po lahat" when the real total is ₱405, or get talked into "sige, 20% off." The prompt tells it not to, but nothing guarantees it won't.
So the model never sets a price. It returns item codes and quantities, and a plain function, priceOrder, does the arithmetic: line totals, subtotal, delivery fee, and how much the cart is short of the minimum order. The same file gets pasted into the chat, shop setup and checkout workflows, so the chat and the payment link can't disagree on a price.
Then checkReply reads what the model wrote before the customer sees it. Every amount written in pesos in the reply has to be a menu price, a delivery fee, the minimum order, or one of the numbers code just computed for this cart. Anything else, and the whole reply is swapped for a plain line that says the system computed the total shown below. A percentage discount gets the same treatment.
Where I got it wrong first
I got the discount part wrong in both directions before it held.
The first version was too strict, and it made the bot worse at saying no. A refusal like "wala pong 50% off" has a percentage next to "off" in it, so the guard replaced a perfectly good refusal with the canned summary line. Totals had the same problem: the model quoting the subtotal that code itself had computed got flagged, because the first version only allowed menu prices, fees and the minimum.
The fix passed the code-computed amounts in as allowed numbers, which was right. For discounts, it let a reply through if it contained a negation word, which was too loose. "Sure, 20% off for you, no problem!" passed, because "no" was in there somewhere.
The current version only accepts a negation that sits just before the percentage, in the same clause, and it reads "no problem" as agreement rather than a refusal. "Wala pong 50% off" passes. "20% off, no problem" gets replaced, and so do "No problem 20% off for you" and "wala pong 50% off, pero 20% off pwede." Variants of each are now test cases, and the fix is deployed on the demo.
I still treat this check as a first filter, not the guarantee. Matching refusals with a regex is a guess about language. The guarantee is the checkout: the payment link is built from priceOrder, not from anything the model said, so a promised discount can never reach the amount the customer pays.
What I'd change for a real shop
A few things would change before a paying shop used this.
The "not already marked paid" check is only as good as the write behind it. In production I'd make the status change conditional in the database itself (set paid only where the order is still awaiting payment), so a repeated or simultaneous callback finds nothing to change.
The owner's phone on the demo page is simulated too. On the sample shop it shows an order only after it's marked PAID, which is the behaviour I'd keep, but a real build would push a notification to the owner instead of a panel polling the order board.
And Xendit is there because it gave me test keys for GCash and Maya. The pattern doesn't depend on it. Any gateway that lets you authenticate its callback and then look the payment up again by ID works the same way.
The part I'd keep
The bot is allowed to be wrong. It can misquote a total or get sweet-talked, and the order still can't be marked paid unless the gateway, asked directly, says the right amount arrived for the right order. The discount bug above is a good example: it was real, and it still couldn't change what anyone paid.
The pricing and reply rules have plain assert tests. The payment side has a small attack script that sends a callback with no token, one with a wrong token, and one with the right token claiming PAID for an invoice Xendit says is unpaid. The last case is the one that matters: the order has to stay unpaid. The script only prints the status codes, so that part is confirmed by looking at the order board. If you sell through chat and still confirm payments by eye, place a test order on the demo and look at the checks it lists once it is paid.
- payments
- llm
- n8n