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 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())