bml-connect-python 1.2.0__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.
@@ -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
+ [![PyPI version](https://badge.fury.io/py/bml-connect-python.svg)](https://badge.fury.io/py/bml-connect-python)
34
+ [![Python Support](https://img.shields.io/pypi/pyversions/bml-connect-python.svg)](https://pypi.org/project/bml-connect-python/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
36
+
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%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)
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
+