-
Notifications
You must be signed in to change notification settings - Fork 19
#28 Feat/orange adapter #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import httpx | ||
| from typing import Dict, Any, Optional | ||
|
|
||
| class MTNMobileMoneyAdapter: | ||
| """ | ||
| MTN Mobile Money API Adapter | ||
| -------------------------------- | ||
| Handles: | ||
| - Authentication (OAuth2) | ||
| - Request-to-Pay (Collections) | ||
| - Transfers (Disbursements) | ||
| - Transaction Status | ||
| - Refunds (if supported) | ||
| """ | ||
|
|
||
| BASE_URL = "https://api.mtn.com/v1/" # Change to sandbox for testing | ||
|
|
||
| def __init__(self, api_key: str, subscription_key: str, environment: str = "sandbox"): | ||
| """ | ||
| :param api_key: API Key from MTN Developer Portal | ||
| :param subscription_key: Subscription Key (Ocp-Apim-Subscription-Key) | ||
| :param environment: "sandbox" or "production" | ||
| """ | ||
| self.api_key = api_key | ||
| self.subscription_key = subscription_key | ||
| self.environment = environment | ||
| self.access_token = None | ||
|
|
||
| async def authenticate(self) -> str: | ||
| """ | ||
| Obtain OAuth2 access token from MTN API. | ||
| """ | ||
| url = f"{self.BASE_URL}token/" | ||
| headers = { | ||
| "Ocp-Apim-Subscription-Key": self.subscription_key, | ||
| } | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.post(url, headers=headers, auth=(self.api_key, "")) | ||
| resp.raise_for_status() | ||
| data = resp.json() | ||
| self.access_token = data.get("access_token") | ||
| return self.access_token | ||
|
|
||
| async def send_payment(self, amount: float, phone_number: str, currency: str, reference: str) -> Dict[str, Any]: | ||
| """ | ||
| Initiate a 'Request to Pay' (Collection) transaction. | ||
| """ | ||
| if not self.access_token: | ||
| await self.authenticate() | ||
|
|
||
| url = f"{self.BASE_URL}collection/request-to-pay" | ||
| headers = { | ||
| "Authorization": f"Bearer {self.access_token}", | ||
| "X-Reference-Id": reference, | ||
| "X-Target-Environment": self.environment, | ||
| "Content-Type": "application/json", | ||
| "Ocp-Apim-Subscription-Key": self.subscription_key, | ||
| } | ||
|
|
||
| payload = { | ||
| "amount": str(amount), | ||
| "currency": currency, | ||
| "externalId": reference, | ||
| "payer": { | ||
| "partyIdType": "MSISDN", | ||
| "partyId": phone_number, | ||
| }, | ||
| "payerMessage": "EasySwitch Payment", | ||
| "payeeNote": "Thank you for using EasySwitch", | ||
| } | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.post(url, headers=headers, json=payload) | ||
| return {"status_code": resp.status_code, "reference": reference} | ||
|
|
||
| async def check_transaction_status(self, reference: str) -> Dict[str, Any]: | ||
| """ | ||
| Check the status of a transaction by reference ID. | ||
| """ | ||
| if not self.access_token: | ||
| await self.authenticate() | ||
|
|
||
| url = f"{self.BASE_URL}transaction/status/{reference}" | ||
| headers = { | ||
| "Authorization": f"Bearer {self.access_token}", | ||
| "Ocp-Apim-Subscription-Key": self.subscription_key, | ||
| } | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.get(url, headers=headers) | ||
| resp.raise_for_status() | ||
| return resp.json() | ||
|
|
||
| async def process_refund(self, original_reference: str, amount: float, currency: str) -> Dict[str, Any]: | ||
| """ | ||
| Process refund for a completed transaction (if supported). | ||
| """ | ||
| if not self.access_token: | ||
| await self.authenticate() | ||
|
|
||
| url = f"{self.BASE_URL}disbursement/transfer" | ||
| headers = { | ||
| "Authorization": f"Bearer {self.access_token}", | ||
| "Ocp-Apim-Subscription-Key": self.subscription_key, | ||
| "Content-Type": "application/json", | ||
| } | ||
|
|
||
| payload = { | ||
| "amount": str(amount), | ||
| "currency": currency, | ||
| "externalId": f"refund_{original_reference}", | ||
| "payee": {"partyIdType": "MSISDN", "partyId": "<customer_number>"}, | ||
| "payerMessage": "Refund Processed", | ||
| "payeeNote": "Refund from EasySwitch", | ||
| } | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.post(url, headers=headers, json=payload) | ||
| return {"status_code": resp.status_code, "refund_reference": original_reference} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| import httpx | ||
| import hmac | ||
| import hashlib | ||
| from typing import Dict, Any, List, Optional | ||
|
|
||
|
|
||
| class OrangeMoneyAdapter: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. EasySwitch Adapters must be instances of Please take a look at https://alldotpy.github.io/EasySwitch/api-reference/base-adapter/ or explore existing adapters ( |
||
| """ | ||
| Orange Money API Adapter | ||
| -------------------------------- | ||
| Supports: | ||
| - Authentication via X-AUTH-TOKEN | ||
| - Single and Bulk Payment Requests | ||
| - Transaction Status Checks | ||
| - Webhook Signature Validation | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| merchant_code: str, | ||
| api_key: str, | ||
| country_code: str = "ci", # Default: Côte d'Ivoire | ||
| environment: str = "sandbox" | ||
| ): | ||
| """ | ||
| :param merchant_code: Your Orange Money merchant code (e.g., MERCH123) | ||
| :param api_key: API key from Orange Developer Portal | ||
| :param country_code: ISO country code (e.g., 'ci', 'sn', 'ml') | ||
| :param environment: 'sandbox' or 'production' | ||
| """ | ||
| self.merchant_code = merchant_code | ||
| self.api_key = api_key | ||
| self.country_code = country_code | ||
| self.environment = environment | ||
| self.auth_token = None | ||
|
|
||
| # Example: https://api.orange.ci/ or https://api.orange.sn/ | ||
| self.base_url = f"https://api.orange.{country_code}/" | ||
| if environment == "sandbox": | ||
| self.base_url = f"https://sandbox-api.orange.{country_code}/" | ||
|
|
||
| async def authenticate(self) -> str: | ||
| """ | ||
| Obtain X-AUTH-TOKEN for subsequent requests. | ||
| """ | ||
| url = f"{self.base_url}api/token" | ||
| headers = {"Content-Type": "application/json"} | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.post(url, json={"merchant_code": self.merchant_code, "api_key": self.api_key}, headers=headers) | ||
| resp.raise_for_status() | ||
| data = resp.json() | ||
| self.auth_token = data.get("token") | ||
| return self.auth_token | ||
|
|
||
| async def send_payment( | ||
| self, | ||
| amount: float, | ||
| phone_number: str, | ||
| currency: str, | ||
| reference: str | ||
| ) -> Dict[str, Any]: | ||
| """ | ||
| Initiate a single payment to a customer. | ||
| """ | ||
| if not self.auth_token: | ||
| await self.authenticate() | ||
|
|
||
| url = f"{self.base_url}api/pay" | ||
| headers = { | ||
| "X-AUTH-TOKEN": self.auth_token, | ||
| "Content-Type": "application/json", | ||
| } | ||
|
|
||
| payload = { | ||
| "merchant_code": self.merchant_code, | ||
| "amount": amount, | ||
| "currency": currency, | ||
| "customer_msisdn": phone_number, | ||
| "order_id": reference, | ||
| } | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.post(url, headers=headers, json=payload) | ||
| return {"status_code": resp.status_code, "reference": reference} | ||
|
|
||
| async def send_bulk_payments( | ||
| self, | ||
| payments: List[Dict[str, Any]], | ||
| batch_reference: str | ||
| ) -> Dict[str, Any]: | ||
| """ | ||
| Send bulk payments in one request. | ||
| Each item in 'payments' must include: | ||
| { | ||
| "phone_number": "22507081234", | ||
| "amount": 5000, | ||
| "currency": "XOF" | ||
| } | ||
| """ | ||
| if not self.auth_token: | ||
| await self.authenticate() | ||
|
|
||
| url = f"{self.base_url}api/bulkpay" | ||
| headers = { | ||
| "X-AUTH-TOKEN": self.auth_token, | ||
| "Content-Type": "application/json", | ||
| } | ||
|
|
||
| payload = { | ||
| "merchant_code": self.merchant_code, | ||
| "batch_reference": batch_reference, | ||
| "transactions": [ | ||
| { | ||
| "customer_msisdn": tx["phone_number"], | ||
| "amount": tx["amount"], | ||
| "currency": tx["currency"] | ||
| } for tx in payments | ||
| ] | ||
| } | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.post(url, headers=headers, json=payload) | ||
| return {"status_code": resp.status_code, "batch_reference": batch_reference} | ||
|
|
||
| async def check_transaction_status(self, reference: str) -> Dict[str, Any]: | ||
| """ | ||
| Check the status of a payment using its reference. | ||
| """ | ||
| if not self.auth_token: | ||
| await self.authenticate() | ||
|
|
||
| url = f"{self.base_url}api/transaction/status/{reference}" | ||
| headers = {"X-AUTH-TOKEN": self.auth_token} | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
| resp = await client.get(url, headers=headers) | ||
| resp.raise_for_status() | ||
| return resp.json() | ||
|
|
||
| @staticmethod | ||
| def validate_webhook_signature(payload: str, signature: str, secret: str) -> bool: | ||
| """ | ||
| Validate webhook signature sent from Orange Money. | ||
|
|
||
| :param payload: Raw webhook request body | ||
| :param signature: X-Signature header from Orange | ||
| :param secret: Shared secret key from Orange dashboard | ||
| """ | ||
| computed_sig = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() | ||
| return hmac.compare_digest(computed_sig, signature) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We recommand using pytest module for tests. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import asyncio | ||
| from easyswitch.adapters.mtn import MTNMobileMoneyAdapter | ||
|
|
||
| async def main(): | ||
| mtn = MTNMobileMoneyAdapter( | ||
| api_key="YOUR_API_KEY", | ||
| subscription_key="YOUR_SUBSCRIPTION_KEY", | ||
| environment="sandbox" | ||
| ) | ||
|
|
||
| response = await mtn.send_payment( | ||
| amount=10.0, | ||
| phone_number="233541234567", | ||
| currency="GHS", | ||
| reference="order_123" | ||
| ) | ||
| print(response) | ||
|
|
||
| asyncio.run(main()) |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We recommand using pytest module for tests. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import asyncio | ||
| from easyswitch.adapters.orange import OrangeMoneyAdapter | ||
|
|
||
| async def main(): | ||
| orange = OrangeMoneyAdapter( | ||
| merchant_code="MERCH123", | ||
| api_key="YOUR_API_KEY", | ||
| country_code="ci", | ||
| environment="sandbox" | ||
| ) | ||
|
|
||
| response = await orange.send_payment( | ||
| amount=5000, | ||
| phone_number="22507081234", | ||
| currency="XOF", | ||
| reference="order_456" | ||
| ) | ||
| print(response) | ||
|
|
||
| asyncio.run(main()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can remove this file from this PR.