paywallex 1.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.
- paywallex-1.0.0/LICENSE +9 -0
- paywallex-1.0.0/PKG-INFO +132 -0
- paywallex-1.0.0/README.md +116 -0
- paywallex-1.0.0/paywallex/__init__.py +183 -0
- paywallex-1.0.0/paywallex.egg-info/PKG-INFO +132 -0
- paywallex-1.0.0/paywallex.egg-info/SOURCES.txt +9 -0
- paywallex-1.0.0/paywallex.egg-info/dependency_links.txt +1 -0
- paywallex-1.0.0/paywallex.egg-info/top_level.txt +1 -0
- paywallex-1.0.0/pyproject.toml +20 -0
- paywallex-1.0.0/setup.cfg +4 -0
- paywallex-1.0.0/tests/test_paywallex.py +77 -0
paywallex-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paywallex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
paywallex-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paywallex
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK serveur officiel de l'API Paywallex /v1 : sessions de paiement Mobile Money, remboursements, litiges, abonnements, webhooks signés.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://paywallex.app/developpeurs
|
|
7
|
+
Project-URL: Source, https://github.com/Paywallex/paywallex-kit/tree/dev/sdk/python
|
|
8
|
+
Keywords: paywallex,paiement,mobile money,orange money,mtn momo,wave,xof
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# paywallex (Python)
|
|
18
|
+
|
|
19
|
+
SDK serveur officiel de l'API Paywallex `/v1` pour Python 3.9 ou plus récent. Il sert à encaisser par Orange Money, MTN MoMo, Moov Money, Wave ou le solde Paywallex, à rembourser, à suivre les litiges et les abonnements, et à vérifier les webhooks signés.
|
|
20
|
+
|
|
21
|
+
Aucune dépendance : seule la bibliothèque standard est utilisée.
|
|
22
|
+
|
|
23
|
+
> Côté serveur uniquement. Ne mettez jamais une clé secrète `pwx_sk_…` dans une page web ou une application mobile.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Installez le paquet :
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install paywallex
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Créer une session de paiement
|
|
34
|
+
|
|
35
|
+
Passez votre clé à la création du client. Une clé `pwx_sk_test_…` vise le bac à sable, une clé `pwx_sk_live_…` vise la production.
|
|
36
|
+
|
|
37
|
+
Créez une session, puis redirigez le client vers `url` :
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import os
|
|
41
|
+
from paywallex import Paywallex
|
|
42
|
+
|
|
43
|
+
paywallex = Paywallex(os.environ["PAYWALLEX_KEY"])
|
|
44
|
+
|
|
45
|
+
session = paywallex.checkout_sessions.create(
|
|
46
|
+
currency="XOF",
|
|
47
|
+
line_items=[
|
|
48
|
+
{"name": "Attiéké poisson", "unit_amount": 2500, "quantity": 2},
|
|
49
|
+
{"name": "Jus de bissap", "unit_amount": 500},
|
|
50
|
+
],
|
|
51
|
+
success_url="https://maboutique.ci/merci",
|
|
52
|
+
metadata={"order": "CMD-1042"},
|
|
53
|
+
)
|
|
54
|
+
# session["amount"] == 5500, redirigez vers session["url"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Les montants sont en XOF entiers : `2500` veut dire 2 500 F.
|
|
58
|
+
|
|
59
|
+
## Vérifier un webhook
|
|
60
|
+
|
|
61
|
+
Vérifiez la signature avant de traiter un événement. Le SDK compare en temps constant et refuse un horodatage de plus de 5 minutes.
|
|
62
|
+
|
|
63
|
+
Passez-lui le corps brut de la requête, pas un dictionnaire déjà décodé :
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from flask import Flask, request
|
|
67
|
+
from paywallex import Webhook, PaywallexError
|
|
68
|
+
|
|
69
|
+
app = Flask(__name__)
|
|
70
|
+
|
|
71
|
+
@app.post("/webhooks/paywallex")
|
|
72
|
+
def paywallex_webhook():
|
|
73
|
+
try:
|
|
74
|
+
event = Webhook.construct_event(request.get_data(), request.headers.get("Paywallex-Signature"), os.environ["PAYWALLEX_WEBHOOK_SECRET"])
|
|
75
|
+
except PaywallexError:
|
|
76
|
+
return "", 400
|
|
77
|
+
if event["type"] == "checkout_session.completed":
|
|
78
|
+
pass # livrer la commande
|
|
79
|
+
return "", 200
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Les erreurs
|
|
83
|
+
|
|
84
|
+
Chaque erreur est une `PaywallexError` avec un code stable. Testez le code, jamais le message :
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from paywallex import PaywallexError
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
paywallex.refunds.create(payment=payment_id, amount=1000)
|
|
91
|
+
except PaywallexError as e:
|
|
92
|
+
if e.code == "refund_not_eligible":
|
|
93
|
+
...
|
|
94
|
+
print(e.status, e.code, e.param, e.request_id)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Passez `language="en"` au client pour recevoir les messages en anglais.
|
|
98
|
+
|
|
99
|
+
## Nouvelles tentatives et idempotence
|
|
100
|
+
|
|
101
|
+
Le SDK réessaie 2 fois en cas de coupure réseau, de `429` ou d'erreur `5xx`, en respectant `Retry-After`. Chaque `POST` garde la même clé d'idempotence d'une tentative à l'autre : un paiement ou un remboursement n'est jamais doublé.
|
|
102
|
+
|
|
103
|
+
Pour rejouer vous-même une requête sans risque, donnez votre propre clé :
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
paywallex.refunds.create(payment=payment_id, idempotency_key="remboursement-CMD-1042")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Ce que couvre le SDK
|
|
110
|
+
|
|
111
|
+
| Objet | Méthodes |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `checkout_sessions` | `create`, `retrieve` |
|
|
114
|
+
| `payments`, `refunds`, `disputes` | `list`, `retrieve`, `all` (toutes les pages) ; `refunds.create` |
|
|
115
|
+
| `payouts`, `events`, `statements` | `list`, `retrieve` |
|
|
116
|
+
| `customers`, `payment_links`, `invoices`, `pots`, `plans`, `coupons`, `subscriptions` | `list`, `create`, `retrieve`, `update`, `action` |
|
|
117
|
+
| `webhook_endpoints` | `list`, `create`, `action("rotate_secret")`, `delete_webhook_endpoint()` |
|
|
118
|
+
| client | `account()`, `balance()`, `network_status()`, `upload_file()`, `add_dispute_evidence()` |
|
|
119
|
+
|
|
120
|
+
Accepter un litige (rembourser) ou le contester se fait dans le portail Paywallex, jamais par l'API. Un abonnement ou une hausse de prix est toujours accepté par le client dans Paywallex.
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
Lancez les tests (le bac à sable est testé si `PAYWALLEX_TEST_KEY` est défini) :
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
python -m unittest discover tests
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Licence
|
|
131
|
+
|
|
132
|
+
MIT.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# paywallex (Python)
|
|
2
|
+
|
|
3
|
+
SDK serveur officiel de l'API Paywallex `/v1` pour Python 3.9 ou plus récent. Il sert à encaisser par Orange Money, MTN MoMo, Moov Money, Wave ou le solde Paywallex, à rembourser, à suivre les litiges et les abonnements, et à vérifier les webhooks signés.
|
|
4
|
+
|
|
5
|
+
Aucune dépendance : seule la bibliothèque standard est utilisée.
|
|
6
|
+
|
|
7
|
+
> Côté serveur uniquement. Ne mettez jamais une clé secrète `pwx_sk_…` dans une page web ou une application mobile.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Installez le paquet :
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install paywallex
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Créer une session de paiement
|
|
18
|
+
|
|
19
|
+
Passez votre clé à la création du client. Une clé `pwx_sk_test_…` vise le bac à sable, une clé `pwx_sk_live_…` vise la production.
|
|
20
|
+
|
|
21
|
+
Créez une session, puis redirigez le client vers `url` :
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import os
|
|
25
|
+
from paywallex import Paywallex
|
|
26
|
+
|
|
27
|
+
paywallex = Paywallex(os.environ["PAYWALLEX_KEY"])
|
|
28
|
+
|
|
29
|
+
session = paywallex.checkout_sessions.create(
|
|
30
|
+
currency="XOF",
|
|
31
|
+
line_items=[
|
|
32
|
+
{"name": "Attiéké poisson", "unit_amount": 2500, "quantity": 2},
|
|
33
|
+
{"name": "Jus de bissap", "unit_amount": 500},
|
|
34
|
+
],
|
|
35
|
+
success_url="https://maboutique.ci/merci",
|
|
36
|
+
metadata={"order": "CMD-1042"},
|
|
37
|
+
)
|
|
38
|
+
# session["amount"] == 5500, redirigez vers session["url"]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Les montants sont en XOF entiers : `2500` veut dire 2 500 F.
|
|
42
|
+
|
|
43
|
+
## Vérifier un webhook
|
|
44
|
+
|
|
45
|
+
Vérifiez la signature avant de traiter un événement. Le SDK compare en temps constant et refuse un horodatage de plus de 5 minutes.
|
|
46
|
+
|
|
47
|
+
Passez-lui le corps brut de la requête, pas un dictionnaire déjà décodé :
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from flask import Flask, request
|
|
51
|
+
from paywallex import Webhook, PaywallexError
|
|
52
|
+
|
|
53
|
+
app = Flask(__name__)
|
|
54
|
+
|
|
55
|
+
@app.post("/webhooks/paywallex")
|
|
56
|
+
def paywallex_webhook():
|
|
57
|
+
try:
|
|
58
|
+
event = Webhook.construct_event(request.get_data(), request.headers.get("Paywallex-Signature"), os.environ["PAYWALLEX_WEBHOOK_SECRET"])
|
|
59
|
+
except PaywallexError:
|
|
60
|
+
return "", 400
|
|
61
|
+
if event["type"] == "checkout_session.completed":
|
|
62
|
+
pass # livrer la commande
|
|
63
|
+
return "", 200
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Les erreurs
|
|
67
|
+
|
|
68
|
+
Chaque erreur est une `PaywallexError` avec un code stable. Testez le code, jamais le message :
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from paywallex import PaywallexError
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
paywallex.refunds.create(payment=payment_id, amount=1000)
|
|
75
|
+
except PaywallexError as e:
|
|
76
|
+
if e.code == "refund_not_eligible":
|
|
77
|
+
...
|
|
78
|
+
print(e.status, e.code, e.param, e.request_id)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Passez `language="en"` au client pour recevoir les messages en anglais.
|
|
82
|
+
|
|
83
|
+
## Nouvelles tentatives et idempotence
|
|
84
|
+
|
|
85
|
+
Le SDK réessaie 2 fois en cas de coupure réseau, de `429` ou d'erreur `5xx`, en respectant `Retry-After`. Chaque `POST` garde la même clé d'idempotence d'une tentative à l'autre : un paiement ou un remboursement n'est jamais doublé.
|
|
86
|
+
|
|
87
|
+
Pour rejouer vous-même une requête sans risque, donnez votre propre clé :
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
paywallex.refunds.create(payment=payment_id, idempotency_key="remboursement-CMD-1042")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Ce que couvre le SDK
|
|
94
|
+
|
|
95
|
+
| Objet | Méthodes |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `checkout_sessions` | `create`, `retrieve` |
|
|
98
|
+
| `payments`, `refunds`, `disputes` | `list`, `retrieve`, `all` (toutes les pages) ; `refunds.create` |
|
|
99
|
+
| `payouts`, `events`, `statements` | `list`, `retrieve` |
|
|
100
|
+
| `customers`, `payment_links`, `invoices`, `pots`, `plans`, `coupons`, `subscriptions` | `list`, `create`, `retrieve`, `update`, `action` |
|
|
101
|
+
| `webhook_endpoints` | `list`, `create`, `action("rotate_secret")`, `delete_webhook_endpoint()` |
|
|
102
|
+
| client | `account()`, `balance()`, `network_status()`, `upload_file()`, `add_dispute_evidence()` |
|
|
103
|
+
|
|
104
|
+
Accepter un litige (rembourser) ou le contester se fait dans le portail Paywallex, jamais par l'API. Un abonnement ou une hausse de prix est toujours accepté par le client dans Paywallex.
|
|
105
|
+
|
|
106
|
+
## Tests
|
|
107
|
+
|
|
108
|
+
Lancez les tests (le bac à sable est testé si `PAYWALLEX_TEST_KEY` est défini) :
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
python -m unittest discover tests
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Licence
|
|
115
|
+
|
|
116
|
+
MIT.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""SDK serveur officiel de l'API Paywallex /v1 (Python 3.9+, sans dépendance).
|
|
2
|
+
|
|
3
|
+
Côté serveur uniquement : ne mettez jamais une clé secrète pwx_sk_… dans une page web ou une app mobile.
|
|
4
|
+
Signé : Superagent (DV3, 24 oct. 2026).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0"
|
|
20
|
+
__all__ = ["Paywallex", "PaywallexError", "Webhook", "__version__"]
|
|
21
|
+
|
|
22
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], Tuple[int, Dict[str, str], bytes]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PaywallexError(Exception):
|
|
26
|
+
"""Erreur de l'API : testez ``code`` (stable), jamais le message."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, status: int, code: str, param: Optional[str] = None, request_id: Optional[str] = None):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message, self.status, self.code, self.param, self.request_id = message, status, code, param, request_id
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Webhook:
|
|
34
|
+
@staticmethod
|
|
35
|
+
def construct_event(raw_body: bytes | str, signature_header: Optional[str], secret: str, tolerance_seconds: int = 300) -> Dict[str, Any]:
|
|
36
|
+
"""Vérifie l'en-tête Paywallex-Signature (t=…,v1=…). Passez le corps BRUT, jamais un dict déjà décodé."""
|
|
37
|
+
m = re.fullmatch(r"t=([0-9]+),v1=([a-f0-9]{64})", (signature_header or "").strip())
|
|
38
|
+
if not m:
|
|
39
|
+
raise PaywallexError("Signature absente ou mal formée.", 400, "invalid_signature")
|
|
40
|
+
if abs(time.time() - int(m.group(1))) > tolerance_seconds:
|
|
41
|
+
raise PaywallexError("Signature expirée.", 400, "signature_expired")
|
|
42
|
+
body = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
|
|
43
|
+
expected = hmac.new(secret.encode(), f"{m.group(1)}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
44
|
+
if not hmac.compare_digest(expected, m.group(2)):
|
|
45
|
+
raise PaywallexError("Signature invalide.", 400, "invalid_signature")
|
|
46
|
+
return json.loads(body)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Service:
|
|
50
|
+
"""Un objet de l'API (/v1/<chemin>) : list, create, retrieve, update, action, all."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, client: "Paywallex", path: str, actions: Tuple[str, ...] = ()):
|
|
53
|
+
self._c, self._path, self._actions = client, path, actions
|
|
54
|
+
|
|
55
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
56
|
+
return self._c.request("GET", f"/v1/{self._path}", params)
|
|
57
|
+
|
|
58
|
+
def create(self, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
59
|
+
return self._c.request("POST", f"/v1/{self._path}", params, idempotency_key)
|
|
60
|
+
|
|
61
|
+
def retrieve(self, id: str) -> Dict[str, Any]:
|
|
62
|
+
return self._c.request("GET", f"/v1/{self._path}/{urllib.parse.quote(id, safe='')}")
|
|
63
|
+
|
|
64
|
+
def update(self, id: str, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
65
|
+
return self._c.request("POST", f"/v1/{self._path}/{urllib.parse.quote(id, safe='')}", params, idempotency_key)
|
|
66
|
+
|
|
67
|
+
def action(self, id: str, action: str, idempotency_key: Optional[str] = None, **params: Any) -> Dict[str, Any]:
|
|
68
|
+
if action not in self._actions:
|
|
69
|
+
raise PaywallexError(f"Action inconnue : {action}. Possibles : {', '.join(self._actions)}.", 0, "configuration_error")
|
|
70
|
+
return self._c.request("POST", f"/v1/{self._path}/{urllib.parse.quote(id, safe='')}/{action}", params, idempotency_key)
|
|
71
|
+
|
|
72
|
+
def all(self, **params: Any) -> Iterator[Dict[str, Any]]:
|
|
73
|
+
"""Parcourt toutes les pages : for p in client.payments.all(): …"""
|
|
74
|
+
after = None
|
|
75
|
+
for _ in range(10000):
|
|
76
|
+
page = self.list(**params, **({"starting_after": after} if after else {}))
|
|
77
|
+
data = page.get("data") or []
|
|
78
|
+
yield from data
|
|
79
|
+
if not page.get("has_more") or not data:
|
|
80
|
+
return
|
|
81
|
+
after = data[-1]["id"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_SERVICES = {
|
|
85
|
+
"checkout_sessions": ("checkout_sessions", ()), "payments": ("payments", ()), "refunds": ("refunds", ()),
|
|
86
|
+
"disputes": ("disputes", ()), "payouts": ("payouts", ()), "events": ("events", ()),
|
|
87
|
+
"customers": ("customers", ("archive",)), "payment_links": ("payment_links", ("cancel",)),
|
|
88
|
+
"invoices": ("invoices", ("issue", "cancel")), "pots": ("pots", ("close", "cancel")), "statements": ("statements", ()),
|
|
89
|
+
"plans": ("plans", ()), "coupons": ("coupons", ("archive",)),
|
|
90
|
+
"subscriptions": ("subscriptions", ("cancel", "pause", "resume", "change_plan", "manage_link")),
|
|
91
|
+
"webhook_endpoints": ("webhook_endpoints", ("rotate_secret",)),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Paywallex:
|
|
96
|
+
"""Client de l'API /v1. Passez votre clé explicitement : pwx_sk_test_… (bac à sable) ou pwx_sk_live_… (production)."""
|
|
97
|
+
|
|
98
|
+
checkout_sessions: Service; payments: Service; refunds: Service; disputes: Service; payouts: Service; events: Service
|
|
99
|
+
customers: Service; payment_links: Service; invoices: Service; pots: Service; statements: Service
|
|
100
|
+
plans: Service; coupons: Service; subscriptions: Service; webhook_endpoints: Service
|
|
101
|
+
|
|
102
|
+
def __init__(self, api_key: str, *, base_url: str = "https://api.paywallex.app", timeout: float = 30, max_retries: int = 2,
|
|
103
|
+
language: str = "fr", transport: Optional[Transport] = None):
|
|
104
|
+
if not api_key:
|
|
105
|
+
raise PaywallexError("Clé manquante : passez votre clé secrète pwx_sk_… explicitement.", 0, "configuration_error")
|
|
106
|
+
self._key, self._base, self._timeout, self._retries, self._lang = api_key, base_url.rstrip("/"), timeout, max_retries, language
|
|
107
|
+
self._transport = transport or self._urllib
|
|
108
|
+
for attr, (path, actions) in _SERVICES.items():
|
|
109
|
+
setattr(self, attr, Service(self, path, actions))
|
|
110
|
+
|
|
111
|
+
def account(self) -> Dict[str, Any]:
|
|
112
|
+
return self.request("GET", "/v1/account")
|
|
113
|
+
|
|
114
|
+
def balance(self) -> Dict[str, Any]:
|
|
115
|
+
return self.request("GET", "/v1/balance")
|
|
116
|
+
|
|
117
|
+
def network_status(self) -> Dict[str, Any]:
|
|
118
|
+
"""Réseaux Mobile Money du pays : masquez ceux qui sont « unavailable » avant le paiement."""
|
|
119
|
+
return self.request("GET", "/v1/network_status")
|
|
120
|
+
|
|
121
|
+
def add_dispute_evidence(self, dispute_id: str, text: str, files: Optional[List[str]] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
122
|
+
return self.request("POST", f"/v1/disputes/{urllib.parse.quote(dispute_id, safe='')}/evidence",
|
|
123
|
+
{"text": text, **({"files": files} if files else {})}, idempotency_key)
|
|
124
|
+
|
|
125
|
+
def upload_file(self, name: str, content_type: str, data: bytes) -> Dict[str, Any]:
|
|
126
|
+
"""Dépose un justificatif PDF, PNG ou JPEG (5 Mo max) et renvoie son id."""
|
|
127
|
+
f = self.request("POST", "/v1/files", {"name": name, "content_type": content_type, "size": len(data)})
|
|
128
|
+
status, _, _ = self._transport("PUT", f["upload_url"], {"Content-Type": content_type}, data)
|
|
129
|
+
if not 200 <= status < 300:
|
|
130
|
+
raise PaywallexError(f"Dépôt du fichier refusé ({status}).", status, "file_upload_failed")
|
|
131
|
+
return f
|
|
132
|
+
|
|
133
|
+
def delete_webhook_endpoint(self, id: str) -> Dict[str, Any]:
|
|
134
|
+
return self.request("DELETE", f"/v1/webhook_endpoints/{urllib.parse.quote(id, safe='')}")
|
|
135
|
+
|
|
136
|
+
def request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
137
|
+
params = {k: v for k, v in (params or {}).items() if v is not None}
|
|
138
|
+
url, body = self._base + path, None
|
|
139
|
+
if method == "GET" and params:
|
|
140
|
+
url += "?" + urllib.parse.urlencode({k: (str(v).lower() if isinstance(v, bool) else v) for k, v in params.items()})
|
|
141
|
+
elif method != "GET":
|
|
142
|
+
body = json.dumps(params, ensure_ascii=False).encode("utf-8")
|
|
143
|
+
# Une seule clé d'idempotence pour toutes les tentatives : un POST rejoué n'est jamais doublé.
|
|
144
|
+
idem = (idempotency_key or str(uuid.uuid4())) if method == "POST" else None
|
|
145
|
+
headers = {"Authorization": f"Bearer {self._key}", "Accept-Language": self._lang,
|
|
146
|
+
"User-Agent": f"paywallex-python/{__version__}", "Paywallex-Sdk": f"python/{__version__}"}
|
|
147
|
+
if body is not None:
|
|
148
|
+
headers["Content-Type"] = "application/json"
|
|
149
|
+
if idem:
|
|
150
|
+
headers["Idempotency-Key"] = idem
|
|
151
|
+
attempt = 0
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
status, res_headers, raw = self._transport(method, url, headers, body)
|
|
155
|
+
except OSError as e:
|
|
156
|
+
if attempt < self._retries:
|
|
157
|
+
time.sleep(0.5 * 2 ** attempt); attempt += 1; continue
|
|
158
|
+
raise PaywallexError(f"Paywallex injoignable : {e}", 0, "network_error") from e
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(raw.decode("utf-8")) if raw else {}
|
|
161
|
+
except ValueError:
|
|
162
|
+
data = {}
|
|
163
|
+
if not isinstance(data, dict):
|
|
164
|
+
data = {}
|
|
165
|
+
if 200 <= status < 300:
|
|
166
|
+
return data
|
|
167
|
+
if (status == 429 or status >= 500) and attempt < self._retries:
|
|
168
|
+
try:
|
|
169
|
+
ra = float(res_headers.get("retry-after", "0"))
|
|
170
|
+
except ValueError:
|
|
171
|
+
ra = 0
|
|
172
|
+
time.sleep(min(ra, 30) if ra > 0 else 0.5 * 2 ** attempt); attempt += 1; continue
|
|
173
|
+
err = data.get("error") if isinstance(data.get("error"), dict) else {}
|
|
174
|
+
raise PaywallexError(err.get("message") or f"Requête refusée ({status}).", status, err.get("code") or "unknown_error",
|
|
175
|
+
err.get("param"), err.get("request_id") or res_headers.get("request-id"))
|
|
176
|
+
|
|
177
|
+
def _urllib(self, method: str, url: str, headers: Dict[str, str], body: Optional[bytes]) -> Tuple[int, Dict[str, str], bytes]:
|
|
178
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
179
|
+
try:
|
|
180
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as r:
|
|
181
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
|
|
182
|
+
except urllib.error.HTTPError as e:
|
|
183
|
+
return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paywallex
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK serveur officiel de l'API Paywallex /v1 : sessions de paiement Mobile Money, remboursements, litiges, abonnements, webhooks signés.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://paywallex.app/developpeurs
|
|
7
|
+
Project-URL: Source, https://github.com/Paywallex/paywallex-kit/tree/dev/sdk/python
|
|
8
|
+
Keywords: paywallex,paiement,mobile money,orange money,mtn momo,wave,xof
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# paywallex (Python)
|
|
18
|
+
|
|
19
|
+
SDK serveur officiel de l'API Paywallex `/v1` pour Python 3.9 ou plus récent. Il sert à encaisser par Orange Money, MTN MoMo, Moov Money, Wave ou le solde Paywallex, à rembourser, à suivre les litiges et les abonnements, et à vérifier les webhooks signés.
|
|
20
|
+
|
|
21
|
+
Aucune dépendance : seule la bibliothèque standard est utilisée.
|
|
22
|
+
|
|
23
|
+
> Côté serveur uniquement. Ne mettez jamais une clé secrète `pwx_sk_…` dans une page web ou une application mobile.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Installez le paquet :
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install paywallex
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Créer une session de paiement
|
|
34
|
+
|
|
35
|
+
Passez votre clé à la création du client. Une clé `pwx_sk_test_…` vise le bac à sable, une clé `pwx_sk_live_…` vise la production.
|
|
36
|
+
|
|
37
|
+
Créez une session, puis redirigez le client vers `url` :
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import os
|
|
41
|
+
from paywallex import Paywallex
|
|
42
|
+
|
|
43
|
+
paywallex = Paywallex(os.environ["PAYWALLEX_KEY"])
|
|
44
|
+
|
|
45
|
+
session = paywallex.checkout_sessions.create(
|
|
46
|
+
currency="XOF",
|
|
47
|
+
line_items=[
|
|
48
|
+
{"name": "Attiéké poisson", "unit_amount": 2500, "quantity": 2},
|
|
49
|
+
{"name": "Jus de bissap", "unit_amount": 500},
|
|
50
|
+
],
|
|
51
|
+
success_url="https://maboutique.ci/merci",
|
|
52
|
+
metadata={"order": "CMD-1042"},
|
|
53
|
+
)
|
|
54
|
+
# session["amount"] == 5500, redirigez vers session["url"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Les montants sont en XOF entiers : `2500` veut dire 2 500 F.
|
|
58
|
+
|
|
59
|
+
## Vérifier un webhook
|
|
60
|
+
|
|
61
|
+
Vérifiez la signature avant de traiter un événement. Le SDK compare en temps constant et refuse un horodatage de plus de 5 minutes.
|
|
62
|
+
|
|
63
|
+
Passez-lui le corps brut de la requête, pas un dictionnaire déjà décodé :
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from flask import Flask, request
|
|
67
|
+
from paywallex import Webhook, PaywallexError
|
|
68
|
+
|
|
69
|
+
app = Flask(__name__)
|
|
70
|
+
|
|
71
|
+
@app.post("/webhooks/paywallex")
|
|
72
|
+
def paywallex_webhook():
|
|
73
|
+
try:
|
|
74
|
+
event = Webhook.construct_event(request.get_data(), request.headers.get("Paywallex-Signature"), os.environ["PAYWALLEX_WEBHOOK_SECRET"])
|
|
75
|
+
except PaywallexError:
|
|
76
|
+
return "", 400
|
|
77
|
+
if event["type"] == "checkout_session.completed":
|
|
78
|
+
pass # livrer la commande
|
|
79
|
+
return "", 200
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Les erreurs
|
|
83
|
+
|
|
84
|
+
Chaque erreur est une `PaywallexError` avec un code stable. Testez le code, jamais le message :
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from paywallex import PaywallexError
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
paywallex.refunds.create(payment=payment_id, amount=1000)
|
|
91
|
+
except PaywallexError as e:
|
|
92
|
+
if e.code == "refund_not_eligible":
|
|
93
|
+
...
|
|
94
|
+
print(e.status, e.code, e.param, e.request_id)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Passez `language="en"` au client pour recevoir les messages en anglais.
|
|
98
|
+
|
|
99
|
+
## Nouvelles tentatives et idempotence
|
|
100
|
+
|
|
101
|
+
Le SDK réessaie 2 fois en cas de coupure réseau, de `429` ou d'erreur `5xx`, en respectant `Retry-After`. Chaque `POST` garde la même clé d'idempotence d'une tentative à l'autre : un paiement ou un remboursement n'est jamais doublé.
|
|
102
|
+
|
|
103
|
+
Pour rejouer vous-même une requête sans risque, donnez votre propre clé :
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
paywallex.refunds.create(payment=payment_id, idempotency_key="remboursement-CMD-1042")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Ce que couvre le SDK
|
|
110
|
+
|
|
111
|
+
| Objet | Méthodes |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `checkout_sessions` | `create`, `retrieve` |
|
|
114
|
+
| `payments`, `refunds`, `disputes` | `list`, `retrieve`, `all` (toutes les pages) ; `refunds.create` |
|
|
115
|
+
| `payouts`, `events`, `statements` | `list`, `retrieve` |
|
|
116
|
+
| `customers`, `payment_links`, `invoices`, `pots`, `plans`, `coupons`, `subscriptions` | `list`, `create`, `retrieve`, `update`, `action` |
|
|
117
|
+
| `webhook_endpoints` | `list`, `create`, `action("rotate_secret")`, `delete_webhook_endpoint()` |
|
|
118
|
+
| client | `account()`, `balance()`, `network_status()`, `upload_file()`, `add_dispute_evidence()` |
|
|
119
|
+
|
|
120
|
+
Accepter un litige (rembourser) ou le contester se fait dans le portail Paywallex, jamais par l'API. Un abonnement ou une hausse de prix est toujours accepté par le client dans Paywallex.
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
Lancez les tests (le bac à sable est testé si `PAYWALLEX_TEST_KEY` est défini) :
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
python -m unittest discover tests
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Licence
|
|
131
|
+
|
|
132
|
+
MIT.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paywallex
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "paywallex"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SDK serveur officiel de l'API Paywallex /v1 : sessions de paiement Mobile Money, remboursements, litiges, abonnements, webhooks signés."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
keywords = ["paywallex", "paiement", "mobile money", "orange money", "mtn momo", "wave", "xof"]
|
|
13
|
+
classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://paywallex.app/developpeurs"
|
|
17
|
+
Source = "https://github.com/Paywallex/paywallex-kit/tree/dev/sdk/python"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
packages = ["paywallex"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests du SDK Python : sans réseau, puis bac à sable si PAYWALLEX_TEST_KEY est défini. Signé : Superagent."""
|
|
2
|
+
import hashlib, hmac, json, os, sys, time, unittest
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
4
|
+
from paywallex import Paywallex, PaywallexError, Webhook # noqa: E402
|
|
5
|
+
|
|
6
|
+
def sign(body, secret, t=None):
|
|
7
|
+
t = t or int(time.time())
|
|
8
|
+
return f"t={t},v1=" + hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
9
|
+
|
|
10
|
+
class Offline(unittest.TestCase):
|
|
11
|
+
def test_webhook(self):
|
|
12
|
+
b = json.dumps({"id": "evt_1", "type": "payment.succeeded"})
|
|
13
|
+
self.assertEqual(Webhook.construct_event(b, sign(b, "k"), "k")["type"], "payment.succeeded")
|
|
14
|
+
self.assertEqual(Webhook.construct_event(b.encode(), sign(b, "k"), "k")["id"], "evt_1")
|
|
15
|
+
for hdr, code in [(sign(b, "x"), "invalid_signature"), (sign(b, "k", 1000), "signature_expired"), (None, "invalid_signature")]:
|
|
16
|
+
with self.assertRaises(PaywallexError) as c: Webhook.construct_event(b, hdr, "k")
|
|
17
|
+
self.assertEqual(c.exception.code, code)
|
|
18
|
+
|
|
19
|
+
def test_retry_same_idempotency_key(self):
|
|
20
|
+
keys, n = [], [0]
|
|
21
|
+
def t(m, u, h, b):
|
|
22
|
+
keys.append(h["Idempotency-Key"]); n[0] += 1
|
|
23
|
+
return (503, {}, b"{}") if n[0] == 1 else (201, {}, json.dumps({"id": "cs_1", "url": "u"}).encode())
|
|
24
|
+
p = Paywallex("pwx_sk_test_x", transport=t)
|
|
25
|
+
self.assertEqual(p.checkout_sessions.create(currency="XOF", amount=1000)["id"], "cs_1")
|
|
26
|
+
self.assertEqual(n[0], 2); self.assertEqual(keys[0], keys[1])
|
|
27
|
+
|
|
28
|
+
def test_error_fields(self):
|
|
29
|
+
t = lambda *a: (400, {}, json.dumps({"error": {"code": "parameter_invalid", "message": "Invalid parameter: amount.", "param": "amount", "request_id": "req_1"}}).encode())
|
|
30
|
+
with self.assertRaises(PaywallexError) as c: Paywallex("k", transport=t).refunds.create(payment="x")
|
|
31
|
+
e = c.exception; self.assertEqual((e.code, e.param, e.request_id, e.status), ("parameter_invalid", "amount", "req_1", 400))
|
|
32
|
+
|
|
33
|
+
def test_headers_and_url(self):
|
|
34
|
+
with self.assertRaises(PaywallexError): Paywallex("")
|
|
35
|
+
seen = {}
|
|
36
|
+
def t(m, u, h, b): seen.update(m=m, u=u, h=h); return 200, {}, b'{"object":"network_status","data":[]}'
|
|
37
|
+
Paywallex("pwx_sk_test_x", base_url="https://api.paywallx.xyz/", language="en", transport=t).network_status()
|
|
38
|
+
self.assertEqual(seen["u"], "https://api.paywallx.xyz/v1/network_status"); self.assertEqual(seen["h"]["Accept-Language"], "en")
|
|
39
|
+
self.assertNotIn("Idempotency-Key", seen["h"]); self.assertTrue(seen["h"]["User-Agent"].startswith("paywallex-python/1."))
|
|
40
|
+
|
|
41
|
+
def test_pagination(self):
|
|
42
|
+
urls = []
|
|
43
|
+
def t(m, u, h, b):
|
|
44
|
+
urls.append(u)
|
|
45
|
+
d = {"data": [{"id": "p3"}], "has_more": False} if "starting_after=p2" in u else {"data": [{"id": "p1"}, {"id": "p2"}], "has_more": True}
|
|
46
|
+
return 200, {}, json.dumps(d).encode()
|
|
47
|
+
self.assertEqual([x["id"] for x in Paywallex("k", transport=t).payments.all(limit=2)], ["p1", "p2", "p3"]); self.assertEqual(len(urls), 2)
|
|
48
|
+
|
|
49
|
+
def test_unknown_action(self):
|
|
50
|
+
with self.assertRaises(PaywallexError) as c: Paywallex("k").invoices.action("x", "delete")
|
|
51
|
+
self.assertEqual(c.exception.code, "configuration_error")
|
|
52
|
+
|
|
53
|
+
KEY, BASE = os.environ.get("PAYWALLEX_TEST_KEY"), os.environ.get("PAYWALLEX_BASE_URL", "https://api.paywallex.app")
|
|
54
|
+
|
|
55
|
+
@unittest.skipUnless(KEY, "PAYWALLEX_TEST_KEY absent")
|
|
56
|
+
class Sandbox(unittest.TestCase):
|
|
57
|
+
def test_reads_and_errors(self):
|
|
58
|
+
p = Paywallex(KEY, base_url=BASE)
|
|
59
|
+
self.assertEqual(p.network_status()["object"], "network_status"); self.assertTrue(p.account()["id"])
|
|
60
|
+
for i, _ in enumerate(p.payments.all(limit=2)):
|
|
61
|
+
if i >= 2: break
|
|
62
|
+
with self.assertRaises(PaywallexError) as c:
|
|
63
|
+
p.checkout_sessions.create(currency="XOF", amount=1000, line_items=[{"name": "x", "unit_amount": 500}])
|
|
64
|
+
self.assertEqual((c.exception.code, c.exception.param), ("amount_mismatch", "amount"))
|
|
65
|
+
with self.assertRaises(PaywallexError) as c: Paywallex(KEY, base_url=BASE, language="en").disputes.retrieve("x")
|
|
66
|
+
self.assertEqual((c.exception.code, c.exception.message), ("resource_missing", "No such resource."))
|
|
67
|
+
|
|
68
|
+
def test_checkout_with_cart(self):
|
|
69
|
+
try:
|
|
70
|
+
s = Paywallex(KEY, base_url=BASE).checkout_sessions.create(currency="XOF", line_items=[{"name": "Garba", "unit_amount": 1500, "quantity": 2}])
|
|
71
|
+
except PaywallexError as e:
|
|
72
|
+
if e.code == "review_demo_blocked": self.skipTest("clé d'un compte de démonstration : création bloquée")
|
|
73
|
+
raise
|
|
74
|
+
self.assertEqual(s["amount"], 3000)
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
unittest.main()
|