monapay 0.1.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.
monapay-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The MONA Group
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.
monapay-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: monapay
3
+ Version: 0.1.0
4
+ Summary: SDK Python zero-dependency cho MONA Pay
5
+ Author-email: The MONA Group <info@themona.global>
6
+ License: MIT
7
+ Project-URL: Documentation, https://monapay.vn/docs
8
+ Project-URL: Repository, https://github.com/monapay/monapay-python
9
+ Keywords: monapay,vietqr,virtual-account,payment,webhook
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # monapay
20
+
21
+ MONA Pay là cổng thanh toán và API ngân hàng của The MONA Group, giúp doanh nghiệp Việt Nam nhận và xác nhận tiền chuyển khoản theo thời gian thực qua tài khoản ảo (VA), VietQR, webhook và Telegram — thiết kế để cả lập trình viên lẫn AI agent tích hợp trong vài phút.
22
+
23
+ SDK Python đồng bộ, chỉ dùng standard library. MONA Pay miễn phí hoàn toàn.
24
+
25
+ ## Cài đặt
26
+
27
+ ```bash
28
+ pip install monapay
29
+ ```
30
+
31
+ ## Bắt đầu nhanh
32
+
33
+ ```python
34
+ import os
35
+ from monapay import MonaPay
36
+
37
+ mona = MonaPay(
38
+ os.environ["MONA_USERNAME"],
39
+ os.environ["MONA_PASSWORD"],
40
+ client_secret=os.getenv("MONA_CLIENT_SECRET"),
41
+ )
42
+
43
+ # Tự login và giữ token.
44
+ print(mona.me())
45
+
46
+ # Lần đầu: secret chỉ hiện một lần. SDK giữ key mới cho instance hiện tại.
47
+ key = mona.keys.generate("Web ban hang")
48
+ print("Lưu MONA_CLIENT_SECRET an toàn:", key["client_secret"])
49
+
50
+ mona.webhooks.create({
51
+ "name": "Web ban hang",
52
+ "webhook_url": "https://shop.vn/webhooks/monapay",
53
+ "auth_type": "HMAC_SHA256",
54
+ "secret_key": os.environ["MONA_WEBHOOK_SECRET"],
55
+ "payload_format": "application/json",
56
+ })
57
+
58
+ qr = mona.qr.generate({
59
+ "ownerNumber": "123456789", "ownerType": "ORG",
60
+ "merchantId": "MC00012345", "terminalId": "TM0001", "orderId": "DH10234",
61
+ "virtualAccountPrefix": "MONA", "beneficiaryName": "CONG TY ABC",
62
+ "amount": 2500000, "description": "Thanh toan DH10234",
63
+ })
64
+ print(qr["qr_data_url"])
65
+ ```
66
+
67
+ Client tự login lại và thử request đúng một lần khi gặp HTTP 401. Các method trả trực tiếp trường `data`; `ApiError` có `status` và `body`.
68
+
69
+ Các nhóm method: `keys`, `va`, `bank_accounts`, `qr`, `transactions`, `webhooks`, `webhook_logs`. Tên method dùng snake_case, ví dụ `va.register_notification(...)` và `transactions.retry(id, target_type="WEBHOOK", target_id=...)`.
70
+
71
+ Đọc hết các trang giao dịch:
72
+
73
+ ```python
74
+ for tx in mona.iter_transactions("MONA0000010234", limit=100):
75
+ print(tx["transaction_code"], tx["amount"])
76
+ ```
77
+
78
+ ## Xác thực webhook
79
+
80
+ Luôn truyền đúng `request.body` dạng bytes, không parse rồi encode lại.
81
+
82
+ ```python
83
+ from monapay import verify_webhook
84
+
85
+ result = verify_webhook(raw_body, headers, os.environ["MONA_WEBHOOK_SECRET"])
86
+ if not result.ok:
87
+ return {"reason": result.reason}, 401
88
+ save_once(result.payload["transaction_code"], result.payload)
89
+ ```
90
+
91
+ Ví dụ nhận webhook cho Flask, FastAPI và Django nằm trong `examples/`. Dùng `transaction_code` làm unique key để chống xử lý trùng.
92
+
93
+ Tài liệu: https://monapay.vn/docs · AI/LLM: https://monapay.vn/llms.txt · Hotline 1900 636 648 · info@themona.global
94
+
95
+ ## Test
96
+
97
+ Từ thư mục chứa `python/`:
98
+
99
+ ```bash
100
+ python3 -m unittest discover python/tests
101
+ ```
102
+
103
+ License MIT.
@@ -0,0 +1,85 @@
1
+ # monapay
2
+
3
+ MONA Pay là cổng thanh toán và API ngân hàng của The MONA Group, giúp doanh nghiệp Việt Nam nhận và xác nhận tiền chuyển khoản theo thời gian thực qua tài khoản ảo (VA), VietQR, webhook và Telegram — thiết kế để cả lập trình viên lẫn AI agent tích hợp trong vài phút.
4
+
5
+ SDK Python đồng bộ, chỉ dùng standard library. MONA Pay miễn phí hoàn toàn.
6
+
7
+ ## Cài đặt
8
+
9
+ ```bash
10
+ pip install monapay
11
+ ```
12
+
13
+ ## Bắt đầu nhanh
14
+
15
+ ```python
16
+ import os
17
+ from monapay import MonaPay
18
+
19
+ mona = MonaPay(
20
+ os.environ["MONA_USERNAME"],
21
+ os.environ["MONA_PASSWORD"],
22
+ client_secret=os.getenv("MONA_CLIENT_SECRET"),
23
+ )
24
+
25
+ # Tự login và giữ token.
26
+ print(mona.me())
27
+
28
+ # Lần đầu: secret chỉ hiện một lần. SDK giữ key mới cho instance hiện tại.
29
+ key = mona.keys.generate("Web ban hang")
30
+ print("Lưu MONA_CLIENT_SECRET an toàn:", key["client_secret"])
31
+
32
+ mona.webhooks.create({
33
+ "name": "Web ban hang",
34
+ "webhook_url": "https://shop.vn/webhooks/monapay",
35
+ "auth_type": "HMAC_SHA256",
36
+ "secret_key": os.environ["MONA_WEBHOOK_SECRET"],
37
+ "payload_format": "application/json",
38
+ })
39
+
40
+ qr = mona.qr.generate({
41
+ "ownerNumber": "123456789", "ownerType": "ORG",
42
+ "merchantId": "MC00012345", "terminalId": "TM0001", "orderId": "DH10234",
43
+ "virtualAccountPrefix": "MONA", "beneficiaryName": "CONG TY ABC",
44
+ "amount": 2500000, "description": "Thanh toan DH10234",
45
+ })
46
+ print(qr["qr_data_url"])
47
+ ```
48
+
49
+ Client tự login lại và thử request đúng một lần khi gặp HTTP 401. Các method trả trực tiếp trường `data`; `ApiError` có `status` và `body`.
50
+
51
+ Các nhóm method: `keys`, `va`, `bank_accounts`, `qr`, `transactions`, `webhooks`, `webhook_logs`. Tên method dùng snake_case, ví dụ `va.register_notification(...)` và `transactions.retry(id, target_type="WEBHOOK", target_id=...)`.
52
+
53
+ Đọc hết các trang giao dịch:
54
+
55
+ ```python
56
+ for tx in mona.iter_transactions("MONA0000010234", limit=100):
57
+ print(tx["transaction_code"], tx["amount"])
58
+ ```
59
+
60
+ ## Xác thực webhook
61
+
62
+ Luôn truyền đúng `request.body` dạng bytes, không parse rồi encode lại.
63
+
64
+ ```python
65
+ from monapay import verify_webhook
66
+
67
+ result = verify_webhook(raw_body, headers, os.environ["MONA_WEBHOOK_SECRET"])
68
+ if not result.ok:
69
+ return {"reason": result.reason}, 401
70
+ save_once(result.payload["transaction_code"], result.payload)
71
+ ```
72
+
73
+ Ví dụ nhận webhook cho Flask, FastAPI và Django nằm trong `examples/`. Dùng `transaction_code` làm unique key để chống xử lý trùng.
74
+
75
+ Tài liệu: https://monapay.vn/docs · AI/LLM: https://monapay.vn/llms.txt · Hotline 1900 636 648 · info@themona.global
76
+
77
+ ## Test
78
+
79
+ Từ thư mục chứa `python/`:
80
+
81
+ ```bash
82
+ python3 -m unittest discover python/tests
83
+ ```
84
+
85
+ License MIT.
@@ -0,0 +1,7 @@
1
+ """MONA Pay Python SDK."""
2
+
3
+ from .client import ApiError, MonaPay
4
+ from .webhook import WebhookResult, verify_webhook
5
+
6
+ __all__ = ["ApiError", "MonaPay", "WebhookResult", "verify_webhook"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,312 @@
1
+ """Synchronous, standard-library-only MONA Pay client."""
2
+
3
+ import json
4
+ import threading
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any, Dict, Iterator, Mapping, Optional
9
+
10
+
11
+ DEFAULT_BASE_URL = "https://api.monapay.vn"
12
+
13
+
14
+ class ApiError(RuntimeError):
15
+ """Error returned by MONA Pay or raised while decoding its response."""
16
+
17
+ def __init__(self, message: str, status: Optional[int] = None, body: Any = None):
18
+ super().__init__(message)
19
+ self.status = status
20
+ self.body = body
21
+
22
+
23
+ def _segment(value: Any) -> str:
24
+ return urllib.parse.quote(str(value), safe="")
25
+
26
+
27
+ def _log_query(options: Mapping[str, Any]) -> Dict[str, Any]:
28
+ return {
29
+ "status": options.get("status"),
30
+ "from_date": options.get("from_date"),
31
+ "to_date": options.get("to_date"),
32
+ "page": options.get("page"),
33
+ "limit": options.get("limit"),
34
+ }
35
+
36
+
37
+ class _Resource:
38
+ def __init__(self, client: "MonaPay"):
39
+ self._client = client
40
+
41
+
42
+ class Keys(_Resource):
43
+ def generate(self, name: str = "Default Key") -> Any:
44
+ data = self._client._request("POST", "/api/v1/client-keys/generate", body={"name": name})
45
+ if not self._client.client_secret and isinstance(data, dict):
46
+ self._client.client_secret = data.get("client_secret")
47
+ return data
48
+
49
+ def list(self) -> Any:
50
+ return self._client._request("GET", "/api/v1/client-keys/list")
51
+
52
+ def destroy(self, key_id: str) -> Any:
53
+ return self._client._request("DELETE", "/api/v1/client-keys/destroy/" + _segment(key_id))
54
+
55
+
56
+ class VirtualAccounts(_Resource):
57
+ def register(self, body: Mapping[str, Any]) -> Any:
58
+ return self._client._request("POST", "/api/v1/acb/virtual-account/registration", body=body)
59
+
60
+ def verify(self, request_id: str, code: str) -> Any:
61
+ return self._client._request(
62
+ "POST",
63
+ "/api/v1/acb/{}/virtual-account/verification".format(_segment(request_id)),
64
+ body={"code": code},
65
+ )
66
+
67
+ def register_notification(self, va_id: str, body: Mapping[str, Any]) -> Any:
68
+ return self._client._request(
69
+ "POST",
70
+ "/api/v1/acb/{}/notification/registration".format(_segment(va_id)),
71
+ body=body,
72
+ )
73
+
74
+ def verify_notification(self, request_id: str, code: str) -> Any:
75
+ return self._client._request(
76
+ "POST",
77
+ "/api/v1/acb/{}/notification/verification".format(_segment(request_id)),
78
+ body={"code": code},
79
+ )
80
+
81
+ def list(self, bank_account_id: str) -> Any:
82
+ return self._client._request(
83
+ "GET", "/api/v1/acb/{}/virtual-account/retrieve".format(_segment(bank_account_id))
84
+ )
85
+
86
+
87
+ class BankAccounts(_Resource):
88
+ def list(self) -> Any:
89
+ return self._client._request("GET", "/api/v1/client/bank-accounts")
90
+
91
+
92
+ class QrPayments(_Resource):
93
+ def generate(self, body: Mapping[str, Any]) -> Any:
94
+ return self._client._request("POST", "/api/v1/acb/qr-payment/generate", body=body)
95
+
96
+ def cancel(self, qr_code_id: str, body: Optional[Mapping[str, Any]] = None) -> Any:
97
+ return self._client._request(
98
+ "DELETE",
99
+ "/api/v1/acb/qr-payment/{}/cancellation".format(_segment(qr_code_id)),
100
+ body=body,
101
+ )
102
+
103
+
104
+ class Transactions(_Resource):
105
+ def list(
106
+ self, virtual_account_number: str, page: int = 1, limit: int = 100
107
+ ) -> Any:
108
+ if not virtual_account_number:
109
+ raise ValueError("virtual_account_number là bắt buộc")
110
+ return self._client._request(
111
+ "GET",
112
+ "/api/v1/acb/virtual-account/transactions",
113
+ query={
114
+ "virtual_account_number": virtual_account_number,
115
+ "page": page,
116
+ "limit": limit,
117
+ },
118
+ )
119
+
120
+ def iterate(
121
+ self, virtual_account_number: str, page: int = 1, limit: int = 100
122
+ ) -> Iterator[Any]:
123
+ current_page = page
124
+ while True:
125
+ result = self.list(virtual_account_number, page=current_page, limit=limit)
126
+ for transaction in (result or {}).get("data", []):
127
+ yield transaction
128
+ if "has_next" in (result or {}):
129
+ has_next = bool(result["has_next"])
130
+ else:
131
+ has_next = current_page < int((result or {}).get("last_page", current_page))
132
+ if not has_next:
133
+ return
134
+ current_page += 1
135
+
136
+ def retry(
137
+ self, transaction_id: str, target_type: str, target_id: Optional[str] = None
138
+ ) -> Any:
139
+ body = {"target_type": target_type}
140
+ if target_id is not None:
141
+ body["target_id"] = target_id
142
+ return self._client._request(
143
+ "POST",
144
+ "/api/v1/acb/virtual-account/transactions/{}/retry".format(
145
+ _segment(transaction_id)
146
+ ),
147
+ body=body,
148
+ )
149
+
150
+
151
+ class Webhooks(_Resource):
152
+ def list(self) -> Any:
153
+ return self._client._request("GET", "/api/v1/client-webhooks")
154
+
155
+ def create(self, body: Mapping[str, Any]) -> Any:
156
+ return self._client._request("POST", "/api/v1/client-webhooks", body=body)
157
+
158
+ def update(self, config_id: str, body: Mapping[str, Any]) -> Any:
159
+ return self._client._request(
160
+ "PUT", "/api/v1/client-webhooks/" + _segment(config_id), body=body
161
+ )
162
+
163
+ def remove(self, config_id: str) -> Any:
164
+ return self._client._request(
165
+ "DELETE", "/api/v1/client-webhooks/" + _segment(config_id)
166
+ )
167
+
168
+ def test(self, body: Mapping[str, Any]) -> Any:
169
+ return self._client._request("POST", "/api/v1/client-webhooks/test", body=body)
170
+
171
+
172
+ class WebhookLogs(_Resource):
173
+ def list(self, **options: Any) -> Any:
174
+ return self._client._request(
175
+ "GET", "/api/v1/webhook-logs", query=_log_query(options)
176
+ )
177
+
178
+ def stats(self, **options: Any) -> Any:
179
+ return self._client._request(
180
+ "GET", "/api/v1/webhook-logs/stats", query=_log_query(options)
181
+ )
182
+
183
+
184
+ class MonaPay:
185
+ """Synchronous MONA Pay API client using urllib."""
186
+
187
+ def __init__(
188
+ self,
189
+ username: str,
190
+ password: str,
191
+ client_secret: Optional[str] = None,
192
+ base_url: str = DEFAULT_BASE_URL,
193
+ timeout: float = 30,
194
+ ):
195
+ if not username or not password:
196
+ raise ValueError("username và password là bắt buộc")
197
+ self.username = username
198
+ self.password = password
199
+ self.client_secret = client_secret
200
+ self.base_url = base_url.rstrip("/")
201
+ self.timeout = timeout
202
+ self._access_token = None # type: Optional[str]
203
+ self._login_lock = threading.Lock()
204
+
205
+ self.keys = Keys(self)
206
+ self.va = VirtualAccounts(self)
207
+ self.bank_accounts = BankAccounts(self)
208
+ self.qr = QrPayments(self)
209
+ self.transactions = Transactions(self)
210
+ self.webhooks = Webhooks(self)
211
+ self.webhook_logs = WebhookLogs(self)
212
+
213
+ def me(self) -> Any:
214
+ return self._request("GET", "/api/v1/client/me")
215
+
216
+ def iter_transactions(
217
+ self, virtual_account_number: str, page: int = 1, limit: int = 100
218
+ ) -> Iterator[Any]:
219
+ return self.transactions.iterate(virtual_account_number, page=page, limit=limit)
220
+
221
+ def _login(self) -> str:
222
+ with self._login_lock:
223
+ if self._access_token:
224
+ return self._access_token
225
+ data = self._send(
226
+ "POST",
227
+ "/api/v1/client/login",
228
+ body={"username": self.username, "password": self.password},
229
+ authenticated=False,
230
+ )
231
+ if not isinstance(data, dict) or not data.get("access_token"):
232
+ raise ApiError("Response đăng nhập không có access_token")
233
+ self._access_token = data["access_token"]
234
+ return self._access_token
235
+
236
+ def _request(
237
+ self,
238
+ method: str,
239
+ path: str,
240
+ body: Optional[Mapping[str, Any]] = None,
241
+ query: Optional[Mapping[str, Any]] = None,
242
+ retry: bool = True,
243
+ ) -> Any:
244
+ if not self._access_token:
245
+ self._login()
246
+ try:
247
+ return self._send(method, path, body=body, query=query, authenticated=True)
248
+ except ApiError as error:
249
+ if error.status == 401 and retry:
250
+ self._access_token = None
251
+ self._login()
252
+ return self._request(method, path, body=body, query=query, retry=False)
253
+ raise
254
+
255
+ def _send(
256
+ self,
257
+ method: str,
258
+ path: str,
259
+ body: Optional[Mapping[str, Any]] = None,
260
+ query: Optional[Mapping[str, Any]] = None,
261
+ authenticated: bool = True,
262
+ ) -> Any:
263
+ clean_query = {key: value for key, value in (query or {}).items() if value is not None}
264
+ url = self.base_url + path
265
+ if clean_query:
266
+ url += "?" + urllib.parse.urlencode(clean_query)
267
+
268
+ headers = {"Accept": "application/json"}
269
+ if authenticated:
270
+ headers["Authorization"] = "Bearer " + str(self._access_token)
271
+ if authenticated and method != "GET" and self.client_secret:
272
+ headers["X-Client-Secret"] = self.client_secret
273
+ encoded_body = None
274
+ if body is not None:
275
+ headers["Content-Type"] = "application/json"
276
+ encoded_body = json.dumps(body, separators=(",", ":")).encode("utf-8")
277
+
278
+ request = urllib.request.Request(
279
+ url, data=encoded_body, headers=headers, method=method
280
+ )
281
+ try:
282
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
283
+ status = response.getcode()
284
+ raw = response.read()
285
+ except urllib.error.HTTPError as error:
286
+ status = error.code
287
+ raw = error.read()
288
+ except urllib.error.URLError as error:
289
+ raise ApiError("Không kết nối được MONA Pay: {}".format(error.reason)) from error
290
+
291
+ if raw:
292
+ try:
293
+ payload = json.loads(raw.decode("utf-8"))
294
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
295
+ raise ApiError(
296
+ "MONA Pay trả response không phải JSON (HTTP {})".format(status),
297
+ status=status,
298
+ body=raw,
299
+ ) from error
300
+ else:
301
+ payload = {}
302
+
303
+ if not 200 <= status < 300 or payload.get("success") is False:
304
+ detail = payload.get("detail")
305
+ if not isinstance(detail, str):
306
+ detail = None
307
+ raise ApiError(
308
+ payload.get("message") or detail or "MONA Pay API lỗi HTTP {}".format(status),
309
+ status=status,
310
+ body=payload,
311
+ )
312
+ return payload.get("data")
@@ -0,0 +1,68 @@
1
+ """MONA Pay webhook signature verification."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Any, Mapping, Optional
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class WebhookResult:
14
+ ok: bool
15
+ reason: Optional[str] = None
16
+ payload: Any = None
17
+
18
+
19
+ def _header(headers: Mapping[str, Any], wanted: str) -> Optional[str]:
20
+ lowered = wanted.lower()
21
+ for name, value in headers.items():
22
+ if str(name).lower() == lowered:
23
+ if isinstance(value, (list, tuple)):
24
+ value = value[0] if value else None
25
+ return None if value is None else str(value)
26
+ return None
27
+
28
+
29
+ def verify_webhook(
30
+ raw_body: bytes,
31
+ headers: Mapping[str, Any],
32
+ secret: str,
33
+ tolerance: int = 300,
34
+ ) -> WebhookResult:
35
+ """Verify timestamp + HMAC against the exact request bytes, then parse JSON."""
36
+ if not isinstance(raw_body, bytes):
37
+ raise TypeError("raw_body phải là bytes")
38
+ if tolerance < 0:
39
+ raise ValueError("tolerance phải là số không âm")
40
+
41
+ timestamp_text = _header(headers, "x-mona-timestamp")
42
+ signature = _header(headers, "x-mona-signature")
43
+ if not timestamp_text:
44
+ return WebhookResult(False, "missing_timestamp")
45
+ if not timestamp_text.isdigit():
46
+ return WebhookResult(False, "invalid_timestamp")
47
+ timestamp = int(timestamp_text)
48
+ if abs(int(time.time()) - timestamp) > tolerance:
49
+ return WebhookResult(False, "timestamp_out_of_tolerance")
50
+ if not signature:
51
+ return WebhookResult(False, "missing_signature")
52
+
53
+ expected = hmac.new(
54
+ secret.encode("utf-8"),
55
+ timestamp_text.encode("ascii") + b"." + raw_body,
56
+ hashlib.sha256,
57
+ ).hexdigest()
58
+ match = re.fullmatch(r"sha256=([0-9a-fA-F]{64})", signature)
59
+ supplied = match.group(1).lower() if match else "0" * 64
60
+ valid_signature = hmac.compare_digest(expected, supplied) and match is not None
61
+ if not valid_signature:
62
+ return WebhookResult(False, "invalid_signature")
63
+
64
+ try:
65
+ payload = json.loads(raw_body.decode("utf-8"))
66
+ except (UnicodeDecodeError, json.JSONDecodeError):
67
+ return WebhookResult(False, "invalid_json")
68
+ return WebhookResult(True, payload=payload)
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: monapay
3
+ Version: 0.1.0
4
+ Summary: SDK Python zero-dependency cho MONA Pay
5
+ Author-email: The MONA Group <info@themona.global>
6
+ License: MIT
7
+ Project-URL: Documentation, https://monapay.vn/docs
8
+ Project-URL: Repository, https://github.com/monapay/monapay-python
9
+ Keywords: monapay,vietqr,virtual-account,payment,webhook
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # monapay
20
+
21
+ MONA Pay là cổng thanh toán và API ngân hàng của The MONA Group, giúp doanh nghiệp Việt Nam nhận và xác nhận tiền chuyển khoản theo thời gian thực qua tài khoản ảo (VA), VietQR, webhook và Telegram — thiết kế để cả lập trình viên lẫn AI agent tích hợp trong vài phút.
22
+
23
+ SDK Python đồng bộ, chỉ dùng standard library. MONA Pay miễn phí hoàn toàn.
24
+
25
+ ## Cài đặt
26
+
27
+ ```bash
28
+ pip install monapay
29
+ ```
30
+
31
+ ## Bắt đầu nhanh
32
+
33
+ ```python
34
+ import os
35
+ from monapay import MonaPay
36
+
37
+ mona = MonaPay(
38
+ os.environ["MONA_USERNAME"],
39
+ os.environ["MONA_PASSWORD"],
40
+ client_secret=os.getenv("MONA_CLIENT_SECRET"),
41
+ )
42
+
43
+ # Tự login và giữ token.
44
+ print(mona.me())
45
+
46
+ # Lần đầu: secret chỉ hiện một lần. SDK giữ key mới cho instance hiện tại.
47
+ key = mona.keys.generate("Web ban hang")
48
+ print("Lưu MONA_CLIENT_SECRET an toàn:", key["client_secret"])
49
+
50
+ mona.webhooks.create({
51
+ "name": "Web ban hang",
52
+ "webhook_url": "https://shop.vn/webhooks/monapay",
53
+ "auth_type": "HMAC_SHA256",
54
+ "secret_key": os.environ["MONA_WEBHOOK_SECRET"],
55
+ "payload_format": "application/json",
56
+ })
57
+
58
+ qr = mona.qr.generate({
59
+ "ownerNumber": "123456789", "ownerType": "ORG",
60
+ "merchantId": "MC00012345", "terminalId": "TM0001", "orderId": "DH10234",
61
+ "virtualAccountPrefix": "MONA", "beneficiaryName": "CONG TY ABC",
62
+ "amount": 2500000, "description": "Thanh toan DH10234",
63
+ })
64
+ print(qr["qr_data_url"])
65
+ ```
66
+
67
+ Client tự login lại và thử request đúng một lần khi gặp HTTP 401. Các method trả trực tiếp trường `data`; `ApiError` có `status` và `body`.
68
+
69
+ Các nhóm method: `keys`, `va`, `bank_accounts`, `qr`, `transactions`, `webhooks`, `webhook_logs`. Tên method dùng snake_case, ví dụ `va.register_notification(...)` và `transactions.retry(id, target_type="WEBHOOK", target_id=...)`.
70
+
71
+ Đọc hết các trang giao dịch:
72
+
73
+ ```python
74
+ for tx in mona.iter_transactions("MONA0000010234", limit=100):
75
+ print(tx["transaction_code"], tx["amount"])
76
+ ```
77
+
78
+ ## Xác thực webhook
79
+
80
+ Luôn truyền đúng `request.body` dạng bytes, không parse rồi encode lại.
81
+
82
+ ```python
83
+ from monapay import verify_webhook
84
+
85
+ result = verify_webhook(raw_body, headers, os.environ["MONA_WEBHOOK_SECRET"])
86
+ if not result.ok:
87
+ return {"reason": result.reason}, 401
88
+ save_once(result.payload["transaction_code"], result.payload)
89
+ ```
90
+
91
+ Ví dụ nhận webhook cho Flask, FastAPI và Django nằm trong `examples/`. Dùng `transaction_code` làm unique key để chống xử lý trùng.
92
+
93
+ Tài liệu: https://monapay.vn/docs · AI/LLM: https://monapay.vn/llms.txt · Hotline 1900 636 648 · info@themona.global
94
+
95
+ ## Test
96
+
97
+ Từ thư mục chứa `python/`:
98
+
99
+ ```bash
100
+ python3 -m unittest discover python/tests
101
+ ```
102
+
103
+ License MIT.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ monapay/__init__.py
5
+ monapay/client.py
6
+ monapay/webhook.py
7
+ monapay.egg-info/PKG-INFO
8
+ monapay.egg-info/SOURCES.txt
9
+ monapay.egg-info/dependency_links.txt
10
+ monapay.egg-info/top_level.txt
11
+ tests/test_client.py
12
+ tests/test_webhook.py
@@ -0,0 +1 @@
1
+ monapay
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "monapay"
7
+ version = "0.1.0"
8
+ description = "SDK Python zero-dependency cho MONA Pay"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "The MONA Group", email = "info@themona.global" }]
13
+ keywords = ["monapay", "vietqr", "virtual-account", "payment", "webhook"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ ]
20
+
21
+ [project.urls]
22
+ Documentation = "https://monapay.vn/docs"
23
+ Repository = "https://github.com/monapay/monapay-python"
24
+
25
+ [tool.setuptools.packages.find]
26
+ include = ["monapay*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,101 @@
1
+ import io
2
+ import json
3
+ import pathlib
4
+ import sys
5
+ import unittest
6
+ import urllib.error
7
+ from unittest.mock import patch
8
+
9
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
10
+
11
+ from monapay import MonaPay
12
+
13
+
14
+ class FakeResponse:
15
+ def __init__(self, payload, status=200):
16
+ self.payload = json.dumps(payload).encode("utf-8")
17
+ self.status = status
18
+
19
+ def __enter__(self):
20
+ return self
21
+
22
+ def __exit__(self, *args):
23
+ return False
24
+
25
+ def read(self):
26
+ return self.payload
27
+
28
+ def getcode(self):
29
+ return self.status
30
+
31
+
32
+ class ClientTests(unittest.TestCase):
33
+ @patch("urllib.request.urlopen")
34
+ def test_builds_url_headers_and_caches_token(self, urlopen):
35
+ urlopen.side_effect = [
36
+ FakeResponse({"success": True, "data": {"access_token": "token-1"}}),
37
+ FakeResponse({"success": True, "data": {"id": "hook-1"}}),
38
+ FakeResponse({"success": True, "data": {"username": "user"}}),
39
+ ]
40
+ client = MonaPay(
41
+ "user", "pass", client_secret="client-secret", base_url="https://example.test/"
42
+ )
43
+ client.webhooks.create({"name": "Shop", "webhook_url": "https://shop.test/hook"})
44
+ client.me()
45
+
46
+ login_request = urlopen.call_args_list[0].args[0]
47
+ write_request = urlopen.call_args_list[1].args[0]
48
+ read_request = urlopen.call_args_list[2].args[0]
49
+ self.assertEqual(login_request.full_url, "https://example.test/api/v1/client/login")
50
+ self.assertEqual(write_request.full_url, "https://example.test/api/v1/client-webhooks")
51
+ self.assertEqual(write_request.get_header("Authorization"), "Bearer token-1")
52
+ self.assertEqual(write_request.get_header("X-client-secret"), "client-secret")
53
+ self.assertIsNone(read_request.get_header("X-client-secret"))
54
+ self.assertEqual(urlopen.call_count, 3)
55
+
56
+ @patch("urllib.request.urlopen")
57
+ def test_refreshes_once_after_401(self, urlopen):
58
+ expired = urllib.error.HTTPError(
59
+ "https://example.test/api/v1/client/me",
60
+ 401,
61
+ "Unauthorized",
62
+ {},
63
+ io.BytesIO(b'{"detail":"expired"}'),
64
+ )
65
+ urlopen.side_effect = [
66
+ FakeResponse({"success": True, "data": {"access_token": "token-1"}}),
67
+ expired,
68
+ FakeResponse({"success": True, "data": {"access_token": "token-2"}}),
69
+ FakeResponse({"success": True, "data": {"username": "user"}}),
70
+ ]
71
+ client = MonaPay("user", "pass", base_url="https://example.test")
72
+ self.assertEqual(client.me(), {"username": "user"})
73
+ final_request = urlopen.call_args_list[3].args[0]
74
+ self.assertEqual(final_request.get_header("Authorization"), "Bearer token-2")
75
+
76
+ @patch("urllib.request.urlopen")
77
+ def test_iter_transactions_reads_all_pages(self, urlopen):
78
+ urlopen.side_effect = [
79
+ FakeResponse({"success": True, "data": {"access_token": "token"}}),
80
+ FakeResponse(
81
+ {
82
+ "success": True,
83
+ "data": {"data": [{"id": "tx-1"}], "has_next": True, "last_page": 2},
84
+ }
85
+ ),
86
+ FakeResponse(
87
+ {
88
+ "success": True,
89
+ "data": {"data": [{"id": "tx-2"}], "has_next": False, "last_page": 2},
90
+ }
91
+ ),
92
+ ]
93
+ client = MonaPay("user", "pass", base_url="https://example.test")
94
+ items = list(client.iter_transactions("MONA 01", limit=1))
95
+ self.assertEqual([item["id"] for item in items], ["tx-1", "tx-2"])
96
+ self.assertIn("virtual_account_number=MONA+01", urlopen.call_args_list[1].args[0].full_url)
97
+ self.assertIn("page=2", urlopen.call_args_list[2].args[0].full_url)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ unittest.main()
@@ -0,0 +1,64 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import pathlib
5
+ import sys
6
+ import time
7
+ import unittest
8
+
9
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
10
+
11
+ from monapay import verify_webhook
12
+
13
+
14
+ class WebhookTests(unittest.TestCase):
15
+ def signature(self, raw_body, secret, timestamp):
16
+ digest = hmac.new(
17
+ secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256
18
+ ).hexdigest()
19
+ return "sha256=" + digest
20
+
21
+ def test_valid_signature(self):
22
+ raw = json.dumps({"amount": 2500000, "transaction_code": "FT1"}).encode()
23
+ timestamp = int(time.time())
24
+ result = verify_webhook(
25
+ raw,
26
+ {
27
+ "X-Mona-Timestamp": str(timestamp),
28
+ "x-mona-signature": self.signature(raw, "secret", timestamp),
29
+ },
30
+ "secret",
31
+ )
32
+ self.assertTrue(result.ok)
33
+ self.assertEqual(result.payload["transaction_code"], "FT1")
34
+
35
+ def test_invalid_signature(self):
36
+ timestamp = int(time.time())
37
+ result = verify_webhook(
38
+ b"{}",
39
+ {
40
+ "x-mona-timestamp": str(timestamp),
41
+ "x-mona-signature": "sha256=" + "0" * 64,
42
+ },
43
+ "secret",
44
+ )
45
+ self.assertFalse(result.ok)
46
+ self.assertEqual(result.reason, "invalid_signature")
47
+
48
+ def test_expired_timestamp(self):
49
+ timestamp = int(time.time()) - 301
50
+ result = verify_webhook(
51
+ b"{}",
52
+ {
53
+ "x-mona-timestamp": str(timestamp),
54
+ "x-mona-signature": self.signature(b"{}", "secret", timestamp),
55
+ },
56
+ "secret",
57
+ tolerance=300,
58
+ )
59
+ self.assertFalse(result.ok)
60
+ self.assertEqual(result.reason, "timestamp_out_of_tolerance")
61
+
62
+
63
+ if __name__ == "__main__":
64
+ unittest.main()