Building a QRIS till app with Codex: notes on making QRIS-EDC from March to July 2026
I am Mohammad Hilmi Bisri, founder of PT Hanz Kode Terpadu (HanzCodes) in Jombang. According to the dates of the project files, I built QRIS-EDC from March to July 2026. QRIS-EDC is a free till app that runs in a phone's browser and shows a QRIS code with the exact amount for each transaction. QRIS is Indonesia's national QR payment standard. I wrote most of its code with OpenAI Codex (external site), a coding agent that, according to OpenAI, works in ChatGPT, in code editors, and in the terminal.
The project folders do not keep any chat logs with Codex, so what I say here about the role of Codex is based on my own account. I took the order of work and the technical details from the file dates in four working folders and from the git history of the backend. If you own a shop and are choosing how to accept QRIS, the comparison is in the article free QRIS till app.
QRIS-EDC started as my entry for the PIDI Digdaya X Hackathon 2026 by Bank Indonesia and OJK, under the working name Kasir Digdaya. It did not make the top 80. After the hackathon, I kept developing it and released it as a free app.
Order of work
| When | What was done |
|---|---|
| 1 March 2026 | First version: one HTML page with one JavaScript file and one CSS file. |
| 2 March 2026 | Split into several pages: till, items, reports, QRIS scanner, and settings. |
| 4 to 6 March 2026 | Rebuilt as a modular SPA (single-page application). The engine folder holds the logic for QRIS, stock, the printer, and syncing, the pages folder holds each screen, and the lib folder holds storage and utilities. I planned some large features in Markdown files first, for example reports and consent for sending data. |
| May 2026 | A FastAPI backend with a Turso database, running on Vercel. Its repository has 33 commits, all on 14 and 19 May. In the app, I added WebLLM, a library that runs small language models directly in the browser. |
| July 2026 | Records of customer debts (kasbon) and a guided tour inside the app. |
QRIS-EDC uses that language model to turn a spoken order, for example "dua kopi, gula kurang" (two coffees, less sugar), into items in the cart. Users choose the model themselves, for example a small version of Qwen or Gemma. The browser downloads the model weights to its cache the first time that model is used.
How I write prompts
In May 2026 I wrote down my prompt pattern in an internal guide:
- The base prompt gives the agent the role of a senior pragmatic software engineer who puts correctness, security, maintainability, and simple architecture first. The agent must not make up facts. If a detail blocks the work, the agent asks one short question.
- The agent does one task, verifies it, and only then moves to the next task. I do not ask for parallel work, and I do not combine unrelated changes in one request.
- Before writing code, the agent rewrites my rough idea as a technical specification: role, goal, context, constraints, files to read, order of work, how to verify, and definition of done. At this stage the agent also removes promotional wording, unfounded assumptions, and features outside the scope.
- If the plan still has gaps, the agent takes the role of an architect and interviews me. It asks at most five questions, only ones that block implementation, and gives a reason for each question.
I wrote the example prompt below for this article, following that pattern. The original prompt I used when I built the dynamic QRIS feature was not kept.
You are a senior pragmatic software engineer. Put correctness, security, maintainability, and simple architecture first. Do not make up facts. If a detail blocks you, ask one short question.
Goal: write a function generateDynamicQris in js/engine/qris.js that turns the shop's static QRIS into a dynamic QRIS with an amount, entirely on the device, with no server.
Context: read js/engine/qris.js and js/pages/pos.js before changing anything. The QRIS content uses the ID, length, and value format from the EMVCo Merchant-Presented Mode specification.
Constraints: do not change the merchant account data (IDs 26 to 51). The amount must be a positive integer. Do not add dependencies and do not touch other files.
Steps: change the value of ID 01 from 11 to 12; remove the old ID 54 if there is one; insert a new ID 54 directly before ID 58 by reading the data structure, not by searching the text; recalculate the CRC-16/CCITT with polynomial 0x1021 and initial value 0xFFFF. Do one step, verify it, then continue.
Verification: the CRC function must return 29B1 for the text 123456789. Also test with made-up data, not with the QRIS of a real shop.
Report: the files changed, the reasons, the test commands with their results, risks, and assumptions.
The technical core: dynamic QRIS created on the phone
The content of a QRIS code is text made up of data objects. Each data object consists of a two-digit ID, a two-digit length, and then the value. This format comes from the EMV QR Code Merchant-Presented Mode specification by EMVCo (external site). According to Mastercard's developer documentation (external site), which refers to that specification, ID 01 has the value 11 for a static QR code and 12 for a dynamic QR code. ID 54 holds the transaction amount, ID 58 holds the country code, and ID 63 holds the CRC, which must be the last data object.
All values in the following table are made-up data.
| ID | Meaning | Static QRIS | Dynamic QRIS |
|---|---|---|---|
| 00 | Format version | 01 | 01 |
| 01 | QR type | 11 | 12 |
| 26 to 51 | Merchant account data from the QRIS provider | As issued | Unchanged |
| 54 | Amount | None | 25000 |
| 58 | Country code | ID | ID |
| 59 | Merchant name | TOKO CONTOH | TOKO CONTOH |
| 63 | CRC | Calculated from the static QR content | Recalculated |
The function in QRIS-EDC changes ID 01, inserts ID 54, and then recalculates the CRC. The sketch below is a simplified version:
function buatDinamis(qris, rp) {
// drop "6304" and old CRC
let s = qris.slice(0, -8);
// ID 01: 11 (static) to 12
s = s.replace('010211',
'010212');
const n = String(rp);
const tag54 = '54' +
String(n.length)
.padStart(2, '0') + n;
// insert ID 54 before 58
const i = posisiTag(s, '58');
s = s.slice(0, i) + tag54
+ s.slice(i) + '6304';
return s + crc16(s);
}
The posisiTag function reads the text from the start, one data object at a time, until it finds ID 58. The digits 58 can appear inside other values, for example the merchant number, so a plain text search could insert the amount in the wrong place. The original code also removes any old ID 54 and rejects amounts that are not a whole number of rupiah.
The CRC is calculated over the whole text up to and including 6304, which is ID 63 with length 04. QRIS-EDC uses CRC-16/CCITT with polynomial 0x1021 and initial value 0xFFFF, then writes the result as four uppercase hexadecimal digits. To test your CRC function, run it on the text 123456789: the result must be 29B1.
Limits of this method
The QR code from this function uses the merchant account data from the shop's QRIS sticker. The shop's QRIS provider processes the payment, and the money goes into the account linked to that sticker. QRIS-EDC does not hold money and is not connected to the provider's system, so the app does not know whether a payment succeeded. The cashier still checks for the incoming money in the shop's bank or digital wallet app before saving the transaction. The QRIS provider's system also does not issue this QR code, and I do not claim this method is approved by Bank Indonesia or ASPI.
Problems where I made the call myself
Turso rejects a PRAGMA from SQLAlchemy
The backend uses SQLAlchemy with Turso (libSQL) through the sqlalchemy-libsql package. On the first connection, the SQLite dialect in SQLAlchemy runs PRAGMA read_uncommitted to read the transaction isolation level. Turso, accessed through the Hrana protocol, rejects that statement, and the connection fails with ValueError 405.
The fix is in the git history of the file app/db.py on 14 May 2026, in seven commits between 03:54 and 04:19 WIB (Western Indonesian Time). I turned off pool_pre_ping, then wrapped the get_isolation_level method so that it returns SERIALIZABLE, the default isolation level of SQLite, when that error occurs. After trying several URL formats, I went back to the format in the SQLAlchemy documentation for Turso (external site). The pattern looks like this, with the host and token as placeholders:
from sqlalchemy import \
create_engine
from sqlalchemy.dialects \
import sqlite
D = sqlite.base.SQLiteDialect
asli = D.get_isolation_level
def aman(self, conn):
try:
return asli(self, conn)
except Exception:
return "SERIALIZABLE"
D.get_isolation_level = aman
engine = create_engine(
"sqlite+libsql://HOST_DB"
"?secure=true",
# TOKEN read from env
connect_args={
"auth_token": TOKEN},
pool_pre_ping=False,
)
A telemetry plan for a million devices
QRIS-EDC has an optional feature that sends staple food price data (for inflation monitoring) and monthly transaction summaries (for alternative credit scoring). This feature is active only if the user agrees to it. On 6 March 2026 I wrote two backend plans, TELEMETRY_BE_PLAN.md and TELEMETRY_PYTHON_ENTERPRISE_PLAN.md, with a target of "1,000,000+ nodes". The design used Apache Kafka as a queue, Redis, InfluxDB for time-based price data, PostgreSQL, and Kubernetes to add capacity automatically.
For the hackathon demo, I chose a much smaller design: one FastAPI monolith, following IMPLEMENTATION_PLAN_SERVER.md. That document calls itself an MVP for the hackathon and demo, and gives its reasons: quick to build, light, and easy to move to PostgreSQL later. The plan put off Redis, Celery, PostgreSQL, and a complex credit scoring model.
I ran that backend on Vercel with a Turso database. The project's deployment document records the Vercel serverless limits that helped shape it:
- SQLite files do not persist in Vercel serverless functions, so the production database uses Turso.
- There are no background tasks that stay running. Cleanup of old data runs at random on about 2% of requests.
- It is better to run Alembic migrations manually before deploying, so that several cold starts do not run migrations at the same time. Automatic migration when the app starts is only a fallback.
This article is a record of building one app, not a substitute for the official QRIS rules. The code snippets above are simplified sketches. To accept QRIS payments, follow the rules of your shop's bank or QRIS provider.
Sources
- QRIS-EDC product page, HanzCodes (external site)
- Codex, OpenAI (external site)
- SQLAlchemy + Turso, Turso documentation (external site)
- EMV QR Code Specification for Payment Systems (EMV QRCPS) Merchant-Presented Mode, EMVCo (external site)
- Generate QR Code for Merchants, Mastercard Developers (external site)
Need help?
Tell us what your agency or business needs. The first discussion is free of charge.