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 +21 -0
- monapay-0.1.0/PKG-INFO +103 -0
- monapay-0.1.0/README.md +85 -0
- monapay-0.1.0/monapay/__init__.py +7 -0
- monapay-0.1.0/monapay/client.py +312 -0
- monapay-0.1.0/monapay/webhook.py +68 -0
- monapay-0.1.0/monapay.egg-info/PKG-INFO +103 -0
- monapay-0.1.0/monapay.egg-info/SOURCES.txt +12 -0
- monapay-0.1.0/monapay.egg-info/dependency_links.txt +1 -0
- monapay-0.1.0/monapay.egg-info/top_level.txt +1 -0
- monapay-0.1.0/pyproject.toml +26 -0
- monapay-0.1.0/setup.cfg +4 -0
- monapay-0.1.0/tests/test_client.py +101 -0
- monapay-0.1.0/tests/test_webhook.py +64 -0
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.
|
monapay-0.1.0/README.md
ADDED
|
@@ -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,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
|
+
|
|
@@ -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*"]
|
monapay-0.1.0/setup.cfg
ADDED
|
@@ -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()
|