bml-connect-python 1.2.1__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bml_connect_python-2.0.0/PKG-INFO +1053 -0
- bml_connect_python-2.0.0/README.md +1022 -0
- {bml_connect_python-1.2.1 → bml_connect_python-2.0.0}/pyproject.toml +5 -4
- bml_connect_python-2.0.0/src/bml_connect/__init__.py +164 -0
- bml_connect_python-2.0.0/src/bml_connect/client.py +383 -0
- bml_connect_python-2.0.0/src/bml_connect/crypto.py +143 -0
- bml_connect_python-2.0.0/src/bml_connect/exceptions.py +55 -0
- bml_connect_python-2.0.0/src/bml_connect/models.py +965 -0
- bml_connect_python-2.0.0/src/bml_connect/resources.py +1207 -0
- bml_connect_python-2.0.0/src/bml_connect/signature.py +283 -0
- bml_connect_python-2.0.0/src/bml_connect/transport.py +182 -0
- bml_connect_python-1.2.1/PKG-INFO +0 -378
- bml_connect_python-1.2.1/README.md +0 -347
- bml_connect_python-1.2.1/src/bml_connect/__init__.py +0 -81
- bml_connect_python-1.2.1/src/bml_connect/client.py +0 -643
- {bml_connect_python-1.2.1 → bml_connect_python-2.0.0}/LICENSE +0 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: bml-connect-python
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Python SDK for Bank of Maldives Connect API v2 with sync and async support, covering all four payment integration methods
|
|
5
|
+
Home-page: https://github.com/quillfires/bml-connect-python
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: bml,bank-of-maldives,payment,sdk,async
|
|
8
|
+
Author: Fayaz (Quill)
|
|
9
|
+
Author-email: fayaz.quill@gmail.com
|
|
10
|
+
Maintainer: Fayaz (Quill)
|
|
11
|
+
Maintainer-email: fayaz.quill@gmail.com
|
|
12
|
+
Requires-Python: >=3.9.2,<4.0
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Dist: aiohttp (>=3.13.3,<4.0.0)
|
|
25
|
+
Requires-Dist: cryptography (>=46.0.0)
|
|
26
|
+
Requires-Dist: requests (>=2.28.0)
|
|
27
|
+
Project-URL: Documentation, https://github.com/quillfires/bml-connect-python/wiki
|
|
28
|
+
Project-URL: Repository, https://github.com/quillfires/bml-connect-python
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# BML Connect Python SDK
|
|
32
|
+
|
|
33
|
+
[](https://badge.fury.io/py/bml-connect-python)
|
|
34
|
+
[](https://pypi.org/project/bml-connect-python/)
|
|
35
|
+
[](https://opensource.org/licenses/MIT)
|
|
36
|
+
|
|
37
|
+
[](https://views.whatilearened.today/views/github/quillfires/bml-connect-python.svg) [](https://github.com/quillfires/bml-connect-python/network) [](https://github.com/quillfires/bml-connect-python/stargazers) [](https://pypi.python.org/pypi/bml-connect-python/) [](https://github.com/quillfires/bml-connect-python/issues) [](https://github.com/quillfires/bml-connect-python/issues)
|
|
38
|
+
|
|
39
|
+
Python SDK for Bank of Maldives Connect API v2 with synchronous and asynchronous support.
|
|
40
|
+
Compatible with all Python frameworks including Django, Flask, FastAPI, and Sanic.
|
|
41
|
+
|
|
42
|
+
> **v2.0.0** - full coverage of the BML Connect v2 API across all four integration methods: Redirect, Direct, Card-On-File Tokenization, and PCI Merchant Tokenization.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Table of Contents
|
|
47
|
+
|
|
48
|
+
- [Features](#features)
|
|
49
|
+
- [Installation](#installation)
|
|
50
|
+
- [Integration Methods](#integration-methods)
|
|
51
|
+
- [Redirect Method](#redirect-method)
|
|
52
|
+
- [Direct Method](#direct-method)
|
|
53
|
+
- [Card-On-File / Tokenization](#card-on-file--tokenization)
|
|
54
|
+
- [PCI Merchant Tokenization](#pci-merchant-tokenization)
|
|
55
|
+
- [Webhooks](#webhooks)
|
|
56
|
+
- [Transaction States](#transaction-states)
|
|
57
|
+
- [Sharing Payment Links](#sharing-payment-links)
|
|
58
|
+
- [Shops & Products](#shops--products)
|
|
59
|
+
- [Customers & Tokens](#customers--tokens)
|
|
60
|
+
- [Company Info](#company-info)
|
|
61
|
+
- [Framework Integration](#framework-integration)
|
|
62
|
+
- [API Reference](#api-reference)
|
|
63
|
+
- [Migration from v1.x](#migration-from-v1x)
|
|
64
|
+
- [Development](#development)
|
|
65
|
+
- [Contributing](#contributing)
|
|
66
|
+
- [Security](#security)
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **🔄 Sync/Async Support** - every resource has both sync and `async/await` variants
|
|
73
|
+
- **🎯 Four Integration Methods** - Redirect, Direct (QR + card), Card-On-File, PCI Tokenization
|
|
74
|
+
- **🪝 Webhook Registration** - register your endpoint directly in BML's backend
|
|
75
|
+
- **🔔 Webhook Event Parsing** - `WebhookEvent` model for `NOTIFY_TRANSACTION_CHANGE` and `NOTIFY_TOKENISATION_STATUS`
|
|
76
|
+
- **🔐 Webhook Verification** - SHA-256 header scheme with legacy MD5 fallback
|
|
77
|
+
- **🔑 PCI Card Encryption** - `CardEncryption` utility for RSA-OAEP SHA-256 server-side card encryption (`cryptography>=46.0.0`)
|
|
78
|
+
- **📝 Type Annotations** - full type hints throughout
|
|
79
|
+
- **🛡️ Error Handling** - structured exception hierarchy
|
|
80
|
+
- **🚀 Framework Agnostic** - works with Django, Flask, FastAPI, Sanic, or plain scripts
|
|
81
|
+
- **📄 MIT Licensed** - open source and free to use
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install bml-connect-python
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Requires Python 3.9+**
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Integration Methods
|
|
96
|
+
|
|
97
|
+
All four methods use the same `POST /public/v2/transactions` endpoint. The payload and the response fields you care about differ by method.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### Redirect Method
|
|
102
|
+
|
|
103
|
+
The easiest integration. BML hosts the payment page - you control branding via the Merchant Dashboard under **Settings → Branding**.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from bml_connect import BMLConnect, Environment
|
|
107
|
+
|
|
108
|
+
with BMLConnect(api_key="sk_...", environment=Environment.PRODUCTION) as client:
|
|
109
|
+
client.webhooks.create("https://yourapp.com/bml-webhook")
|
|
110
|
+
|
|
111
|
+
txn = client.transactions.create({
|
|
112
|
+
"redirectUrl": "https://yourapp.com/payment-complete",
|
|
113
|
+
"localId": "INV-001",
|
|
114
|
+
"customerReference": "Order #42", # shown on customer receipt
|
|
115
|
+
"webhook": "https://yourapp.com/bml-webhook",
|
|
116
|
+
"locale": "en", # or th_TH, en_GB, etc.
|
|
117
|
+
"order": {
|
|
118
|
+
"shopId": "SHOP_ID",
|
|
119
|
+
"products": [{"productId": "PROD_ID", "numberOfItems": 2}],
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
print(txn.url) # full payment page URL
|
|
124
|
+
print(txn.short_url) # shortened URL - ideal for SMS and messaging apps
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
After payment, BML redirects back to `redirectUrl` and appends `transactionId`, `state`, and `signature` as query parameters. Always confirm the final state via the API - never rely solely on redirect parameters:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
txn = client.transactions.get("TRANSACTION_ID")
|
|
131
|
+
print(txn.state) # e.g. TransactionState.CONFIRMED
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### Customising the Payment Portal
|
|
135
|
+
|
|
136
|
+
Use `paymentPortalExperience` to streamline the hosted page:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
txn = client.transactions.create({
|
|
140
|
+
"redirectUrl": "https://yourapp.com/thanks",
|
|
141
|
+
"provider": "alipay", # pre-select provider
|
|
142
|
+
"customer": {"name": "Alice", "email": "alice@example.com", ...},
|
|
143
|
+
"paymentPortalExperience": {
|
|
144
|
+
"skipCustomerForm": True, # requires customer info in request
|
|
145
|
+
"skipProviderSelection": True, # requires provider in request
|
|
146
|
+
"externalWebsiteTermsAccepted": True,
|
|
147
|
+
"externalWebsiteTermsUrl": "https://yourapp.com/terms",
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
#### Handling Payment Failures
|
|
153
|
+
|
|
154
|
+
| Scenario | How to configure |
|
|
155
|
+
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
156
|
+
| Let BML handle retries (default) | No extra fields needed |
|
|
157
|
+
| Redirect on cancel/fail | Set `redirectUrl` - BML appends state, id, errors |
|
|
158
|
+
| No retries allowed | Add `"allowRetry": false` |
|
|
159
|
+
| Merchant handles retries | Set `"paymentAttemptFailureUrl": "https://yourapp.com/checkout/123"` - transaction stays `QR_CODE_GENERATED` so it can be retried with the same ID |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
### Direct Method
|
|
164
|
+
|
|
165
|
+
Your UI, your checkout experience. You handle displaying the payment interface.
|
|
166
|
+
|
|
167
|
+
**Provider values and what they return:**
|
|
168
|
+
|
|
169
|
+
| Provider | Value | Response field |
|
|
170
|
+
| -------------------- | ------------------- | --------------------------------------- |
|
|
171
|
+
| Domestic card (MPGS) | `mpgs` | `url` - redirect to secure card form |
|
|
172
|
+
| International card | `debit_credit_card` | `url` - redirect to secure card form |
|
|
173
|
+
| Alipay online | `alipay_online` | `url` - redirect |
|
|
174
|
+
| Alipay in-person QR | `alipay` | `vendor_qr_code` - encode into QR image |
|
|
175
|
+
| UnionPay QR | `unionpay` | `vendor_qr_code` |
|
|
176
|
+
| WechatPay QR | `wechatpay` | `vendor_qr_code` |
|
|
177
|
+
| BML MobilePay QR | `bml_mobilepay` | `vendor_qr_code` |
|
|
178
|
+
| Cash | `cash` | - |
|
|
179
|
+
|
|
180
|
+
**QR providers** - generate and display a QR code:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import qrcode
|
|
184
|
+
|
|
185
|
+
txn = client.transactions.create({
|
|
186
|
+
"amount": 1000,
|
|
187
|
+
"currency": "USD",
|
|
188
|
+
"provider": "alipay", # or unionpay / wechatpay / bml_mobilepay
|
|
189
|
+
"webhook": "https://yourapp.com/bml-webhook",
|
|
190
|
+
"locale": "en",
|
|
191
|
+
"customer": {"name": "Alice", "email": "alice@example.com"},
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
# Encode vendor_qr_code into a QR image and display to the customer
|
|
195
|
+
qr = qrcode.make(txn.vendor_qr_code)
|
|
196
|
+
qr.save("payment_qr.png")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Card / online providers** - redirect customer to the secure form:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
txn = client.transactions.create({
|
|
203
|
+
"amount": 2500,
|
|
204
|
+
"currency": "USD",
|
|
205
|
+
"provider": "mpgs", # or debit_credit_card / alipay_online
|
|
206
|
+
"redirectUrl": "https://yourapp.com/payment-complete",
|
|
207
|
+
"webhook": "https://yourapp.com/bml-webhook",
|
|
208
|
+
"customer": {
|
|
209
|
+
"name": "Bob Jones",
|
|
210
|
+
"email": "bob@example.com",
|
|
211
|
+
"billingAddress1": "1 Main Street",
|
|
212
|
+
"billingCity": "Malé",
|
|
213
|
+
"billingCountry": "MV",
|
|
214
|
+
},
|
|
215
|
+
"paymentPortalExperience": {
|
|
216
|
+
"skipCustomerForm": True,
|
|
217
|
+
"skipProviderSelection": True,
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
redirect_to(txn.url)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
You can also use the `Provider` enum:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from bml_connect import Provider
|
|
228
|
+
|
|
229
|
+
txn = client.transactions.create({
|
|
230
|
+
"provider": Provider.ALIPAY.value,
|
|
231
|
+
...
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Use **polling** or **webhooks** to track payment status. See [Webhooks](#webhooks) for details.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
### Card-On-File / Tokenization
|
|
240
|
+
|
|
241
|
+
Store a customer's card for future recurring or one-click charges. Only `mpgs` and `debit_credit_card` support tokenisation.
|
|
242
|
+
|
|
243
|
+
#### Step 1 - Capture the card on the first transaction
|
|
244
|
+
|
|
245
|
+
You can create the customer and capture their card in **one call**:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
txn = client.transactions.create({
|
|
249
|
+
"amount": 100,
|
|
250
|
+
"currency": "USD",
|
|
251
|
+
"tokenizationDetails": {
|
|
252
|
+
"tokenize": True,
|
|
253
|
+
"paymentType": "UNSCHEDULED",
|
|
254
|
+
"recurringFrequency": "UNSCHEDULED",
|
|
255
|
+
},
|
|
256
|
+
"customer": {
|
|
257
|
+
"name": "Alice Smith",
|
|
258
|
+
"email": "alice@example.com",
|
|
259
|
+
"billingAddress1": "1 Coral Way",
|
|
260
|
+
"billingCity": "Malé",
|
|
261
|
+
"billingCountry": "MV",
|
|
262
|
+
"currency": "MVR",
|
|
263
|
+
},
|
|
264
|
+
"customerAsPayer": True,
|
|
265
|
+
"webhook": "https://yourapp.com/bml-webhook",
|
|
266
|
+
"redirectUrl": "https://yourapp.com/payment-complete",
|
|
267
|
+
})
|
|
268
|
+
# customer_id is available at txn.customer_id after the response
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Or use an **existing customer**:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
txn = client.transactions.create({
|
|
275
|
+
"amount": 100,
|
|
276
|
+
"currency": "USD",
|
|
277
|
+
"tokenizationDetails": {
|
|
278
|
+
"tokenize": True,
|
|
279
|
+
"paymentType": "UNSCHEDULED",
|
|
280
|
+
"recurringFrequency": "UNSCHEDULED",
|
|
281
|
+
},
|
|
282
|
+
"customerId": "EXISTING_CUSTOMER_ID",
|
|
283
|
+
"customerAsPayer": True,
|
|
284
|
+
"webhook": "https://yourapp.com/bml-webhook",
|
|
285
|
+
})
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
After the customer completes the payment, BML fires a `NOTIFY_TOKENISATION_STATUS` webhook confirming the card was stored.
|
|
289
|
+
|
|
290
|
+
#### Step 2 - List stored tokens
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
tokens = client.customers.list_tokens("CUSTOMER_ID")
|
|
294
|
+
for t in tokens:
|
|
295
|
+
print(t.id, t.brand, t.padded_card_number,
|
|
296
|
+
f"{t.token_expiry_month}/{t.token_expiry_year}",
|
|
297
|
+
"default" if t.default_token else "")
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
#### Step 3 - Charge a stored token
|
|
301
|
+
|
|
302
|
+
First create a transaction for the customer, then charge it against their token:
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
# Create the transaction shell
|
|
306
|
+
txn = client.transactions.create({
|
|
307
|
+
"amount": 5000,
|
|
308
|
+
"currency": "USD",
|
|
309
|
+
"customerId": "CUSTOMER_ID",
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
# Option 1 - specify token by ID (recommended)
|
|
313
|
+
result = client.customers.charge({
|
|
314
|
+
"customerId": "CUSTOMER_ID",
|
|
315
|
+
"transactionId": txn.id,
|
|
316
|
+
"tokenId": tokens[0].id,
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
# Option 2 - specify by raw token string
|
|
320
|
+
result = client.customers.charge({
|
|
321
|
+
"customerId": "CUSTOMER_ID",
|
|
322
|
+
"transactionId": txn.id,
|
|
323
|
+
"token": tokens[0].token,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
# Option 3 - use default token (no token field)
|
|
327
|
+
result = client.customers.charge({
|
|
328
|
+
"customerId": "CUSTOMER_ID",
|
|
329
|
+
"transactionId": txn.id,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
# Always confirm via API query
|
|
333
|
+
confirmed = client.transactions.get(txn.id)
|
|
334
|
+
print(confirmed.state) # TransactionState.CONFIRMED
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
### PCI Merchant Tokenization
|
|
340
|
+
|
|
341
|
+
For PCI-approved merchants who capture card details directly. Requires a separate public/private key pair created in **Merchant Dashboard → Connect**.
|
|
342
|
+
|
|
343
|
+
> **Key rules:** Your private key (`sk_...`) and public key (`pk_...`) must be from the **same app**. Never mix keys across apps.
|
|
344
|
+
|
|
345
|
+
#### Setup
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
from bml_connect import BMLConnect, CardEncryption, Environment
|
|
349
|
+
|
|
350
|
+
client = BMLConnect(
|
|
351
|
+
api_key="sk_your_private_key", # private key - creates transactions
|
|
352
|
+
public_key="pk_your_public_key", # public key - calls /public-client/* endpoints
|
|
353
|
+
environment=Environment.PRODUCTION,
|
|
354
|
+
)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
#### Step 1 - Fetch the RSA encryption key
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
# Always fetch fresh - this key can rotate at any time
|
|
361
|
+
enc_key = client.public_client.get_tokens_public_key()
|
|
362
|
+
print(enc_key.key_id)
|
|
363
|
+
print(enc_key.pem) # PEM-formatted public key ready for encryption
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
#### Step 2 - Encrypt card data
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
card_b64 = CardEncryption.encrypt(enc_key.pem, {
|
|
370
|
+
"cardNumberRaw": "4111111111111111",
|
|
371
|
+
"cardVDRaw": "123",
|
|
372
|
+
"cardExpiryMonth": 12,
|
|
373
|
+
"cardExpiryYear": 29,
|
|
374
|
+
})
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
`CardEncryption.encrypt` uses RSA-OAEP with SHA-256, matching the algorithm documented by BML.
|
|
378
|
+
|
|
379
|
+
You can also validate before encrypting:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
from bml_connect import CardEncryption
|
|
383
|
+
|
|
384
|
+
CardEncryption.validate_card_payload({...}) # raises ValueError if invalid
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
#### Step 3 - Submit encrypted card data
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
result = client.public_client.add_card(
|
|
391
|
+
card_data=card_b64,
|
|
392
|
+
key_id=enc_key.key_id,
|
|
393
|
+
customer_id="CUSTOMER_ID",
|
|
394
|
+
redirect="https://yourapp.com/tokenisation-callback",
|
|
395
|
+
webhook="https://yourapp.com/bml-webhook", # optional
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Redirect customer to 3DS authentication
|
|
399
|
+
redirect_to(result.next_action.url)
|
|
400
|
+
|
|
401
|
+
# Store for correlation (not a payment token)
|
|
402
|
+
client_side_token_id = result.next_action.client_side_token_id
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
#### Step 4 - Handle the callback
|
|
406
|
+
|
|
407
|
+
BML redirects to your `redirect` URL with query parameters:
|
|
408
|
+
|
|
409
|
+
```
|
|
410
|
+
# Success
|
|
411
|
+
https://yourapp.com/tokenisation-callback?tokenId=<id>&clientSideTokenId=<id>&customerId=<id>&status=TOKENISATION_SUCCESS
|
|
412
|
+
|
|
413
|
+
# Failure
|
|
414
|
+
https://yourapp.com/tokenisation-callback?clientSideTokenId=<id>&customerId=<id>&status=TOKENISATION_FAILURE
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
The `tokenId` on success is the **Customer Token ID** - use this for charging.
|
|
418
|
+
|
|
419
|
+
```python
|
|
420
|
+
# Flask callback handler example
|
|
421
|
+
@app.route("/tokenisation-callback")
|
|
422
|
+
def tokenisation_callback():
|
|
423
|
+
status = request.args.get("status")
|
|
424
|
+
token_id = request.args.get("tokenId") # only on success
|
|
425
|
+
|
|
426
|
+
if status == "TOKENISATION_SUCCESS" and token_id:
|
|
427
|
+
# Store token_id in your database, then charge it when needed
|
|
428
|
+
pass
|
|
429
|
+
else:
|
|
430
|
+
# Handle failure - prompt customer to re-enter card details
|
|
431
|
+
pass
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
> Always implement the async webhook listener too - the customer may close the browser before the redirect completes.
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Webhooks
|
|
439
|
+
|
|
440
|
+
### Register / Unregister
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
hook = client.webhooks.create("https://yourapp.com/bml-webhook")
|
|
444
|
+
print(hook.id, hook.hook_url)
|
|
445
|
+
|
|
446
|
+
client.webhooks.delete("https://yourapp.com/bml-webhook")
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Receiving & Verifying
|
|
450
|
+
|
|
451
|
+
BML signs every webhook POST with three headers:
|
|
452
|
+
|
|
453
|
+
| Header | Description |
|
|
454
|
+
| ----------------------- | ----------------------------------------------- |
|
|
455
|
+
| `X-Signature-Nonce` | Unique request identifier |
|
|
456
|
+
| `X-Signature-Timestamp` | Unix timestamp of the request |
|
|
457
|
+
| `X-Signature` | `SHA-256("{nonce}{timestamp}{api_key}")` as hex |
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
# Verify from a headers dict (works with Flask, Django, Sanic, etc.)
|
|
461
|
+
if not client.verify_webhook_headers(request.headers):
|
|
462
|
+
abort(403)
|
|
463
|
+
|
|
464
|
+
# Or verify the three values individually
|
|
465
|
+
if not client.verify_webhook_signature(nonce, timestamp, signature):
|
|
466
|
+
abort(403)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Parsing Webhook Events
|
|
470
|
+
|
|
471
|
+
Use `WebhookEvent` to parse the notification body:
|
|
472
|
+
|
|
473
|
+
```python
|
|
474
|
+
from bml_connect import WebhookEvent, WebhookEventType, TokenisationStatus
|
|
475
|
+
|
|
476
|
+
event = WebhookEvent.from_dict(request.get_json())
|
|
477
|
+
|
|
478
|
+
if event.event_type == WebhookEventType.NOTIFY_TRANSACTION_CHANGE:
|
|
479
|
+
print(f"Transaction {event.transaction_id} → {event.state}")
|
|
480
|
+
print(f"Amount: {event.amount_formatted}")
|
|
481
|
+
|
|
482
|
+
elif event.event_type == WebhookEventType.NOTIFY_TOKENISATION_STATUS:
|
|
483
|
+
if event.tokenisation_status == TokenisationStatus.SUCCESS:
|
|
484
|
+
tokens = client.customers.list_tokens(event.customer_id)
|
|
485
|
+
print(f"Card stored - {len(tokens)} token(s) on file")
|
|
486
|
+
else:
|
|
487
|
+
print(f"Tokenisation failed for customer {event.customer_id}")
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Legacy `originalSignature` (deprecated)
|
|
491
|
+
|
|
492
|
+
Older v1 payloads included an `originalSignature` field in the body. The SDK still supports verification for backward compatibility, but BML recommends always querying the API for the authoritative state:
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
payload = request.get_json()
|
|
496
|
+
if not client.verify_legacy_webhook_signature(payload, payload.get("originalSignature", "")):
|
|
497
|
+
abort(403)
|
|
498
|
+
|
|
499
|
+
# Confirm via API
|
|
500
|
+
txn = client.transactions.get(payload["transactionId"])
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## Transaction States
|
|
506
|
+
|
|
507
|
+
| State | Description |
|
|
508
|
+
| ------------------- | -------------------------------------------- |
|
|
509
|
+
| `INITIATED` | Payment created; QR asset not yet ready |
|
|
510
|
+
| `QR_CODE_GENERATED` | Pending - awaiting customer payment action |
|
|
511
|
+
| `CONFIRMED` | Payment completed successfully |
|
|
512
|
+
| `CANCELLED` | User cancelled or link timed out |
|
|
513
|
+
| `FAILED` | Permanently failed - cannot be retried |
|
|
514
|
+
| `EXPIRED` | Payment link expired |
|
|
515
|
+
| `VOIDED` | Payment reversed - excluded from settlements |
|
|
516
|
+
| `AUTHORIZED` | Pre-auth approved; funds not yet captured |
|
|
517
|
+
| `REFUND_REQUESTED` | Refund requested, under review |
|
|
518
|
+
| `REFUNDED` | Refund completed |
|
|
519
|
+
|
|
520
|
+
```python
|
|
521
|
+
from bml_connect import TransactionState
|
|
522
|
+
|
|
523
|
+
txn = client.transactions.get("TRANSACTION_ID")
|
|
524
|
+
|
|
525
|
+
if txn.state == TransactionState.CONFIRMED:
|
|
526
|
+
# fulfil order
|
|
527
|
+
pass
|
|
528
|
+
elif txn.state in (TransactionState.CANCELLED, TransactionState.FAILED):
|
|
529
|
+
# notify customer, initiate new transaction
|
|
530
|
+
pass
|
|
531
|
+
elif txn.state == TransactionState.AUTHORIZED:
|
|
532
|
+
# capture funds before pre-auth expires
|
|
533
|
+
pass
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Sharing Payment Links
|
|
539
|
+
|
|
540
|
+
Both methods are **rate-limited to once per minute** per transaction.
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
# SMS - country code prefix is optional
|
|
544
|
+
client.transactions.send_sms("TRANSACTION_ID", "9609601234")
|
|
545
|
+
|
|
546
|
+
# Email - single address or a list
|
|
547
|
+
client.transactions.send_email("TRANSACTION_ID", "customer@example.com")
|
|
548
|
+
client.transactions.send_email("TRANSACTION_ID", ["alice@example.com", "bob@example.com"])
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Use `txn.short_url` (instead of `txn.url`) when sharing in SMS or messaging apps to save characters.
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
## Transactions - Additional Operations
|
|
556
|
+
|
|
557
|
+
### Update
|
|
558
|
+
|
|
559
|
+
Update mutable metadata after creation:
|
|
560
|
+
|
|
561
|
+
```python
|
|
562
|
+
txn = client.transactions.update(
|
|
563
|
+
"TRANSACTION_ID",
|
|
564
|
+
customer_reference="Booking Ref #99", # up to 140 chars
|
|
565
|
+
local_data='{"reservationId": "R-001"}', # up to 1000 chars, merchant-side only
|
|
566
|
+
pnr="ABC123", # up to 64 chars
|
|
567
|
+
)
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### Retrieve
|
|
571
|
+
|
|
572
|
+
```python
|
|
573
|
+
txn = client.transactions.get("TRANSACTION_ID")
|
|
574
|
+
print(txn.state, txn.amount_formatted, txn.can_refund_if_confirmed)
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### List
|
|
578
|
+
|
|
579
|
+
```python
|
|
580
|
+
page = client.transactions.list(page=1, per_page=20, state="CONFIRMED")
|
|
581
|
+
for txn in page.items:
|
|
582
|
+
print(txn.id, txn.amount, txn.state)
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## Shops & Products
|
|
588
|
+
|
|
589
|
+
### Shops
|
|
590
|
+
|
|
591
|
+
```python
|
|
592
|
+
shops = client.shops.list()
|
|
593
|
+
shop = client.shops.get("SHOP_ID")
|
|
594
|
+
shop = client.shops.create({"name": "My Café", "reference": "cafe-main"})
|
|
595
|
+
shop = client.shops.update("SHOP_ID", {"name": "My Café & Bar"})
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Products
|
|
599
|
+
|
|
600
|
+
```python
|
|
601
|
+
products = client.shops.list_products("SHOP_ID")
|
|
602
|
+
product = client.shops.create_product("SHOP_ID", {
|
|
603
|
+
"name": "Flat White", "price": 2500, "currency": "MVR", "sku": "FW-001",
|
|
604
|
+
})
|
|
605
|
+
product = client.shops.get_product("SHOP_ID", "PRODUCT_ID")
|
|
606
|
+
product = client.shops.update_product("SHOP_ID", "PRODUCT_ID", {"price": 3000})
|
|
607
|
+
product = client.shops.update_product_by_sku("SHOP_ID", {"sku": "FW-001", "price": 3000})
|
|
608
|
+
products = client.shops.create_products_batch("SHOP_ID", [
|
|
609
|
+
{"name": "Espresso", "price": 1500, "currency": "MVR"},
|
|
610
|
+
{"name": "Latte", "price": 2000, "currency": "MVR"},
|
|
611
|
+
])
|
|
612
|
+
with open("espresso.jpg", "rb") as f:
|
|
613
|
+
client.shops.upload_product_image("SHOP_ID", "PRODUCT_ID", f.read(), "espresso.jpg")
|
|
614
|
+
client.shops.delete_product("SHOP_ID", "PRODUCT_ID")
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Categories, Taxes, Order Fields, Custom Fees
|
|
618
|
+
|
|
619
|
+
```python
|
|
620
|
+
# Categories
|
|
621
|
+
cats = client.shops.list_categories("SHOP_ID")
|
|
622
|
+
cat = client.shops.create_category("SHOP_ID", {"name": "Hot Drinks"})
|
|
623
|
+
cat = client.shops.update_category("SHOP_ID", "CAT_ID", {"name": "Hot Beverages"})
|
|
624
|
+
client.shops.delete_category("SHOP_ID", "CAT_ID")
|
|
625
|
+
|
|
626
|
+
# Taxes
|
|
627
|
+
taxes = client.shops.list_taxes("SHOP_ID")
|
|
628
|
+
tax = client.shops.create_tax("SHOP_ID", {
|
|
629
|
+
"name": "Tourist Tax", "code": "TT", "percentage": 10.0, "applyOn": "PRODUCT"
|
|
630
|
+
})
|
|
631
|
+
client.shops.delete_tax("SHOP_ID", "TAX_ID")
|
|
632
|
+
client.shops.update_products_taxes("SHOP_ID", {"taxIds": ["TAX_ID_1", "TAX_ID_2"]})
|
|
633
|
+
|
|
634
|
+
# Order Fields
|
|
635
|
+
field = client.shops.create_order_field("SHOP_ID", {"label": "Table Number", "type": "text"})
|
|
636
|
+
client.shops.update_order_field("SHOP_ID", "FIELD_ID", {"checked": True})
|
|
637
|
+
|
|
638
|
+
# Custom Fees
|
|
639
|
+
fee = client.shops.create_custom_fee("SHOP_ID", {
|
|
640
|
+
"name": "Nature Donation", "description": "Optional donation", "fee": 100
|
|
641
|
+
})
|
|
642
|
+
client.shops.update_custom_fee("SHOP_ID", "FEE_ID", {"fee": 200})
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
## Customers & Tokens
|
|
648
|
+
|
|
649
|
+
### Customers
|
|
650
|
+
|
|
651
|
+
```python
|
|
652
|
+
customers = client.customers.list()
|
|
653
|
+
customer = client.customers.create({
|
|
654
|
+
"name": "Alice Smith",
|
|
655
|
+
"email": "alice@example.com",
|
|
656
|
+
"companyId": "YOUR_COMPANY_ID",
|
|
657
|
+
"currency": "MVR",
|
|
658
|
+
})
|
|
659
|
+
customer = client.customers.get("CUSTOMER_ID")
|
|
660
|
+
customer = client.customers.update("CUSTOMER_ID", {"name": "Alice J. Smith"})
|
|
661
|
+
client.customers.delete("CUSTOMER_ID") # archives, does not hard-delete
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### Tokens
|
|
665
|
+
|
|
666
|
+
```python
|
|
667
|
+
tokens = client.customers.list_tokens("CUSTOMER_ID")
|
|
668
|
+
token = client.customers.get_token("CUSTOMER_ID", "TOKEN_ID")
|
|
669
|
+
client.customers.delete_token("CUSTOMER_ID", "TOKEN_ID")
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## Company Info
|
|
675
|
+
|
|
676
|
+
```python
|
|
677
|
+
companies = client.company.get()
|
|
678
|
+
for co in companies:
|
|
679
|
+
print(co.trading_name, co.enabled_currencies)
|
|
680
|
+
for provider in co.payment_providers:
|
|
681
|
+
print(f" {provider.value} - ecommerce={provider.ecommerce}")
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
---
|
|
685
|
+
|
|
686
|
+
## Framework Integration
|
|
687
|
+
|
|
688
|
+
### Flask - full webhook receiver
|
|
689
|
+
|
|
690
|
+
```python
|
|
691
|
+
import os
|
|
692
|
+
from flask import Flask, jsonify, request
|
|
693
|
+
from bml_connect import BMLConnect, Environment, WebhookEvent
|
|
694
|
+
|
|
695
|
+
app = Flask(__name__)
|
|
696
|
+
client = BMLConnect(api_key=os.environ["BML_API_KEY"], environment=Environment.PRODUCTION)
|
|
697
|
+
HOOK = "https://yourapp.com/bml-webhook"
|
|
698
|
+
|
|
699
|
+
with app.app_context():
|
|
700
|
+
client.webhooks.create(HOOK)
|
|
701
|
+
|
|
702
|
+
@app.route("/bml-webhook", methods=["POST"])
|
|
703
|
+
def webhook():
|
|
704
|
+
nonce = request.headers.get("X-Signature-Nonce", "")
|
|
705
|
+
timestamp = request.headers.get("X-Signature-Timestamp", "")
|
|
706
|
+
signature = request.headers.get("X-Signature", "")
|
|
707
|
+
|
|
708
|
+
if nonce and timestamp and signature:
|
|
709
|
+
if not client.verify_webhook_signature(nonce, timestamp, signature):
|
|
710
|
+
return jsonify({"error": "Invalid signature"}), 403
|
|
711
|
+
else:
|
|
712
|
+
payload = request.get_json(force=True) or {}
|
|
713
|
+
if not client.verify_legacy_webhook_signature(payload, payload.get("originalSignature", "")):
|
|
714
|
+
return jsonify({"error": "Invalid signature"}), 403
|
|
715
|
+
|
|
716
|
+
event = WebhookEvent.from_dict(request.get_json(force=True) or {})
|
|
717
|
+
app.logger.info("Webhook: type=%s txn=%s state=%s", event.event_type, event.transaction_id, event.state)
|
|
718
|
+
return jsonify({"status": "ok"})
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
### FastAPI - async webhook receiver
|
|
722
|
+
|
|
723
|
+
```python
|
|
724
|
+
import os
|
|
725
|
+
from fastapi import FastAPI, Header, HTTPException, Request
|
|
726
|
+
from fastapi.responses import JSONResponse
|
|
727
|
+
from bml_connect import BMLConnect, Environment, WebhookEvent
|
|
728
|
+
|
|
729
|
+
app = FastAPI()
|
|
730
|
+
client = BMLConnect(api_key=os.environ["BML_API_KEY"], environment=Environment.PRODUCTION, async_mode=True)
|
|
731
|
+
HOOK = "https://yourapp.com/bml-webhook"
|
|
732
|
+
|
|
733
|
+
@app.on_event("startup")
|
|
734
|
+
async def startup():
|
|
735
|
+
await client.webhooks.create(HOOK)
|
|
736
|
+
|
|
737
|
+
@app.on_event("shutdown")
|
|
738
|
+
async def shutdown():
|
|
739
|
+
await client.webhooks.delete(HOOK)
|
|
740
|
+
await client.aclose()
|
|
741
|
+
|
|
742
|
+
@app.post("/bml-webhook")
|
|
743
|
+
async def webhook(
|
|
744
|
+
request: Request,
|
|
745
|
+
x_signature_nonce: str = Header(default=""),
|
|
746
|
+
x_signature_timestamp: str = Header(default=""),
|
|
747
|
+
x_signature: str = Header(default=""),
|
|
748
|
+
):
|
|
749
|
+
if x_signature_nonce and x_signature_timestamp and x_signature:
|
|
750
|
+
if not client.verify_webhook_signature(x_signature_nonce, x_signature_timestamp, x_signature):
|
|
751
|
+
raise HTTPException(403, "Invalid signature")
|
|
752
|
+
else:
|
|
753
|
+
payload = await request.json()
|
|
754
|
+
if not client.verify_legacy_webhook_signature(payload, payload.get("originalSignature", "")):
|
|
755
|
+
raise HTTPException(403, "Invalid signature")
|
|
756
|
+
|
|
757
|
+
event = WebhookEvent.from_dict(await request.json())
|
|
758
|
+
return JSONResponse({"status": "ok"})
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
### Sanic
|
|
762
|
+
|
|
763
|
+
```python
|
|
764
|
+
from sanic import Sanic, response
|
|
765
|
+
from bml_connect import BMLConnect, Environment
|
|
766
|
+
|
|
767
|
+
app = Sanic("BMLApp")
|
|
768
|
+
client = BMLConnect(api_key="your_api_key", environment=Environment.PRODUCTION)
|
|
769
|
+
|
|
770
|
+
@app.post("/bml-webhook")
|
|
771
|
+
async def webhook(request):
|
|
772
|
+
nonce = request.headers.get("X-Signature-Nonce", "")
|
|
773
|
+
timestamp = request.headers.get("X-Signature-Timestamp", "")
|
|
774
|
+
signature = request.headers.get("X-Signature", "")
|
|
775
|
+
|
|
776
|
+
if nonce and timestamp and signature:
|
|
777
|
+
if not client.verify_webhook_signature(nonce, timestamp, signature):
|
|
778
|
+
return response.json({"error": "Invalid signature"}, status=403)
|
|
779
|
+
else:
|
|
780
|
+
payload = request.json or {}
|
|
781
|
+
if not client.verify_legacy_webhook_signature(payload, payload.get("originalSignature", "")):
|
|
782
|
+
return response.json({"error": "Invalid signature"}, status=403)
|
|
783
|
+
|
|
784
|
+
return response.json({"status": "ok"})
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## API Reference
|
|
790
|
+
|
|
791
|
+
### `BMLConnect(api_key, environment, *, async_mode, public_key)`
|
|
792
|
+
|
|
793
|
+
| Parameter | Type | Default | Description |
|
|
794
|
+
| ------------- | ---------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
795
|
+
| `api_key` | `str` | required | Private API key (`sk_...`) from BML merchant portal |
|
|
796
|
+
| `environment` | `Environment` or `str` | `PRODUCTION` | `Environment.SANDBOX` or `Environment.PRODUCTION` |
|
|
797
|
+
| `async_mode` | `bool` | `False` | Enable async/await mode |
|
|
798
|
+
| `public_key` | `str` | `None` | Public application key (`pk_...`) - required for PCI Merchant Tokenization only. Must be from the same app as `api_key`. |
|
|
799
|
+
|
|
800
|
+
### Resources
|
|
801
|
+
|
|
802
|
+
| Attribute | Description |
|
|
803
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------- |
|
|
804
|
+
| `client.company` | `GET /public/me` |
|
|
805
|
+
| `client.webhooks` | Register / unregister webhook URLs |
|
|
806
|
+
| `client.transactions` | Create (all four methods), retrieve, update, SMS/email share |
|
|
807
|
+
| `client.shops` | Shops, products, categories, taxes, order fields, custom fees |
|
|
808
|
+
| `client.customers` | Customer CRUD, token management, charge stored tokens |
|
|
809
|
+
| `client.public_client` | PCI Tokenization - fetch RSA key, submit encrypted card data. `None` if `public_key` not provided. |
|
|
810
|
+
|
|
811
|
+
### Models
|
|
812
|
+
|
|
813
|
+
| Class | Description |
|
|
814
|
+
| --------------------- | ----------------------------------------------------------------------------------------- |
|
|
815
|
+
| `Transaction` | Full transaction record - all V2 fields |
|
|
816
|
+
| `WebhookEvent` | Parsed webhook notification - `NOTIFY_TRANSACTION_CHANGE` or `NOTIFY_TOKENISATION_STATUS` |
|
|
817
|
+
| `Webhook` | Registered webhook record |
|
|
818
|
+
| `Company` | Merchant company details |
|
|
819
|
+
| `PaymentProvider` | Provider info within a company |
|
|
820
|
+
| `Shop` | Shop / storefront |
|
|
821
|
+
| `Product` | Product with price and SKU |
|
|
822
|
+
| `Category` | Product category |
|
|
823
|
+
| `Tax` | Tax rule |
|
|
824
|
+
| `OrderField` | Custom order form field |
|
|
825
|
+
| `CustomFee` | Custom surcharge/fee |
|
|
826
|
+
| `Customer` | Customer record |
|
|
827
|
+
| `CustomerToken` | Stored card-on-file token |
|
|
828
|
+
| `QRCode` | QR code URL/image |
|
|
829
|
+
| `PaginatedResponse` | Paginated transaction list |
|
|
830
|
+
| `TokensPublicKey` | RSA encryption key for PCI tokenization |
|
|
831
|
+
| `ClientTokenResponse` | Response from `POST /public-client/tokens` - contains 3DS redirect URL |
|
|
832
|
+
|
|
833
|
+
### Enums
|
|
834
|
+
|
|
835
|
+
```python
|
|
836
|
+
from bml_connect import Environment, TransactionState, Provider, WebhookEventType, TokenisationStatus
|
|
837
|
+
|
|
838
|
+
Environment.SANDBOX # → https://api.uat.merchants.bankofmaldives.com.mv
|
|
839
|
+
Environment.PRODUCTION # → https://api.merchants.bankofmaldives.com.mv
|
|
840
|
+
|
|
841
|
+
TransactionState.INITIATED # created, QR not yet ready
|
|
842
|
+
TransactionState.QR_CODE_GENERATED
|
|
843
|
+
TransactionState.CONFIRMED
|
|
844
|
+
TransactionState.CANCELLED
|
|
845
|
+
TransactionState.FAILED
|
|
846
|
+
TransactionState.EXPIRED
|
|
847
|
+
TransactionState.VOIDED
|
|
848
|
+
TransactionState.AUTHORIZED
|
|
849
|
+
TransactionState.REFUND_REQUESTED
|
|
850
|
+
TransactionState.REFUNDED
|
|
851
|
+
|
|
852
|
+
Provider.MPGS # domestic card (tokenisation supported)
|
|
853
|
+
Provider.DEBIT_CREDIT_CARD # international card (tokenisation supported)
|
|
854
|
+
Provider.ALIPAY # in-person QR
|
|
855
|
+
Provider.ALIPAY_ONLINE # e-commerce redirect
|
|
856
|
+
Provider.UNIONPAY # QR
|
|
857
|
+
Provider.WECHATPAY # QR
|
|
858
|
+
Provider.BML_MOBILEPAY # QR
|
|
859
|
+
Provider.CASH
|
|
860
|
+
|
|
861
|
+
WebhookEventType.NOTIFY_TRANSACTION_CHANGE
|
|
862
|
+
WebhookEventType.NOTIFY_TOKENISATION_STATUS
|
|
863
|
+
|
|
864
|
+
TokenisationStatus.SUCCESS # TOKENISATION_SUCCESS
|
|
865
|
+
TokenisationStatus.FAILURE # TOKENISATION_FAILURE
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
### Exception Hierarchy
|
|
869
|
+
|
|
870
|
+
```
|
|
871
|
+
BMLConnectError
|
|
872
|
+
├── AuthenticationError # 401 - invalid or missing API key
|
|
873
|
+
├── ValidationError # 400 - malformed request
|
|
874
|
+
├── NotFoundError # 404 - resource not found
|
|
875
|
+
├── RateLimitError # 429 - too many requests (SMS/email: once/min)
|
|
876
|
+
└── ServerError # 5xx - BML server error
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
```python
|
|
880
|
+
from bml_connect import BMLConnectError, AuthenticationError, RateLimitError
|
|
881
|
+
import time
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
txn = client.transactions.create({...})
|
|
885
|
+
except RateLimitError:
|
|
886
|
+
time.sleep(60)
|
|
887
|
+
txn = client.transactions.create({...})
|
|
888
|
+
except AuthenticationError:
|
|
889
|
+
print("Check your API key")
|
|
890
|
+
except BMLConnectError as e:
|
|
891
|
+
print(f"[{e.code}] {e.message} (HTTP {e.status_code})")
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
### `SignatureUtils` - Webhook Verification
|
|
895
|
+
|
|
896
|
+
```python
|
|
897
|
+
from bml_connect import SignatureUtils
|
|
898
|
+
|
|
899
|
+
# Current - SHA-256 of nonce + timestamp + api_key
|
|
900
|
+
is_valid = SignatureUtils.verify_webhook_signature(nonce, timestamp, received_sig, api_key)
|
|
901
|
+
is_valid = SignatureUtils.verify_webhook_headers(headers_dict, api_key)
|
|
902
|
+
|
|
903
|
+
# Deprecated - MD5 originalSignature in JSON body (v1 payloads only)
|
|
904
|
+
is_valid = SignatureUtils.verify_legacy_signature(payload_dict, original_sig, api_key)
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
### `CardEncryption` - PCI Card Encryption
|
|
908
|
+
|
|
909
|
+
```python
|
|
910
|
+
from bml_connect import CardEncryption
|
|
911
|
+
|
|
912
|
+
# Validate before encrypting
|
|
913
|
+
CardEncryption.validate_card_payload({
|
|
914
|
+
"cardNumberRaw": "4111111111111111",
|
|
915
|
+
"cardVDRaw": "123",
|
|
916
|
+
"cardExpiryMonth": 12,
|
|
917
|
+
"cardExpiryYear": 29,
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
# Encrypt - returns Base64 RSA-OAEP SHA-256 ciphertext
|
|
921
|
+
card_b64 = CardEncryption.encrypt(enc_key.pem, {
|
|
922
|
+
"cardNumberRaw": "4111111111111111",
|
|
923
|
+
"cardVDRaw": "123",
|
|
924
|
+
"cardExpiryMonth": 12,
|
|
925
|
+
"cardExpiryYear": 29,
|
|
926
|
+
})
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
---
|
|
930
|
+
|
|
931
|
+
## Migration from v1.x
|
|
932
|
+
|
|
933
|
+
| v1.x | v2.0 | Notes |
|
|
934
|
+
| ----------------------------------------------- | ------------------------------------------------------ | --------------------------------------------- |
|
|
935
|
+
| `BMLConnect(api_key, app_id, ...)` | `BMLConnect(api_key, ...)` | `app_id` optional, `public_key` new |
|
|
936
|
+
| `client.transactions.create_transaction({...})` | `client.transactions.create({...})` | V2 endpoint, no signature, all four methods |
|
|
937
|
+
| `client.transactions.get_transaction(id)` | `client.transactions.get(id)` | Alias preserved |
|
|
938
|
+
| `client.transactions.list_transactions(...)` | `client.transactions.list(...)` | Alias preserved |
|
|
939
|
+
| `SignatureUtils.generate_signature(...)` | Raises `NotImplementedError` | V2 transactions don't need request signatures |
|
|
940
|
+
| `SignatureUtils.verify_signature(...)` | `SignatureUtils.verify_legacy_signature(...)` | Old name kept as alias |
|
|
941
|
+
| `client.verify_webhook_signature(payload, sig)` | `client.verify_legacy_webhook_signature(payload, sig)` | Renamed to clarify it's the deprecated path |
|
|
942
|
+
| `client.verify_webhook_payload(raw, sig)` | `client.verify_webhook_headers(headers)` | New header-based scheme |
|
|
943
|
+
|
|
944
|
+
V2 transactions require no `signature`, `signMethod`, or `apiKey` in the payload:
|
|
945
|
+
|
|
946
|
+
```python
|
|
947
|
+
# Before (v1.x)
|
|
948
|
+
client.transactions.create_transaction({
|
|
949
|
+
"amount": 1500, "currency": "MVR",
|
|
950
|
+
"provider": "alipay", "signMethod": "sha1",
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
# After (v2.0)
|
|
954
|
+
client.transactions.create({
|
|
955
|
+
"redirectUrl": "https://yourapp.com/thanks",
|
|
956
|
+
"localId": "INV-001",
|
|
957
|
+
"order": {"shopId": "SHOP_ID", "products": [...]},
|
|
958
|
+
})
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
---
|
|
962
|
+
|
|
963
|
+
## Project Structure
|
|
964
|
+
|
|
965
|
+
```
|
|
966
|
+
bml-connect-python/
|
|
967
|
+
├── src/bml_connect/
|
|
968
|
+
│ ├── __init__.py # Public API surface
|
|
969
|
+
│ ├── client.py # BMLConnect façade
|
|
970
|
+
│ ├── resources.py # Resource managers
|
|
971
|
+
│ ├── models.py # Dataclass models + enums
|
|
972
|
+
│ ├── transport.py # HTTP layer (sync + async)
|
|
973
|
+
│ ├── signature.py # SignatureUtils
|
|
974
|
+
│ ├── crypto.py # CardEncryption (PCI tokenization)
|
|
975
|
+
│ └── exceptions.py # Exception hierarchy
|
|
976
|
+
├── tests/
|
|
977
|
+
│ ├── test_sdk.py
|
|
978
|
+
│ └── test_client.py
|
|
979
|
+
├── examples/
|
|
980
|
+
│ ├── basic_sync.py
|
|
981
|
+
│ ├── basic_async.py
|
|
982
|
+
│ ├── direct_method.py # Direct Method - QR and card
|
|
983
|
+
│ ├── card_on_file.py # Card-On-File tokenization + recurring charge
|
|
984
|
+
│ ├── pci_tokenization.py # PCI Merchant Tokenization
|
|
985
|
+
│ ├── webhook_flask.py
|
|
986
|
+
│ ├── webhook_fastapi.py
|
|
987
|
+
│ └── webhook_sanic.py
|
|
988
|
+
├── pyproject.toml
|
|
989
|
+
└── README.md
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
---
|
|
993
|
+
|
|
994
|
+
## Development
|
|
995
|
+
|
|
996
|
+
### Setup
|
|
997
|
+
|
|
998
|
+
```bash
|
|
999
|
+
git clone https://github.com/quillfires/bml-connect-python.git
|
|
1000
|
+
cd bml-connect-python
|
|
1001
|
+
poetry install
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
### Running Tests
|
|
1005
|
+
|
|
1006
|
+
```bash
|
|
1007
|
+
poetry run pytest -v
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
### Code Quality
|
|
1011
|
+
|
|
1012
|
+
```bash
|
|
1013
|
+
poetry run isort src/ tests/
|
|
1014
|
+
poetry run black src/ tests/
|
|
1015
|
+
poetry run mypy src/
|
|
1016
|
+
poetry run flake8 src/ tests/
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
---
|
|
1020
|
+
|
|
1021
|
+
## Contributing
|
|
1022
|
+
|
|
1023
|
+
Contributions are welcome! Please read [CONTRIBUTING.md](https://github.com/quillfires/bml-connect-python/blob/main/CONTRIBUTING.md) before submitting a pull request.
|
|
1024
|
+
|
|
1025
|
+
1. Fork the repository
|
|
1026
|
+
2. Create a feature branch (`git checkout -b feat/my-feature`)
|
|
1027
|
+
3. Make your changes and add tests
|
|
1028
|
+
4. Ensure all checks pass (`poetry run pytest`)
|
|
1029
|
+
5. Submit a pull request
|
|
1030
|
+
|
|
1031
|
+
---
|
|
1032
|
+
|
|
1033
|
+
## License
|
|
1034
|
+
|
|
1035
|
+
MIT - see [LICENSE](https://github.com/quillfires/bml-connect-python/blob/main/LICENSE) for details.
|
|
1036
|
+
|
|
1037
|
+
---
|
|
1038
|
+
|
|
1039
|
+
## Support
|
|
1040
|
+
|
|
1041
|
+
- 📖 [Documentation](https://github.com/quillfires/bml-connect-python/wiki)
|
|
1042
|
+
- 🐛 [Issue Tracker](https://github.com/quillfires/bml-connect-python/issues)
|
|
1043
|
+
- 💬 [Discussions](https://github.com/quillfires/bml-connect-python/discussions)
|
|
1044
|
+
- 📋 [Changelog](https://github.com/quillfires/bml-connect-python/blob/main/CHANGELOG.md)
|
|
1045
|
+
|
|
1046
|
+
## Security
|
|
1047
|
+
|
|
1048
|
+
Please report security vulnerabilities by email to fayaz.quill@gmail.com rather than opening a public issue. See [SECURITY.md](https://github.com/quillfires/bml-connect-python/blob/main/SECURITY.md) for the full policy.
|
|
1049
|
+
|
|
1050
|
+
---
|
|
1051
|
+
|
|
1052
|
+
Made with ❤️ for the Maldivian developer community
|
|
1053
|
+
|