Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
29ebe98
feat(types): add missing providers (Paystack, MTN, Airtel, KkiaPay, Q…
Einswilli Jul 4, 2026
2d78dea
fix(base): correct debug_mode always-True bug, align validate_credent…
Einswilli Jul 4, 2026
8d9f6e0
fix(client): correct defaulf_currency typo to default_currency in int…
Einswilli Jul 4, 2026
27cd3d7
clean(adapters): remove empty unused interfaces.py file
Einswilli Jul 4, 2026
f28cd5e
fix(setup): sync install_requires with pyproject.toml (httpx→aiohttp,…
Einswilli Jul 4, 2026
7eef264
fix(cinetpay): remove debug print() statements from check_status
Einswilli Jul 4, 2026
8cffe8b
fix(paystack): align validate_webhook signature with BaseAdapter, use…
Einswilli Jul 4, 2026
44773fb
fix(bizao): implement validate_webhook and parse_webhook (were delega…
Einswilli Jul 4, 2026
e45f0cc
fix(semoa): multiple critical bugs - missing @register decorator, _va…
Einswilli Jul 4, 2026
763d123
fix(mtn): complete rewrite to follow BaseAdapter pattern - @register,…
Einswilli Jul 4, 2026
adc108c
feat(providers): add skeleton stubs for QosPay, PayPlus, KkiaPay, Pay…
Einswilli Jul 4, 2026
612ebb3
refactor(paygate): standardize HTTP pattern (self.client -> async wit…
Einswilli Jul 4, 2026
f109f56
fix(fedapay,exceptions): add missing provider= kwarg to UnsupportedOp…
Einswilli Jul 4, 2026
9d40ee8
refactor(paystack,airtel): simplify response.data access (remove dead…
Einswilli Jul 4, 2026
e41dfb0
refactor(cinetpay): validate_webhook returns False instead of raising…
Einswilli Jul 4, 2026
214c672
clean(config): remove legacy Config dataclass (unused, replaced by Ro…
Einswilli Jul 4, 2026
ac39ab1
clean(config): add sources/__init__.py, remove commented imports in c…
Einswilli Jul 4, 2026
90699e7
fix(validators): validate_webhook_signature use json.dumps instead of…
Einswilli Jul 4, 2026
728bf27
fix(core): http.py - fix RateLimitError headers kwarg; logger.py - op…
Einswilli Jul 4, 2026
b3152cf
fix(tests): remove duplicate paygate_adapter fixture in test_paygate.py
Einswilli Jul 4, 2026
5a22be3
fix(tests,config): align EnvConfigSource output with RootConfig schem…
Einswilli Jul 4, 2026
f8f7f85
fix(tests,base): handle dict config in BaseAdapter.__init__, handle c…
Einswilli Jul 4, 2026
509be7b
fix(tests): fix cinetpay tests - dict config, provider field, mock pa…
Einswilli Jul 4, 2026
1b5fd34
fix(tests,paygate): adapt paygate tests to new patterns (mock get_cli…
Einswilli Jul 4, 2026
2fb9864
docs(.env.sample): fix EVIRONMENT typos to ENVIRONMENT, add Paystack,…
Einswilli Jul 4, 2026
538c3bc
docs: add comprehensive user guides for Paystack, MTN, Airtel Money; …
Einswilli Jul 4, 2026
7f5dcb9
docs: align all documentation with current codebase - rewrite cinetpa…
Einswilli Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
340 changes: 138 additions & 202 deletions docs/api-reference/base-adapter.md
Original file line number Diff line number Diff line change
@@ -1,251 +1,187 @@
# Base Adapter (`easyswitch.adapters.base`)

This module provides the **foundation of EasySwitch’s adapter system**.

* An **adapter** is a small class that knows how to talk to a specific **payment provider (aggregator)**.
* Each adapter implements the same **common interface**, so that EasySwitch can interact with **any provider** in a consistent way.
* The **adapter registry** keeps track of all registered providers, so you can dynamically load them at runtime.
This module provides the foundation of EasySwitch's adapter system. Each payment provider has its own **adapter** class that implements a common interface, allowing EasySwitch to interact with any provider consistently.

---

## 🔹 `AdaptersRegistry`

The registry is the **central directory of all adapters**.
Instead of hardcoding adapter classes, EasySwitch lets you **register** and **retrieve** them dynamically.

Think of it as a plugin manager:

* Developers implement an adapter for a new provider.
* They register it under a **provider name**.
* Later, EasySwitch can fetch it by name and use it.

### Methods
## AdaptersRegistry

#### `AdaptersRegistry.register(name: Optional[str] = None)`

Decorator used to register an adapter under the given name.

* If `name` is provided → adapter is registered under that name.
* If omitted → EasySwitch will use the adapter class name (`SemoaAdapter → semoa`).
The **registry** is the central directory of all adapters. Adapters register themselves via a decorator.

```python
@AdaptersRegistry.register("semoa")
class SemoaAdapter(BaseAdapter):
@AdaptersRegistry.register() # Name is auto-derived from class name
class PaystackAdapter(BaseAdapter):
...
```

This means you can later do:

```python
adapter_cls = AdaptersRegistry.get("semoa")
adapter = adapter_cls(config=my_provider_config)
```

---

#### `AdaptersRegistry.get(name: str) -> Type[BaseAdapter]`

Fetches an adapter by name.

* If found → returns the adapter **class** (not an instance).
* If not found → raises `InvalidProviderError`.

---

#### `AdaptersRegistry.all() -> List[Type[BaseAdapter]]`

Returns a list of **all registered adapter classes**.
Useful for debugging or auto-loading providers.

---

#### `AdaptersRegistry.list() -> List[str]`

Returns just the **names** of all registered adapters.

```python
print(AdaptersRegistry.list())
# ["semoa", "wave", "mtn", ...]
```

---
**Methods:**

#### `AdaptersRegistry.clear() -> None`

Removes all registered adapters (used in tests).
| Method | Description |
|--------|-------------|
| `register(name=None)` | Decorator. Registers an adapter class. Name defaults to `ClassName.replace("Adapter", "").lower()` |
| `get(name)` | Returns the adapter **class** by name (case-insensitive). Raises `InvalidProviderError` if not found |
| `list()` | Returns a list of all registered adapter names |
| `all()` | Returns a list of all registered adapter classes |
| `clear()` | Clears the registry (for testing) |

---

## 🔹 `BaseAdapter`

The **abstract base class** for all adapters.
Every provider must implement this interface to ensure consistency across EasySwitch.
## BaseAdapter

It defines:
Abstract base class for all payment adapters. Every provider adapter must implement its methods.

* ✅ Common configuration logic (sandbox/production, client setup).
* ✅ Utility methods (validation, required fields, formatting).
* ✅ Abstract methods that **MUST** be implemented per provider.
### Class Attributes (set per provider)

---

### Class Attributes

* `REQUIRED_FIELDS: List[str]` → List of required fields (ex: `["api_key", "merchant_id"]`).
* `SANDBOX_URL: str` → Provider sandbox base URL.
* `PRODUCTION_URL: str` → Provider production base URL.
* `SUPPORTED_CURRENCIES: List[Currency]` → Currencies supported by the provider.
* `MIN_AMOUNT: Dict[Currency, float]` → Minimum transaction amount per currency.
* `MAX_AMOUNT: Dict[Currency, float]` → Maximum transaction amount per currency.
* `VERSION: str` → Version of the adapter (default `"1.0.0"`).
* `client: Optional[HTTPClient]` → Reusable HTTP client instance.

---
| Attribute | Type | Description |
|-----------|------|-------------|
| `SANDBOX_URL` | `str` | Sandbox API base URL |
| `PRODUCTION_URL` | `str` | Production API base URL |
| `ENDPOINTS` | `Dict[str, str]` | API endpoint paths |
| `SUPPORTED_CURRENCIES` | `List[Currency]` | Currencies the provider supports |
| `MIN_AMOUNT` | `Dict[Currency, float]` | Minimum transaction amount per currency |
| `MAX_AMOUNT` | `Dict[Currency, float]` | Maximum transaction amount per currency |

### Constructor

```python
def __init__(self, config: ProviderConfig, context: Optional[Dict[str, Any]] = None)
```

* `config` → Holds provider credentials and environment info (sandbox/production).
* `context` → Optional dict with extra metadata (e.g., debug flags, request ID, etc.).
- `config` may be a `ProviderConfig` instance or a plain dict (auto-converted)
- `context` provides additional runtime info (`debug_mode`, `log_config`, `default_currency`)

This constructor is automatically called when you **instantiate** an adapter.
### Abstract Methods (MUST be implemented)

---
```python
def get_headers(self, authorization=False) -> Dict[str, str]
def get_credentials(self) -> Dict[str, Any]
def format_transaction(self, data: TransactionDetail) -> Dict[str, Any]
def get_normalize_status(self, status: str) -> TransactionStatus
def validate_credentials(self) -> bool

async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse
async def check_status(self, transaction_id: str) -> TransactionStatusResponse
async def cancel_transaction(self, transaction_id: str) -> bool
async def get_transaction_detail(self, transaction_id: str) -> TransactionDetail
async def refund(self, transaction_id: str, amount=None, reason=None) -> PaymentResponse
async def validate_webhook(self, payload: Dict, headers: Dict) -> bool
async def parse_webhook(self, payload: Dict, headers: Dict) -> WebhookEvent
```

### Utility Methods

#### `get_client() -> HTTPClient`

* Ensures an HTTP client is available.
* Reuses the same client for performance.

#### `get_context() -> Dict[str, Any]`

* Returns extra context passed at instantiation.
* Useful for logging, tracing, or debugging.

#### `supports_partial_refund() -> bool`

* Returns `True` if the provider supports **partial refunds**.
* Default: `False`.

#### `provider_name() -> str`

* Returns a **normalized provider name**.
* E.g. `SemoaAdapter` → `"semoa"`.

---

### Abstract Methods (Must Be Implemented)

Every adapter **must implement** the following methods.

| Method | Purpose | Example Use |
| ---------------------------------------- | ------------------------------------------------ | -------------------------------------- |
| `get_headers(authorization=False)` | Build HTTP headers for requests | Add `"Authorization: Bearer <token>"` |
| `get_credentials()` | Return provider credentials | Used internally to sign requests |
| `send_payment(transaction)` | Send a new payment request | User pays via Semoa/MTN/Wave |
| `check_status(transaction_id)` | Query transaction status | Polling until success/failure |
| `cancel_transaction(transaction_id)` | Cancel a pending transaction | Not all providers support it |
| `get_transaction_detail(transaction_id)` | Get detailed transaction info | Fetch amount, payer, status |
| `refund(transaction_id, amount, reason)` | Process a refund | Full or partial refund |
| `validate_webhook(payload, headers)` | Verify incoming webhook signature | Prevent spoofed requests |
| `parse_webhook(payload, headers)` | Parse provider webhook → EasySwitch format | Normalize webhook events |
| `validate_credentials(credentials)` | Ensure credentials are valid | Check API key correctness |
| `format_transaction(data)` | Convert EasySwitch transaction → provider format | For sending requests |
| `get_normalize_status(status)` | Map provider status → standardized status | `"paid"` → `TransactionStatus.SUCCESS` |

---

### Validation Methods

#### `get_required_fields() -> List[str]`

Returns the required config fields for this adapter.

#### `validate_transaction(transaction: TransactionDetail) -> bool`

Checks if the transaction is valid:

* Amount within min/max range.
* Currency supported.
* Phone number format valid.

Raises exception if invalid.
| Method | Description |
|--------|-------------|
| `get_client()` | Returns (or creates) an `HTTPClient` for the adapter |
| `get_context()` | Returns the context dict passed at construction |
| `provider_name()` | Returns the provider name (e.g. `"paystack"`) |
| `validate_transaction(tx)` | Validates amount, currency, and phone number |
| `supports_partial_refund()` | Returns `True` if provider supports partial refunds |
| `_get_base_url()` | Returns `SANDBOX_URL` or `PRODUCTION_URL` based on `config.environment` |

---

### URL Resolver

#### `_get_base_url() -> str`

Returns the correct base URL depending on the environment:

* Sandbox → `SANDBOX_URL`.
* Production → `PRODUCTION_URL`.

---

## ✅ Example – Implementing a Custom Adapter
## Implementing a Custom Adapter

```python
from easyswitch.adapters.base import BaseAdapter, AdaptersRegistry
from easyswitch.types import PaymentResponse, TransactionDetail, TransactionStatus
from easyswitch.types import (
PaymentResponse, TransactionDetail, TransactionStatus,
TransactionStatusResponse, WebhookEvent, Currency
)

@AdaptersRegistry.register("semoa")
class SemoaAdapter(BaseAdapter):
SANDBOX_URL = "https://sandbox.semoa.com/api"
PRODUCTION_URL = "https://api.semoa.com"
SUPPORTED_CURRENCIES = ["XOF"]
@AdaptersRegistry.register()
class MyProviderAdapter(BaseAdapter):
SANDBOX_URL = "https://sandbox.myprovider.com/api"
PRODUCTION_URL = "https://api.myprovider.com"
SUPPORTED_CURRENCIES = [Currency.XOF, Currency.USD]

def get_headers(self, authorization=False):
return {
"Authorization": f"Bearer {self.config.api_key}" if authorization else "",
"Content-Type": "application/json"
}
def validate_credentials(self) -> bool:
return bool(self.config.api_key)

def get_credentials(self):
return self.config
return {"api_key": self.config.api_key}

async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse:
# TODO: Call Semoa API
...
def get_headers(self, authorization=False):
headers = {"Content-Type": "application/json"}
if authorization:
headers["Authorization"] = f"Bearer {self.config.api_key}"
return headers

async def check_status(self, transaction_id: str) -> TransactionStatus:
# TODO: Implement status polling
def format_transaction(self, tx: TransactionDetail) -> dict:
return {
"amount": tx.amount,
"currency": tx.currency,
"phone": tx.customer.phone_number,
}

def get_normalize_status(self, status: str) -> TransactionStatus:
mapping = {"success": TransactionStatus.SUCCESSFUL, "fail": TransactionStatus.FAILED}
return mapping.get(status.lower(), TransactionStatus.UNKNOWN)

async def send_payment(self, tx: TransactionDetail) -> PaymentResponse:
async with self.get_client() as client:
response = await client.post(
endpoint="/pay",
json_data=self.format_transaction(tx),
headers=self.get_headers(authorization=True),
)
return PaymentResponse(
transaction_id=tx.transaction_id,
provider=self.provider_name(),
status=TransactionStatus.PENDING,
amount=tx.amount,
currency=tx.currency,
)

async def check_status(self, transaction_id: str) -> TransactionStatusResponse:
...

async def cancel_transaction(self, transaction_id: str) -> bool:
return False # not supported
raise UnsupportedOperationError(
message="Cancellation not supported",
provider=self.provider_name(),
)

async def refund(self, transaction_id: str, amount=None, reason=None) -> PaymentResponse:
...

async def validate_webhook(self, payload, headers) -> bool:
return True

async def parse_webhook(self, payload, headers) -> dict:
return {"status": "parsed"}

def validate_credentials(self, credentials) -> bool:
return bool(credentials.api_key)
raise UnsupportedOperationError(
message="Refunds not supported",
provider=self.provider_name(),
)

async def validate_webhook(self, payload: dict, headers: dict) -> bool:
return True # Implement HMAC verification

async def parse_webhook(self, payload: dict, headers: dict) -> WebhookEvent:
return WebhookEvent(
event_type=payload.get("event", "unknown"),
provider=self.provider_name(),
transaction_id=payload.get("id", ""),
status=TransactionStatus.UNKNOWN,
amount=0,
currency="XOF",
)

async def get_transaction_detail(self, transaction_id: str) -> TransactionDetail:
raise UnsupportedOperationError(
message="Not supported",
provider=self.provider_name(),
)
```

---

## 📝 Developer Checklist for Writing a New Adapter

Before publishing your adapter, make sure you:

* [ ] Define `SANDBOX_URL` and `PRODUCTION_URL`.
* [ ] Set `SUPPORTED_CURRENCIES`.
* [ ] Implement `send_payment()`.
* [ ] Implement `check_status()`.
* [ ] Implement `refund()` (if supported).
* [ ] Handle webhooks: `validate_webhook()` + `parse_webhook()`.
* [ ] Normalize provider-specific statuses with `get_normalize_status()`.
* [ ] Validate credentials in `validate_credentials()`.
* [ ] Add proper headers in `get_headers()`.
### Developer Checklist

Before publishing a new adapter, ensure you:

- [ ] Add `@AdaptersRegistry.register()` decorator
- [ ] Define `SANDBOX_URL` and `PRODUCTION_URL`
- [ ] Set `SUPPORTED_CURRENCIES`, `MIN_AMOUNT`, `MAX_AMOUNT`
- [ ] Implement `validate_credentials()` returning `bool`
- [ ] Implement `get_headers()` and `get_credentials()`
- [ ] Implement `send_payment()` and `check_status()`
- [ ] Implement `refund()` if the provider supports it
- [ ] Implement `validate_webhook()` and `parse_webhook()`
- [ ] Map provider statuses with `get_normalize_status()`
- [ ] Raise `UnsupportedOperationError(provider=self.provider_name())` for unsupported operations
- [ ] Use `async with self.get_client() as client:` for all HTTP calls
- [ ] Add the new `Provider` enum member to `easyswitch/types.py`
Loading
Loading