|
| 1 | +# Base Adapter (`easyswitch.adapters.base`) |
| 2 | + |
| 3 | +This module provides the **foundation of EasySwitch’s adapter system**. |
| 4 | + |
| 5 | +* An **adapter** is a small class that knows how to talk to a specific **payment provider (aggregator)**. |
| 6 | +* Each adapter implements the same **common interface**, so that EasySwitch can interact with **any provider** in a consistent way. |
| 7 | +* The **adapter registry** keeps track of all registered providers, so you can dynamically load them at runtime. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## 🔹 `AdaptersRegistry` |
| 12 | + |
| 13 | +The registry is the **central directory of all adapters**. |
| 14 | +Instead of hardcoding adapter classes, EasySwitch lets you **register** and **retrieve** them dynamically. |
| 15 | + |
| 16 | +Think of it as a plugin manager: |
| 17 | + |
| 18 | +* Developers implement an adapter for a new provider. |
| 19 | +* They register it under a **provider name**. |
| 20 | +* Later, EasySwitch can fetch it by name and use it. |
| 21 | + |
| 22 | +### Methods |
| 23 | + |
| 24 | +#### `AdaptersRegistry.register(name: Optional[str] = None)` |
| 25 | + |
| 26 | +Decorator used to register an adapter under the given name. |
| 27 | + |
| 28 | +* If `name` is provided → adapter is registered under that name. |
| 29 | +* If omitted → EasySwitch will use the adapter class name (`SemoaAdapter → semoa`). |
| 30 | + |
| 31 | +```python |
| 32 | +@AdaptersRegistry.register("semoa") |
| 33 | +class SemoaAdapter(BaseAdapter): |
| 34 | + ... |
| 35 | +``` |
| 36 | + |
| 37 | +This means you can later do: |
| 38 | + |
| 39 | +```python |
| 40 | +adapter_cls = AdaptersRegistry.get("semoa") |
| 41 | +adapter = adapter_cls(config=my_provider_config) |
| 42 | +``` |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +#### `AdaptersRegistry.get(name: str) -> Type[BaseAdapter]` |
| 47 | + |
| 48 | +Fetches an adapter by name. |
| 49 | + |
| 50 | +* If found → returns the adapter **class** (not an instance). |
| 51 | +* If not found → raises `InvalidProviderError`. |
| 52 | + |
| 53 | +--- |
| 54 | + |
| 55 | +#### `AdaptersRegistry.all() -> List[Type[BaseAdapter]]` |
| 56 | + |
| 57 | +Returns a list of **all registered adapter classes**. |
| 58 | +Useful for debugging or auto-loading providers. |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +#### `AdaptersRegistry.list() -> List[str]` |
| 63 | + |
| 64 | +Returns just the **names** of all registered adapters. |
| 65 | + |
| 66 | +```python |
| 67 | +print(AdaptersRegistry.list()) |
| 68 | +# ["semoa", "wave", "mtn", ...] |
| 69 | +``` |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +#### `AdaptersRegistry.clear() -> None` |
| 74 | + |
| 75 | +Removes all registered adapters (used in tests). |
| 76 | + |
| 77 | +--- |
| 78 | + |
| 79 | +## 🔹 `BaseAdapter` |
| 80 | + |
| 81 | +The **abstract base class** for all adapters. |
| 82 | +Every provider must implement this interface to ensure consistency across EasySwitch. |
| 83 | + |
| 84 | +It defines: |
| 85 | + |
| 86 | +* ✅ Common configuration logic (sandbox/production, client setup). |
| 87 | +* ✅ Utility methods (validation, required fields, formatting). |
| 88 | +* ✅ Abstract methods that **MUST** be implemented per provider. |
| 89 | + |
| 90 | +--- |
| 91 | + |
| 92 | +### Class Attributes |
| 93 | + |
| 94 | +* `REQUIRED_FIELDS: List[str]` → List of required fields (ex: `["api_key", "merchant_id"]`). |
| 95 | +* `SANDBOX_URL: str` → Provider sandbox base URL. |
| 96 | +* `PRODUCTION_URL: str` → Provider production base URL. |
| 97 | +* `SUPPORTED_CURRENCIES: List[Currency]` → Currencies supported by the provider. |
| 98 | +* `MIN_AMOUNT: Dict[Currency, float]` → Minimum transaction amount per currency. |
| 99 | +* `MAX_AMOUNT: Dict[Currency, float]` → Maximum transaction amount per currency. |
| 100 | +* `VERSION: str` → Version of the adapter (default `"1.0.0"`). |
| 101 | +* `client: Optional[HTTPClient]` → Reusable HTTP client instance. |
| 102 | + |
| 103 | +--- |
| 104 | + |
| 105 | +### Constructor |
| 106 | + |
| 107 | +```python |
| 108 | +def __init__(self, config: ProviderConfig, context: Optional[Dict[str, Any]] = None) |
| 109 | +``` |
| 110 | + |
| 111 | +* `config` → Holds provider credentials and environment info (sandbox/production). |
| 112 | +* `context` → Optional dict with extra metadata (e.g., debug flags, request ID, etc.). |
| 113 | + |
| 114 | +This constructor is automatically called when you **instantiate** an adapter. |
| 115 | + |
| 116 | +--- |
| 117 | + |
| 118 | +### Utility Methods |
| 119 | + |
| 120 | +#### `get_client() -> HTTPClient` |
| 121 | + |
| 122 | +* Ensures an HTTP client is available. |
| 123 | +* Reuses the same client for performance. |
| 124 | + |
| 125 | +#### `get_context() -> Dict[str, Any]` |
| 126 | + |
| 127 | +* Returns extra context passed at instantiation. |
| 128 | +* Useful for logging, tracing, or debugging. |
| 129 | + |
| 130 | +#### `supports_partial_refund() -> bool` |
| 131 | + |
| 132 | +* Returns `True` if the provider supports **partial refunds**. |
| 133 | +* Default: `False`. |
| 134 | + |
| 135 | +#### `provider_name() -> str` |
| 136 | + |
| 137 | +* Returns a **normalized provider name**. |
| 138 | +* E.g. `SemoaAdapter` → `"semoa"`. |
| 139 | + |
| 140 | +--- |
| 141 | + |
| 142 | +### Abstract Methods (Must Be Implemented) |
| 143 | + |
| 144 | +Every adapter **must implement** the following methods. |
| 145 | + |
| 146 | +| Method | Purpose | Example Use | |
| 147 | +| ---------------------------------------- | ------------------------------------------------ | -------------------------------------- | |
| 148 | +| `get_headers(authorization=False)` | Build HTTP headers for requests | Add `"Authorization: Bearer <token>"` | |
| 149 | +| `get_credentials()` | Return provider credentials | Used internally to sign requests | |
| 150 | +| `send_payment(transaction)` | Send a new payment request | User pays via Semoa/MTN/Wave | |
| 151 | +| `check_status(transaction_id)` | Query transaction status | Polling until success/failure | |
| 152 | +| `cancel_transaction(transaction_id)` | Cancel a pending transaction | Not all providers support it | |
| 153 | +| `get_transaction_detail(transaction_id)` | Get detailed transaction info | Fetch amount, payer, status | |
| 154 | +| `refund(transaction_id, amount, reason)` | Process a refund | Full or partial refund | |
| 155 | +| `validate_webhook(payload, headers)` | Verify incoming webhook signature | Prevent spoofed requests | |
| 156 | +| `parse_webhook(payload, headers)` | Parse provider webhook → EasySwitch format | Normalize webhook events | |
| 157 | +| `validate_credentials(credentials)` | Ensure credentials are valid | Check API key correctness | |
| 158 | +| `format_transaction(data)` | Convert EasySwitch transaction → provider format | For sending requests | |
| 159 | +| `get_normalize_status(status)` | Map provider status → standardized status | `"paid"` → `TransactionStatus.SUCCESS` | |
| 160 | + |
| 161 | +--- |
| 162 | + |
| 163 | +### Validation Methods |
| 164 | + |
| 165 | +#### `get_required_fields() -> List[str]` |
| 166 | + |
| 167 | +Returns the required config fields for this adapter. |
| 168 | + |
| 169 | +#### `validate_transaction(transaction: TransactionDetail) -> bool` |
| 170 | + |
| 171 | +Checks if the transaction is valid: |
| 172 | + |
| 173 | +* Amount within min/max range. |
| 174 | +* Currency supported. |
| 175 | +* Phone number format valid. |
| 176 | + |
| 177 | +Raises exception if invalid. |
| 178 | + |
| 179 | +--- |
| 180 | + |
| 181 | +### URL Resolver |
| 182 | + |
| 183 | +#### `_get_base_url() -> str` |
| 184 | + |
| 185 | +Returns the correct base URL depending on the environment: |
| 186 | + |
| 187 | +* Sandbox → `SANDBOX_URL`. |
| 188 | +* Production → `PRODUCTION_URL`. |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## ✅ Example – Implementing a Custom Adapter |
| 193 | + |
| 194 | +```python |
| 195 | +from easyswitch.adapters.base import BaseAdapter, AdaptersRegistry |
| 196 | +from easyswitch.types import PaymentResponse, TransactionDetail, TransactionStatus |
| 197 | + |
| 198 | +@AdaptersRegistry.register("semoa") |
| 199 | +class SemoaAdapter(BaseAdapter): |
| 200 | + SANDBOX_URL = "https://sandbox.semoa.com/api" |
| 201 | + PRODUCTION_URL = "https://api.semoa.com" |
| 202 | + SUPPORTED_CURRENCIES = ["XOF"] |
| 203 | + |
| 204 | + def get_headers(self, authorization=False): |
| 205 | + return { |
| 206 | + "Authorization": f"Bearer {self.config.api_key}" if authorization else "", |
| 207 | + "Content-Type": "application/json" |
| 208 | + } |
| 209 | + |
| 210 | + def get_credentials(self): |
| 211 | + return self.config |
| 212 | + |
| 213 | + async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse: |
| 214 | + # TODO: Call Semoa API |
| 215 | + ... |
| 216 | + |
| 217 | + async def check_status(self, transaction_id: str) -> TransactionStatus: |
| 218 | + # TODO: Implement status polling |
| 219 | + ... |
| 220 | + |
| 221 | + async def cancel_transaction(self, transaction_id: str) -> bool: |
| 222 | + return False # not supported |
| 223 | + |
| 224 | + async def refund(self, transaction_id: str, amount=None, reason=None) -> PaymentResponse: |
| 225 | + ... |
| 226 | + |
| 227 | + async def validate_webhook(self, payload, headers) -> bool: |
| 228 | + return True |
| 229 | + |
| 230 | + async def parse_webhook(self, payload, headers) -> dict: |
| 231 | + return {"status": "parsed"} |
| 232 | + |
| 233 | + def validate_credentials(self, credentials) -> bool: |
| 234 | + return bool(credentials.api_key) |
| 235 | +``` |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## 📝 Developer Checklist for Writing a New Adapter |
| 240 | + |
| 241 | +Before publishing your adapter, make sure you: |
| 242 | + |
| 243 | +* [ ] Define `SANDBOX_URL` and `PRODUCTION_URL`. |
| 244 | +* [ ] Set `SUPPORTED_CURRENCIES`. |
| 245 | +* [ ] Implement `send_payment()`. |
| 246 | +* [ ] Implement `check_status()`. |
| 247 | +* [ ] Implement `refund()` (if supported). |
| 248 | +* [ ] Handle webhooks: `validate_webhook()` + `parse_webhook()`. |
| 249 | +* [ ] Normalize provider-specific statuses with `get_normalize_status()`. |
| 250 | +* [ ] Validate credentials in `validate_credentials()`. |
| 251 | +* [ ] Add proper headers in `get_headers()`. |
0 commit comments