EMVCo Merchant-Presented QR (NepalQR / FonePay) toolkit: parse, mutate, and validate
Find a file
2026-08-08 17:35:02 +05:45
src/emvco_qrkit feat: MerchantQR.build() convenience constructor + numeric amounts 2026-08-07 03:36:31 +05:45
tests feat: MerchantQR.build() convenience constructor + numeric amounts 2026-08-07 03:36:31 +05:45
.gitignore chore: scaffold emvco-qrkit package with metadata, GPL-2.0 license 2026-08-07 01:47:41 +05:45
.python-version chore: pin project python version (3.12) 2026-08-07 01:47:45 +05:45
LICENSE docs: dual-license notice — GPL-2.0-only plus custom licenses via agreement with Emuren 2026-08-08 17:35:02 +05:45
pyproject.toml feat: CLI — load from data/image, --set/--get/--validate, raw/html/terminal_qr output 2026-08-07 02:07:07 +05:45
README.md docs: dual-license notice — GPL-2.0-only plus custom licenses via agreement with Emuren 2026-08-08 17:35:02 +05:45
uv.lock feat: CLI — load from data/image, --set/--get/--validate, raw/html/terminal_qr output 2026-08-07 02:07:07 +05:45

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:

  1. Payload Format Indicator (tag 00) is "01" — canonical per EMVCo MPQR.
  2. 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 for fonepay.com GUIDs.
  3. 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.
  4. 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".
  5. The purpose field (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.