bml-connect-python 1.1.2__tar.gz → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bml-connect-python
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support
5
5
  Home-page: https://github.com/quillfires/bml-connect-python
6
6
  License: MIT
@@ -34,8 +34,7 @@ Description-Content-Type: text/markdown
34
34
  [![Python Support](https://img.shields.io/pypi/pyversions/bml-connect-python.svg)](https://pypi.org/project/bml-connect-python/)
35
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
36
36
 
37
-
38
- [![ViewCount](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg)](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg) [![GitHub forks](https://img.shields.io/github/forks/quillfires/bml-connect-python)](https://github.com/quillfires/bml-connect-python/network) [![GitHub stars](https://img.shields.io/github/stars/quillfires/bml-connect-python.svg?color=ffd40c)](https://github.com/quillfires/bml-connect-python/stargazers) [![PyPI - Downloads](https://img.shields.io/pypi/dm/bml-connect-python?color=orange&label=PIP%20-%20Installs)](https://pypi.python.org/pypi/bml-connect-python/) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/quillfires/bml-connect-python/issues) [![GitHub issues](https://img.shields.io/github/issues/quillfires/bml-connect-python.svg?color=808080)](https://github.com/quillfires/bml-connect-python/issues)
37
+ [![ViewCount](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg)](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg) [![GitHub forks](https://img.shields.io/github/forks/quillfires/bml-connect-python)](https://github.com/quillfires/bml-connect-python/network) [![GitHub stars](https://img.shields.io/github/stars/quillfires/bml-connect-python.svg?color=ffd40c)](https://github.com/quillfires/bml-connect-python/stargazers) [![PyPI - Downloads](https://img.shields.io/pypi/dm/bml-connect-python?color=orange&label=PIP%20-%20Installs)](https://pypi.python.org/pypi/bml-connect-python/) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/quillfires/bml-connect-python/issues) [![GitHub issues](https://img.shields.io/github/issues/quillfires/bml-connect-python.svg?color=808080)](https://github.com/quillfires/bml-connect-python/issues)
39
38
 
40
39
  Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support.
41
40
  Compatible with all Python frameworks including Django, Flask, FastAPI, and Sanic.
@@ -43,10 +42,11 @@ Compatible with all Python frameworks including Django, Flask, FastAPI, and Sani
43
42
  ## Features
44
43
 
45
44
  - **🔄 Sync/Async Support**: Choose your preferred programming style
46
- - **🎯 Full API Coverage**: Transactions, webhooks, and signature verification
45
+ - **🎯 Full API Coverage**: Create, retrieve, cancel transactions; webhook signature verification
47
46
  - **📝 Type Annotations**: Full type hint support for better development experience
48
47
  - **🛡️ Error Handling**: Comprehensive error hierarchy for easy debugging
49
48
  - **🚀 Framework Agnostic**: Works with any Python web framework
49
+ - **🔒 Context Manager Support**: Automatic resource cleanup with `with`/`async with`
50
50
  - **📄 MIT Licensed**: Open source and free to use
51
51
 
52
52
  ## Installation
@@ -62,13 +62,12 @@ pip install bml-connect-python
62
62
  ```python
63
63
  from bml_connect import BMLConnect, Environment
64
64
 
65
- client = BMLConnect(
65
+ # Use as a context manager for automatic cleanup
66
+ with BMLConnect(
66
67
  api_key="your_api_key",
67
68
  app_id="your_app_id",
68
69
  environment=Environment.SANDBOX
69
- )
70
-
71
- try:
70
+ ) as client:
72
71
  transaction = client.transactions.create_transaction({
73
72
  "amount": 1500, # 15.00 MVR
74
73
  "currency": "MVR",
@@ -79,10 +78,6 @@ try:
79
78
  })
80
79
  print(f"Transaction ID: {transaction.transaction_id}")
81
80
  print(f"Payment URL: {transaction.url}")
82
- except Exception as e:
83
- print(f"Error: {e}")
84
- finally:
85
- client.close()
86
81
  ```
87
82
 
88
83
  ### Asynchronous Usage
@@ -92,14 +87,12 @@ import asyncio
92
87
  from bml_connect import BMLConnect, Environment
93
88
 
94
89
  async def main():
95
- client = BMLConnect(
90
+ async with BMLConnect(
96
91
  api_key="your_api_key",
97
92
  app_id="your_app_id",
98
93
  environment=Environment.SANDBOX,
99
94
  async_mode=True
100
- )
101
-
102
- try:
95
+ ) as client:
103
96
  transaction = await client.transactions.create_transaction({
104
97
  "amount": 2000,
105
98
  "currency": "MVR",
@@ -107,8 +100,6 @@ async def main():
107
100
  "redirectUrl": "https://yourstore.com/success"
108
101
  })
109
102
  print(f"Transaction ID: {transaction.transaction_id}")
110
- finally:
111
- await client.aclose()
112
103
 
113
104
  asyncio.run(main())
114
105
  ```
@@ -128,7 +119,7 @@ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
128
119
  def webhook():
129
120
  payload = request.get_json()
130
121
  signature = payload.get('signature')
131
-
122
+
132
123
  if client.verify_webhook_signature(payload, signature):
133
124
  # Process webhook
134
125
  return jsonify({"status": "success"}), 200
@@ -149,7 +140,7 @@ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
149
140
  async def handle_webhook(request: Request):
150
141
  payload = await request.json()
151
142
  signature = payload.get("signature")
152
-
143
+
153
144
  if client.verify_webhook_signature(payload, signature):
154
145
  return {"status": "success"}
155
146
  else:
@@ -169,7 +160,7 @@ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
169
160
  async def webhook(request):
170
161
  payload = request.json
171
162
  signature = payload.get('signature')
172
-
163
+
173
164
  if client.verify_webhook_signature(payload, signature):
174
165
  return response.json({"status": "success"})
175
166
  else:
@@ -178,25 +169,59 @@ async def webhook(request):
178
169
 
179
170
  ## API Reference
180
171
 
172
+ ### `BMLConnect(api_key, app_id, environment, async_mode, timeout)`
173
+
174
+ Main entry point for the SDK.
175
+
176
+ | Parameter | Type | Default | Description |
177
+ | ------------- | ---------------------- | ------------ | ------------------------------------------------- |
178
+ | `api_key` | `str` | required | Your API key from the BML merchant portal |
179
+ | `app_id` | `str` | required | Your application ID from the BML merchant portal |
180
+ | `environment` | `Environment` or `str` | `PRODUCTION` | `Environment.SANDBOX` or `Environment.PRODUCTION` |
181
+ | `async_mode` | `bool` | `False` | Set `True` to use async methods |
182
+ | `timeout` | `int` | `30` | Request timeout in seconds |
183
+
184
+ ### Transaction Methods
185
+
186
+ | Method | Sync | Async | Description |
187
+ | -------------------------- | ---- | ----- | --------------------------------------- |
188
+ | `create_transaction(data)` | ✅ | ✅ | Create a new payment transaction |
189
+ | `get_transaction(id)` | ✅ | ✅ | Retrieve a transaction by ID |
190
+ | `cancel_transaction(id)` | ✅ | ✅ | Cancel a transaction by ID |
191
+ | `list_transactions(...)` | ✅ | ✅ | List transactions with optional filters |
192
+
193
+ ### `list_transactions` Parameters
194
+
195
+ ```python
196
+ client.transactions.list_transactions(
197
+ page=1,
198
+ per_page=20,
199
+ state="CONFIRMED", # Filter by TransactionState value
200
+ provider="alipay", # Filter by provider
201
+ start_date="2026-01-01", # Filter from date
202
+ end_date="2026-02-01", # Filter to date
203
+ )
204
+ ```
205
+
181
206
  ### Core Classes
182
207
 
183
208
  - **`BMLConnect`**: Main entry point for the SDK
184
- - **`Transaction`**: Transaction object with all transaction details
185
- - **`QRCode`**: QR code details for payment processing
186
- - **`PaginatedResponse`**: For paginated transaction lists
187
- - **`Environment`**: Enum for `SANDBOX` and `PRODUCTION` environments
188
- - **`SignMethod`**: Enum for signature methods
189
- - **`TransactionState`**: Enum for transaction states
209
+ - **`Transaction`**: Typed transaction object returned by all transaction methods
210
+ - **`QRCode`**: QR code details attached to a transaction
211
+ - **`PaginatedResponse`**: Wraps paginated transaction list results
212
+ - **`Environment`**: `SANDBOX` or `PRODUCTION`
213
+ - **`SignMethod`**: `SHA1` (default) or `MD5`
214
+ - **`TransactionState`**: `CREATED`, `QR_CODE_GENERATED`, `CONFIRMED`, `CANCELLED`, `FAILED`, `EXPIRED`, `REFUND_REQUESTED`, `REFUNDED`
190
215
 
191
216
  ### Exception Hierarchy
192
217
 
193
218
  ```
194
219
  BMLConnectError
195
- ├── AuthenticationError
196
- ├── ValidationError
197
- ├── NotFoundError
198
- ├── ServerError
199
- └── RateLimitError
220
+ ├── AuthenticationError (401)
221
+ ├── ValidationError (400)
222
+ ├── NotFoundError (404)
223
+ ├── ServerError (5xx)
224
+ └── RateLimitError (429)
200
225
  ```
201
226
 
202
227
  ### Signature Utilities
@@ -204,10 +229,10 @@ BMLConnectError
204
229
  ```python
205
230
  from bml_connect import SignatureUtils
206
231
 
207
- # Generate signature
232
+ # Generate a signature manually
208
233
  signature = SignatureUtils.generate_signature(data, api_key, method)
209
234
 
210
- # Verify signature
235
+ # Verify a signature with constant-time comparison
211
236
  is_valid = SignatureUtils.verify_signature(data, signature, api_key, method)
212
237
  ```
213
238
 
@@ -216,24 +241,31 @@ is_valid = SignatureUtils.verify_signature(data, signature, api_key, method)
216
241
  ### Transaction Management
217
242
 
218
243
  ```python
219
- # Create a transaction
220
- transaction = client.transactions.create_transaction({
221
- "amount": 5000,
222
- "currency": "MVR",
223
- "provider": "alipay",
224
- "redirectUrl": "https://yourstore.com/success",
225
- "localId": "order_456"
226
- })
227
-
228
- # Get transaction details
229
- details = client.transactions.get_transaction(transaction.transaction_id)
230
-
231
- # List transactions with pagination
232
- transactions = client.transactions.list_transactions(
233
- page=1,
234
- per_page=10,
235
- status="completed"
236
- )
244
+ with BMLConnect(api_key="your_api_key", app_id="your_app_id") as client:
245
+ # Create
246
+ transaction = client.transactions.create_transaction({
247
+ "amount": 5000,
248
+ "currency": "MVR",
249
+ "provider": "alipay",
250
+ "redirectUrl": "https://yourstore.com/success",
251
+ "localId": "order_456"
252
+ })
253
+
254
+ # Retrieve
255
+ details = client.transactions.get_transaction(transaction.transaction_id)
256
+ print(f"State: {details.state}")
257
+
258
+ # Cancel
259
+ cancelled = client.transactions.cancel_transaction(transaction.transaction_id)
260
+
261
+ # List with filters
262
+ results = client.transactions.list_transactions(
263
+ page=1,
264
+ per_page=10,
265
+ state="CONFIRMED"
266
+ )
267
+ for t in results.items:
268
+ print(t.transaction_id, t.amount, t.state)
237
269
  ```
238
270
 
239
271
  ### Webhook Handling
@@ -242,39 +274,45 @@ transactions = client.transactions.list_transactions(
242
274
  @app.route('/webhook', methods=['POST'])
243
275
  def handle_webhook():
244
276
  payload = request.get_json()
245
-
246
- # Verify webhook signature
277
+
247
278
  if not client.verify_webhook_signature(payload, payload.get('signature')):
248
279
  return {"error": "Invalid signature"}, 403
249
-
250
- # Process different webhook events
251
- event_type = payload.get('event_type')
252
- if event_type == 'transaction.completed':
253
- # Handle completed transaction
254
- transaction_id = payload.get('transaction_id')
255
- # Your business logic here
256
- elif event_type == 'transaction.failed':
257
- # Handle failed transaction
258
- pass
259
-
280
+
281
+ state = payload.get('state')
282
+ if state == 'CONFIRMED':
283
+ pass # fulfil the order
284
+ elif state == 'REFUND_REQUESTED':
285
+ pass # initiate refund flow
286
+ elif state == 'REFUNDED':
287
+ pass # mark order as refunded
288
+
260
289
  return {"status": "success"}
261
290
  ```
262
291
 
292
+ ### Custom Timeout
293
+
294
+ ```python
295
+ # For slow network environments or large payloads
296
+ client = BMLConnect(
297
+ api_key="your_api_key",
298
+ app_id="your_app_id",
299
+ timeout=60
300
+ )
301
+ ```
302
+
263
303
  ## Requirements
264
304
 
265
- - Python 3.7+
266
- - See `requirements.txt` and `requirements-dev.txt` for dependencies
305
+ - Python 3.9+
306
+ - `requests`
307
+ - `aiohttp`
267
308
 
268
309
  ## Development
269
310
 
270
- ### Setup Development Environment
311
+ ### Setup
271
312
 
272
313
  ```bash
273
- # Clone the repository
274
314
  git clone https://github.com/quillfires/bml-connect-python.git
275
315
  cd bml-connect-python
276
-
277
- # Install in development mode
278
316
  pip install -e .[dev]
279
317
  ```
280
318
 
@@ -287,13 +325,8 @@ pytest
287
325
  ### Code Quality
288
326
 
289
327
  ```bash
290
- # Format code
291
328
  black .
292
-
293
- # Lint code
294
329
  flake8 .
295
-
296
- # Type checking
297
330
  mypy .
298
331
  ```
299
332
 
@@ -312,7 +345,7 @@ bml-connect-python/
312
345
 
313
346
  ## Contributing
314
347
 
315
- We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details.
348
+ Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
316
349
 
317
350
  1. Fork the repository
318
351
  2. Create a feature branch
@@ -323,7 +356,7 @@ We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.
323
356
 
324
357
  ## License
325
358
 
326
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
359
+ MIT License see [LICENSE](LICENSE) for details.
327
360
 
328
361
  ## Support
329
362
 
@@ -333,14 +366,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
333
366
 
334
367
  ## Changelog
335
368
 
336
- See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes.
369
+ See [CHANGELOG.md](https://github.com/quillfires/bml-connect-python/blob/main/CHANGELOG.md) for a full history of changes.
337
370
 
338
371
  ## Security
339
372
 
340
- If you discover any security-related issues, please email fayaz.quill@gmail.com instead of using the issue tracker.
373
+ If you discover a security issue, please email fayaz.quill@gmail.com instead of opening a public issue.
341
374
 
342
375
  ---
343
376
 
344
-
345
377
  Made with ❤️ for the Maldivian developer community
346
378
 
@@ -4,8 +4,7 @@
4
4
  [![Python Support](https://img.shields.io/pypi/pyversions/bml-connect-python.svg)](https://pypi.org/project/bml-connect-python/)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
-
8
- [![ViewCount](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg)](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg) [![GitHub forks](https://img.shields.io/github/forks/quillfires/bml-connect-python)](https://github.com/quillfires/bml-connect-python/network) [![GitHub stars](https://img.shields.io/github/stars/quillfires/bml-connect-python.svg?color=ffd40c)](https://github.com/quillfires/bml-connect-python/stargazers) [![PyPI - Downloads](https://img.shields.io/pypi/dm/bml-connect-python?color=orange&label=PIP%20-%20Installs)](https://pypi.python.org/pypi/bml-connect-python/) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/quillfires/bml-connect-python/issues) [![GitHub issues](https://img.shields.io/github/issues/quillfires/bml-connect-python.svg?color=808080)](https://github.com/quillfires/bml-connect-python/issues)
7
+ [![ViewCount](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg)](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg) [![GitHub forks](https://img.shields.io/github/forks/quillfires/bml-connect-python)](https://github.com/quillfires/bml-connect-python/network) [![GitHub stars](https://img.shields.io/github/stars/quillfires/bml-connect-python.svg?color=ffd40c)](https://github.com/quillfires/bml-connect-python/stargazers) [![PyPI - Downloads](https://img.shields.io/pypi/dm/bml-connect-python?color=orange&label=PIP%20-%20Installs)](https://pypi.python.org/pypi/bml-connect-python/) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/quillfires/bml-connect-python/issues) [![GitHub issues](https://img.shields.io/github/issues/quillfires/bml-connect-python.svg?color=808080)](https://github.com/quillfires/bml-connect-python/issues)
9
8
 
10
9
  Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support.
11
10
  Compatible with all Python frameworks including Django, Flask, FastAPI, and Sanic.
@@ -13,10 +12,11 @@ Compatible with all Python frameworks including Django, Flask, FastAPI, and Sani
13
12
  ## Features
14
13
 
15
14
  - **🔄 Sync/Async Support**: Choose your preferred programming style
16
- - **🎯 Full API Coverage**: Transactions, webhooks, and signature verification
15
+ - **🎯 Full API Coverage**: Create, retrieve, cancel transactions; webhook signature verification
17
16
  - **📝 Type Annotations**: Full type hint support for better development experience
18
17
  - **🛡️ Error Handling**: Comprehensive error hierarchy for easy debugging
19
18
  - **🚀 Framework Agnostic**: Works with any Python web framework
19
+ - **🔒 Context Manager Support**: Automatic resource cleanup with `with`/`async with`
20
20
  - **📄 MIT Licensed**: Open source and free to use
21
21
 
22
22
  ## Installation
@@ -32,13 +32,12 @@ pip install bml-connect-python
32
32
  ```python
33
33
  from bml_connect import BMLConnect, Environment
34
34
 
35
- client = BMLConnect(
35
+ # Use as a context manager for automatic cleanup
36
+ with BMLConnect(
36
37
  api_key="your_api_key",
37
38
  app_id="your_app_id",
38
39
  environment=Environment.SANDBOX
39
- )
40
-
41
- try:
40
+ ) as client:
42
41
  transaction = client.transactions.create_transaction({
43
42
  "amount": 1500, # 15.00 MVR
44
43
  "currency": "MVR",
@@ -49,10 +48,6 @@ try:
49
48
  })
50
49
  print(f"Transaction ID: {transaction.transaction_id}")
51
50
  print(f"Payment URL: {transaction.url}")
52
- except Exception as e:
53
- print(f"Error: {e}")
54
- finally:
55
- client.close()
56
51
  ```
57
52
 
58
53
  ### Asynchronous Usage
@@ -62,14 +57,12 @@ import asyncio
62
57
  from bml_connect import BMLConnect, Environment
63
58
 
64
59
  async def main():
65
- client = BMLConnect(
60
+ async with BMLConnect(
66
61
  api_key="your_api_key",
67
62
  app_id="your_app_id",
68
63
  environment=Environment.SANDBOX,
69
64
  async_mode=True
70
- )
71
-
72
- try:
65
+ ) as client:
73
66
  transaction = await client.transactions.create_transaction({
74
67
  "amount": 2000,
75
68
  "currency": "MVR",
@@ -77,8 +70,6 @@ async def main():
77
70
  "redirectUrl": "https://yourstore.com/success"
78
71
  })
79
72
  print(f"Transaction ID: {transaction.transaction_id}")
80
- finally:
81
- await client.aclose()
82
73
 
83
74
  asyncio.run(main())
84
75
  ```
@@ -98,7 +89,7 @@ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
98
89
  def webhook():
99
90
  payload = request.get_json()
100
91
  signature = payload.get('signature')
101
-
92
+
102
93
  if client.verify_webhook_signature(payload, signature):
103
94
  # Process webhook
104
95
  return jsonify({"status": "success"}), 200
@@ -119,7 +110,7 @@ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
119
110
  async def handle_webhook(request: Request):
120
111
  payload = await request.json()
121
112
  signature = payload.get("signature")
122
-
113
+
123
114
  if client.verify_webhook_signature(payload, signature):
124
115
  return {"status": "success"}
125
116
  else:
@@ -139,7 +130,7 @@ client = BMLConnect(api_key="your_api_key", app_id="your_app_id")
139
130
  async def webhook(request):
140
131
  payload = request.json
141
132
  signature = payload.get('signature')
142
-
133
+
143
134
  if client.verify_webhook_signature(payload, signature):
144
135
  return response.json({"status": "success"})
145
136
  else:
@@ -148,25 +139,59 @@ async def webhook(request):
148
139
 
149
140
  ## API Reference
150
141
 
142
+ ### `BMLConnect(api_key, app_id, environment, async_mode, timeout)`
143
+
144
+ Main entry point for the SDK.
145
+
146
+ | Parameter | Type | Default | Description |
147
+ | ------------- | ---------------------- | ------------ | ------------------------------------------------- |
148
+ | `api_key` | `str` | required | Your API key from the BML merchant portal |
149
+ | `app_id` | `str` | required | Your application ID from the BML merchant portal |
150
+ | `environment` | `Environment` or `str` | `PRODUCTION` | `Environment.SANDBOX` or `Environment.PRODUCTION` |
151
+ | `async_mode` | `bool` | `False` | Set `True` to use async methods |
152
+ | `timeout` | `int` | `30` | Request timeout in seconds |
153
+
154
+ ### Transaction Methods
155
+
156
+ | Method | Sync | Async | Description |
157
+ | -------------------------- | ---- | ----- | --------------------------------------- |
158
+ | `create_transaction(data)` | ✅ | ✅ | Create a new payment transaction |
159
+ | `get_transaction(id)` | ✅ | ✅ | Retrieve a transaction by ID |
160
+ | `cancel_transaction(id)` | ✅ | ✅ | Cancel a transaction by ID |
161
+ | `list_transactions(...)` | ✅ | ✅ | List transactions with optional filters |
162
+
163
+ ### `list_transactions` Parameters
164
+
165
+ ```python
166
+ client.transactions.list_transactions(
167
+ page=1,
168
+ per_page=20,
169
+ state="CONFIRMED", # Filter by TransactionState value
170
+ provider="alipay", # Filter by provider
171
+ start_date="2026-01-01", # Filter from date
172
+ end_date="2026-02-01", # Filter to date
173
+ )
174
+ ```
175
+
151
176
  ### Core Classes
152
177
 
153
178
  - **`BMLConnect`**: Main entry point for the SDK
154
- - **`Transaction`**: Transaction object with all transaction details
155
- - **`QRCode`**: QR code details for payment processing
156
- - **`PaginatedResponse`**: For paginated transaction lists
157
- - **`Environment`**: Enum for `SANDBOX` and `PRODUCTION` environments
158
- - **`SignMethod`**: Enum for signature methods
159
- - **`TransactionState`**: Enum for transaction states
179
+ - **`Transaction`**: Typed transaction object returned by all transaction methods
180
+ - **`QRCode`**: QR code details attached to a transaction
181
+ - **`PaginatedResponse`**: Wraps paginated transaction list results
182
+ - **`Environment`**: `SANDBOX` or `PRODUCTION`
183
+ - **`SignMethod`**: `SHA1` (default) or `MD5`
184
+ - **`TransactionState`**: `CREATED`, `QR_CODE_GENERATED`, `CONFIRMED`, `CANCELLED`, `FAILED`, `EXPIRED`, `REFUND_REQUESTED`, `REFUNDED`
160
185
 
161
186
  ### Exception Hierarchy
162
187
 
163
188
  ```
164
189
  BMLConnectError
165
- ├── AuthenticationError
166
- ├── ValidationError
167
- ├── NotFoundError
168
- ├── ServerError
169
- └── RateLimitError
190
+ ├── AuthenticationError (401)
191
+ ├── ValidationError (400)
192
+ ├── NotFoundError (404)
193
+ ├── ServerError (5xx)
194
+ └── RateLimitError (429)
170
195
  ```
171
196
 
172
197
  ### Signature Utilities
@@ -174,10 +199,10 @@ BMLConnectError
174
199
  ```python
175
200
  from bml_connect import SignatureUtils
176
201
 
177
- # Generate signature
202
+ # Generate a signature manually
178
203
  signature = SignatureUtils.generate_signature(data, api_key, method)
179
204
 
180
- # Verify signature
205
+ # Verify a signature with constant-time comparison
181
206
  is_valid = SignatureUtils.verify_signature(data, signature, api_key, method)
182
207
  ```
183
208
 
@@ -186,24 +211,31 @@ is_valid = SignatureUtils.verify_signature(data, signature, api_key, method)
186
211
  ### Transaction Management
187
212
 
188
213
  ```python
189
- # Create a transaction
190
- transaction = client.transactions.create_transaction({
191
- "amount": 5000,
192
- "currency": "MVR",
193
- "provider": "alipay",
194
- "redirectUrl": "https://yourstore.com/success",
195
- "localId": "order_456"
196
- })
197
-
198
- # Get transaction details
199
- details = client.transactions.get_transaction(transaction.transaction_id)
200
-
201
- # List transactions with pagination
202
- transactions = client.transactions.list_transactions(
203
- page=1,
204
- per_page=10,
205
- status="completed"
206
- )
214
+ with BMLConnect(api_key="your_api_key", app_id="your_app_id") as client:
215
+ # Create
216
+ transaction = client.transactions.create_transaction({
217
+ "amount": 5000,
218
+ "currency": "MVR",
219
+ "provider": "alipay",
220
+ "redirectUrl": "https://yourstore.com/success",
221
+ "localId": "order_456"
222
+ })
223
+
224
+ # Retrieve
225
+ details = client.transactions.get_transaction(transaction.transaction_id)
226
+ print(f"State: {details.state}")
227
+
228
+ # Cancel
229
+ cancelled = client.transactions.cancel_transaction(transaction.transaction_id)
230
+
231
+ # List with filters
232
+ results = client.transactions.list_transactions(
233
+ page=1,
234
+ per_page=10,
235
+ state="CONFIRMED"
236
+ )
237
+ for t in results.items:
238
+ print(t.transaction_id, t.amount, t.state)
207
239
  ```
208
240
 
209
241
  ### Webhook Handling
@@ -212,39 +244,45 @@ transactions = client.transactions.list_transactions(
212
244
  @app.route('/webhook', methods=['POST'])
213
245
  def handle_webhook():
214
246
  payload = request.get_json()
215
-
216
- # Verify webhook signature
247
+
217
248
  if not client.verify_webhook_signature(payload, payload.get('signature')):
218
249
  return {"error": "Invalid signature"}, 403
219
-
220
- # Process different webhook events
221
- event_type = payload.get('event_type')
222
- if event_type == 'transaction.completed':
223
- # Handle completed transaction
224
- transaction_id = payload.get('transaction_id')
225
- # Your business logic here
226
- elif event_type == 'transaction.failed':
227
- # Handle failed transaction
228
- pass
229
-
250
+
251
+ state = payload.get('state')
252
+ if state == 'CONFIRMED':
253
+ pass # fulfil the order
254
+ elif state == 'REFUND_REQUESTED':
255
+ pass # initiate refund flow
256
+ elif state == 'REFUNDED':
257
+ pass # mark order as refunded
258
+
230
259
  return {"status": "success"}
231
260
  ```
232
261
 
262
+ ### Custom Timeout
263
+
264
+ ```python
265
+ # For slow network environments or large payloads
266
+ client = BMLConnect(
267
+ api_key="your_api_key",
268
+ app_id="your_app_id",
269
+ timeout=60
270
+ )
271
+ ```
272
+
233
273
  ## Requirements
234
274
 
235
- - Python 3.7+
236
- - See `requirements.txt` and `requirements-dev.txt` for dependencies
275
+ - Python 3.9+
276
+ - `requests`
277
+ - `aiohttp`
237
278
 
238
279
  ## Development
239
280
 
240
- ### Setup Development Environment
281
+ ### Setup
241
282
 
242
283
  ```bash
243
- # Clone the repository
244
284
  git clone https://github.com/quillfires/bml-connect-python.git
245
285
  cd bml-connect-python
246
-
247
- # Install in development mode
248
286
  pip install -e .[dev]
249
287
  ```
250
288
 
@@ -257,13 +295,8 @@ pytest
257
295
  ### Code Quality
258
296
 
259
297
  ```bash
260
- # Format code
261
298
  black .
262
-
263
- # Lint code
264
299
  flake8 .
265
-
266
- # Type checking
267
300
  mypy .
268
301
  ```
269
302
 
@@ -282,7 +315,7 @@ bml-connect-python/
282
315
 
283
316
  ## Contributing
284
317
 
285
- We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details.
318
+ Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
286
319
 
287
320
  1. Fork the repository
288
321
  2. Create a feature branch
@@ -293,7 +326,7 @@ We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.
293
326
 
294
327
  ## License
295
328
 
296
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
329
+ MIT License see [LICENSE](LICENSE) for details.
297
330
 
298
331
  ## Support
299
332
 
@@ -303,13 +336,12 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
303
336
 
304
337
  ## Changelog
305
338
 
306
- See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes.
339
+ See [CHANGELOG.md](https://github.com/quillfires/bml-connect-python/blob/main/CHANGELOG.md) for a full history of changes.
307
340
 
308
341
  ## Security
309
342
 
310
- If you discover any security-related issues, please email fayaz.quill@gmail.com instead of using the issue tracker.
343
+ If you discover a security issue, please email fayaz.quill@gmail.com instead of opening a public issue.
311
344
 
312
345
  ---
313
346
 
314
-
315
347
  Made with ❤️ for the Maldivian developer community
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bml-connect-python"
3
- version = "1.1.2"
3
+ version = "1.2.0"
4
4
  description = "Python SDK for Bank of Maldives Connect API with synchronous and asynchronous support"
5
5
  authors = ["Fayaz (Quill) <fayaz.quill@gmail.com>"]
6
6
  maintainers = ["Fayaz (Quill) <fayaz.quill@gmail.com>"]
@@ -49,9 +49,9 @@ class SignMethod(Enum):
49
49
  @classmethod
50
50
  def _missing_(cls, value: object) -> "SignMethod":
51
51
  if isinstance(value, str):
52
- value = value.lower()
52
+ normalized = value.lower()
53
53
  for member in cls:
54
- if member.value == value:
54
+ if member.value == normalized:
55
55
  return member
56
56
  return cls.SHA1 # Default to SHA1
57
57
 
@@ -63,6 +63,8 @@ class TransactionState(Enum):
63
63
  CANCELLED = "CANCELLED"
64
64
  FAILED = "FAILED"
65
65
  EXPIRED = "EXPIRED"
66
+ REFUND_REQUESTED = "REFUND_REQUESTED"
67
+ REFUNDED = "REFUNDED"
66
68
 
67
69
 
68
70
  @dataclass
@@ -103,7 +105,7 @@ class Transaction:
103
105
  try:
104
106
  state = TransactionState(data["state"])
105
107
  except ValueError:
106
- logger.warning(f"Unknown transaction state: {data['state']}")
108
+ logger.warning("Unknown transaction state: %s", data["state"])
107
109
  state = None
108
110
 
109
111
  sign_method = None
@@ -111,7 +113,7 @@ class Transaction:
111
113
  try:
112
114
  sign_method = SignMethod(data["signMethod"])
113
115
  except ValueError:
114
- logger.warning(f"Unknown sign method: {data['signMethod']}")
116
+ logger.warning("Unknown sign method: %s", data["signMethod"])
115
117
  sign_method = None
116
118
 
117
119
  return cls(
@@ -202,16 +204,17 @@ class SignatureUtils:
202
204
  ) -> str:
203
205
  """Generate signature with proper key sorting and encoding"""
204
206
  if isinstance(method, str):
207
+ original_value = method
205
208
  try:
206
209
  method = SignMethod(method)
207
210
  except ValueError:
211
+ logger.warning("Invalid sign method '%s', defaulting to SHA1", original_value)
208
212
  method = SignMethod.SHA1
209
- logger.warning(f"Invalid sign method '{method}', defaulting to SHA1")
210
213
 
211
214
  amount = data.get("amount")
212
215
  currency = data.get("currency")
213
216
 
214
- if not amount or not currency:
217
+ if amount is None or not currency:
215
218
  raise ValueError(
216
219
  "Amount and currency are required for signature generation"
217
220
  )
@@ -245,19 +248,19 @@ class BaseClient:
245
248
  api_key: str,
246
249
  app_id: str,
247
250
  environment: Environment = Environment.PRODUCTION,
251
+ timeout: int = 30,
248
252
  ):
249
253
  self.api_key = api_key
250
254
  self.app_id = app_id
251
255
  self.environment = environment
252
256
  self.base_url = environment.base_url
253
- self.session: Optional[Union[requests.Session, aiohttp.ClientSession]] = (
254
- None # Will be set in child classes
255
- )
257
+ self.timeout = timeout
258
+ self.session: Optional[Union[requests.Session, aiohttp.ClientSession]] = None
256
259
  logger.info("Initialized BML Client for %s environment", environment.name)
257
260
 
258
261
  def _get_headers(self) -> Dict[str, str]:
259
262
  return {
260
- "Authorization": f"{self.api_key}",
263
+ "Authorization": self.api_key,
261
264
  "Accept": "application/json",
262
265
  "Content-Type": "application/json",
263
266
  "User-Agent": USER_AGENT,
@@ -310,12 +313,18 @@ class SyncClient(BaseClient):
310
313
  self.session.headers.update(self._get_headers())
311
314
  logger.debug("Initialized synchronous HTTP session")
312
315
 
316
+ def __enter__(self) -> "SyncClient":
317
+ return self
318
+
319
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
320
+ self.close()
321
+
313
322
  def _request(self, method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]:
314
323
  url = f"{self.base_url}{endpoint}"
315
324
  logger.debug("Request: %s %s %s", method, url, kwargs.get("params"))
316
325
 
317
326
  try:
318
- response = self.session.request(method, url, timeout=30, **kwargs)
327
+ response = self.session.request(method, url, timeout=self.timeout, **kwargs)
319
328
 
320
329
  try:
321
330
  response_data: Dict[str, Any] = response.json()
@@ -357,6 +366,12 @@ class SyncClient(BaseClient):
357
366
  response = self._request("GET", f"/transactions/{transaction_id}")
358
367
  return Transaction.from_dict(response)
359
368
 
369
+ def cancel_transaction(self, transaction_id: str) -> Transaction:
370
+ """Cancel a transaction by ID"""
371
+ logger.info("Cancelling transaction: %s", transaction_id)
372
+ response = self._request("DELETE", f"/transactions/{transaction_id}")
373
+ return Transaction.from_dict(response)
374
+
360
375
  def list_transactions(
361
376
  self,
362
377
  page: int = 1,
@@ -369,14 +384,14 @@ class SyncClient(BaseClient):
369
384
  logger.info("Listing transactions: page=%s, per_page=%s", page, per_page)
370
385
  params: Dict[str, Any] = {"page": page, "perPage": per_page}
371
386
 
372
- # Add filters
373
- if state:
387
+ # FIX: Use `is not None` so that an explicit empty string isn't silently dropped
388
+ if state is not None:
374
389
  params["state"] = state
375
- if provider:
390
+ if provider is not None:
376
391
  params["provider"] = provider
377
- if start_date:
392
+ if start_date is not None:
378
393
  params["startDate"] = start_date
379
- if end_date:
394
+ if end_date is not None:
380
395
  params["endDate"] = end_date
381
396
 
382
397
  response = self._request("GET", "/transactions", params=params)
@@ -392,10 +407,24 @@ class SyncClient(BaseClient):
392
407
  class AsyncClient(BaseClient):
393
408
  def __init__(self, *args: Any, **kwargs: Any):
394
409
  super().__init__(*args, **kwargs)
395
- self.session: aiohttp.ClientSession = aiohttp.ClientSession(
396
- headers=self._get_headers(), timeout=aiohttp.ClientTimeout(total=30)
397
- )
398
- logger.debug("Initialized asynchronous HTTP session")
410
+ self._session: Optional[aiohttp.ClientSession] = None
411
+ logger.debug("Initialized asynchronous client (session deferred)")
412
+
413
+ async def __aenter__(self) -> "AsyncClient":
414
+ return self
415
+
416
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
417
+ await self.close()
418
+
419
+ def _get_session(self) -> aiohttp.ClientSession:
420
+ """Lazily create the aiohttp session within an async context"""
421
+ if self._session is None or self._session.closed:
422
+ self._session = aiohttp.ClientSession(
423
+ headers=self._get_headers(),
424
+ timeout=aiohttp.ClientTimeout(total=self.timeout),
425
+ )
426
+ logger.debug("Created asynchronous HTTP session")
427
+ return self._session
399
428
 
400
429
  async def _request(
401
430
  self, method: str, endpoint: str, **kwargs: Any
@@ -403,8 +432,9 @@ class AsyncClient(BaseClient):
403
432
  url = f"{self.base_url}{endpoint}"
404
433
  logger.debug("Async Request: %s %s %s", method, url, kwargs.get("params"))
405
434
 
435
+ session = self._get_session()
406
436
  try:
407
- async with self.session.request(method, url, **kwargs) as response:
437
+ async with session.request(method, url, **kwargs) as response:
408
438
  try:
409
439
  response_data: Dict[str, Any] = await response.json()
410
440
  except aiohttp.ContentTypeError:
@@ -446,6 +476,12 @@ class AsyncClient(BaseClient):
446
476
  response = await self._request("GET", f"/transactions/{transaction_id}")
447
477
  return Transaction.from_dict(response)
448
478
 
479
+ async def cancel_transaction(self, transaction_id: str) -> Transaction:
480
+ """Cancel a transaction by ID"""
481
+ logger.info("Cancelling transaction (async): %s", transaction_id)
482
+ response = await self._request("DELETE", f"/transactions/{transaction_id}")
483
+ return Transaction.from_dict(response)
484
+
449
485
  async def list_transactions(
450
486
  self,
451
487
  page: int = 1,
@@ -460,14 +496,14 @@ class AsyncClient(BaseClient):
460
496
  )
461
497
  params: Dict[str, Any] = {"page": page, "perPage": per_page}
462
498
 
463
- # Add filters
464
- if state:
499
+ # FIX: Use `is not None` so that an explicit empty string isn't silently dropped
500
+ if state is not None:
465
501
  params["state"] = state
466
- if provider:
502
+ if provider is not None:
467
503
  params["provider"] = provider
468
- if start_date:
504
+ if start_date is not None:
469
505
  params["startDate"] = start_date
470
- if end_date:
506
+ if end_date is not None:
471
507
  params["endDate"] = end_date
472
508
 
473
509
  response = await self._request("GET", "/transactions", params=params)
@@ -475,8 +511,8 @@ class AsyncClient(BaseClient):
475
511
 
476
512
  async def close(self) -> None:
477
513
  """Close the HTTP session"""
478
- if self.session and not self.session.closed:
479
- await self.session.close()
514
+ if self._session and not self._session.closed:
515
+ await self._session.close()
480
516
  logger.debug("Closed asynchronous HTTP session")
481
517
 
482
518
 
@@ -487,6 +523,7 @@ class BMLConnect:
487
523
  app_id: str,
488
524
  environment: Union[Environment, str] = Environment.PRODUCTION,
489
525
  async_mode: bool = False,
526
+ timeout: int = 30,
490
527
  ):
491
528
  """
492
529
  Initialize BML Connect client
@@ -496,6 +533,7 @@ class BMLConnect:
496
533
  app_id: Your application ID from BML merchant portal
497
534
  environment: 'production' or 'sandbox' (default: production)
498
535
  async_mode: Whether to use async operations (default: False)
536
+ timeout: Request timeout in seconds (default: 30)
499
537
  """
500
538
  self.api_key = api_key
501
539
  self.app_id = app_id
@@ -514,9 +552,29 @@ class BMLConnect:
514
552
  self.client: Union[SyncClient, AsyncClient]
515
553
 
516
554
  if async_mode:
517
- self.client = AsyncClient(api_key, app_id, self.environment)
555
+ self.client = AsyncClient(api_key, app_id, self.environment, timeout)
518
556
  else:
519
- self.client = SyncClient(api_key, app_id, self.environment)
557
+ self.client = SyncClient(api_key, app_id, self.environment, timeout)
558
+
559
+ def __enter__(self) -> "BMLConnect":
560
+ if isinstance(self.client, AsyncClient):
561
+ raise TypeError(
562
+ "Use 'async with' for async_mode=True clients"
563
+ )
564
+ return self
565
+
566
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
567
+ self.close()
568
+
569
+ async def __aenter__(self) -> "BMLConnect":
570
+ if isinstance(self.client, SyncClient):
571
+ raise TypeError(
572
+ "Use 'with' (not 'async with') for async_mode=False clients"
573
+ )
574
+ return self
575
+
576
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
577
+ await self.aclose()
520
578
 
521
579
  @property
522
580
  def transactions(self) -> Union[SyncClient, AsyncClient]:
@@ -545,10 +603,10 @@ class BMLConnect:
545
603
  except json.JSONDecodeError:
546
604
  raise ValidationError("Invalid JSON payload")
547
605
 
548
- # Create a copy and remove signature if present
549
- assert isinstance(payload, dict)
550
- payload_dict: Dict[str, Any] = payload
551
- verification_payload = payload_dict.copy()
606
+ if not isinstance(payload, dict):
607
+ raise ValidationError("Payload must be a JSON object")
608
+
609
+ verification_payload = payload.copy()
552
610
  if "signature" in verification_payload:
553
611
  del verification_payload["signature"]
554
612
 
@@ -582,4 +640,4 @@ __all__ = [
582
640
  "ServerError",
583
641
  "RateLimitError",
584
642
  "SignatureUtils",
585
- ]
643
+ ]