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.
@@ -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.
@@ -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,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ paywallex/__init__.py
5
+ paywallex.egg-info/PKG-INFO
6
+ paywallex.egg-info/SOURCES.txt
7
+ paywallex.egg-info/dependency_links.txt
8
+ paywallex.egg-info/top_level.txt
9
+ tests/test_paywallex.py
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()