cemacqr 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cemacqr/__init__.py +96 -0
- cemacqr/__main__.py +310 -0
- cemacqr/builder.py +345 -0
- cemacqr/crc.py +92 -0
- cemacqr/errors.py +84 -0
- cemacqr/payload.py +281 -0
- cemacqr/profile.py +129 -0
- cemacqr/py.typed +0 -0
- cemacqr/tags.py +242 -0
- cemacqr/tlv.py +141 -0
- cemacqr/validate.py +507 -0
- cemacqr-0.1.1.dist-info/METADATA +487 -0
- cemacqr-0.1.1.dist-info/RECORD +16 -0
- cemacqr-0.1.1.dist-info/WHEEL +4 -0
- cemacqr-0.1.1.dist-info/entry_points.txt +2 -0
- cemacqr-0.1.1.dist-info/licenses/LICENSE +202 -0
cemacqr/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""cemacqr — build, read and check the CEMAC common QR code.
|
|
2
|
+
|
|
3
|
+
The QR Code communautaire de la CEMAC was homologated by *Règlement
|
|
4
|
+
n° 02/26/CEMAC/UMAC/CORENOFI du 8 avril 2026* and launched in Douala on
|
|
5
|
+
29 July 2026. It is built on **EMV QRCPS v1.1** and **ISO/IEC 18004**, encoded as
|
|
6
|
+
TLV, and routed by GIMAC across the six member states. The BEAC's stated target
|
|
7
|
+
is 120,000 codes deployed by 2027, and every bank, microfinance institution,
|
|
8
|
+
payment institution and electronic-money issuer in the union must be able to
|
|
9
|
+
generate and read them.
|
|
10
|
+
|
|
11
|
+
This library implements the public half of that stack. No dependency, no
|
|
12
|
+
registration, no permission to ask for.
|
|
13
|
+
|
|
14
|
+
Quick start
|
|
15
|
+
-----------
|
|
16
|
+
|
|
17
|
+
>>> from cemacqr import parse_payload
|
|
18
|
+
>>> qr = parse_payload(
|
|
19
|
+
... "00020101021229300012D156000000000510A93FO3230Q31280012D15600000001030812"
|
|
20
|
+
... "345678520441115802CN5914BEST TRANSPORT6007BEIJING64200002ZH0104\u6700\u4f73\u8fd0\u8f93"
|
|
21
|
+
... "0202\u5317\u4eac540523.7253031565502016233030412340603***0708A60086670902ME"
|
|
22
|
+
... "91320016A0112233449988770708123456786304A13A"
|
|
23
|
+
... )
|
|
24
|
+
>>> qr.merchant_name
|
|
25
|
+
'BEST TRANSPORT'
|
|
26
|
+
>>> qr.crc_ok
|
|
27
|
+
True
|
|
28
|
+
|
|
29
|
+
The one thing to know before anything else
|
|
30
|
+
------------------------------------------
|
|
31
|
+
|
|
32
|
+
The checksum is a **16-bit** CRC — polynomial ``0x1021``, initial value
|
|
33
|
+
``0xFFFF`` — rendered as four hexadecimal characters. It is not CRC-32. Several
|
|
34
|
+
published descriptions of the CEMAC QR code say CRC-32, and an implementation
|
|
35
|
+
built from those descriptions will produce codes that no reader accepts. See
|
|
36
|
+
:mod:`cemacqr.crc`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from .builder import MerchantQr
|
|
42
|
+
from .crc import CRC_INITIAL, CRC_POLYNOMIAL, crc16, crc16_hex
|
|
43
|
+
from .errors import (
|
|
44
|
+
CemacQrError,
|
|
45
|
+
ProfileIncomplete,
|
|
46
|
+
QrSyntaxError,
|
|
47
|
+
QrValidationError,
|
|
48
|
+
UnsupportedProfile,
|
|
49
|
+
)
|
|
50
|
+
from .payload import EmvQr, build, parse_payload
|
|
51
|
+
from .profile import CEMAC, CEMAC_COUNTRIES, CEMAC_CURRENCY, EMVCO, Profile, get_profile
|
|
52
|
+
from .tags import TagSpec, resolve
|
|
53
|
+
from .tlv import Tlv, encode, parse_tlv
|
|
54
|
+
from .validate import ValidationReport, Violation, prompt_requested, validate
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.1"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"__version__",
|
|
60
|
+
# payload
|
|
61
|
+
"EmvQr",
|
|
62
|
+
"parse_payload",
|
|
63
|
+
"build",
|
|
64
|
+
# builder
|
|
65
|
+
"MerchantQr",
|
|
66
|
+
# checksum
|
|
67
|
+
"crc16",
|
|
68
|
+
"crc16_hex",
|
|
69
|
+
"CRC_POLYNOMIAL",
|
|
70
|
+
"CRC_INITIAL",
|
|
71
|
+
# tlv
|
|
72
|
+
"Tlv",
|
|
73
|
+
"encode",
|
|
74
|
+
"parse_tlv",
|
|
75
|
+
# tags
|
|
76
|
+
"TagSpec",
|
|
77
|
+
"resolve",
|
|
78
|
+
# profiles
|
|
79
|
+
"Profile",
|
|
80
|
+
"EMVCO",
|
|
81
|
+
"CEMAC",
|
|
82
|
+
"CEMAC_CURRENCY",
|
|
83
|
+
"CEMAC_COUNTRIES",
|
|
84
|
+
"get_profile",
|
|
85
|
+
# validation
|
|
86
|
+
"validate",
|
|
87
|
+
"ValidationReport",
|
|
88
|
+
"Violation",
|
|
89
|
+
"prompt_requested",
|
|
90
|
+
# errors
|
|
91
|
+
"CemacQrError",
|
|
92
|
+
"QrSyntaxError",
|
|
93
|
+
"QrValidationError",
|
|
94
|
+
"ProfileIncomplete",
|
|
95
|
+
"UnsupportedProfile",
|
|
96
|
+
]
|
cemacqr/__main__.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Command line interface: ``python -m cemacqr`` or ``cemacqr``.
|
|
2
|
+
|
|
3
|
+
Exit codes are part of the contract, because the useful way to run this is in a
|
|
4
|
+
build pipeline or a merchant onboarding script:
|
|
5
|
+
|
|
6
|
+
==== ==========================================================
|
|
7
|
+
0 the payload is valid
|
|
8
|
+
1 the payload is invalid, or the checksum does not match
|
|
9
|
+
2 the invocation was wrong (bad option, unreadable file)
|
|
10
|
+
==== ==========================================================
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .crc import crc16_hex
|
|
22
|
+
from .errors import CemacQrError
|
|
23
|
+
from .payload import parse_payload
|
|
24
|
+
from .profile import PROFILES, get_profile
|
|
25
|
+
from .tags import ROOT, merchant_account_info_owner
|
|
26
|
+
from .tlv import parse as parse_tlv
|
|
27
|
+
from .validate import validate
|
|
28
|
+
|
|
29
|
+
EXIT_OK = 0
|
|
30
|
+
EXIT_INVALID = 1
|
|
31
|
+
EXIT_USAGE = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_payload(args: argparse.Namespace) -> str:
|
|
35
|
+
"""Payload from the argument, from ``--file``, or from standard input."""
|
|
36
|
+
if getattr(args, "payload", None):
|
|
37
|
+
return args.payload
|
|
38
|
+
if getattr(args, "file", None):
|
|
39
|
+
with open(args.file, encoding="utf-8") as handle:
|
|
40
|
+
return handle.read().strip()
|
|
41
|
+
data = sys.stdin.read().strip()
|
|
42
|
+
if not data:
|
|
43
|
+
raise SystemExit("error: no payload given (pass it as an argument, with --file, or on stdin)")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -- commands -------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_parse(args: argparse.Namespace) -> int:
|
|
51
|
+
payload = _read_payload(args)
|
|
52
|
+
qr = parse_payload(payload)
|
|
53
|
+
|
|
54
|
+
if args.json:
|
|
55
|
+
print(
|
|
56
|
+
json.dumps(
|
|
57
|
+
{
|
|
58
|
+
"payload": qr.to_string(),
|
|
59
|
+
"objects": [{"id": item.tag, "value": item.value} for item in qr.objects],
|
|
60
|
+
"crc_stated": qr.stated_crc,
|
|
61
|
+
"crc_computed": qr.computed_crc,
|
|
62
|
+
"crc_ok": qr.crc_ok,
|
|
63
|
+
"merchant_accounts": qr.merchant_accounts,
|
|
64
|
+
},
|
|
65
|
+
ensure_ascii=False,
|
|
66
|
+
indent=2,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
return EXIT_OK
|
|
70
|
+
|
|
71
|
+
print("payload %s" % qr.to_string())
|
|
72
|
+
print("objects %d" % len(qr.objects))
|
|
73
|
+
print()
|
|
74
|
+
for item in qr.objects:
|
|
75
|
+
spec = ROOT.get(item.tag)
|
|
76
|
+
if spec is not None:
|
|
77
|
+
label = spec.name
|
|
78
|
+
elif item.tag.isdigit() and 2 <= int(item.tag) <= 51:
|
|
79
|
+
owner = merchant_account_info_owner(item.tag)
|
|
80
|
+
label = "Merchant Account Information (%s)" % owner
|
|
81
|
+
else:
|
|
82
|
+
label = "unknown"
|
|
83
|
+
print(" ID %s %-46s %s" % (item.tag, label, item.value))
|
|
84
|
+
if args.expand:
|
|
85
|
+
for child in item.children():
|
|
86
|
+
print(" └ ID %s %s" % (child.tag, child.value))
|
|
87
|
+
|
|
88
|
+
print()
|
|
89
|
+
for name, value in (
|
|
90
|
+
("Payload Format Indicator", qr.payload_format_indicator),
|
|
91
|
+
("Point of Initiation", "static (reusable)" if qr.is_static else "dynamic (single use)")
|
|
92
|
+
if qr.is_static is not None
|
|
93
|
+
else None,
|
|
94
|
+
("Merchant Category Code", qr.merchant_category_code),
|
|
95
|
+
("Transaction Currency", qr.currency),
|
|
96
|
+
("Transaction Amount", qr.amount),
|
|
97
|
+
("Country Code", qr.country_code),
|
|
98
|
+
("Merchant Name", qr.merchant_name),
|
|
99
|
+
("Merchant City", qr.merchant_city),
|
|
100
|
+
):
|
|
101
|
+
if value is not None:
|
|
102
|
+
print(" %-24s %s" % (name, value))
|
|
103
|
+
return EXIT_OK
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_crc(args: argparse.Namespace) -> int:
|
|
107
|
+
"""The headline command: recompute the checksum and compare."""
|
|
108
|
+
payload = _read_payload(args)
|
|
109
|
+
|
|
110
|
+
marker = None
|
|
111
|
+
for item in parse_tlv(payload):
|
|
112
|
+
if item.tag == "63":
|
|
113
|
+
marker = len(payload) - len(item.value)
|
|
114
|
+
if marker is None:
|
|
115
|
+
print("no CRC object (ID 63) in the payload")
|
|
116
|
+
return EXIT_INVALID
|
|
117
|
+
|
|
118
|
+
computed = crc16_hex(payload[:marker].encode("utf-8"))
|
|
119
|
+
stated = payload[marker : marker + 4]
|
|
120
|
+
|
|
121
|
+
print("checksum covered %d characters (%d bytes)" % (marker, len(payload[:marker].encode("utf-8"))))
|
|
122
|
+
print("stated %s" % stated)
|
|
123
|
+
print("computed %s" % computed)
|
|
124
|
+
if stated.upper() == computed:
|
|
125
|
+
print("match")
|
|
126
|
+
return EXIT_OK
|
|
127
|
+
|
|
128
|
+
print()
|
|
129
|
+
print("MISMATCH.")
|
|
130
|
+
print("EMV QRCPS v1.1 section 4.7.3.1 requires a 16-bit checksum: polynomial 0x1021,")
|
|
131
|
+
print("initial value 0xFFFF, four hexadecimal characters. It is not CRC-32, and the")
|
|
132
|
+
print("two produce unrelated values.")
|
|
133
|
+
return EXIT_INVALID
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
137
|
+
payload = _read_payload(args)
|
|
138
|
+
qr = parse_payload(payload)
|
|
139
|
+
profile = get_profile(args.profile)
|
|
140
|
+
report = validate(qr, profile)
|
|
141
|
+
|
|
142
|
+
if args.json:
|
|
143
|
+
print(
|
|
144
|
+
json.dumps(
|
|
145
|
+
{
|
|
146
|
+
"status": report.status,
|
|
147
|
+
"ok": report.ok,
|
|
148
|
+
"fully_checked": report.fully_checked,
|
|
149
|
+
"profile": profile.name,
|
|
150
|
+
"violations": [
|
|
151
|
+
{"code": item.code, "tag": item.tag, "message": item.message} for item in report.violations
|
|
152
|
+
],
|
|
153
|
+
"unchecked": report.unchecked,
|
|
154
|
+
},
|
|
155
|
+
ensure_ascii=False,
|
|
156
|
+
indent=2,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
print(report)
|
|
161
|
+
|
|
162
|
+
return EXIT_OK if report.ok else EXIT_INVALID
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_profiles(args: argparse.Namespace) -> int:
|
|
166
|
+
for name, profile in sorted(PROFILES.items()):
|
|
167
|
+
print("%s — %s" % (name, profile.title))
|
|
168
|
+
if profile.legal_basis:
|
|
169
|
+
print(" legal basis: %s" % profile.legal_basis)
|
|
170
|
+
if profile.currencies:
|
|
171
|
+
print(" currencies : %s" % ", ".join(profile.currencies))
|
|
172
|
+
if profile.countries:
|
|
173
|
+
print(" countries : %s" % ", ".join(profile.countries))
|
|
174
|
+
if profile.undefined:
|
|
175
|
+
print(" NOT DEFINED IN THIS PROFILE (%d):" % len(profile.undefined))
|
|
176
|
+
for item in profile.undefined:
|
|
177
|
+
print(" - %s" % item)
|
|
178
|
+
print()
|
|
179
|
+
return EXIT_OK
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_tags(args: argparse.Namespace) -> int:
|
|
183
|
+
from . import tags as registry
|
|
184
|
+
|
|
185
|
+
groups = [
|
|
186
|
+
("root (EMV QRCPS v1.1, Table 3.3)", registry.ROOT),
|
|
187
|
+
("Additional Data Field Template, ID 62 (Table 3.7)", registry.ADDITIONAL_DATA_FIELD),
|
|
188
|
+
("Merchant Information—Language Template, ID 64 (Table 3.8)", registry.LANGUAGE_TEMPLATE),
|
|
189
|
+
("Merchant Account Information template, IDs 26-51 (Table 4.2)", registry.MERCHANT_ACCOUNT_INFO),
|
|
190
|
+
]
|
|
191
|
+
for title, table in groups:
|
|
192
|
+
print(title)
|
|
193
|
+
for tag, spec in sorted(table.items()):
|
|
194
|
+
print(" %s %-46s %-4s max %-3d %s" % (tag, spec.name, spec.format, spec.max_length, spec.presence))
|
|
195
|
+
print()
|
|
196
|
+
return EXIT_OK
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
200
|
+
from .builder import MerchantQr
|
|
201
|
+
|
|
202
|
+
builder = (
|
|
203
|
+
MerchantQr()
|
|
204
|
+
.merchant(name=args.name, city=args.city, country=args.country, postal_code=args.postal_code)
|
|
205
|
+
.category(args.mcc)
|
|
206
|
+
.currency(args.currency)
|
|
207
|
+
)
|
|
208
|
+
if args.amount is not None:
|
|
209
|
+
builder.amount(args.amount)
|
|
210
|
+
if args.reusable:
|
|
211
|
+
builder.reusable(True)
|
|
212
|
+
if args.bill:
|
|
213
|
+
builder.bill_number(args.bill)
|
|
214
|
+
if args.reference:
|
|
215
|
+
builder.reference(args.reference)
|
|
216
|
+
if args.alternate_language:
|
|
217
|
+
builder.alternate_language(language=args.alternate_language, merchant_name=args.name)
|
|
218
|
+
|
|
219
|
+
builder.account(args.account_id, gui=args.gui, merchant_pan=args.merchant_pan)
|
|
220
|
+
|
|
221
|
+
qr = builder.build()
|
|
222
|
+
if args.json:
|
|
223
|
+
print(json.dumps({"payload": qr.to_string(), "crc": qr.stated_crc}, ensure_ascii=False))
|
|
224
|
+
else:
|
|
225
|
+
print(qr.to_string())
|
|
226
|
+
if args.gui.startswith("com.example"):
|
|
227
|
+
print()
|
|
228
|
+
print("note: the GUI is a placeholder. CEMAC has not published the identifier it", file=sys.stderr)
|
|
229
|
+
print(" allocates to participants, so this payload is valid EMVCo and will not", file=sys.stderr)
|
|
230
|
+
print(" be accepted by a participant institution until that value is known.", file=sys.stderr)
|
|
231
|
+
return EXIT_OK
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# -- parser ---------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _add_payload_source(parser: argparse.ArgumentParser) -> None:
|
|
238
|
+
parser.add_argument("payload", nargs="?", help="the payload string")
|
|
239
|
+
parser.add_argument("--file", help="read the payload from a file instead")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
243
|
+
parser = argparse.ArgumentParser(
|
|
244
|
+
prog="cemacqr",
|
|
245
|
+
description="Build, read and check the CEMAC common QR code (EMV QRCPS v1.1).",
|
|
246
|
+
)
|
|
247
|
+
parser.add_argument("--version", action="version", version="cemacqr %s" % __version__)
|
|
248
|
+
sub = parser.add_subparsers(dest="command")
|
|
249
|
+
|
|
250
|
+
p_parse = sub.add_parser("parse", help="decode a payload into its data objects")
|
|
251
|
+
_add_payload_source(p_parse)
|
|
252
|
+
p_parse.add_argument("--expand", action="store_true", help="also decode nested templates")
|
|
253
|
+
p_parse.add_argument("--json", action="store_true", help="emit JSON")
|
|
254
|
+
p_parse.set_defaults(func=cmd_parse)
|
|
255
|
+
|
|
256
|
+
p_crc = sub.add_parser("crc", help="recompute the checksum and compare it")
|
|
257
|
+
_add_payload_source(p_crc)
|
|
258
|
+
p_crc.set_defaults(func=cmd_crc)
|
|
259
|
+
|
|
260
|
+
p_check = sub.add_parser("check", help="validate a payload")
|
|
261
|
+
_add_payload_source(p_check)
|
|
262
|
+
p_check.add_argument("--profile", default="emvco", help="profile to validate against (default: emvco)")
|
|
263
|
+
p_check.add_argument("--json", action="store_true", help="emit JSON")
|
|
264
|
+
p_check.set_defaults(func=cmd_check)
|
|
265
|
+
|
|
266
|
+
sub.add_parser("profiles", help="list the known profiles and what they cannot check").set_defaults(
|
|
267
|
+
func=cmd_profiles
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
sub.add_parser("tags", help="list the EMVCo data object registry").set_defaults(func=cmd_tags)
|
|
271
|
+
|
|
272
|
+
p_build = sub.add_parser("build", help="assemble a payload")
|
|
273
|
+
p_build.add_argument("--name", required=True, help="Merchant Name (59)")
|
|
274
|
+
p_build.add_argument("--city", required=True, help="Merchant City (60)")
|
|
275
|
+
p_build.add_argument("--country", required=True, help="Country Code (58), ISO 3166-1 alpha-2")
|
|
276
|
+
p_build.add_argument("--postal-code", help="Postal Code (61)")
|
|
277
|
+
p_build.add_argument("--mcc", required=True, help="Merchant Category Code (52)")
|
|
278
|
+
p_build.add_argument("--currency", default="950", help="ISO 4217 numeric code (default: 950, the CFA franc)")
|
|
279
|
+
p_build.add_argument("--amount", help="Transaction Amount (54); omit for a reusable code")
|
|
280
|
+
p_build.add_argument("--reusable", action="store_true", help="mark the code reusable (01 = 11)")
|
|
281
|
+
p_build.add_argument("--bill", help="Bill Number (ID 01 under 62)")
|
|
282
|
+
p_build.add_argument("--reference", help="Reference Label (ID 05 under 62)")
|
|
283
|
+
p_build.add_argument("--alternate-language", help="add template 64 with this ISO 639 language")
|
|
284
|
+
p_build.add_argument("--account-id", default="26", help="Merchant Account Information ID (26-51)")
|
|
285
|
+
p_build.add_argument("--gui", required=True, help="Globally Unique Identifier")
|
|
286
|
+
p_build.add_argument("--merchant-pan", help="Merchant PAN (ID 01 in the account template)")
|
|
287
|
+
p_build.add_argument("--json", action="store_true", help="emit JSON")
|
|
288
|
+
p_build.set_defaults(func=cmd_build)
|
|
289
|
+
|
|
290
|
+
return parser
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
294
|
+
parser = build_parser()
|
|
295
|
+
args = parser.parse_args(argv)
|
|
296
|
+
if not getattr(args, "command", None):
|
|
297
|
+
parser.print_help()
|
|
298
|
+
return EXIT_USAGE
|
|
299
|
+
try:
|
|
300
|
+
return args.func(args)
|
|
301
|
+
except CemacQrError as error:
|
|
302
|
+
print("error: %s" % error, file=sys.stderr)
|
|
303
|
+
return EXIT_INVALID
|
|
304
|
+
except (OSError, ValueError) as error:
|
|
305
|
+
print("error: %s" % error, file=sys.stderr)
|
|
306
|
+
return EXIT_USAGE
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__": # pragma: no cover
|
|
310
|
+
sys.exit(main())
|