shwary-python 1.0.1__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.
- shwary_python-1.0.1/.gitignore +10 -0
- shwary_python-1.0.1/PKG-INFO +170 -0
- shwary_python-1.0.1/README.md +153 -0
- shwary_python-1.0.1/pyproject.toml +49 -0
- shwary_python-1.0.1/shwary/__init__.py +4 -0
- shwary_python-1.0.1/shwary/clients/__init__.py +4 -0
- shwary_python-1.0.1/shwary/clients/async_client.py +59 -0
- shwary_python-1.0.1/shwary/clients/sync.py +59 -0
- shwary_python-1.0.1/shwary/core.py +32 -0
- shwary_python-1.0.1/shwary/exceptions.py +41 -0
- shwary_python-1.0.1/shwary/py.typed +0 -0
- shwary_python-1.0.1/shwary/schemas.py +56 -0
- shwary_python-1.0.1/shwary/tests/test_client.py +90 -0
- shwary_python-1.0.1/shwary/tests/test_client_async.py +30 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shwary-python
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: SDK Python moderne (Async/Sync) pour l'API de paiement Shwary.
|
|
5
|
+
Author-email: Josué Luis Panzu <josuepanzu8@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: africa,fintech,kenya,mobile-money,payment,rdc,shwary,uganda
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: httpx>=0.28.1
|
|
14
|
+
Requires-Dist: phonenumbers>=9.0.22
|
|
15
|
+
Requires-Dist: pydantic>=2.12.5
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Shwary Python SDK
|
|
19
|
+
|
|
20
|
+
[](https://pypi.org/project/shwary-python/)
|
|
21
|
+
[](https://pypi.org/project/shwary-python/)
|
|
22
|
+
[](https://opensource.org/licenses/MIT)
|
|
23
|
+
|
|
24
|
+
**Shwary Python** est une bibliothèque cliente moderne, asynchrone et performante (non officielle) pour l'intégration de l'API [Shwary](https://shwary.com). Elle permet d'initier des paiements Mobile Money en **RDC**, au **Kenya** et en **Ouganda** avec une validation stricte des données avant l'envoi.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Caractéristiques
|
|
29
|
+
|
|
30
|
+
* **Gestion d'erreurs native** : Pas besoin de vérifier les `status_code` manuellement. Le SDK lève des exceptions explicites (`AuthenticationError`, `ValidationError`, etc.).
|
|
31
|
+
* **Async-first** : Construit sur `httpx` pour des performances optimales (Pooling de connexions).
|
|
32
|
+
* **Dual-mode** : Support complet des modes Synchrone et Asynchrone.
|
|
33
|
+
* **Validation Robuste** : Vérification des numéros (E.164) et des montants minimums (ex: 2900 CDF pour la RDC).
|
|
34
|
+
* **Type-safe** : Basé sur Pydantic V2 pour une autocomplétion parfaite dans votre IDE.
|
|
35
|
+
* **Ultra-rapide** : Optimisé avec `uv` et `__slots__` pour minimiser l'empreinte mémoire.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Avec `uv` (recommandé) :
|
|
40
|
+
```bash
|
|
41
|
+
uv add shwary-python
|
|
42
|
+
```
|
|
43
|
+
Ou avec `pip`
|
|
44
|
+
```bash
|
|
45
|
+
pip install shwary-python
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Utilisation Rapide
|
|
49
|
+
### Initier un paiement (Async)
|
|
50
|
+
Le SDK gère les erreurs pour vous. Enveloppez simplement votre appel dans un bloc `try...except`.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
from shwary import ShwaryAsync
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
async with ShwaryAsync(
|
|
58
|
+
merchant_id="votre-uuid",
|
|
59
|
+
merchant_key="votre-cle-secrete",
|
|
60
|
+
is_sandbox=True
|
|
61
|
+
) as client:
|
|
62
|
+
try:
|
|
63
|
+
# Le SDK lève une exception si l'API répond avec une erreur
|
|
64
|
+
payment = await client.initiate_payment(
|
|
65
|
+
country="DRC",
|
|
66
|
+
amount=5000,
|
|
67
|
+
phone_number="+243972345678",
|
|
68
|
+
callback_url="[https://votre-site.com/webhooks/shwary](https://votre-site.com/webhooks/shwary)"
|
|
69
|
+
)
|
|
70
|
+
print(f"ID Transaction: {payment['id']}")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"Le paiement a échoué: {e}")
|
|
73
|
+
|
|
74
|
+
asyncio.run(main())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Initier un paiement (Sync)
|
|
78
|
+
```python
|
|
79
|
+
from shwary import Shwary
|
|
80
|
+
|
|
81
|
+
with Shwary(merchant_id="...", merchant_key="...", is_sandbox=False) as client:
|
|
82
|
+
response = client.initiate_payment(
|
|
83
|
+
country="KE",
|
|
84
|
+
amount=150.5,
|
|
85
|
+
phone_number="+254700000000"
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Vérifier une transaction
|
|
90
|
+
Si vous n'avez pas reçu de webhook ou souhaitez vérifier le statut actuel :
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
# Mode Synchrone
|
|
94
|
+
from shwary import Shwary
|
|
95
|
+
|
|
96
|
+
with Shwary(merchant_id="...", merchant_key="...") as client:
|
|
97
|
+
tx = client.get_transaction("votre-id-transaction")
|
|
98
|
+
print(f"Statut actuel : {tx['status']}")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Validation par pays
|
|
102
|
+
Le SDK applique les règles métiers de Shwary localement pour économiser des appels réseau :
|
|
103
|
+
|
|
104
|
+
| Pays | Code | Devise | Montant Min. |
|
|
105
|
+
| :--- | :--- | :--- | :--- |
|
|
106
|
+
| RDC | DRC | CDF | 2900 |
|
|
107
|
+
| Kenya | KE | KES | > 0 |
|
|
108
|
+
| Ouganda | UG | UGX | > 0 |
|
|
109
|
+
|
|
110
|
+
## Gestion des Webhooks (Callbacks)
|
|
111
|
+
Lorsque le statut d'un paiement change (ex: pending -> completed), Shwary envoie un POST JSON à votre callbackUrl. Voici comment traiter la charge utile avec les modèles du SDK (exemple FastAPI) :
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from fastapi import FastAPI, Request
|
|
115
|
+
|
|
116
|
+
app = FastAPI()
|
|
117
|
+
|
|
118
|
+
@app.post("/webhooks/shwary")
|
|
119
|
+
async def shwary_webhook(data: dict):
|
|
120
|
+
# Shwary envoie le même format que la réponse initiate_payment
|
|
121
|
+
status = data.get("status")
|
|
122
|
+
transaction_id = data.get("id")
|
|
123
|
+
|
|
124
|
+
if status == "completed":
|
|
125
|
+
# Livrez votre service ici
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return {"status": "ok"}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Gestion des Erreurs
|
|
132
|
+
Le SDK transforme les erreurs HTTP en exceptions Python. Vous n'avez pas besoin de vérifier manuellement les codes de statut, gérez simplement les exceptions :
|
|
133
|
+
|
|
134
|
+
| Exception | Cause |
|
|
135
|
+
| :--- | :--- |
|
|
136
|
+
| **ValidationError** | Données invalides (ex: montant < 2900 CDF en RDC, numéro mal formé). |
|
|
137
|
+
| **AuthenticationError** | Identifiants `merchant_id` ou `merchant_key` incorrects. |
|
|
138
|
+
| **ShwaryAPIError** | Erreur côté serveur Shwary ou problème réseau. |
|
|
139
|
+
| **ShwaryError** | Classe de base pour toutes les exceptions du SDK. |
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from shwary.exceptions import ValidationError, AuthenticationError, ShwaryAPIError
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
client.initiate_payment(...)
|
|
147
|
+
except ValidationError as e:
|
|
148
|
+
# Erreur de format téléphone ou montant insuffisant
|
|
149
|
+
print(f"Données invalides : {e}")
|
|
150
|
+
except AuthenticationError:
|
|
151
|
+
# Identifiants merchant_id / merchant_key invalides
|
|
152
|
+
print("Erreur d'authentification Shwary")
|
|
153
|
+
except ShwaryAPIError as e:
|
|
154
|
+
# Autres erreurs API (404, 500, etc.)
|
|
155
|
+
print(f"Erreur API {e.status_code}: {e.message}")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Développement
|
|
159
|
+
|
|
160
|
+
Pour contribuer au SDK :
|
|
161
|
+
|
|
162
|
+
1. Installez uv : curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
163
|
+
|
|
164
|
+
2. Installez les dépendances : uv sync
|
|
165
|
+
|
|
166
|
+
3. Lancez les tests : uv run pytest
|
|
167
|
+
|
|
168
|
+
### Licence
|
|
169
|
+
|
|
170
|
+
Distribué sous la licence MIT. Voir [](https://opensource.org/licenses/MIT) pour plus d'informations.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Shwary Python SDK
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/shwary-python/)
|
|
4
|
+
[](https://pypi.org/project/shwary-python/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**Shwary Python** est une bibliothèque cliente moderne, asynchrone et performante (non officielle) pour l'intégration de l'API [Shwary](https://shwary.com). Elle permet d'initier des paiements Mobile Money en **RDC**, au **Kenya** et en **Ouganda** avec une validation stricte des données avant l'envoi.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## Caractéristiques
|
|
12
|
+
|
|
13
|
+
* **Gestion d'erreurs native** : Pas besoin de vérifier les `status_code` manuellement. Le SDK lève des exceptions explicites (`AuthenticationError`, `ValidationError`, etc.).
|
|
14
|
+
* **Async-first** : Construit sur `httpx` pour des performances optimales (Pooling de connexions).
|
|
15
|
+
* **Dual-mode** : Support complet des modes Synchrone et Asynchrone.
|
|
16
|
+
* **Validation Robuste** : Vérification des numéros (E.164) et des montants minimums (ex: 2900 CDF pour la RDC).
|
|
17
|
+
* **Type-safe** : Basé sur Pydantic V2 pour une autocomplétion parfaite dans votre IDE.
|
|
18
|
+
* **Ultra-rapide** : Optimisé avec `uv` et `__slots__` pour minimiser l'empreinte mémoire.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Avec `uv` (recommandé) :
|
|
23
|
+
```bash
|
|
24
|
+
uv add shwary-python
|
|
25
|
+
```
|
|
26
|
+
Ou avec `pip`
|
|
27
|
+
```bash
|
|
28
|
+
pip install shwary-python
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Utilisation Rapide
|
|
32
|
+
### Initier un paiement (Async)
|
|
33
|
+
Le SDK gère les erreurs pour vous. Enveloppez simplement votre appel dans un bloc `try...except`.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from shwary import ShwaryAsync
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
async with ShwaryAsync(
|
|
41
|
+
merchant_id="votre-uuid",
|
|
42
|
+
merchant_key="votre-cle-secrete",
|
|
43
|
+
is_sandbox=True
|
|
44
|
+
) as client:
|
|
45
|
+
try:
|
|
46
|
+
# Le SDK lève une exception si l'API répond avec une erreur
|
|
47
|
+
payment = await client.initiate_payment(
|
|
48
|
+
country="DRC",
|
|
49
|
+
amount=5000,
|
|
50
|
+
phone_number="+243972345678",
|
|
51
|
+
callback_url="[https://votre-site.com/webhooks/shwary](https://votre-site.com/webhooks/shwary)"
|
|
52
|
+
)
|
|
53
|
+
print(f"ID Transaction: {payment['id']}")
|
|
54
|
+
except Exception as e:
|
|
55
|
+
print(f"Le paiement a échoué: {e}")
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Initier un paiement (Sync)
|
|
61
|
+
```python
|
|
62
|
+
from shwary import Shwary
|
|
63
|
+
|
|
64
|
+
with Shwary(merchant_id="...", merchant_key="...", is_sandbox=False) as client:
|
|
65
|
+
response = client.initiate_payment(
|
|
66
|
+
country="KE",
|
|
67
|
+
amount=150.5,
|
|
68
|
+
phone_number="+254700000000"
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Vérifier une transaction
|
|
73
|
+
Si vous n'avez pas reçu de webhook ou souhaitez vérifier le statut actuel :
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# Mode Synchrone
|
|
77
|
+
from shwary import Shwary
|
|
78
|
+
|
|
79
|
+
with Shwary(merchant_id="...", merchant_key="...") as client:
|
|
80
|
+
tx = client.get_transaction("votre-id-transaction")
|
|
81
|
+
print(f"Statut actuel : {tx['status']}")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Validation par pays
|
|
85
|
+
Le SDK applique les règles métiers de Shwary localement pour économiser des appels réseau :
|
|
86
|
+
|
|
87
|
+
| Pays | Code | Devise | Montant Min. |
|
|
88
|
+
| :--- | :--- | :--- | :--- |
|
|
89
|
+
| RDC | DRC | CDF | 2900 |
|
|
90
|
+
| Kenya | KE | KES | > 0 |
|
|
91
|
+
| Ouganda | UG | UGX | > 0 |
|
|
92
|
+
|
|
93
|
+
## Gestion des Webhooks (Callbacks)
|
|
94
|
+
Lorsque le statut d'un paiement change (ex: pending -> completed), Shwary envoie un POST JSON à votre callbackUrl. Voici comment traiter la charge utile avec les modèles du SDK (exemple FastAPI) :
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from fastapi import FastAPI, Request
|
|
98
|
+
|
|
99
|
+
app = FastAPI()
|
|
100
|
+
|
|
101
|
+
@app.post("/webhooks/shwary")
|
|
102
|
+
async def shwary_webhook(data: dict):
|
|
103
|
+
# Shwary envoie le même format que la réponse initiate_payment
|
|
104
|
+
status = data.get("status")
|
|
105
|
+
transaction_id = data.get("id")
|
|
106
|
+
|
|
107
|
+
if status == "completed":
|
|
108
|
+
# Livrez votre service ici
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
return {"status": "ok"}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Gestion des Erreurs
|
|
115
|
+
Le SDK transforme les erreurs HTTP en exceptions Python. Vous n'avez pas besoin de vérifier manuellement les codes de statut, gérez simplement les exceptions :
|
|
116
|
+
|
|
117
|
+
| Exception | Cause |
|
|
118
|
+
| :--- | :--- |
|
|
119
|
+
| **ValidationError** | Données invalides (ex: montant < 2900 CDF en RDC, numéro mal formé). |
|
|
120
|
+
| **AuthenticationError** | Identifiants `merchant_id` ou `merchant_key` incorrects. |
|
|
121
|
+
| **ShwaryAPIError** | Erreur côté serveur Shwary ou problème réseau. |
|
|
122
|
+
| **ShwaryError** | Classe de base pour toutes les exceptions du SDK. |
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from shwary.exceptions import ValidationError, AuthenticationError, ShwaryAPIError
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
client.initiate_payment(...)
|
|
130
|
+
except ValidationError as e:
|
|
131
|
+
# Erreur de format téléphone ou montant insuffisant
|
|
132
|
+
print(f"Données invalides : {e}")
|
|
133
|
+
except AuthenticationError:
|
|
134
|
+
# Identifiants merchant_id / merchant_key invalides
|
|
135
|
+
print("Erreur d'authentification Shwary")
|
|
136
|
+
except ShwaryAPIError as e:
|
|
137
|
+
# Autres erreurs API (404, 500, etc.)
|
|
138
|
+
print(f"Erreur API {e.status_code}: {e.message}")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Développement
|
|
142
|
+
|
|
143
|
+
Pour contribuer au SDK :
|
|
144
|
+
|
|
145
|
+
1. Installez uv : curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
146
|
+
|
|
147
|
+
2. Installez les dépendances : uv sync
|
|
148
|
+
|
|
149
|
+
3. Lancez les tests : uv run pytest
|
|
150
|
+
|
|
151
|
+
### Licence
|
|
152
|
+
|
|
153
|
+
Distribué sous la licence MIT. Voir [](https://opensource.org/licenses/MIT) pour plus d'informations.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "shwary-python"
|
|
3
|
+
version = "1.0.1"
|
|
4
|
+
description = "SDK Python moderne (Async/Sync) pour l'API de paiement Shwary."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Josué Luis Panzu", email = "josuepanzu8@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28.1",
|
|
12
|
+
"phonenumbers>=9.0.22",
|
|
13
|
+
"pydantic>=2.12.5",
|
|
14
|
+
]
|
|
15
|
+
license = { text = "MIT" }
|
|
16
|
+
keywords = ["shwary", "fintech", "payment", "mobile-money", "africa", "rdc", "kenya", "uganda"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=9.0.2",
|
|
31
|
+
"pytest-asyncio>=1.3.0",
|
|
32
|
+
"pytest-mock>=3.15.1",
|
|
33
|
+
"respx>=0.22.0",
|
|
34
|
+
"ruff>=0.14.14",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 88
|
|
43
|
+
select = ["E", "F", "I"] # Erreurs, Flakes, et tris d'Imports
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
packages = ["src/shwary"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/shwary"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from ..core import prepare_payment_request
|
|
3
|
+
from ..exceptions import raise_from_response
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ShwaryAsync:
|
|
7
|
+
"""
|
|
8
|
+
Client asynchrone haute performance pour l'API Shwary.
|
|
9
|
+
Doit être utilisé comme context manager ou fermé manuellement.
|
|
10
|
+
"""
|
|
11
|
+
__slots__ = ('merchant_id', 'merchant_key', 'is_sandbox', '_client', '_base_url')
|
|
12
|
+
|
|
13
|
+
def __init__(self, merchant_id: str, merchant_key: str, is_sandbox: bool = False, timeout: float = 30.0):
|
|
14
|
+
self.is_sandbox = is_sandbox
|
|
15
|
+
self.merchant_id = merchant_id
|
|
16
|
+
self.merchant_key = merchant_key
|
|
17
|
+
self._base_url = "https://api.shwary.com/api/v1/merchants"
|
|
18
|
+
self._client = httpx.AsyncClient(
|
|
19
|
+
base_url=self._base_url,
|
|
20
|
+
timeout=timeout,
|
|
21
|
+
headers={
|
|
22
|
+
"x-merchant-id": merchant_id,
|
|
23
|
+
"x-merchant-key": merchant_key,
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"User-Agent": "Shwary-Python-SDK/0.1.0"
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
async def close(self):
|
|
30
|
+
await self._client.aclose()
|
|
31
|
+
|
|
32
|
+
async def __aenter__(self):
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
36
|
+
await self.close()
|
|
37
|
+
|
|
38
|
+
async def initiate_payment(self, country: str, amount: float, phone_number: str, callback_url: str = None) -> dict:
|
|
39
|
+
"""Initie une demande de paiement."""
|
|
40
|
+
|
|
41
|
+
endpoint, json_data = prepare_payment_request(country, amount, phone_number, callback_url, self.is_sandbox)
|
|
42
|
+
|
|
43
|
+
response = await self._client.post(endpoint, json=json_data)
|
|
44
|
+
|
|
45
|
+
# Gestion centralisée des erreurs
|
|
46
|
+
raise_from_response(response)
|
|
47
|
+
|
|
48
|
+
return response.json()
|
|
49
|
+
|
|
50
|
+
async def get_transaction(self, transaction_id: str) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Récupère les détails d'une transaction unique à partir de son ID.
|
|
53
|
+
"""
|
|
54
|
+
endpoint: str = f"/transactions/{transaction_id}"
|
|
55
|
+
response = await self._client.get(endpoint)
|
|
56
|
+
|
|
57
|
+
raise_from_response(response)
|
|
58
|
+
|
|
59
|
+
return response.json()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from ..core import prepare_payment_request
|
|
3
|
+
from ..exceptions import raise_from_response
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Shwary:
|
|
7
|
+
"""
|
|
8
|
+
Client synchrone pour l'API Shwary.
|
|
9
|
+
À utiliser pour des scripts `standard, Django, Flask,` etc.
|
|
10
|
+
Doit être utilisé comme context manager ou fermé manuellement.
|
|
11
|
+
"""
|
|
12
|
+
__slots__ = ('merchant_id', 'merchant_key', 'is_sandbox', '_client', '_base_url')
|
|
13
|
+
|
|
14
|
+
def __init__(self, merchant_id: str, merchant_key: str, is_sandbox: bool = False, timeout: float = 30.0):
|
|
15
|
+
self.is_sandbox = is_sandbox
|
|
16
|
+
self.merchant_id = merchant_id
|
|
17
|
+
self.merchant_key = merchant_key
|
|
18
|
+
self._base_url = "https://api.shwary.com/api/v1/merchants"
|
|
19
|
+
self._client = httpx.Client(
|
|
20
|
+
base_url=self._base_url,
|
|
21
|
+
timeout=timeout,
|
|
22
|
+
headers={
|
|
23
|
+
"x-merchant-id": merchant_id,
|
|
24
|
+
"x-merchant-key": merchant_key,
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
"User-Agent": "Shwary-Python-SDK/0.1.0"
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def close(self):
|
|
31
|
+
self._client.close()
|
|
32
|
+
|
|
33
|
+
def __enter__(self):
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
37
|
+
self.close()
|
|
38
|
+
|
|
39
|
+
def initiate_payment(self, country: str, amount: float, phone_number: str, callback_url: str = None) -> dict:
|
|
40
|
+
"""Initie une demande de paiement."""
|
|
41
|
+
|
|
42
|
+
endpoint, json_data = prepare_payment_request(country, amount, phone_number, callback_url, self.is_sandbox)
|
|
43
|
+
|
|
44
|
+
response = self._client.post(endpoint, json=json_data)
|
|
45
|
+
|
|
46
|
+
raise_from_response(response)
|
|
47
|
+
|
|
48
|
+
return response.json()
|
|
49
|
+
|
|
50
|
+
def get_transaction(self, transaction_id: str) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Récupère les détails d'une transaction unique à partir de son ID.
|
|
53
|
+
"""
|
|
54
|
+
endpoint: str = f"/transactions/{transaction_id}"
|
|
55
|
+
response = self._client.get(endpoint)
|
|
56
|
+
|
|
57
|
+
raise_from_response(response)
|
|
58
|
+
|
|
59
|
+
return response.json()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from .schemas import PaymentPayload, CountryCode
|
|
3
|
+
from .exceptions import ValidationError
|
|
4
|
+
|
|
5
|
+
def prepare_payment_request(
|
|
6
|
+
country: CountryCode | str,
|
|
7
|
+
amount: float,
|
|
8
|
+
phone: str,
|
|
9
|
+
callback: str,
|
|
10
|
+
is_sandbox: bool
|
|
11
|
+
) -> tuple[str, dict[str, Any]]:
|
|
12
|
+
"""
|
|
13
|
+
Prépare l'URL et le Payload validé.
|
|
14
|
+
Partagé par les clients Sync et Async pour éviter la duplication de logique métier.
|
|
15
|
+
"""
|
|
16
|
+
# Validation & Conversion
|
|
17
|
+
try:
|
|
18
|
+
country_enum: CountryCode = CountryCode(country) if isinstance(country, str) else country
|
|
19
|
+
payload: PaymentPayload = PaymentPayload(
|
|
20
|
+
amount=amount,
|
|
21
|
+
clientPhoneNumber=phone,
|
|
22
|
+
callbackUrl=callback,
|
|
23
|
+
country_target=country_enum
|
|
24
|
+
)
|
|
25
|
+
except ValueError as e:
|
|
26
|
+
# On capture les erreurs Pydantic pour les relancer en ValidationError du SDK
|
|
27
|
+
raise ValidationError(str(e)) from e
|
|
28
|
+
|
|
29
|
+
env_path: str = "sandbox/" if is_sandbox else ""
|
|
30
|
+
endpoint: str = f"/payment/{env_path}{country_enum.value}"
|
|
31
|
+
|
|
32
|
+
return endpoint, payload.model_dump(exclude={'country_target'}, exclude_none=True)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
class ShwaryError(Exception):
|
|
2
|
+
"""Exception de base pour toutes les erreurs Shwary."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class ValidationError(ShwaryError):
|
|
6
|
+
"""Levée avant même l'envoi de la requête (ex: mauvais numéro)."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(ShwaryError):
|
|
10
|
+
"""Levée sur une erreur 401 (Clé API invalide)."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class InsufficientFundsError(ShwaryError):
|
|
14
|
+
"""Levée si le solde marchand est insuffisant ou règles métiers non respectées."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class ShwaryAPIError(ShwaryError):
|
|
18
|
+
"""Erreur générique retournée par l'API (400, 500, etc.)."""
|
|
19
|
+
def __init__(self, status_code: int, message: str, raw_response: dict = None):
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.raw_response = raw_response
|
|
22
|
+
super().__init__(f"Shwary Error {status_code}: {message}")
|
|
23
|
+
|
|
24
|
+
def raise_from_response(response):
|
|
25
|
+
"""Helper pour convertir une réponse HTTP en exception Python."""
|
|
26
|
+
if response.is_success:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
status = response.status_code
|
|
30
|
+
try:
|
|
31
|
+
data = response.json()
|
|
32
|
+
message = data.get("message", response.text)
|
|
33
|
+
except Exception:
|
|
34
|
+
message = response.text
|
|
35
|
+
|
|
36
|
+
if status == 401:
|
|
37
|
+
raise AuthenticationError(f"Échec d'authentification : {message}")
|
|
38
|
+
elif status == 400 and "balance" in message.lower():
|
|
39
|
+
raise InsufficientFundsError(message)
|
|
40
|
+
else:
|
|
41
|
+
raise ShwaryAPIError(status, message, raw_response=data if 'data' in locals() else None)
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import phonenumbers
|
|
4
|
+
from phonenumbers import NumberParseException
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
class CountryCode(str, Enum):
|
|
8
|
+
DRC = "DRC"
|
|
9
|
+
KENYA = "KE"
|
|
10
|
+
UGANDA = "UG"
|
|
11
|
+
|
|
12
|
+
class PaymentPayload(BaseModel):
|
|
13
|
+
amount: float = Field(..., gt=0, description="Montant de la transaction")
|
|
14
|
+
clientPhoneNumber: str = Field(..., description="Numéro au format international")
|
|
15
|
+
callbackUrl: Optional[str] = None
|
|
16
|
+
country_target: CountryCode = Field(..., exclude=True)
|
|
17
|
+
|
|
18
|
+
@field_validator("clientPhoneNumber")
|
|
19
|
+
@classmethod
|
|
20
|
+
def validate_phone(cls, v: str) -> str:
|
|
21
|
+
try:
|
|
22
|
+
# LazyParsing pour accepter +243... ou 243...
|
|
23
|
+
parsed = phonenumbers.parse(v, None)
|
|
24
|
+
if not phonenumbers.is_valid_number(parsed):
|
|
25
|
+
raise ValueError("Numéro de téléphone invalide.")
|
|
26
|
+
|
|
27
|
+
# Formatage strict E.164 (ex: +24381...) requis par Shwary
|
|
28
|
+
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
|
29
|
+
except NumberParseException:
|
|
30
|
+
raise ValueError("Format de numéro impossible à analyser.")
|
|
31
|
+
|
|
32
|
+
@model_validator(mode='after')
|
|
33
|
+
def validate_country_consistency(self):
|
|
34
|
+
"""
|
|
35
|
+
Fait une validation croisée :
|
|
36
|
+
On ne peut pas envoyer un numéro +254 (Kenya) vers l'endpoint DRC
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
phone: str = self.clientPhoneNumber
|
|
40
|
+
country: CountryCode = self.country_target
|
|
41
|
+
|
|
42
|
+
prefixes: dict[CountryCode, str] = {
|
|
43
|
+
CountryCode.DRC: "+243",
|
|
44
|
+
CountryCode.KENYA: "+254",
|
|
45
|
+
CountryCode.UGANDA: "+256"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
expected_prefix = prefixes.get(country)
|
|
49
|
+
if expected_prefix and not phone.startswith(expected_prefix):
|
|
50
|
+
raise ValueError(f"Le numéro {phone} ne correspond pas au pays ciblé ({country.value}).")
|
|
51
|
+
|
|
52
|
+
# Règle métier Shwary : Montant min pour RDC
|
|
53
|
+
if country == CountryCode.DRC and self.amount < 2900:
|
|
54
|
+
raise ValueError("Le montant minimum pour la RDC est de 2900 CDF.")
|
|
55
|
+
|
|
56
|
+
return self
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import respx
|
|
3
|
+
from httpx import Response
|
|
4
|
+
from shwary import Shwary
|
|
5
|
+
from shwary.exceptions import ValidationError, AuthenticationError
|
|
6
|
+
|
|
7
|
+
# --- FIXTURES (Données de test) ---
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def client() -> Shwary:
|
|
10
|
+
"""Crée un client synchrone pour les tests."""
|
|
11
|
+
return Shwary(
|
|
12
|
+
merchant_id="merchant-test-id",
|
|
13
|
+
merchant_key="merchant-test-key",
|
|
14
|
+
is_sandbox=True
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_validation_fail_fast(client):
|
|
19
|
+
"""Vérifie que le SDK bloque les requêtes invalides localement."""
|
|
20
|
+
# Test numéro invalide
|
|
21
|
+
with pytest.raises(ValidationError) as exc:
|
|
22
|
+
client.initiate_payment("DRC", 5000, "ceci n'est pas un numéro")
|
|
23
|
+
|
|
24
|
+
# Vérification de la présence d'un mot clé réellement présent dans le message d'erreur
|
|
25
|
+
error_msg = str(exc.value)
|
|
26
|
+
assert "impossible à analyser" in error_msg or "invalide" in error_msg
|
|
27
|
+
|
|
28
|
+
# Test montant trop bas pour la RDC
|
|
29
|
+
with pytest.raises(ValidationError) as exc:
|
|
30
|
+
client.initiate_payment("DRC", 100, "+243840000000")
|
|
31
|
+
assert "2900" in str(exc.value)
|
|
32
|
+
|
|
33
|
+
@respx.mock
|
|
34
|
+
def test_initiate_payment_success(client):
|
|
35
|
+
"""Simule une réponse 200 OK de l'API Shwary."""
|
|
36
|
+
|
|
37
|
+
# On définit ce que l'API est censée répondre
|
|
38
|
+
mock_response: dict[str, bool | str] = {
|
|
39
|
+
"id": "trans-123",
|
|
40
|
+
"status": "pending",
|
|
41
|
+
"isSandbox": True
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Interception de la requête POST vers l'URL sandbox
|
|
45
|
+
route = respx.post("https://api.shwary.com/api/v1/merchants/payment/sandbox/DRC").mock(
|
|
46
|
+
return_value=Response(200, json=mock_response)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# On initialise la demande de paiement
|
|
50
|
+
response = client.initiate_payment("DRC", 5000, "+243972345678")
|
|
51
|
+
|
|
52
|
+
# Vérifications
|
|
53
|
+
assert route.called
|
|
54
|
+
assert response["id"] == "trans-123"
|
|
55
|
+
assert response["status"] == "pending"
|
|
56
|
+
|
|
57
|
+
@respx.mock
|
|
58
|
+
def test_authentication_error(client):
|
|
59
|
+
"""Simule une erreur 401 (Mauvaise clé API)."""
|
|
60
|
+
|
|
61
|
+
respx.post("https://api.shwary.com/api/v1/merchants/payment/sandbox/KE").mock(
|
|
62
|
+
return_value=Response(401, json={"message": "Invalid merchant key"})
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
with pytest.raises(AuthenticationError) as exc:
|
|
66
|
+
client.initiate_payment("KE", 1000, "+254700000000")
|
|
67
|
+
|
|
68
|
+
assert "Invalid merchant key" in str(exc.value)
|
|
69
|
+
|
|
70
|
+
@respx.mock
|
|
71
|
+
def test_get_transaction_success(client):
|
|
72
|
+
"""Vérifie la récupération d'une transaction par ID."""
|
|
73
|
+
transaction_id = "c0fdfe50-24be-4de1-9f66-84608fd45a5f"
|
|
74
|
+
|
|
75
|
+
mock_response: dict[str, int | str] = {
|
|
76
|
+
"id": transaction_id,
|
|
77
|
+
"status": "completed",
|
|
78
|
+
"amount": 5000
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# On intercepte le GET
|
|
82
|
+
route = respx.get(f"https://api.shwary.com/api/v1/merchants/transactions/{transaction_id}").mock(
|
|
83
|
+
return_value=Response(200, json=mock_response)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
response = client.get_transaction(transaction_id)
|
|
87
|
+
|
|
88
|
+
assert route.called
|
|
89
|
+
assert response["id"] == transaction_id
|
|
90
|
+
assert response["status"] == "completed"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import respx
|
|
3
|
+
from httpx import Response
|
|
4
|
+
from shwary import ShwaryAsync
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def async_client() -> ShwaryAsync:
|
|
8
|
+
"""Crée un client asynchrone pour les tests."""
|
|
9
|
+
return ShwaryAsync(
|
|
10
|
+
merchant_id="merchant-test-id",
|
|
11
|
+
merchant_key="merchant-test-key",
|
|
12
|
+
is_sandbox=True
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
@pytest.mark.asyncio
|
|
16
|
+
@respx.mock
|
|
17
|
+
async def test_async_payment_success(async_client):
|
|
18
|
+
"""Vérifie que le client Async fonctionne aussi."""
|
|
19
|
+
|
|
20
|
+
mock_response: dict[str, str] = {"id": "async-123", "status": "completed"}
|
|
21
|
+
|
|
22
|
+
respx.post("https://api.shwary.com/api/v1/merchants/payment/sandbox/UG").mock(
|
|
23
|
+
return_value=Response(200, json=mock_response)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Notez l'utilisation de 'async with' et 'await'
|
|
27
|
+
async with async_client as c:
|
|
28
|
+
response = await c.initiate_payment("UG", 5000, "+256700000000")
|
|
29
|
+
|
|
30
|
+
assert response["id"] == "async-123"
|