| src/emvco_qrkit | ||
| tests | ||
| .gitignore | ||
| .python-version | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
emvco-qrkit
EMVCo Merchant-Presented QR (NepalQR / FonePay) toolkit: parse, mutate, validate and render scan-ready QR payloads in Python.
Compliant with the NRB NepalQR / EMVCo Merchant-Presented QR spec, so output is scannable by eSewa, Khalti, ConnectIPS, FonePay and all NepalQR-enabled apps. Verified against FonePay's app-issued static merchant QRs and observed dynamic-QR payloads.
Install
uv add emvco-qrkit
Quick start
from emvco_qrkit import MerchantQR
qr = MerchantQR.load(raw_string) # never raises
qr = MerchantQR(raw_string) # strict variant (raises on bad TLV)
qr.set_amount("150.00") # fluent mutation, returns self
.set_bill_number("INV-0007") # -> chain as much as you like
# or build one from scratch in a single call — numbers are fine for amount
qr = MerchantQR.build(
merchant_name="Sagarmatha Momo House",
city="Kathmandu",
merchant_code="9800000000", # your PSP-registered account/terminal ID
merchant_guid="fonepay.com", # confirm with your PSP
mcc="5812",
).set_amount(250).set_reference_label("Table 4")
qr.poi # "12" (auto-flipped by set_amount)
issues = qr.validate() # list[ValidationWarning] — NEVER raises
issues = qr.validate_detail() # list[ValidationIssue] with messages
qr.is_valid() # True if no ERROR-level issues
data = qr.breakdown() # ordered, documented field records
html = qr.to_html() # standalone HTML "page" of the data
md = qr.to_markdown() # markdown table
Validation model
validate() never crashes and never raises. It returns a list of
ValidationWarning enum members — the identity of each compliance
finding, e.g.:
[ValidationWarning.CONFLICTING_USE_OF_POI_AND_AMOUNT,
ValidationWarning.MERCHANT_NAME_TOO_LONG]
Each member carries a severity (.severity: ERROR / WARNING / NOTE), the
tag it concerns (.tag) and a message template (.message). Use
validate_detail() for fully rendered messages with the offending values.
Static vs dynamic + prefilled amounts
The NepalQR generators (e.g. qrsansar) switch a fixed amount to
Point-of-Initiation 12 (dynamic). set_amount(x) does the same by
default. If you want a reusable static code (POI 11) that still carries
a prefilled amount — e.g. an entry fee or donation box code — pass
poi="11":
qr.set_amount("500.00", poi="11") # static + amount
qr.validate() # -> [.. CONFLICTING_USE_OF_POI_AND_AMOUNT (WARNING)]
Most wallet apps read tag 54 and prefill regardless of POI, but POI-11-with- amount is outside the strict reading of the spec, so it is reported as a WARNING (not an error).
Command-line interface
# amount-preset dynamic QR from a vendor's static code
emvco-qrkit --from-data '<vendor payload>' --set amount=150.00 --validate
# static code with a fixed amount (reusable, e.g. entry fee)
emvco-qrkit --from-data '<payload>' --set amount=500.00 --set poi=11
# decode a photo and print fields
emvco-qrkit --from-image photo.jpg --get merchant_name --get merchant_city
# build from scratch and render the QR in the terminal
emvco-qrkit --set merchant_name=TEA HOUSE --set mcc=5812 --set amount=99.50 \
--output terminal_qr
# full HTML breakdown page
emvco-qrkit --from-data '<payload>' --output html --output-file breakdown.html
Options: --from-data PAYLOAD / --from-image PATH (needs the optional
image extra: uv add emvco-qrkit[image]), repeatable --set KEY=VALUE
applied left to right (empty value clears a field), repeatable --get KEY
(query mode), --validate, --output {raw,html,terminal_qr},
--crc-style {auto,emv,fonepay}, --output-file PATH.
Known FonePay quirks
Verified by scanning real merchant QRs and testing against the FonePay wallet:
- Payload Format Indicator (tag 00) is
"01"— canonical per EMVCo MPQR. - FonePay's generator computes tag 63 CRC over body +
"6304"header. Standard EMVCo covers body only. Both are checked;render()auto uses the FonePay style forfonepay.comGUIDs. - Remarks cannot be embedded in the QR. The FonePay wallet builds the remarks line client-side (e.g. "Payment of amount, 500.00") from the amount — no payload tag changes it. FonePay's own generator (qrsansar.com) exposes no remarks field either.
- The FonePay wallet only honors tag 54 (amount) in POI 12 mode, where
the amount and remarks scan locked. A POI 11 QR carrying tag 54
scans as a plain manual-input QR: amount NOT prefilled (tag 54
ignored) and remarks stay editable.
The one reference you can put in the QR is
bill_number→ tag 62 sub-tag 05 (Reference Label, max 25 chars) — the same field FonePay's generator calls "Bill / Reference Number". - The
purposefield (tag 62 sub-tag 08, Purpose of Transaction) can carry remarks-style text. eSewa surfaces it; Khalti ignores it.
Development
uv sync # install deps + dev tools (pytest, mypy)
uv run pytest # run the test suite
uv run mypy src tests # strict type check
License
GPL-2.0-only by default — see LICENSE.
Custom/commercial license versions are available on demand under a separate agreement with Emuren.