bakong-v2 1.0.0__tar.gz

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.
Files changed (37) hide show
  1. bakong_v2-1.0.0/LICENSE +21 -0
  2. bakong_v2-1.0.0/MANIFEST.in +1 -0
  3. bakong_v2-1.0.0/PKG-INFO +104 -0
  4. bakong_v2-1.0.0/README.md +79 -0
  5. bakong_v2-1.0.0/bakong_v2/__init__.py +3 -0
  6. bakong_v2-1.0.0/bakong_v2/khqr.py +277 -0
  7. bakong_v2-1.0.0/bakong_v2/sdk/__init__.py +37 -0
  8. bakong_v2-1.0.0/bakong_v2/sdk/additional_data_field.py +94 -0
  9. bakong_v2-1.0.0/bakong_v2/sdk/amount.py +39 -0
  10. bakong_v2-1.0.0/bakong_v2/sdk/assets/KHR.png +0 -0
  11. bakong_v2-1.0.0/bakong_v2/sdk/assets/USD.png +0 -0
  12. bakong_v2-1.0.0/bakong_v2/sdk/assets/bold.ttf +0 -0
  13. bakong_v2-1.0.0/bakong_v2/sdk/assets/khqr.png +0 -0
  14. bakong_v2-1.0.0/bakong_v2/sdk/assets/logo.png +0 -0
  15. bakong_v2-1.0.0/bakong_v2/sdk/assets/regular.ttf +0 -0
  16. bakong_v2-1.0.0/bakong_v2/sdk/country_code.py +25 -0
  17. bakong_v2-1.0.0/bakong_v2/sdk/crc.py +49 -0
  18. bakong_v2-1.0.0/bakong_v2/sdk/emv.py +88 -0
  19. bakong_v2-1.0.0/bakong_v2/sdk/emv_parser.py +25 -0
  20. bakong_v2-1.0.0/bakong_v2/sdk/global_unique_identifier.py +47 -0
  21. bakong_v2-1.0.0/bakong_v2/sdk/hash.py +27 -0
  22. bakong_v2-1.0.0/bakong_v2/sdk/image_tools.py +231 -0
  23. bakong_v2-1.0.0/bakong_v2/sdk/mcc.py +36 -0
  24. bakong_v2-1.0.0/bakong_v2/sdk/merchant_city.py +41 -0
  25. bakong_v2-1.0.0/bakong_v2/sdk/merchant_name.py +39 -0
  26. bakong_v2-1.0.0/bakong_v2/sdk/payload_format_indicator.py +26 -0
  27. bakong_v2-1.0.0/bakong_v2/sdk/point_of_initiation.py +30 -0
  28. bakong_v2-1.0.0/bakong_v2/sdk/timestamp.py +55 -0
  29. bakong_v2-1.0.0/bakong_v2/sdk/transaction_currency.py +39 -0
  30. bakong_v2-1.0.0/bakong_v2/sdk/version.py +1 -0
  31. bakong_v2-1.0.0/bakong_v2.egg-info/PKG-INFO +104 -0
  32. bakong_v2-1.0.0/bakong_v2.egg-info/SOURCES.txt +35 -0
  33. bakong_v2-1.0.0/bakong_v2.egg-info/dependency_links.txt +1 -0
  34. bakong_v2-1.0.0/bakong_v2.egg-info/requires.txt +4 -0
  35. bakong_v2-1.0.0/bakong_v2.egg-info/top_level.txt +1 -0
  36. bakong_v2-1.0.0/setup.cfg +4 -0
  37. bakong_v2-1.0.0/setup.py +27 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 BAN Sothen
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
+ recursive-include bakong_v2/sdk/assets *
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: bakong-v2
3
+ Version: 1.0.0
4
+ Summary: A Python package for Bakong KHQR payment integration using the BakongV2 API. (Unofficial)
5
+ Home-page: https://bakong-v2.vercel.app
6
+ Author: Lim Visa
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.6
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: image
14
+ Requires-Dist: pillow; extra == "image"
15
+ Requires-Dist: qrcode; extra == "image"
16
+ Dynamic: author
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license-file
22
+ Dynamic: provides-extra
23
+ Dynamic: requires-python
24
+ Dynamic: summary
25
+
26
+ # Bakong-V2
27
+
28
+ A Python package for Bakong KHQR payment integration powered by [BakongV2 API](https://bakong-v2.vercel.app).
29
+
30
+ All requests go through the BakongV2 relay — no direct connection to NBC needed, no Cambodia IP required.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install bakong-v2
36
+ ```
37
+
38
+ For QR image generation:
39
+
40
+ ```bash
41
+ pip install "bakong-v2[image]"
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ```python
47
+ from bakong_v2 import KHQR
48
+
49
+ # Use your token from https://t.me/bakong_relay_bot
50
+ khqr = KHQR("your_token_here")
51
+
52
+ # Generate QR string (local, no API call)
53
+ qr = khqr.create_qr(
54
+ bank_account="your_name@bank",
55
+ merchant_name="Your Name",
56
+ merchant_city="Phnom Penh",
57
+ amount=9800,
58
+ currency="KHR",
59
+ )
60
+
61
+ # Generate MD5 hash (local, no API call)
62
+ md5 = khqr.generate_md5(qr)
63
+
64
+ # Check payment status
65
+ status = khqr.check_payment(md5)
66
+ print(status) # "PAID" or "UNPAID"
67
+
68
+ # Get payment details
69
+ info = khqr.get_payment(md5)
70
+ print(info)
71
+
72
+ # Generate deeplink
73
+ link = khqr.generate_deeplink(
74
+ qr=qr,
75
+ appDeepLinkCallback="https://your-site.com/callback",
76
+ appIconUrl="https://your-site.com/logo.png",
77
+ appName="MyApp",
78
+ )
79
+ print(link)
80
+ ```
81
+
82
+ ## All Methods
83
+
84
+ | Method | Description |
85
+ |--------|-------------|
86
+ | `create_qr()` | Generate KHQR string locally |
87
+ | `generate_md5()` | Compute MD5 hash of QR string |
88
+ | `generate_deeplink()` | Generate Bakong deeplink |
89
+ | `check_payment(md5)` | Check payment by MD5 → "PAID"/"UNPAID" |
90
+ | `get_payment(md5)` | Get payment details by MD5 |
91
+ | `check_payment_by_hash(hash)` | Check payment by transaction hash |
92
+ | `get_payment_by_hash(hash)` | Get payment details by hash |
93
+ | `check_payment_by_short_hash(hash, amount, currency)` | Check payment by short hash |
94
+ | `get_payment_by_short_hash(hash, amount, currency)` | Get payment details by short hash |
95
+ | `check_payment_by_instruction_ref(ref)` | Check payment by instruction ref |
96
+ | `check_payment_by_external_ref(ref)` | Check payment by external ref |
97
+ | `check_account(account_id)` | Check Bakong account info |
98
+ | `check_bulk_payments(md5_list)` | Bulk check by MD5 list (max 50) |
99
+ | `check_bulk_payments_by_hash(hash_list)` | Bulk check by hash list (max 50) |
100
+ | `qr_image()` | Generate QR image (requires `[image]` extras) |
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,79 @@
1
+ # Bakong-V2
2
+
3
+ A Python package for Bakong KHQR payment integration powered by [BakongV2 API](https://bakong-v2.vercel.app).
4
+
5
+ All requests go through the BakongV2 relay — no direct connection to NBC needed, no Cambodia IP required.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install bakong-v2
11
+ ```
12
+
13
+ For QR image generation:
14
+
15
+ ```bash
16
+ pip install "bakong-v2[image]"
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```python
22
+ from bakong_v2 import KHQR
23
+
24
+ # Use your token from https://t.me/bakong_relay_bot
25
+ khqr = KHQR("your_token_here")
26
+
27
+ # Generate QR string (local, no API call)
28
+ qr = khqr.create_qr(
29
+ bank_account="your_name@bank",
30
+ merchant_name="Your Name",
31
+ merchant_city="Phnom Penh",
32
+ amount=9800,
33
+ currency="KHR",
34
+ )
35
+
36
+ # Generate MD5 hash (local, no API call)
37
+ md5 = khqr.generate_md5(qr)
38
+
39
+ # Check payment status
40
+ status = khqr.check_payment(md5)
41
+ print(status) # "PAID" or "UNPAID"
42
+
43
+ # Get payment details
44
+ info = khqr.get_payment(md5)
45
+ print(info)
46
+
47
+ # Generate deeplink
48
+ link = khqr.generate_deeplink(
49
+ qr=qr,
50
+ appDeepLinkCallback="https://your-site.com/callback",
51
+ appIconUrl="https://your-site.com/logo.png",
52
+ appName="MyApp",
53
+ )
54
+ print(link)
55
+ ```
56
+
57
+ ## All Methods
58
+
59
+ | Method | Description |
60
+ |--------|-------------|
61
+ | `create_qr()` | Generate KHQR string locally |
62
+ | `generate_md5()` | Compute MD5 hash of QR string |
63
+ | `generate_deeplink()` | Generate Bakong deeplink |
64
+ | `check_payment(md5)` | Check payment by MD5 → "PAID"/"UNPAID" |
65
+ | `get_payment(md5)` | Get payment details by MD5 |
66
+ | `check_payment_by_hash(hash)` | Check payment by transaction hash |
67
+ | `get_payment_by_hash(hash)` | Get payment details by hash |
68
+ | `check_payment_by_short_hash(hash, amount, currency)` | Check payment by short hash |
69
+ | `get_payment_by_short_hash(hash, amount, currency)` | Get payment details by short hash |
70
+ | `check_payment_by_instruction_ref(ref)` | Check payment by instruction ref |
71
+ | `check_payment_by_external_ref(ref)` | Check payment by external ref |
72
+ | `check_account(account_id)` | Check Bakong account info |
73
+ | `check_bulk_payments(md5_list)` | Bulk check by MD5 list (max 50) |
74
+ | `check_bulk_payments_by_hash(hash_list)` | Bulk check by hash list (max 50) |
75
+ | `qr_image()` | Generate QR image (requires `[image]` extras) |
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,3 @@
1
+ from .khqr import KHQR
2
+
3
+ __all__ = ["KHQR"]
@@ -0,0 +1,277 @@
1
+ import json
2
+ import warnings
3
+ import urllib.request
4
+ import urllib.parse
5
+ from typing import Any
6
+ from urllib.error import HTTPError
7
+
8
+ from .sdk.crc import CRC
9
+ from .sdk.mcc import MCC
10
+ from .sdk.hash import HASH
11
+ from .sdk.amount import Amount
12
+ from .sdk.timestamp import TimeStamp
13
+ from .sdk.image_tools import ImageTools
14
+ from .sdk.country_code import CountryCode
15
+ from .sdk.merchant_city import MerchantCity
16
+ from .sdk.merchant_name import MerchantName
17
+ from .sdk.point_of_initiation import PointOfInitiation
18
+ from .sdk.transaction_currency import TransactionCurrency
19
+ from .sdk.additional_data_field import AdditionalDataField
20
+ from .sdk.payload_format_indicator import PayloadFormatIndicator
21
+ from .sdk.global_unique_identifier import GlobalUniqueIdentifier
22
+
23
+ from .sdk.version import __version__
24
+
25
+ API_BASE = "https://bakong-v2.vercel.app/api/v2/bakong"
26
+
27
+
28
+ class KHQR:
29
+ def __init__(self, token: str | None = None):
30
+ self.__crc = CRC()
31
+ self.__mcc = MCC()
32
+ self.__hash = HASH()
33
+ self.__amount = Amount()
34
+ self.__timestamp = TimeStamp()
35
+ self.__image_tools = ImageTools()
36
+ self.__country_code = CountryCode()
37
+ self.__merchant_city = MerchantCity()
38
+ self.__merchant_name = MerchantName()
39
+ self.__point_of_initiation = PointOfInitiation()
40
+ self.__transaction_currency = TransactionCurrency()
41
+ self.__additional_data_field = AdditionalDataField()
42
+ self.__payload_format_indicator = PayloadFormatIndicator()
43
+ self.__global_unique_identifier = GlobalUniqueIdentifier()
44
+ self.__token = token
45
+
46
+ def __check_token(self):
47
+ if not self.__token:
48
+ raise ValueError("Token is required. Get yours at https://t.me/bakong_relay_bot")
49
+
50
+ def __api_get(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
51
+ self.__check_token()
52
+ params["token"] = self.__token
53
+ url = f"{API_BASE}{endpoint}?{urllib.parse.urlencode(params, doseq=True)}"
54
+ try:
55
+ with urllib.request.urlopen(url, timeout=15) as resp:
56
+ data = json.loads(resp.read().decode())
57
+ except HTTPError as e:
58
+ body = e.read().decode()
59
+ try:
60
+ err = json.loads(body)
61
+ msg = err.get("error", body)
62
+ except json.JSONDecodeError:
63
+ msg = body
64
+ raise ValueError(f"API error ({e.code}): {msg}")
65
+ except TimeoutError:
66
+ raise ValueError("API took too long to respond. Please try again later.")
67
+ except Exception as e:
68
+ raise ValueError(f"Failed to connect to API: {e}")
69
+ if isinstance(data, dict) and "error" in data:
70
+ raise ValueError(f"API error: {data['error']}")
71
+ return data
72
+
73
+ def __api_get_with_retry(self, endpoint: str, params: dict[str, Any], retries: int = 2) -> dict[str, Any]:
74
+ last_err = None
75
+ for attempt in range(retries + 1):
76
+ try:
77
+ return self.__api_get(endpoint, params)
78
+ except ValueError as e:
79
+ last_err = e
80
+ if attempt < retries:
81
+ continue
82
+ raise last_err
83
+
84
+ def create_qr(
85
+ self,
86
+ bank_account: str,
87
+ merchant_name: str,
88
+ merchant_city: str,
89
+ amount: float,
90
+ currency: str,
91
+ store_label: str | None = None,
92
+ phone_number: str | None = None,
93
+ bill_number: str | None = None,
94
+ terminal_label: str | None = None,
95
+ static: bool = False,
96
+ expiration: int = 1
97
+ ) -> str:
98
+ if amount <= 0:
99
+ static = True
100
+ qr_data = self.__payload_format_indicator.value()
101
+ qr_data += self.__point_of_initiation.static() if static else self.__point_of_initiation.dynamic()
102
+ qr_data += self.__global_unique_identifier.value(bank_account)
103
+ qr_data += self.__mcc.value()
104
+ qr_data += self.__transaction_currency.value(currency)
105
+ if not static:
106
+ qr_data += self.__amount.value(amount)
107
+ qr_data += self.__country_code.value()
108
+ qr_data += self.__merchant_name.value(merchant_name)
109
+ qr_data += self.__merchant_city.value(merchant_city)
110
+ additional_data = self.__additional_data_field.value(
111
+ store_label=store_label,
112
+ phone_number=phone_number,
113
+ bill_number=bill_number,
114
+ terminal_label=terminal_label,
115
+ )
116
+ if additional_data:
117
+ qr_data += additional_data
118
+ qr_data += self.__timestamp.value(static, expiration)
119
+ qr_data += self.__crc.value(qr_data)
120
+ return qr_data
121
+
122
+ def generate_md5(self, qr: str) -> str:
123
+ return self.__hash.md5(qr)
124
+
125
+ def generate_deeplink(
126
+ self,
127
+ qr: str,
128
+ appDeepLinkCallback: str | None = None,
129
+ appIconUrl: str = "https://bakong.nbc.gov.kh/images/logo.svg",
130
+ appName: str = "MyAppName",
131
+ callback: str | None = None
132
+ ) -> str | None:
133
+ if callback is not None:
134
+ warnings.warn(
135
+ f"\n\n{'!'*31} DEPRECATION WARNING {'!'*31}\n"
136
+ f"Parameter 'callback' is deprecated in bakong-v2.\n"
137
+ f"Please update your code to use 'appDeepLinkCallback' instead.\n"
138
+ f"{'!'*83}\n",
139
+ DeprecationWarning,
140
+ stacklevel=2
141
+ )
142
+ if appDeepLinkCallback is None:
143
+ appDeepLinkCallback = callback
144
+ if appDeepLinkCallback is None:
145
+ appDeepLinkCallback = "https://bakong.nbc.org.kh"
146
+
147
+ params = {
148
+ "qr": qr,
149
+ "AppName": appName,
150
+ "AppIconUrl": appIconUrl,
151
+ "callback": appDeepLinkCallback,
152
+ }
153
+ data = self.__api_get_with_retry("/generate-deeplink", params)
154
+ if data.get("responseCode") == 0:
155
+ d = data.get("data")
156
+ if isinstance(d, dict):
157
+ return d.get("shortLink") or d.get("fullLink")
158
+ return None
159
+
160
+ def check_payment(self, md5: str) -> str:
161
+ params = {"md5": md5}
162
+ data = self.__api_get_with_retry("/check-payment", params)
163
+ if data.get("responseCode") == 0:
164
+ return "PAID"
165
+ return "UNPAID"
166
+
167
+ def get_payment(self, md5: str) -> dict[str, Any] | None:
168
+ params = {"md5": md5}
169
+ data = self.__api_get_with_retry("/check-payment", params)
170
+ if data.get("responseCode") == 0:
171
+ d = data.get("data")
172
+ return d if isinstance(d, dict) else None
173
+ return None
174
+
175
+ def check_account(self, account_id: str) -> dict[str, Any] | None:
176
+ params = {"accountId": account_id}
177
+ data = self.__api_get_with_retry("/check-account", params)
178
+ if data.get("responseCode") == 0:
179
+ d = data.get("data")
180
+ return d if isinstance(d, dict) else None
181
+ return None
182
+
183
+ def check_payment_by_hash(self, hash: str) -> str:
184
+ params = {"hash": hash}
185
+ data = self.__api_get_with_retry("/check-payment", params)
186
+ if data.get("responseCode") == 0:
187
+ return "PAID"
188
+ return "UNPAID"
189
+
190
+ def get_payment_by_hash(self, hash: str) -> dict[str, Any] | None:
191
+ params = {"hash": hash}
192
+ data = self.__api_get_with_retry("/check-payment", params)
193
+ if data.get("responseCode") == 0:
194
+ d = data.get("data")
195
+ return d if isinstance(d, dict) else None
196
+ return None
197
+
198
+ def check_payment_by_short_hash(self, hash: str, amount: float, currency: str) -> str:
199
+ params = {"hash": hash, "amount": amount, "currency": currency}
200
+ data = self.__api_get_with_retry("/check-payment", params)
201
+ if data.get("responseCode") == 0:
202
+ return "PAID"
203
+ return "UNPAID"
204
+
205
+ def get_payment_by_short_hash(self, hash: str, amount: float, currency: str) -> dict[str, Any] | None:
206
+ params = {"hash": hash, "amount": amount, "currency": currency}
207
+ data = self.__api_get_with_retry("/check-payment", params)
208
+ if data.get("responseCode") == 0:
209
+ d = data.get("data")
210
+ return d if isinstance(d, dict) else None
211
+ return None
212
+
213
+ def check_payment_by_instruction_ref(self, instruction_ref: str) -> str:
214
+ params = {"instructionRef": instruction_ref}
215
+ data = self.__api_get_with_retry("/check-payment", params)
216
+ if data.get("responseCode") == 0:
217
+ return "PAID"
218
+ return "UNPAID"
219
+
220
+ def check_payment_by_external_ref(self, external_ref: str) -> str:
221
+ params = {"externalRef": external_ref}
222
+ data = self.__api_get_with_retry("/check-payment", params)
223
+ if data.get("responseCode") == 0:
224
+ return "PAID"
225
+ return "UNPAID"
226
+
227
+ def check_bulk_payments(self, md5_list: list[str]) -> list[str]:
228
+ if len(md5_list) > 50:
229
+ raise ValueError("The md5_list exceeds the allowed limit of 50 hashes per request.")
230
+ params = {"md5": md5_list}
231
+ data = self.__api_get_with_retry("/check-payment-list", params)
232
+ data_list = data.get("data")
233
+ if not isinstance(data_list, list):
234
+ return []
235
+ paid = []
236
+ for item in data_list:
237
+ if isinstance(item, dict) and item.get("status") == "SUCCESS":
238
+ md5 = item.get("md5")
239
+ if isinstance(md5, str):
240
+ paid.append(md5)
241
+ return paid
242
+
243
+ def check_bulk_payments_by_hash(self, hash_list: list[str]) -> list[str]:
244
+ if len(hash_list) > 50:
245
+ raise ValueError("The hash_list exceeds the allowed limit of 50 hashes per request.")
246
+ params = {"hash": hash_list}
247
+ data = self.__api_get_with_retry("/check-payment-list", params)
248
+ data_list = data.get("data")
249
+ if not isinstance(data_list, list):
250
+ return []
251
+ paid = []
252
+ for item in data_list:
253
+ if isinstance(item, dict) and item.get("status") == "SUCCESS":
254
+ h = item.get("hash")
255
+ if isinstance(h, str):
256
+ paid.append(h)
257
+ return paid
258
+
259
+ def qr_image(
260
+ self, qr: str,
261
+ format: str = "png",
262
+ output_path: str | None = None,
263
+ ) -> str | bytes:
264
+ result = self.__image_tools.generate(qr)
265
+ f = format.lower()
266
+ if f in ("jpeg", "jpg"):
267
+ return result.to_jpeg(output_path)
268
+ elif f == "webp":
269
+ return result.to_webp(output_path)
270
+ elif f == "bytes":
271
+ return result.to_bytes()
272
+ elif f == "base64":
273
+ return result.to_base64()
274
+ elif f == "base64_uri":
275
+ return result.to_data_uri()
276
+ else:
277
+ return result.to_png(output_path)
@@ -0,0 +1,37 @@
1
+ from .emv import EMV
2
+ from .crc import CRC
3
+ from .mcc import MCC
4
+ from .hash import HASH
5
+ from .amount import Amount
6
+ from .timestamp import TimeStamp
7
+ from .emv_parser import EMVParser
8
+ from .image_tools import ImageTools
9
+ from .country_code import CountryCode
10
+ from .merchant_city import MerchantCity
11
+ from .merchant_name import MerchantName
12
+ from .point_of_initiation import PointOfInitiation
13
+ from .transaction_currency import TransactionCurrency
14
+ from .additional_data_field import AdditionalDataField
15
+ from .global_unique_identifier import GlobalUniqueIdentifier
16
+ from .payload_format_indicator import PayloadFormatIndicator
17
+ from .version import __version__
18
+
19
+ __all__ = [
20
+ "EMV",
21
+ "CRC",
22
+ "MCC",
23
+ "HASH",
24
+ "Amount",
25
+ "TimeStamp",
26
+ "EMVParser",
27
+ "ImageTools",
28
+ "CountryCode",
29
+ "MerchantCity",
30
+ "MerchantName",
31
+ "PointOfInitiation",
32
+ "TransactionCurrency",
33
+ "AdditionalDataField",
34
+ "GlobalUniqueIdentifier",
35
+ "PayloadFormatIndicator",
36
+ "__version__"
37
+ ]
@@ -0,0 +1,94 @@
1
+ from .emv import EMV
2
+
3
+ # Initialize EMV instance
4
+ emv = EMV()
5
+
6
+ class AdditionalDataField:
7
+ def __init__(self):
8
+ """
9
+ Initialize the AdditionalDataField class with settings from the EMV configuration.
10
+ """
11
+ self.__additional_data_tag = emv.addtion_data_tag
12
+ self.__store_label_tag = emv.store_label
13
+ self.__mobile_number_tag = emv.addition_data_field_mobile_number
14
+ self.__bill_number_tag = emv.billnumber_tag
15
+ self.__terminal_label_tag = emv.terminal_label
16
+
17
+ # Maximum lengths according to EMVCo / Bakong spec
18
+ self.__store_label_max = emv.invalid_length_store_label
19
+ self.__mobile_max = emv.invalid_length_mobile_number
20
+ self.__bill_max = emv.invalid_length_bill_number
21
+ self.__terminal_max = emv.invalid_length_terminal_label
22
+
23
+ def __format_field(self, tag: str, value: str) -> str:
24
+ """Format a single sub-field: TAG + LENGTH (02) + VALUE"""
25
+ value_str = str(value).strip()
26
+ if not value_str:
27
+ return ""
28
+ length = f"{len(value_str):02d}"
29
+ return f"{tag}{length}{value_str}"
30
+
31
+ def __validate_length(self, value: str, max_length: int, field_name: str):
32
+ """
33
+ Validate the length of a field value.
34
+
35
+ :param value: The value to be validated.
36
+ :param field_name: The name of the field for error reporting.
37
+ :raises ValueError: If the value exceeds the maximum allowed length.
38
+ """
39
+ if len(value) > max_length:
40
+ raise ValueError(f"{field_name} cannot exceed {max_length} characters. Your input length: {len(value)} characters.")
41
+
42
+ def value(
43
+ self,
44
+ store_label: str | None = None,
45
+ phone_number: str | None = None,
46
+ bill_number: str | None = None,
47
+ terminal_label: str | None = None,
48
+ ) -> str:
49
+ """
50
+ Combine all formatted values into a single string with a length prefix.
51
+
52
+ :param store_label: The store label.
53
+ :param phone_number: The phone number.
54
+ :param bill_number: The bill number.
55
+ :param terminal_label: The terminal label.
56
+ :return: Combined formatted string with length prefix.
57
+ """
58
+ sub_fields = []
59
+
60
+ # Store Label (usually Tag 03)
61
+ if store_label:
62
+ self.__validate_length(store_label, self.__store_label_max, "Store label")
63
+ sub_fields.append(self.__format_field(self.__store_label_tag, store_label))
64
+
65
+ # Phone Number (usually Tag 04) - with Khmer phone normalization
66
+ if phone_number:
67
+ digits = ''.join(c for c in str(phone_number) if c.isdigit())
68
+ if digits.startswith('855'):
69
+ digits = digits[3:]
70
+ if not digits.startswith('0'):
71
+ digits = '0' + digits
72
+
73
+ self.__validate_length(digits, self.__mobile_max, "Phone number")
74
+ sub_fields.append(self.__format_field(self.__mobile_number_tag, digits))
75
+
76
+ # Bill Number (usually Tag 05)
77
+ if bill_number:
78
+ self.__validate_length(bill_number, self.__bill_max, "Bill number")
79
+ sub_fields.append(self.__format_field(self.__bill_number_tag, bill_number))
80
+
81
+ # Terminal Label (usually Tag 07)
82
+ if terminal_label:
83
+ self.__validate_length(terminal_label, self.__terminal_max, "Terminal label")
84
+ sub_fields.append(self.__format_field(self.__terminal_label_tag, terminal_label))
85
+
86
+ if not sub_fields:
87
+ # No additional data → return empty (do not include tag 62 at all)
88
+ return ""
89
+
90
+ combined = "".join(sub_fields)
91
+ total_length = f"{len(combined):02d}"
92
+
93
+ # Final format: 62 + LL + subfields
94
+ return f"{self.__additional_data_tag}{total_length}{combined}"
@@ -0,0 +1,39 @@
1
+ from .emv import EMV
2
+
3
+ emv = EMV()
4
+
5
+ class Amount:
6
+ def __init__(self):
7
+ self.__transaction_amount = emv.transaction_amount # "54"
8
+ self.__max_length = emv.invalid_length_amount
9
+
10
+ def value(self, amount: float | int | str) -> str:
11
+ """
12
+ Get the formatted amount value.
13
+
14
+ :param amount: The transaction amount to be formatted.
15
+ :return: Formatted string including transaction amount tag, length, and amount.
16
+ """
17
+ if not isinstance(amount, (int, float, str)):
18
+ raise ValueError("Amount must be a number or numeric string")
19
+
20
+ try:
21
+ amount_float = float(amount)
22
+ except ValueError:
23
+ raise ValueError(f"Invalid amount value: {amount}. Amount must be a number or a string representing a number.")
24
+
25
+ # Format amount (no trailing zeros)
26
+ amount_str = f"{amount_float:.2f}".rstrip("0").rstrip(".")
27
+
28
+ # EMV length = length of VALUE ONLY
29
+ length_of_amount = len(amount_str)
30
+
31
+ if length_of_amount > self.__max_length:
32
+ raise ValueError(
33
+ f"Formatted Amount exceeds maximum length of {self.__max_length} characters."
34
+ f"Your input length: {length_of_amount} characters."
35
+ )
36
+
37
+ length_str = str(length_of_amount).zfill(2)
38
+
39
+ return f"{self.__transaction_amount}{length_str}{amount_str}"