konthaina-khqr 0.1.2__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.
- konthaina_khqr/__init__.py +13 -0
- konthaina_khqr/cli.py +74 -0
- konthaina_khqr/crc16.py +270 -0
- konthaina_khqr/decode.py +8 -0
- konthaina_khqr/enums.py +17 -0
- konthaina_khqr/exceptions.py +2 -0
- konthaina_khqr/generator.py +193 -0
- konthaina_khqr/py.typed +0 -0
- konthaina_khqr/tlv.py +31 -0
- konthaina_khqr/verify.py +14 -0
- konthaina_khqr-0.1.2.dist-info/METADATA +145 -0
- konthaina_khqr-0.1.2.dist-info/RECORD +16 -0
- konthaina_khqr-0.1.2.dist-info/WHEEL +5 -0
- konthaina_khqr-0.1.2.dist-info/entry_points.txt +2 -0
- konthaina_khqr-0.1.2.dist-info/licenses/LICENSE +21 -0
- konthaina_khqr-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .decode import decode
|
|
2
|
+
from .enums import Currency, MerchantType
|
|
3
|
+
from .generator import KHQRGenerator, KHQRResult
|
|
4
|
+
from .verify import verify
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Currency",
|
|
8
|
+
"MerchantType",
|
|
9
|
+
"KHQRGenerator",
|
|
10
|
+
"KHQRResult",
|
|
11
|
+
"verify",
|
|
12
|
+
"decode",
|
|
13
|
+
]
|
konthaina_khqr/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .enums import Currency, MerchantType
|
|
7
|
+
from .generator import KHQRGenerator
|
|
8
|
+
from .verify import verify
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_args() -> argparse.Namespace:
|
|
12
|
+
p = argparse.ArgumentParser(
|
|
13
|
+
prog="khqr", description="Generate KHQR payload strings (Bakong / Cambodia)"
|
|
14
|
+
)
|
|
15
|
+
p.add_argument("--type", choices=["individual", "merchant"], default="individual")
|
|
16
|
+
p.add_argument("--bakong", required=True, help="Bakong account ID / username")
|
|
17
|
+
p.add_argument("--name", required=True, help="Merchant name")
|
|
18
|
+
p.add_argument("--merchant-id", help="Merchant ID (required for merchant type)")
|
|
19
|
+
p.add_argument("--bank", help="Acquiring bank (required for merchant type)")
|
|
20
|
+
p.add_argument("--currency", choices=["KHR", "USD"], default="KHR")
|
|
21
|
+
p.add_argument("--amount", type=float, help="Amount (optional)")
|
|
22
|
+
p.add_argument("--city", default="Phnom Penh")
|
|
23
|
+
p.add_argument("--bill", help="Bill number (optional)")
|
|
24
|
+
p.add_argument("--mobile", help="Mobile number (optional)")
|
|
25
|
+
p.add_argument("--png", help="Output PNG path (requires konthaina-khqr[qrcode])")
|
|
26
|
+
p.add_argument(
|
|
27
|
+
"--verify", action="store_true", help="Verify generated payload CRC and exit 0/1"
|
|
28
|
+
)
|
|
29
|
+
return p.parse_args()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_png(payload: str, out_path: str) -> None:
|
|
33
|
+
try:
|
|
34
|
+
import qrcode # type: ignore
|
|
35
|
+
except Exception as e: # pragma: no cover
|
|
36
|
+
raise SystemExit(
|
|
37
|
+
'PNG output requires optional dependency. Install: pip install "konthaina-khqr[qrcode]"'
|
|
38
|
+
) from e
|
|
39
|
+
|
|
40
|
+
img = qrcode.make(payload)
|
|
41
|
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
img.save(out_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
ns = _parse_args()
|
|
47
|
+
|
|
48
|
+
mtype = MerchantType.INDIVIDUAL if ns.type == "individual" else MerchantType.MERCHANT
|
|
49
|
+
g = KHQRGenerator(mtype).set_bakong_account_id(ns.bakong).set_merchant_name(ns.name)
|
|
50
|
+
|
|
51
|
+
if ns.type == "merchant":
|
|
52
|
+
if ns.merchant_id:
|
|
53
|
+
g.set_merchant_id(ns.merchant_id)
|
|
54
|
+
if ns.bank:
|
|
55
|
+
g.set_acquiring_bank(ns.bank)
|
|
56
|
+
|
|
57
|
+
g.set_currency(Currency.KHR if ns.currency == "KHR" else Currency.USD)
|
|
58
|
+
if ns.amount is not None:
|
|
59
|
+
g.set_amount(ns.amount)
|
|
60
|
+
if ns.city:
|
|
61
|
+
g.set_merchant_city(ns.city)
|
|
62
|
+
if ns.bill:
|
|
63
|
+
g.set_bill_number(ns.bill)
|
|
64
|
+
if ns.mobile:
|
|
65
|
+
g.set_mobile_number(ns.mobile)
|
|
66
|
+
|
|
67
|
+
res = g.generate()
|
|
68
|
+
print(res.qr)
|
|
69
|
+
|
|
70
|
+
if ns.png:
|
|
71
|
+
_write_png(res.qr, ns.png)
|
|
72
|
+
|
|
73
|
+
if ns.verify:
|
|
74
|
+
raise SystemExit(0 if verify(res.qr) else 1)
|
konthaina_khqr/crc16.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# CRC-16/CCITT-FALSE lookup table
|
|
4
|
+
CRC16_TABLE: list[int] = [
|
|
5
|
+
0x0000,
|
|
6
|
+
0x1021,
|
|
7
|
+
0x2042,
|
|
8
|
+
0x3063,
|
|
9
|
+
0x4084,
|
|
10
|
+
0x50A5,
|
|
11
|
+
0x60C6,
|
|
12
|
+
0x70E7,
|
|
13
|
+
0x8108,
|
|
14
|
+
0x9129,
|
|
15
|
+
0xA14A,
|
|
16
|
+
0xB16B,
|
|
17
|
+
0xC18C,
|
|
18
|
+
0xD1AD,
|
|
19
|
+
0xE1CE,
|
|
20
|
+
0xF1EF,
|
|
21
|
+
0x1231,
|
|
22
|
+
0x0210,
|
|
23
|
+
0x3273,
|
|
24
|
+
0x2252,
|
|
25
|
+
0x52B5,
|
|
26
|
+
0x4294,
|
|
27
|
+
0x72F7,
|
|
28
|
+
0x62D6,
|
|
29
|
+
0x9339,
|
|
30
|
+
0x8318,
|
|
31
|
+
0xB37B,
|
|
32
|
+
0xA35A,
|
|
33
|
+
0xD3BD,
|
|
34
|
+
0xC39C,
|
|
35
|
+
0xF3FF,
|
|
36
|
+
0xE3DE,
|
|
37
|
+
0x2462,
|
|
38
|
+
0x3443,
|
|
39
|
+
0x0420,
|
|
40
|
+
0x1401,
|
|
41
|
+
0x64E6,
|
|
42
|
+
0x74C7,
|
|
43
|
+
0x44A4,
|
|
44
|
+
0x5485,
|
|
45
|
+
0xA56A,
|
|
46
|
+
0xB54B,
|
|
47
|
+
0x8528,
|
|
48
|
+
0x9509,
|
|
49
|
+
0xE5EE,
|
|
50
|
+
0xF5CF,
|
|
51
|
+
0xC5AC,
|
|
52
|
+
0xD58D,
|
|
53
|
+
0x3653,
|
|
54
|
+
0x2672,
|
|
55
|
+
0x1611,
|
|
56
|
+
0x0630,
|
|
57
|
+
0x76D7,
|
|
58
|
+
0x66F6,
|
|
59
|
+
0x5695,
|
|
60
|
+
0x46B4,
|
|
61
|
+
0xB75B,
|
|
62
|
+
0xA77A,
|
|
63
|
+
0x9719,
|
|
64
|
+
0x8738,
|
|
65
|
+
0xF7DF,
|
|
66
|
+
0xE7FE,
|
|
67
|
+
0xD79D,
|
|
68
|
+
0xC7BC,
|
|
69
|
+
0x48C4,
|
|
70
|
+
0x58E5,
|
|
71
|
+
0x6886,
|
|
72
|
+
0x78A7,
|
|
73
|
+
0x0840,
|
|
74
|
+
0x1861,
|
|
75
|
+
0x2802,
|
|
76
|
+
0x3823,
|
|
77
|
+
0xC9CC,
|
|
78
|
+
0xD9ED,
|
|
79
|
+
0xE98E,
|
|
80
|
+
0xF9AF,
|
|
81
|
+
0x8948,
|
|
82
|
+
0x9969,
|
|
83
|
+
0xA90A,
|
|
84
|
+
0xB92B,
|
|
85
|
+
0x5AF5,
|
|
86
|
+
0x4AD4,
|
|
87
|
+
0x7AB7,
|
|
88
|
+
0x6A96,
|
|
89
|
+
0x1A71,
|
|
90
|
+
0x0A50,
|
|
91
|
+
0x3A33,
|
|
92
|
+
0x2A12,
|
|
93
|
+
0xDBFD,
|
|
94
|
+
0xCBDC,
|
|
95
|
+
0xFBBF,
|
|
96
|
+
0xEB9E,
|
|
97
|
+
0x9B79,
|
|
98
|
+
0x8B58,
|
|
99
|
+
0xBB3B,
|
|
100
|
+
0xAB1A,
|
|
101
|
+
0x6CA6,
|
|
102
|
+
0x7C87,
|
|
103
|
+
0x4CE4,
|
|
104
|
+
0x5CC5,
|
|
105
|
+
0x2C22,
|
|
106
|
+
0x3C03,
|
|
107
|
+
0x0C60,
|
|
108
|
+
0x1C41,
|
|
109
|
+
0xEDAE,
|
|
110
|
+
0xFD8F,
|
|
111
|
+
0xCDEC,
|
|
112
|
+
0xDDCD,
|
|
113
|
+
0xAD2A,
|
|
114
|
+
0xBD0B,
|
|
115
|
+
0x8D68,
|
|
116
|
+
0x9D49,
|
|
117
|
+
0x7E97,
|
|
118
|
+
0x6EB6,
|
|
119
|
+
0x5ED5,
|
|
120
|
+
0x4EF4,
|
|
121
|
+
0x3E13,
|
|
122
|
+
0x2E32,
|
|
123
|
+
0x1E51,
|
|
124
|
+
0x0E70,
|
|
125
|
+
0xFF9F,
|
|
126
|
+
0xEFBE,
|
|
127
|
+
0xDFDD,
|
|
128
|
+
0xCFFC,
|
|
129
|
+
0xBF1B,
|
|
130
|
+
0xAF3A,
|
|
131
|
+
0x9F59,
|
|
132
|
+
0x8F78,
|
|
133
|
+
0x9188,
|
|
134
|
+
0x81A9,
|
|
135
|
+
0xB1CA,
|
|
136
|
+
0xA1EB,
|
|
137
|
+
0xD10C,
|
|
138
|
+
0xC12D,
|
|
139
|
+
0xF14E,
|
|
140
|
+
0xE16F,
|
|
141
|
+
0x1080,
|
|
142
|
+
0x00A1,
|
|
143
|
+
0x30C2,
|
|
144
|
+
0x20E3,
|
|
145
|
+
0x5004,
|
|
146
|
+
0x4025,
|
|
147
|
+
0x7046,
|
|
148
|
+
0x6067,
|
|
149
|
+
0x83B9,
|
|
150
|
+
0x9398,
|
|
151
|
+
0xA3FB,
|
|
152
|
+
0xB3DA,
|
|
153
|
+
0xC33D,
|
|
154
|
+
0xD31C,
|
|
155
|
+
0xE37F,
|
|
156
|
+
0xF35E,
|
|
157
|
+
0x02B1,
|
|
158
|
+
0x1290,
|
|
159
|
+
0x22F3,
|
|
160
|
+
0x32D2,
|
|
161
|
+
0x4235,
|
|
162
|
+
0x5214,
|
|
163
|
+
0x6277,
|
|
164
|
+
0x7256,
|
|
165
|
+
0xB5EA,
|
|
166
|
+
0xA5CB,
|
|
167
|
+
0x95A8,
|
|
168
|
+
0x8589,
|
|
169
|
+
0xF56E,
|
|
170
|
+
0xE54F,
|
|
171
|
+
0xD52C,
|
|
172
|
+
0xC50D,
|
|
173
|
+
0x34E2,
|
|
174
|
+
0x24C3,
|
|
175
|
+
0x14A0,
|
|
176
|
+
0x0481,
|
|
177
|
+
0x7466,
|
|
178
|
+
0x6447,
|
|
179
|
+
0x5424,
|
|
180
|
+
0x4405,
|
|
181
|
+
0xA7DB,
|
|
182
|
+
0xB7FA,
|
|
183
|
+
0x8799,
|
|
184
|
+
0x97B8,
|
|
185
|
+
0xE75F,
|
|
186
|
+
0xF77E,
|
|
187
|
+
0xC71D,
|
|
188
|
+
0xD73C,
|
|
189
|
+
0x26D3,
|
|
190
|
+
0x36F2,
|
|
191
|
+
0x0691,
|
|
192
|
+
0x16B0,
|
|
193
|
+
0x6657,
|
|
194
|
+
0x7676,
|
|
195
|
+
0x4615,
|
|
196
|
+
0x5634,
|
|
197
|
+
0xD94C,
|
|
198
|
+
0xC96D,
|
|
199
|
+
0xF90E,
|
|
200
|
+
0xE92F,
|
|
201
|
+
0x99C8,
|
|
202
|
+
0x89E9,
|
|
203
|
+
0xB98A,
|
|
204
|
+
0xA9AB,
|
|
205
|
+
0x5844,
|
|
206
|
+
0x4865,
|
|
207
|
+
0x7806,
|
|
208
|
+
0x6827,
|
|
209
|
+
0x18C0,
|
|
210
|
+
0x08E1,
|
|
211
|
+
0x3882,
|
|
212
|
+
0x28A3,
|
|
213
|
+
0xCB7D,
|
|
214
|
+
0xDB5C,
|
|
215
|
+
0xEB3F,
|
|
216
|
+
0xFB1E,
|
|
217
|
+
0x8BF9,
|
|
218
|
+
0x9BD8,
|
|
219
|
+
0xABBB,
|
|
220
|
+
0xBB9A,
|
|
221
|
+
0x4A75,
|
|
222
|
+
0x5A54,
|
|
223
|
+
0x6A37,
|
|
224
|
+
0x7A16,
|
|
225
|
+
0x0AF1,
|
|
226
|
+
0x1AD0,
|
|
227
|
+
0x2AB3,
|
|
228
|
+
0x3A92,
|
|
229
|
+
0xFD2E,
|
|
230
|
+
0xED0F,
|
|
231
|
+
0xDD6C,
|
|
232
|
+
0xCD4D,
|
|
233
|
+
0xBDAA,
|
|
234
|
+
0xAD8B,
|
|
235
|
+
0x9DE8,
|
|
236
|
+
0x8DC9,
|
|
237
|
+
0x7C26,
|
|
238
|
+
0x6C07,
|
|
239
|
+
0x5C64,
|
|
240
|
+
0x4C45,
|
|
241
|
+
0x3CA2,
|
|
242
|
+
0x2C83,
|
|
243
|
+
0x1CE0,
|
|
244
|
+
0x0CC1,
|
|
245
|
+
0xEF1F,
|
|
246
|
+
0xFF3E,
|
|
247
|
+
0xCF5D,
|
|
248
|
+
0xDF7C,
|
|
249
|
+
0xAF9B,
|
|
250
|
+
0xBFBA,
|
|
251
|
+
0x8FD9,
|
|
252
|
+
0x9FF8,
|
|
253
|
+
0x6E17,
|
|
254
|
+
0x7E36,
|
|
255
|
+
0x4E55,
|
|
256
|
+
0x5E74,
|
|
257
|
+
0x2E93,
|
|
258
|
+
0x3EB2,
|
|
259
|
+
0x0ED1,
|
|
260
|
+
0x1EF0,
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def crc16_ccitt_false(data: str) -> str:
|
|
265
|
+
"""Return CRC-16/CCITT-FALSE as 4-char uppercase hex."""
|
|
266
|
+
crc = 0xFFFF
|
|
267
|
+
for ch in data:
|
|
268
|
+
c = ord(ch)
|
|
269
|
+
crc = ((crc << 8) ^ CRC16_TABLE[((crc >> 8) ^ c) & 0xFF]) & 0xFFFF
|
|
270
|
+
return format(crc, "04X")
|
konthaina_khqr/decode.py
ADDED
konthaina_khqr/enums.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Currency(str, Enum):
|
|
7
|
+
"""Currency numeric codes used in KHQR."""
|
|
8
|
+
|
|
9
|
+
KHR = "116"
|
|
10
|
+
USD = "840"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MerchantType(str, Enum):
|
|
14
|
+
"""Merchant account structure used in KHQR."""
|
|
15
|
+
|
|
16
|
+
INDIVIDUAL = "individual"
|
|
17
|
+
MERCHANT = "merchant"
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Union
|
|
7
|
+
|
|
8
|
+
from .crc16 import crc16_ccitt_false
|
|
9
|
+
from .enums import Currency, MerchantType
|
|
10
|
+
from .exceptions import KHQRValidationError
|
|
11
|
+
from .tlv import format_tag
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class KHQRResult:
|
|
16
|
+
qr: str
|
|
17
|
+
timestamp: str
|
|
18
|
+
type: str
|
|
19
|
+
md5: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KHQRGenerator:
|
|
23
|
+
"""KHQR payload generator (merchant-presented).
|
|
24
|
+
|
|
25
|
+
Generates KHQR payment payload strings compliant with the NBC KHQR v2.7-style layout.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, merchant_type: MerchantType = MerchantType.INDIVIDUAL):
|
|
29
|
+
self.merchant_type = merchant_type
|
|
30
|
+
self.data: dict[str, str] = {}
|
|
31
|
+
|
|
32
|
+
# -----------------------
|
|
33
|
+
# Builder setters
|
|
34
|
+
# -----------------------
|
|
35
|
+
def set_bakong_account_id(self, account_id: str) -> KHQRGenerator:
|
|
36
|
+
self.data["bakong_account_id"] = (account_id or "")[:32]
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def set_merchant_name(self, name: str) -> KHQRGenerator:
|
|
40
|
+
self.data["merchant_name"] = (name or "")[:25]
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def set_merchant_id(self, merchant_id: str) -> KHQRGenerator:
|
|
44
|
+
self.data["merchant_id"] = (merchant_id or "")[:32]
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def set_acquiring_bank(self, bank: str) -> KHQRGenerator:
|
|
48
|
+
self.data["acquiring_bank"] = (bank or "")[:32]
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def set_account_information(self, info: str) -> KHQRGenerator:
|
|
52
|
+
self.data["account_information"] = (info or "")[:32]
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def set_currency(self, currency: Union[str, Currency]) -> KHQRGenerator:
|
|
56
|
+
if isinstance(currency, str):
|
|
57
|
+
cur = currency.upper().strip()
|
|
58
|
+
currency = Currency.KHR if cur == "KHR" else Currency.USD
|
|
59
|
+
self.data["currency"] = currency.value
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def set_amount(self, amount: float) -> KHQRGenerator:
|
|
63
|
+
self.data["amount"] = f"{amount:.2f}"
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def set_merchant_city(self, city: str) -> KHQRGenerator:
|
|
67
|
+
self.data["merchant_city"] = (city or "")[:15]
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def set_bill_number(self, bill_number: str) -> KHQRGenerator:
|
|
71
|
+
self.data["bill_number"] = (bill_number or "")[:25]
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def set_mobile_number(self, mobile: str) -> KHQRGenerator:
|
|
75
|
+
self.data["mobile_number"] = (mobile or "")[:25]
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def set_store_label(self, label: str) -> KHQRGenerator:
|
|
79
|
+
self.data["store_label"] = (label or "")[:25]
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def set_terminal_label(self, label: str) -> KHQRGenerator:
|
|
83
|
+
self.data["terminal_label"] = (label or "")[:25]
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def set_purpose_of_transaction(self, purpose: str) -> KHQRGenerator:
|
|
87
|
+
self.data["purpose_of_transaction"] = (purpose or "")[:25]
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def set_upi_account_information(self, upi: str) -> KHQRGenerator:
|
|
91
|
+
self.data["upi_account_information"] = (upi or "")[:31]
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def set_language_preference(self, lang: str) -> KHQRGenerator:
|
|
95
|
+
self.data["language_preference"] = (lang or "")[:2]
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def set_merchant_name_alternate(self, name: str) -> KHQRGenerator:
|
|
99
|
+
self.data["merchant_name_alternate"] = (name or "")[:25]
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def set_merchant_city_alternate(self, city: str) -> KHQRGenerator:
|
|
103
|
+
self.data["merchant_city_alternate"] = (city or "")[:15]
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
# -----------------------
|
|
107
|
+
# Generate
|
|
108
|
+
# -----------------------
|
|
109
|
+
def generate(self) -> KHQRResult:
|
|
110
|
+
self._validate()
|
|
111
|
+
|
|
112
|
+
qr = ""
|
|
113
|
+
qr += format_tag("00", "01") # Payload Format Indicator
|
|
114
|
+
qr += format_tag("01", "12") # Point of Initiation Method (static)
|
|
115
|
+
|
|
116
|
+
# UPI Merchant Account (Tag 15) - Optional
|
|
117
|
+
if self.data.get("upi_account_information"):
|
|
118
|
+
qr += format_tag("15", self.data["upi_account_information"])
|
|
119
|
+
|
|
120
|
+
# Account information template
|
|
121
|
+
if self.merchant_type == MerchantType.INDIVIDUAL:
|
|
122
|
+
tag29 = format_tag("00", self.data["bakong_account_id"])
|
|
123
|
+
if self.data.get("account_information"):
|
|
124
|
+
tag29 += format_tag("01", self.data["account_information"])
|
|
125
|
+
if self.data.get("acquiring_bank"):
|
|
126
|
+
tag29 += format_tag("02", self.data["acquiring_bank"])
|
|
127
|
+
qr += format_tag("29", tag29)
|
|
128
|
+
else:
|
|
129
|
+
tag30 = format_tag("00", self.data["bakong_account_id"])
|
|
130
|
+
tag30 += format_tag("01", self.data["merchant_id"])
|
|
131
|
+
tag30 += format_tag("02", self.data["acquiring_bank"])
|
|
132
|
+
qr += format_tag("30", tag30)
|
|
133
|
+
|
|
134
|
+
qr += format_tag("52", "5999") # Merchant Category Code
|
|
135
|
+
qr += format_tag("53", self.data.get("currency", Currency.KHR.value)) # Currency
|
|
136
|
+
|
|
137
|
+
if self.data.get("amount"):
|
|
138
|
+
qr += format_tag("54", self.data["amount"])
|
|
139
|
+
|
|
140
|
+
qr += format_tag("58", "KH") # Country Code
|
|
141
|
+
qr += format_tag("59", self.data["merchant_name"])
|
|
142
|
+
qr += format_tag("60", self.data.get("merchant_city", "Phnom Penh"))
|
|
143
|
+
|
|
144
|
+
# Additional Data Field (Tag 62)
|
|
145
|
+
tag62 = ""
|
|
146
|
+
if self.data.get("bill_number"):
|
|
147
|
+
tag62 += format_tag("01", self.data["bill_number"])
|
|
148
|
+
if self.data.get("mobile_number"):
|
|
149
|
+
tag62 += format_tag("02", self.data["mobile_number"])
|
|
150
|
+
if self.data.get("store_label"):
|
|
151
|
+
tag62 += format_tag("03", self.data["store_label"])
|
|
152
|
+
if self.data.get("terminal_label"):
|
|
153
|
+
tag62 += format_tag("07", self.data["terminal_label"])
|
|
154
|
+
if self.data.get("purpose_of_transaction"):
|
|
155
|
+
tag62 += format_tag("08", self.data["purpose_of_transaction"])
|
|
156
|
+
if tag62:
|
|
157
|
+
qr += format_tag("62", tag62)
|
|
158
|
+
|
|
159
|
+
# Merchant Alternate Language (Tag 64)
|
|
160
|
+
tag64 = ""
|
|
161
|
+
if self.data.get("language_preference"):
|
|
162
|
+
tag64 += format_tag("00", self.data["language_preference"])
|
|
163
|
+
if self.data.get("merchant_name_alternate"):
|
|
164
|
+
tag64 += format_tag("01", self.data["merchant_name_alternate"])
|
|
165
|
+
if self.data.get("merchant_city_alternate"):
|
|
166
|
+
tag64 += format_tag("02", self.data["merchant_city_alternate"])
|
|
167
|
+
if tag64:
|
|
168
|
+
qr += format_tag("64", tag64)
|
|
169
|
+
|
|
170
|
+
# Timestamp (Tag 99) - proprietary
|
|
171
|
+
timestamp = str(int(time.time() * 1000))
|
|
172
|
+
qr += format_tag("99", format_tag("00", timestamp))
|
|
173
|
+
|
|
174
|
+
# CRC (Tag 63)
|
|
175
|
+
crc = crc16_ccitt_false(qr + "6304")
|
|
176
|
+
qr += "6304" + crc
|
|
177
|
+
|
|
178
|
+
return KHQRResult(
|
|
179
|
+
qr=qr,
|
|
180
|
+
timestamp=timestamp,
|
|
181
|
+
type=self.merchant_type.value,
|
|
182
|
+
md5=hashlib.md5(qr.encode("utf-8")).hexdigest(),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _validate(self) -> None:
|
|
186
|
+
if not self.data.get("bakong_account_id") or not self.data.get("merchant_name"):
|
|
187
|
+
raise KHQRValidationError("Bakong Account ID and Merchant Name are required")
|
|
188
|
+
|
|
189
|
+
if self.merchant_type == MerchantType.MERCHANT:
|
|
190
|
+
if not self.data.get("merchant_id") or not self.data.get("acquiring_bank"):
|
|
191
|
+
raise KHQRValidationError(
|
|
192
|
+
"Merchant ID and Acquiring Bank are required for merchant type"
|
|
193
|
+
)
|
konthaina_khqr/py.typed
ADDED
|
File without changes
|
konthaina_khqr/tlv.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_tag(tag: str, value: str) -> str:
|
|
5
|
+
"""Format EMVCo tag-length-value segment."""
|
|
6
|
+
if value is None or value == "":
|
|
7
|
+
return ""
|
|
8
|
+
length = str(len(value)).zfill(2)
|
|
9
|
+
return f"{tag}{length}{value}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def decode_tlv(payload: str) -> dict[str, str]:
|
|
13
|
+
"""Decode a *flat* TLV string into a dict of tag->value.
|
|
14
|
+
|
|
15
|
+
Notes:
|
|
16
|
+
- KHQR has nested templates (e.g., tag 29/30/62/64). This helper is intentionally simple:
|
|
17
|
+
it only parses the top-level TLV into raw strings.
|
|
18
|
+
- Use this for debugging/logging; not as a full KHQR parser.
|
|
19
|
+
"""
|
|
20
|
+
i = 0
|
|
21
|
+
out: dict[str, str] = {}
|
|
22
|
+
while i + 4 <= len(payload):
|
|
23
|
+
tag = payload[i : i + 2]
|
|
24
|
+
length = int(payload[i + 2 : i + 4])
|
|
25
|
+
start = i + 4
|
|
26
|
+
end = start + length
|
|
27
|
+
if end > len(payload):
|
|
28
|
+
break
|
|
29
|
+
out[tag] = payload[start:end]
|
|
30
|
+
i = end
|
|
31
|
+
return out
|
konthaina_khqr/verify.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .crc16 import crc16_ccitt_false
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def verify(qr: str) -> bool:
|
|
7
|
+
"""Verify KHQR string by checking CRC tag (63)."""
|
|
8
|
+
if not qr or len(qr) < 10:
|
|
9
|
+
return False
|
|
10
|
+
# CRC is last 4 hex chars; before it should contain "6304"
|
|
11
|
+
extracted_crc = qr[-4:]
|
|
12
|
+
body = qr[:-4] # includes 6304
|
|
13
|
+
expected = crc16_ccitt_false(body)
|
|
14
|
+
return extracted_crc.upper() == expected.upper()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: konthaina-khqr
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: KHQR / EMVCo merchant-presented QR payload generator for Bakong (Cambodia), with CRC16 verification.
|
|
5
|
+
Author-email: Konthaina <konthaina87@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Konthaina
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/yourname/konthaina-khqr
|
|
29
|
+
Project-URL: Repository, https://github.com/yourname/konthaina-khqr
|
|
30
|
+
Project-URL: Issues, https://github.com/yourname/konthaina-khqr/issues
|
|
31
|
+
Keywords: khqr,bakong,emvco,qr,cambodia
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Typing :: Typed
|
|
42
|
+
Requires-Python: >=3.9
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Provides-Extra: qrcode
|
|
46
|
+
Requires-Dist: qrcode[pil]>=7.0; extra == "qrcode"
|
|
47
|
+
Provides-Extra: dev
|
|
48
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
49
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
50
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
51
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
52
|
+
Requires-Dist: twine>=5.1; extra == "dev"
|
|
53
|
+
Dynamic: license-file
|
|
54
|
+
|
|
55
|
+
# konthaina-khqr
|
|
56
|
+
|
|
57
|
+
KHQR / EMVCo merchant-presented QR payload generator for **Bakong / Cambodia** (NBC KHQR spec v2.7-style TLV) with **CRC-16/CCITT-FALSE** verification.
|
|
58
|
+
|
|
59
|
+
> This package generates the **payload string** (the EMV tag-length-value text) that you can encode into a QR image.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install konthaina-khqr
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Optional: generate QR images (PNG) using `qrcode`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install "konthaina-khqr[qrcode]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from konthaina_khqr import KHQRGenerator, MerchantType, Currency
|
|
77
|
+
|
|
78
|
+
result = (
|
|
79
|
+
KHQRGenerator(MerchantType.INDIVIDUAL)
|
|
80
|
+
.set_bakong_account_id("john_smith@devb")
|
|
81
|
+
.set_merchant_name("John Smith")
|
|
82
|
+
.set_currency(Currency.USD)
|
|
83
|
+
.set_amount(100.50)
|
|
84
|
+
.set_merchant_city("Phnom Penh")
|
|
85
|
+
.generate()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
print(result.qr) # KHQR payload string
|
|
89
|
+
print(result.md5) # md5 of payload
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Verify / decode
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from konthaina_khqr import verify, decode
|
|
96
|
+
|
|
97
|
+
ok = verify(result.qr)
|
|
98
|
+
data = decode(result.qr) # simple TLV decode
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## CLI
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
khqr --type individual --bakong john_smith@devb --name "John Smith" --amount 1.25 --currency USD
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Generate a PNG (requires extras):
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
khqr --type individual --bakong john_smith@devb --name "John Smith" --amount 1.25 --currency USD --png out.png
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python -m venv .venv
|
|
117
|
+
. .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
118
|
+
pip install -e ".[dev]"
|
|
119
|
+
pytest
|
|
120
|
+
ruff check .
|
|
121
|
+
mypy src
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Build & publish
|
|
125
|
+
|
|
126
|
+
Build:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
python -m build
|
|
130
|
+
twine check dist/*
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Publish to **TestPyPI**:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
twine upload -r testpypi dist/*
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Publish to **PyPI**:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
twine upload dist/*
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
For GitHub Actions + Trusted Publishing, see `.github/workflows/publish.yml`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
konthaina_khqr/__init__.py,sha256=Kj3FqXU-fQygbDIfEgXGUl2CqKSCUkj-ocGzkSh6Gtw,263
|
|
2
|
+
konthaina_khqr/cli.py,sha256=-9BtG1cETYo1W6GuKFmo8hR0d5w59SJUZplmHAdDP1k,2561
|
|
3
|
+
konthaina_khqr/crc16.py,sha256=tEvN5dpz3pyPTYRTQ81a8yDMZiNqs_IDEu4WDvM-LSQ,3437
|
|
4
|
+
konthaina_khqr/decode.py,sha256=tPtMF2di1c125oeCQlFk4kLUK_XpxIigaFu94sNxuhA,190
|
|
5
|
+
konthaina_khqr/enums.py,sha256=R0jAiNlVD4mhggdJgGHLOSrq6srZZZB8otcS-K4rMTs,308
|
|
6
|
+
konthaina_khqr/exceptions.py,sha256=JtpAkdWkVg7GY-PfgzcWocTq_N-sFOdSOXPbdJCPyJ8,106
|
|
7
|
+
konthaina_khqr/generator.py,sha256=NlEP_62kmsX8Xoh5oAseEzhqi66UGPd0DNptuj3zTuw,7180
|
|
8
|
+
konthaina_khqr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
konthaina_khqr/tlv.py,sha256=-1tNFpunqeH5u8WktDCVh3S7owdkAPKHIXpoPjzl_kY,931
|
|
10
|
+
konthaina_khqr/verify.py,sha256=gbtY9ogyBnfffI743lc1JgONKQNUfutFf4sL-YCEfv8,430
|
|
11
|
+
konthaina_khqr-0.1.2.dist-info/licenses/LICENSE,sha256=SOTOaaGP2gKbSUcDvVAifOB9mbQPVOfUCohjHZ7RpdI,1066
|
|
12
|
+
konthaina_khqr-0.1.2.dist-info/METADATA,sha256=cAR-M9XjcHEGn1ThbTSKrZzBgpDp3SSrCRuGyBKmiEI,4330
|
|
13
|
+
konthaina_khqr-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
konthaina_khqr-0.1.2.dist-info/entry_points.txt,sha256=Zya-0-1FxubsDK7mZ4XBpaKsWppYnhXjeMFCRZHGy50,49
|
|
15
|
+
konthaina_khqr-0.1.2.dist-info/top_level.txt,sha256=1dRcgTNZi79QSczwDc0npVJVlCkBNcrTOx23kuIK2og,15
|
|
16
|
+
konthaina_khqr-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konthaina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
konthaina_khqr
|