bml-connect-python 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bml_connect/__init__.py +79 -0
- bml_connect/client.py +536 -0
- bml_connect_python-1.0.0.dist-info/METADATA +279 -0
- bml_connect_python-1.0.0.dist-info/RECORD +7 -0
- bml_connect_python-1.0.0.dist-info/WHEEL +5 -0
- bml_connect_python-1.0.0.dist-info/licenses/LICENSE +9 -0
- bml_connect_python-1.0.0.dist-info/top_level.txt +1 -0
bml_connect/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BML Connect Python SDK
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Create, retrieve, and list transactions
|
|
9
|
+
- Verify webhook signatures
|
|
10
|
+
- Supports both production and sandbox environments
|
|
11
|
+
- Full sync/async compatibility
|
|
12
|
+
|
|
13
|
+
Basic Usage:
|
|
14
|
+
from bml_connect import BMLConnect, Environment
|
|
15
|
+
|
|
16
|
+
# Sync client
|
|
17
|
+
client = BMLConnect('your-api-key', 'your-app-id', Environment.SANDBOX)
|
|
18
|
+
transaction = client.transactions.create_transaction({...})
|
|
19
|
+
|
|
20
|
+
# Async client
|
|
21
|
+
async_client = BMLConnect('your-api-key', 'your-app-id', Environment.SANDBOX, async_mode=True)
|
|
22
|
+
transaction = await async_client.transactions.create_transaction({...})
|
|
23
|
+
|
|
24
|
+
For detailed documentation and examples, visit:
|
|
25
|
+
https://github.com/quillfires/bml-connect-python
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__version__ = "1.0.0"
|
|
29
|
+
__author__ = "Ali Fayaz"
|
|
30
|
+
__email__ = "fayaz.quill@gmail.com"
|
|
31
|
+
|
|
32
|
+
from .client import (
|
|
33
|
+
BMLConnect,
|
|
34
|
+
Transaction,
|
|
35
|
+
QRCode,
|
|
36
|
+
PaginatedResponse,
|
|
37
|
+
Environment,
|
|
38
|
+
SignMethod,
|
|
39
|
+
TransactionState,
|
|
40
|
+
BMLConnectError,
|
|
41
|
+
AuthenticationError,
|
|
42
|
+
ValidationError,
|
|
43
|
+
NotFoundError,
|
|
44
|
+
ServerError,
|
|
45
|
+
RateLimitError,
|
|
46
|
+
SignatureUtils
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
'BMLConnect',
|
|
51
|
+
'Transaction',
|
|
52
|
+
'QRCode',
|
|
53
|
+
'PaginatedResponse',
|
|
54
|
+
'Environment',
|
|
55
|
+
'SignMethod',
|
|
56
|
+
'TransactionState',
|
|
57
|
+
'BMLConnectError',
|
|
58
|
+
'AuthenticationError',
|
|
59
|
+
'ValidationError',
|
|
60
|
+
'NotFoundError',
|
|
61
|
+
'ServerError',
|
|
62
|
+
'RateLimitError',
|
|
63
|
+
'SignatureUtils'
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
BMLConnect.__module__ = "bml_connect"
|
|
67
|
+
Transaction.__module__ = "bml_connect"
|
|
68
|
+
QRCode.__module__ = "bml_connect"
|
|
69
|
+
PaginatedResponse.__module__ = "bml_connect"
|
|
70
|
+
Environment.__module__ = "bml_connect"
|
|
71
|
+
SignMethod.__module__ = "bml_connect"
|
|
72
|
+
TransactionState.__module__ = "bml_connect"
|
|
73
|
+
BMLConnectError.__module__ = "bml_connect"
|
|
74
|
+
AuthenticationError.__module__ = "bml_connect"
|
|
75
|
+
ValidationError.__module__ = "bml_connect"
|
|
76
|
+
NotFoundError.__module__ = "bml_connect"
|
|
77
|
+
ServerError.__module__ = "bml_connect"
|
|
78
|
+
RateLimitError.__module__ = "bml_connect"
|
|
79
|
+
SignatureUtils.__module__ = "bml_connect"
|
bml_connect/client.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BML Connect Python SDK
|
|
3
|
+
======================
|
|
4
|
+
|
|
5
|
+
Robust Python SDK for Bank of Maldives Connect API with comprehensive sync/async support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Optional, Dict, Any, Union, List
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
import requests
|
|
16
|
+
import aiohttp
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("bml_connect")
|
|
22
|
+
logger.addHandler(logging.NullHandler())
|
|
23
|
+
|
|
24
|
+
SDK_VERSION = "1.0.0"
|
|
25
|
+
USER_AGENT = f"BML-Connect-Python/{SDK_VERSION}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Environment(Enum):
|
|
29
|
+
SANDBOX = "sandbox"
|
|
30
|
+
PRODUCTION = "production"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def base_url(self) -> str:
|
|
34
|
+
return {
|
|
35
|
+
Environment.SANDBOX: "https://api.uat.merchants.bankofmaldives.com.mv/public",
|
|
36
|
+
Environment.PRODUCTION: "https://api.merchants.bankofmaldives.com.mv/public"
|
|
37
|
+
}[self]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SignMethod(Enum):
|
|
41
|
+
SHA1 = "sha1"
|
|
42
|
+
MD5 = "md5"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def _missing_(cls, value: str) -> 'SignMethod':
|
|
46
|
+
value = value.lower()
|
|
47
|
+
for member in cls:
|
|
48
|
+
if member.value == value:
|
|
49
|
+
return member
|
|
50
|
+
return cls.SHA1 # Default to SHA1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TransactionState(Enum):
|
|
54
|
+
CREATED = "CREATED"
|
|
55
|
+
QR_CODE_GENERATED = "QR_CODE_GENERATED"
|
|
56
|
+
CONFIRMED = "CONFIRMED"
|
|
57
|
+
CANCELLED = "CANCELLED"
|
|
58
|
+
FAILED = "FAILED"
|
|
59
|
+
EXPIRED = "EXPIRED"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class QRCode:
|
|
64
|
+
url: str
|
|
65
|
+
image: Optional[str] = None # Base64 encoded image if available
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Transaction:
|
|
70
|
+
transaction_id: Optional[str] = None
|
|
71
|
+
local_id: Optional[str] = None
|
|
72
|
+
customer_reference: Optional[str] = None
|
|
73
|
+
amount: Optional[int] = None
|
|
74
|
+
currency: Optional[str] = None
|
|
75
|
+
provider: Optional[str] = None
|
|
76
|
+
state: Optional[TransactionState] = None
|
|
77
|
+
created: Optional[str] = None
|
|
78
|
+
signature: Optional[str] = None
|
|
79
|
+
url: Optional[str] = None
|
|
80
|
+
qr_code: Optional[QRCode] = None
|
|
81
|
+
redirect_url: Optional[str] = None
|
|
82
|
+
app_version: Optional[str] = None
|
|
83
|
+
api_version: Optional[str] = None
|
|
84
|
+
device_id: Optional[str] = None
|
|
85
|
+
sign_method: Optional[SignMethod] = None
|
|
86
|
+
expires_at: Optional[str] = None
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
|
|
90
|
+
qr_code = None
|
|
91
|
+
qr_data = data.get('qrCode', {})
|
|
92
|
+
if qr_data and 'url' in qr_data:
|
|
93
|
+
qr_code = QRCode(
|
|
94
|
+
url=qr_data['url'],
|
|
95
|
+
image=qr_data.get('image')
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Parse state enum
|
|
99
|
+
state = None
|
|
100
|
+
if 'state' in data:
|
|
101
|
+
try:
|
|
102
|
+
state = TransactionState(data['state'])
|
|
103
|
+
except ValueError:
|
|
104
|
+
logger.warning(f"Unknown transaction state: {data['state']}")
|
|
105
|
+
state = None
|
|
106
|
+
|
|
107
|
+
# Parse sign method enum
|
|
108
|
+
sign_method = None
|
|
109
|
+
if 'signMethod' in data:
|
|
110
|
+
try:
|
|
111
|
+
sign_method = SignMethod(data['signMethod'])
|
|
112
|
+
except ValueError:
|
|
113
|
+
logger.warning(f"Unknown sign method: {data['signMethod']}")
|
|
114
|
+
sign_method = None
|
|
115
|
+
|
|
116
|
+
return cls(
|
|
117
|
+
transaction_id=data.get('transactionId'),
|
|
118
|
+
local_id=data.get('localId'),
|
|
119
|
+
customer_reference=data.get('customerReference'),
|
|
120
|
+
amount=data.get('amount'),
|
|
121
|
+
currency=data.get('currency'),
|
|
122
|
+
provider=data.get('provider'),
|
|
123
|
+
state=state,
|
|
124
|
+
created=data.get('created'),
|
|
125
|
+
signature=data.get('signature'),
|
|
126
|
+
url=data.get('url'),
|
|
127
|
+
qr_code=qr_code,
|
|
128
|
+
redirect_url=data.get('redirectUrl'),
|
|
129
|
+
app_version=data.get('appVersion'),
|
|
130
|
+
api_version=data.get('apiVersion'),
|
|
131
|
+
device_id=data.get('deviceId'),
|
|
132
|
+
sign_method=sign_method,
|
|
133
|
+
expires_at=data.get('expiresAt')
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class PaginatedResponse:
|
|
139
|
+
count: int
|
|
140
|
+
items: List[Transaction]
|
|
141
|
+
current_page: int
|
|
142
|
+
total_pages: int
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'PaginatedResponse':
|
|
146
|
+
items = [Transaction.from_dict(item) for item in data.get('items', [])]
|
|
147
|
+
return cls(
|
|
148
|
+
count=data.get('count', 0),
|
|
149
|
+
items=items,
|
|
150
|
+
current_page=data.get('currentPage', 1),
|
|
151
|
+
total_pages=data.get('totalPages', 1)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class BMLConnectError(Exception):
|
|
156
|
+
"""Base exception for BML Connect errors"""
|
|
157
|
+
def __init__(self, message: str, code: Optional[str] = None, status_code: Optional[int] = None):
|
|
158
|
+
self.message = message
|
|
159
|
+
self.code = code
|
|
160
|
+
self.status_code = status_code
|
|
161
|
+
super().__init__(self.message)
|
|
162
|
+
|
|
163
|
+
def __str__(self) -> str:
|
|
164
|
+
if self.code:
|
|
165
|
+
return f"[{self.code}] {self.message}"
|
|
166
|
+
return self.message
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class AuthenticationError(BMLConnectError):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ValidationError(BMLConnectError):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class NotFoundError(BMLConnectError):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ServerError(BMLConnectError):
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RateLimitError(BMLConnectError):
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SignatureUtils:
|
|
190
|
+
@staticmethod
|
|
191
|
+
def generate_signature(data: Dict[str, Any], api_key: str, method: Union[SignMethod, str] = SignMethod.SHA1) -> str:
|
|
192
|
+
"""Generate signature with proper key sorting and encoding"""
|
|
193
|
+
if isinstance(method, str):
|
|
194
|
+
try:
|
|
195
|
+
method = SignMethod(method)
|
|
196
|
+
except ValueError:
|
|
197
|
+
method = SignMethod.SHA1
|
|
198
|
+
logger.warning(f"Invalid sign method '{method}', defaulting to SHA1")
|
|
199
|
+
|
|
200
|
+
# Filtering None values and empty strings
|
|
201
|
+
filtered_data = {k: v for k, v in data.items() if v is not None and v != ""}
|
|
202
|
+
|
|
203
|
+
sorted_params = sorted(filtered_data.items(), key=lambda x: x[0].lower())
|
|
204
|
+
query_string = urlencode(sorted_params, doseq=True)
|
|
205
|
+
|
|
206
|
+
signature_string = f"{query_string}&apiKey={api_key}"
|
|
207
|
+
|
|
208
|
+
if method == SignMethod.SHA1:
|
|
209
|
+
return hashlib.sha1(signature_string.encode('utf-8')).hexdigest()
|
|
210
|
+
elif method == SignMethod.MD5:
|
|
211
|
+
return base64.b64encode(hashlib.md5(signature_string.encode('utf-8')).digest()).decode()
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(f"Unsupported signature method: {method}")
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def verify_signature(data: Dict[str, Any], signature: str, api_key: str, method: Union[SignMethod, str] = SignMethod.SHA1) -> bool:
|
|
217
|
+
"""Secure signature verification with constant-time comparison"""
|
|
218
|
+
expected = SignatureUtils.generate_signature(data, api_key, method)
|
|
219
|
+
return hmac.compare_digest(expected, signature)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class BaseClient:
|
|
223
|
+
def __init__(self, api_key: str, app_id: str, environment: Environment = Environment.PRODUCTION):
|
|
224
|
+
self.api_key = api_key
|
|
225
|
+
self.app_id = app_id
|
|
226
|
+
self.environment = environment
|
|
227
|
+
self.base_url = environment.base_url
|
|
228
|
+
self.session = None # Will be set in child classes
|
|
229
|
+
logger.info(f"Initialized BML Client for {environment.name} environment")
|
|
230
|
+
|
|
231
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
232
|
+
return {
|
|
233
|
+
'Authorization': f"Bearer {self.api_key}",
|
|
234
|
+
'Content-Type': 'application/json',
|
|
235
|
+
'User-Agent': USER_AGENT,
|
|
236
|
+
'X-App-Id': self.app_id
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
def _prepare_transaction_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
240
|
+
"""Prepare transaction data with required defaults"""
|
|
241
|
+
prepared = data.copy()
|
|
242
|
+
prepared.setdefault('apiVersion', '2.0')
|
|
243
|
+
prepared.setdefault('signMethod', 'sha1')
|
|
244
|
+
prepared.setdefault('appVersion', USER_AGENT)
|
|
245
|
+
prepared.setdefault('deviceId', str(uuid.uuid4()))
|
|
246
|
+
return prepared
|
|
247
|
+
|
|
248
|
+
def _handle_response(self, response: Union[requests.Response, aiohttp.ClientResponse], response_data: Dict[str, Any]):
|
|
249
|
+
"""Handle API response with proper error mapping"""
|
|
250
|
+
status_code = response.status if isinstance(response, aiohttp.ClientResponse) else response.status_code
|
|
251
|
+
message = response_data.get('message', 'Unknown error')
|
|
252
|
+
code = response_data.get('code')
|
|
253
|
+
|
|
254
|
+
logger.debug(f"API Response: {status_code} - {message}")
|
|
255
|
+
|
|
256
|
+
if status_code == 400:
|
|
257
|
+
raise ValidationError(message, code, status_code)
|
|
258
|
+
elif status_code == 401:
|
|
259
|
+
raise AuthenticationError(message, code, status_code)
|
|
260
|
+
elif status_code == 404:
|
|
261
|
+
raise NotFoundError(message, code, status_code)
|
|
262
|
+
elif status_code == 429:
|
|
263
|
+
raise RateLimitError(message, code, status_code)
|
|
264
|
+
elif status_code >= 500:
|
|
265
|
+
raise ServerError(message, code, status_code)
|
|
266
|
+
elif status_code >= 400:
|
|
267
|
+
raise BMLConnectError(message, code, status_code)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class SyncClient(BaseClient):
|
|
271
|
+
def __init__(self, *args, **kwargs):
|
|
272
|
+
super().__init__(*args, **kwargs)
|
|
273
|
+
self.session = requests.Session()
|
|
274
|
+
self.session.headers.update(self._get_headers())
|
|
275
|
+
logger.debug("Initialized synchronous HTTP session")
|
|
276
|
+
|
|
277
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
278
|
+
url = f"{self.base_url}{endpoint}"
|
|
279
|
+
logger.debug(f"Request: {method} {url} {kwargs.get('params')}")
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
response = self.session.request(
|
|
283
|
+
method,
|
|
284
|
+
url,
|
|
285
|
+
timeout=30,
|
|
286
|
+
**kwargs
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
response_data = response.json()
|
|
291
|
+
except json.JSONDecodeError:
|
|
292
|
+
logger.error(f"Invalid JSON response: {response.text[:500]}")
|
|
293
|
+
raise ServerError("Invalid JSON response", status_code=response.status_code)
|
|
294
|
+
|
|
295
|
+
logger.debug(f"Response: {response.status_code} - {response_data}")
|
|
296
|
+
|
|
297
|
+
if not response.ok:
|
|
298
|
+
self._handle_response(response, response_data)
|
|
299
|
+
|
|
300
|
+
return response_data
|
|
301
|
+
except requests.exceptions.RequestException as e:
|
|
302
|
+
logger.error(f"Network error: {str(e)}")
|
|
303
|
+
raise BMLConnectError(f"Network error: {str(e)}")
|
|
304
|
+
|
|
305
|
+
def create_transaction(self, data: Dict[str, Any]) -> Transaction:
|
|
306
|
+
logger.info("Creating new transaction")
|
|
307
|
+
data = self._prepare_transaction_data(data)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
sign_method = SignMethod(data.get('signMethod', 'sha1'))
|
|
311
|
+
except ValueError:
|
|
312
|
+
sign_method = SignMethod.SHA1
|
|
313
|
+
logger.warning(f"Invalid sign method, defaulting to SHA1")
|
|
314
|
+
|
|
315
|
+
data['signature'] = SignatureUtils.generate_signature(data, self.api_key, sign_method)
|
|
316
|
+
|
|
317
|
+
response = self._request('POST', '/transactions', json=data)
|
|
318
|
+
return Transaction.from_dict(response)
|
|
319
|
+
|
|
320
|
+
def get_transaction(self, transaction_id: str) -> Transaction:
|
|
321
|
+
logger.info(f"Fetching transaction: {transaction_id}")
|
|
322
|
+
response = self._request('GET', f'/transactions/{transaction_id}')
|
|
323
|
+
return Transaction.from_dict(response)
|
|
324
|
+
|
|
325
|
+
def list_transactions(
|
|
326
|
+
self,
|
|
327
|
+
page: int = 1,
|
|
328
|
+
per_page: int = 20,
|
|
329
|
+
state: Optional[str] = None,
|
|
330
|
+
provider: Optional[str] = None,
|
|
331
|
+
start_date: Optional[str] = None,
|
|
332
|
+
end_date: Optional[str] = None
|
|
333
|
+
) -> PaginatedResponse:
|
|
334
|
+
logger.info(f"Listing transactions: page={page}, per_page={per_page}")
|
|
335
|
+
params = {'page': page, 'perPage': per_page}
|
|
336
|
+
|
|
337
|
+
# Add filters
|
|
338
|
+
if state:
|
|
339
|
+
params['state'] = state
|
|
340
|
+
if provider:
|
|
341
|
+
params['provider'] = provider
|
|
342
|
+
if start_date:
|
|
343
|
+
params['startDate'] = start_date
|
|
344
|
+
if end_date:
|
|
345
|
+
params['endDate'] = end_date
|
|
346
|
+
|
|
347
|
+
response = self._request('GET', '/transactions', params=params)
|
|
348
|
+
return PaginatedResponse.from_dict(response)
|
|
349
|
+
|
|
350
|
+
def close(self):
|
|
351
|
+
"""Close the HTTP session"""
|
|
352
|
+
if self.session:
|
|
353
|
+
self.session.close()
|
|
354
|
+
logger.debug("Closed synchronous HTTP session")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class AsyncClient(BaseClient):
|
|
358
|
+
def __init__(self, *args, **kwargs):
|
|
359
|
+
super().__init__(*args, **kwargs)
|
|
360
|
+
self.session = aiohttp.ClientSession(
|
|
361
|
+
headers=self._get_headers(),
|
|
362
|
+
timeout=aiohttp.ClientTimeout(total=30)
|
|
363
|
+
)
|
|
364
|
+
logger.debug("Initialized asynchronous HTTP session")
|
|
365
|
+
|
|
366
|
+
async def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
367
|
+
url = f"{self.base_url}{endpoint}"
|
|
368
|
+
logger.debug(f"Async Request: {method} {url} {kwargs.get('params')}")
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
async with self.session.request(method, url, **kwargs) as response:
|
|
372
|
+
try:
|
|
373
|
+
response_data = await response.json()
|
|
374
|
+
except aiohttp.ContentTypeError:
|
|
375
|
+
text = await response.text()
|
|
376
|
+
logger.error(f"Invalid JSON response: {text[:500]}")
|
|
377
|
+
raise ServerError(f"Invalid JSON: {text[:200]}", status_code=response.status)
|
|
378
|
+
|
|
379
|
+
logger.debug(f"Async Response: {response.status} - {response_data}")
|
|
380
|
+
|
|
381
|
+
if not response.ok:
|
|
382
|
+
self._handle_response(response, response_data)
|
|
383
|
+
|
|
384
|
+
return response_data
|
|
385
|
+
except aiohttp.ClientError as e:
|
|
386
|
+
logger.error(f"Network error: {str(e)}")
|
|
387
|
+
raise BMLConnectError(f"Network error: {str(e)}")
|
|
388
|
+
|
|
389
|
+
async def create_transaction(self, data: Dict[str, Any]) -> Transaction:
|
|
390
|
+
logger.info("Creating new transaction (async)")
|
|
391
|
+
data = self._prepare_transaction_data(data)
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
sign_method = SignMethod(data.get('signMethod', 'sha1'))
|
|
395
|
+
except ValueError:
|
|
396
|
+
sign_method = SignMethod.SHA1
|
|
397
|
+
logger.warning(f"Invalid sign method, defaulting to SHA1")
|
|
398
|
+
|
|
399
|
+
data['signature'] = SignatureUtils.generate_signature(data, self.api_key, sign_method)
|
|
400
|
+
|
|
401
|
+
response = await self._request('POST', '/transactions', json=data)
|
|
402
|
+
return Transaction.from_dict(response)
|
|
403
|
+
|
|
404
|
+
async def get_transaction(self, transaction_id: str) -> Transaction:
|
|
405
|
+
logger.info(f"Fetching transaction (async): {transaction_id}")
|
|
406
|
+
response = await self._request('GET', f'/transactions/{transaction_id}')
|
|
407
|
+
return Transaction.from_dict(response)
|
|
408
|
+
|
|
409
|
+
async def list_transactions(
|
|
410
|
+
self,
|
|
411
|
+
page: int = 1,
|
|
412
|
+
per_page: int = 20,
|
|
413
|
+
state: Optional[str] = None,
|
|
414
|
+
provider: Optional[str] = None,
|
|
415
|
+
start_date: Optional[str] = None,
|
|
416
|
+
end_date: Optional[str] = None
|
|
417
|
+
) -> PaginatedResponse:
|
|
418
|
+
logger.info(f"Listing transactions (async): page={page}, per_page={per_page}")
|
|
419
|
+
params = {'page': page, 'perPage': per_page}
|
|
420
|
+
|
|
421
|
+
# Add filters
|
|
422
|
+
if state:
|
|
423
|
+
params['state'] = state
|
|
424
|
+
if provider:
|
|
425
|
+
params['provider'] = provider
|
|
426
|
+
if start_date:
|
|
427
|
+
params['startDate'] = start_date
|
|
428
|
+
if end_date:
|
|
429
|
+
params['endDate'] = end_date
|
|
430
|
+
|
|
431
|
+
response = await self._request('GET', '/transactions', params=params)
|
|
432
|
+
return PaginatedResponse.from_dict(response)
|
|
433
|
+
|
|
434
|
+
async def close(self):
|
|
435
|
+
"""Close the HTTP session"""
|
|
436
|
+
if self.session and not self.session.closed:
|
|
437
|
+
await self.session.close()
|
|
438
|
+
logger.debug("Closed asynchronous HTTP session")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class BMLConnect:
|
|
442
|
+
def __init__(
|
|
443
|
+
self,
|
|
444
|
+
api_key: str,
|
|
445
|
+
app_id: str,
|
|
446
|
+
environment: Union[Environment, str] = Environment.PRODUCTION,
|
|
447
|
+
async_mode: bool = False
|
|
448
|
+
):
|
|
449
|
+
"""
|
|
450
|
+
Initialize BML Connect client
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
api_key: Your API key from BML merchant portal
|
|
454
|
+
app_id: Your application ID from BML merchant portal
|
|
455
|
+
environment: 'production' or 'sandbox' (default: production)
|
|
456
|
+
async_mode: Whether to use async operations (default: False)
|
|
457
|
+
"""
|
|
458
|
+
self.api_key = api_key
|
|
459
|
+
self.app_id = app_id
|
|
460
|
+
|
|
461
|
+
if isinstance(environment, str):
|
|
462
|
+
try:
|
|
463
|
+
self.environment = Environment[environment.upper()]
|
|
464
|
+
except KeyError:
|
|
465
|
+
raise ValueError(f"Invalid environment: {environment}. Use 'production' or 'sandbox'")
|
|
466
|
+
else:
|
|
467
|
+
self.environment = environment
|
|
468
|
+
|
|
469
|
+
self.async_mode = async_mode
|
|
470
|
+
|
|
471
|
+
if async_mode:
|
|
472
|
+
self.client = AsyncClient(api_key, app_id, self.environment)
|
|
473
|
+
else:
|
|
474
|
+
self.client = SyncClient(api_key, app_id, self.environment)
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def transactions(self):
|
|
478
|
+
return self.client
|
|
479
|
+
|
|
480
|
+
def verify_webhook_signature(
|
|
481
|
+
self,
|
|
482
|
+
payload: Union[Dict[str, Any], str],
|
|
483
|
+
signature: str,
|
|
484
|
+
method: Union[SignMethod, str] = SignMethod.SHA1
|
|
485
|
+
) -> bool:
|
|
486
|
+
"""
|
|
487
|
+
Verify webhook signature for data integrity
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
payload: Webhook payload data (dict or JSON string)
|
|
491
|
+
signature: Received signature to verify
|
|
492
|
+
method: Signature method (default: SHA1)
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
bool: True if signature is valid, False otherwise
|
|
496
|
+
"""
|
|
497
|
+
if isinstance(payload, str):
|
|
498
|
+
try:
|
|
499
|
+
payload = json.loads(payload)
|
|
500
|
+
except json.JSONDecodeError:
|
|
501
|
+
raise ValidationError("Invalid JSON payload")
|
|
502
|
+
|
|
503
|
+
# Create a copy and remove signature if present
|
|
504
|
+
verification_payload = payload.copy()
|
|
505
|
+
if 'signature' in verification_payload:
|
|
506
|
+
del verification_payload['signature']
|
|
507
|
+
|
|
508
|
+
return SignatureUtils.verify_signature(verification_payload, signature, self.api_key, method)
|
|
509
|
+
|
|
510
|
+
def close(self):
|
|
511
|
+
"""Clean up resources (synchronous)"""
|
|
512
|
+
if not self.async_mode and hasattr(self.client, 'close'):
|
|
513
|
+
self.client.close()
|
|
514
|
+
|
|
515
|
+
async def aclose(self):
|
|
516
|
+
"""Clean up resources (asynchronous)"""
|
|
517
|
+
if self.async_mode and hasattr(self.client, 'close'):
|
|
518
|
+
await self.client.close()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
__all__ = [
|
|
522
|
+
'BMLConnect',
|
|
523
|
+
'Transaction',
|
|
524
|
+
'QRCode',
|
|
525
|
+
'PaginatedResponse',
|
|
526
|
+
'Environment',
|
|
527
|
+
'SignMethod',
|
|
528
|
+
'TransactionState',
|
|
529
|
+
'BMLConnectError',
|
|
530
|
+
'AuthenticationError',
|
|
531
|
+
'ValidationError',
|
|
532
|
+
'NotFoundError',
|
|
533
|
+
'ServerError',
|
|
534
|
+
'RateLimitError',
|
|
535
|
+
'SignatureUtils'
|
|
536
|
+
]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bml-connect-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for Bank of Maldives Connect API with sync/async support
|
|
5
|
+
Author-email: Ali Fayaz <fayaz.quill@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/quillfires/bml-connect-python
|
|
8
|
+
Project-URL: Documentation, https://bml-connect-python.readthedocs.io
|
|
9
|
+
Project-URL: Repository, https://github.com/quillfires/bml-connect-python
|
|
10
|
+
Project-URL: Issues, https://github.com/quillfires/bml-connect-python/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/quillfires/bml-connect-python/releases
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
25
|
+
Requires-Python: >=3.7
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: requests>=2.28.0
|
|
29
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.20.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: black>=22.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: flake8>=5.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=0.990; extra == "dev"
|
|
37
|
+
Requires-Dist: pre-commit>=2.20.0; extra == "dev"
|
|
38
|
+
Requires-Dist: types-requests>=2.28.0; extra == "dev"
|
|
39
|
+
Provides-Extra: django
|
|
40
|
+
Requires-Dist: django>=3.2; extra == "django"
|
|
41
|
+
Provides-Extra: flask
|
|
42
|
+
Requires-Dist: flask>=2.0; extra == "flask"
|
|
43
|
+
Provides-Extra: fastapi
|
|
44
|
+
Requires-Dist: fastapi>=0.68.0; extra == "fastapi"
|
|
45
|
+
Requires-Dist: uvicorn>=0.15.0; extra == "fastapi"
|
|
46
|
+
Provides-Extra: sanic
|
|
47
|
+
Requires-Dist: sanic>=22.0.0; extra == "sanic"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# BML Connect Python SDK
|
|
51
|
+
|
|
52
|
+
[](https://pypi.org/project/bml-connect-python/)
|
|
53
|
+
[](https://pypi.org/project/bml-connect-python/)
|
|
54
|
+
[](https://opensource.org/licenses/MIT)
|
|
55
|
+
|
|
56
|
+
Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support.
|
|
57
|
+
Compatible with all Python frameworks including Django, Flask, FastAPI, and Sanic.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Features
|
|
62
|
+
|
|
63
|
+
- **Sync/Async Support:** Choose your preferred programming style
|
|
64
|
+
- **Full API Coverage:** Transactions, webhooks, and signature verification
|
|
65
|
+
- **Type Annotations:** Full type hint support for better development experience
|
|
66
|
+
- **Error Handling:** Comprehensive error hierarchy for easy debugging
|
|
67
|
+
- **Framework Agnostic:** Works with any Python web framework
|
|
68
|
+
- **MIT Licensed:** Open source and free to use
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install bml-connect-python
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Quick Start
|
|
81
|
+
|
|
82
|
+
### Synchronous Client
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from bml_connect import BMLConnect, Environment
|
|
86
|
+
|
|
87
|
+
client = BMLConnect(
|
|
88
|
+
api_key="your_api_key",
|
|
89
|
+
app_id="your_app_id",
|
|
90
|
+
environment=Environment.SANDBOX
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
transaction = client.transactions.create_transaction({
|
|
95
|
+
"amount": 1500, # 15.00 MVR
|
|
96
|
+
"currency": "MVR",
|
|
97
|
+
"provider": "alipay",
|
|
98
|
+
"redirectUrl": "https://yourstore.com/success",
|
|
99
|
+
"localId": "order_123",
|
|
100
|
+
"customerReference": "Customer #456"
|
|
101
|
+
})
|
|
102
|
+
print(f"Transaction ID: {transaction.transaction_id}")
|
|
103
|
+
print(f"Payment URL: {transaction.url}")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"Error: {e}")
|
|
106
|
+
finally:
|
|
107
|
+
client.close()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Asynchronous Client
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import asyncio
|
|
114
|
+
from bml_connect import BMLConnect, Environment
|
|
115
|
+
|
|
116
|
+
async def main():
|
|
117
|
+
client = BMLConnect(
|
|
118
|
+
api_key="your_api_key",
|
|
119
|
+
app_id="your_app_id",
|
|
120
|
+
environment=Environment.SANDBOX,
|
|
121
|
+
async_mode=True
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
transaction = await client.transactions.create_transaction({
|
|
125
|
+
"amount": 2000,
|
|
126
|
+
"currency": "MVR",
|
|
127
|
+
"provider": "wechat",
|
|
128
|
+
"redirectUrl": "https://yourstore.com/success"
|
|
129
|
+
})
|
|
130
|
+
print(f"Transaction ID: {transaction.transaction_id}")
|
|
131
|
+
finally:
|
|
132
|
+
await client.aclose()
|
|
133
|
+
|
|
134
|
+
asyncio.run(main())
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Webhook Verification
|
|
140
|
+
|
|
141
|
+
### Flask Example
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from flask import Flask, request, jsonify
|
|
145
|
+
from bml_connect import BMLConnect
|
|
146
|
+
|
|
147
|
+
app = Flask(__name__)
|
|
148
|
+
client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
|
|
149
|
+
|
|
150
|
+
@app.route('/webhook', methods=['POST'])
|
|
151
|
+
def webhook():
|
|
152
|
+
payload = request.get_json()
|
|
153
|
+
signature = payload.get('signature')
|
|
154
|
+
if client.verify_webhook_signature(payload, signature):
|
|
155
|
+
# Process webhook
|
|
156
|
+
return jsonify({"status": "success"}), 200
|
|
157
|
+
else:
|
|
158
|
+
return jsonify({"error": "Invalid signature"}), 403
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### FastAPI Example
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
165
|
+
from bml_connect import BMLConnect
|
|
166
|
+
|
|
167
|
+
app = FastAPI()
|
|
168
|
+
client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
|
|
169
|
+
|
|
170
|
+
@app.post("/webhook")
|
|
171
|
+
async def handle_webhook(request: Request):
|
|
172
|
+
payload = await request.json()
|
|
173
|
+
signature = payload.get("signature")
|
|
174
|
+
if client.verify_webhook_signature(payload, signature):
|
|
175
|
+
return {"status": "success"}
|
|
176
|
+
else:
|
|
177
|
+
raise HTTPException(403, "Invalid signature")
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Sanic Example
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from sanic import Sanic, response
|
|
184
|
+
from bml_connect import BMLConnect
|
|
185
|
+
|
|
186
|
+
app = Sanic("BMLWebhook")
|
|
187
|
+
client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
|
|
188
|
+
|
|
189
|
+
@app.post('/webhook')
|
|
190
|
+
async def webhook(request):
|
|
191
|
+
payload = request.json
|
|
192
|
+
signature = payload.get('signature')
|
|
193
|
+
if client.verify_webhook_signature(payload, signature):
|
|
194
|
+
return response.json({"status": "success"})
|
|
195
|
+
else:
|
|
196
|
+
return response.json({"error": "Invalid signature"}, status=403)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## API Reference
|
|
202
|
+
|
|
203
|
+
### Main Classes
|
|
204
|
+
|
|
205
|
+
- `BMLConnect`: Main entry point for the SDK.
|
|
206
|
+
- `Transaction`: Transaction object.
|
|
207
|
+
- `QRCode`: QR code details.
|
|
208
|
+
- `PaginatedResponse`: For paginated transaction lists.
|
|
209
|
+
- `Environment`: Enum for `SANDBOX` and `PRODUCTION`.
|
|
210
|
+
- `SignMethod`: Enum for signature methods.
|
|
211
|
+
- `TransactionState`: Enum for transaction states.
|
|
212
|
+
|
|
213
|
+
### Error Classes
|
|
214
|
+
|
|
215
|
+
- `BMLConnectError`
|
|
216
|
+
- `AuthenticationError`
|
|
217
|
+
- `ValidationError`
|
|
218
|
+
- `NotFoundError`
|
|
219
|
+
- `ServerError`
|
|
220
|
+
- `RateLimitError`
|
|
221
|
+
|
|
222
|
+
### Utilities
|
|
223
|
+
|
|
224
|
+
- `SignatureUtils.generate_signature(data, api_key, method)`
|
|
225
|
+
- `SignatureUtils.verify_signature(data, signature, api_key, method)`
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Development
|
|
230
|
+
|
|
231
|
+
### Requirements
|
|
232
|
+
|
|
233
|
+
- Python 3.7+
|
|
234
|
+
- See `requirements.txt` and `requirements-dev.txt` for dependencies.
|
|
235
|
+
|
|
236
|
+
### Testing
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
pip install -e .[dev]
|
|
240
|
+
pytest
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Formatting & Linting
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
black .
|
|
247
|
+
flake8 .
|
|
248
|
+
mypy .
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Packaging
|
|
254
|
+
|
|
255
|
+
- Uses `pyproject.toml` for build configuration.
|
|
256
|
+
- Source code is in `src/bml_connect/`.
|
|
257
|
+
- Examples in `examples/`.
|
|
258
|
+
- Tests in `tests/`.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT License. See `LICENSE` for details.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Links
|
|
269
|
+
|
|
270
|
+
- [Homepage](https://github.com/bankofmaldives/bml-connect-python)
|
|
271
|
+
- [Documentation](https://bml-connect-python.readthedocs.io)
|
|
272
|
+
- [API Reference](docs/api_reference.md)
|
|
273
|
+
- [Changelog](https://github.com/bankofmaldives/bml-connect-python/releases)
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Contributing
|
|
278
|
+
|
|
279
|
+
Pull requests and issues are welcome! See the documentation for guidelines.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
bml_connect/__init__.py,sha256=7dAgLGnl8f3GclavYhbyPsJY9KHskuKrDam59FTm1hE,2080
|
|
2
|
+
bml_connect/client.py,sha256=sEsO4pqBIs91f8AH0UJJLOlDZtqXARmT5KKIj4vym8U,18430
|
|
3
|
+
bml_connect_python-1.0.0.dist-info/licenses/LICENSE,sha256=CB2PmSDOuvgp-Rts5KdYBPZrjg5Vi2qkeoMMTGj1WiE,1104
|
|
4
|
+
bml_connect_python-1.0.0.dist-info/METADATA,sha256=q5RwJkQ6L24clG35tRsEsn8oCsLECtYrXyesWWTq1KY,7907
|
|
5
|
+
bml_connect_python-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
bml_connect_python-1.0.0.dist-info/top_level.txt,sha256=VfPbi_18vywwgVjitexLr0B1g1z0jjyAipDXCl_zh8I,12
|
|
7
|
+
bml_connect_python-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ali Fayaz (Quill) (quillfires)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bml_connect
|