paysawa 0.1.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.
paysawa-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paysawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
paysawa-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: paysawa
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Paysawa mobile money gateway (MTN / Orange): collect, withdraw, payment links and signed webhooks.
5
+ Author: Paysawa
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ChrinaerDev/paysawa-python
8
+ Project-URL: Issues, https://github.com/ChrinaerDev/paysawa-python/issues
9
+ Project-URL: Changelog, https://github.com/ChrinaerDev/paysawa-python/blob/main/CHANGELOG.md
10
+ Keywords: paysawa,mobile money,payments,cameroon,mtn,orange money,campay,xaf
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: httpx>=0.25
26
+ Requires-Dist: PyJWT>=2.4
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
+ Requires-Dist: build>=1.0; extra == "dev"
31
+ Requires-Dist: twine>=5.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # paysawa
35
+
36
+ Python SDK for the **Paysawa** mobile money gateway (MTN Mobile Money and Orange Money, XAF).
37
+ Collect payments, send money, create payment links and verify signed webhooks with a few lines of code.
38
+ It speaks Paysawa's Campay-style API, so the concepts are the same as Campay's.
39
+
40
+ ```bash
41
+ pip install paysawa
42
+ ```
43
+
44
+ ## 1. Get your credentials
45
+
46
+ In your Paysawa dashboard create an **app**. It gives you three values (you can show them again any time):
47
+
48
+ | Value | What it is |
49
+ |---|---|
50
+ | App username | like `pa_1a2b3c4d5e6f7a8b` |
51
+ | App password | secret, never put it in front-end code or in git |
52
+ | Webhook key | verifies that webhooks really come from Paysawa |
53
+
54
+ A **test** app uses the sandbox (no real money). A **live** app moves real money.
55
+
56
+ ## 2. Quick start
57
+
58
+ ```python
59
+ from paysawa import Client
60
+
61
+ client = Client(
62
+ app_username="pa_xxxxxxxxxxxxxxxx",
63
+ app_password="your-app-password",
64
+ webhook_key="whk_xxxxxxxx",
65
+ base_url="https://YOUR-PAYSAWA-URL/api/", # or set PAYSAWA_BASE_URL
66
+ )
67
+
68
+ # Ask a customer to pay: they get a prompt on their phone and approve with their PIN.
69
+ tx = client.collect(amount=5000, phone="670000001", description="Order 1001",
70
+ external_reference="order-1001", wait=True) # wait=True blocks until final
71
+ if tx.is_successful:
72
+ print("Paid!", tx.reference)
73
+ else:
74
+ print("Not paid:", tx.status)
75
+
76
+ print(client.get_balance().total_balance) # your Paysawa balance
77
+ client.withdraw(amount=1000, phone="690000001", description="Refund") # pay money out
78
+ link = client.get_payment_link(amount=2000, description="Order 1002", redirect_url="https://shop.example.com/thanks")
79
+ print(link.link) # send this to your customer
80
+ ```
81
+
82
+ Put the credentials in environment variables instead of code and use `Client.from_env()`:
83
+ `PAYSAWA_APP_USERNAME`, `PAYSAWA_APP_PASSWORD`, `PAYSAWA_WEBHOOK_KEY`, `PAYSAWA_BASE_URL`.
84
+
85
+ ## 3. Statuses
86
+
87
+ Every transaction is `PENDING`, `SUCCESSFUL` or `FAILED` (`tx.is_pending`, `tx.is_successful`, `tx.is_failed`, `tx.is_final`).
88
+ A collection stays `PENDING` until the customer approves. Without `wait=True`, check it later with
89
+ `client.get_transaction(reference)` or use `client.wait_for_transaction(reference, timeout=120)`, or rely on webhooks.
90
+
91
+ ## 4. Webhooks
92
+
93
+ Set your app's webhook URL in the dashboard. Paysawa calls it with a `GET` and query parameters plus a signed `signature`.
94
+ **Always verify it**:
95
+
96
+ ```python
97
+ # Flask
98
+ @app.get("/webhooks/paysawa")
99
+ def paysawa_webhook():
100
+ try:
101
+ event = client.verify_webhook(request.args.to_dict())
102
+ except WebhookVerificationError:
103
+ return "invalid", 401
104
+ if event.is_successful:
105
+ mark_order_paid(event.external_reference, event.amount)
106
+ return "ok"
107
+ ```
108
+
109
+ ```python
110
+ # FastAPI
111
+ @app.get("/webhooks/paysawa")
112
+ async def paysawa_webhook(request: Request):
113
+ event = client.verify_webhook(dict(request.query_params)) # raises WebhookVerificationError if not authentic
114
+ ...
115
+ ```
116
+
117
+ Django: `client.verify_webhook(request.GET.dict())`. Reply with any 2xx status; otherwise Paysawa retries.
118
+ Check `event.amount` against the order before you ship anything.
119
+
120
+ ## 5. Async
121
+
122
+ ```python
123
+ from paysawa import AsyncClient
124
+
125
+ async with AsyncClient.from_env() as client:
126
+ tx = await client.collect(5000, "670000001", "Order 1001", "order-1001", wait=True)
127
+ ```
128
+
129
+ ## 6. Errors
130
+
131
+ All errors inherit from `PaysawaError` (`.message`, `.status`, `.code`):
132
+
133
+ | Exception | When |
134
+ |---|---|
135
+ | `AuthenticationError` | wrong app username/password |
136
+ | `PermissionDeniedError` | live mode not enabled, account suspended |
137
+ | `InvalidRequestError` | bad amount or phone number, unknown reference, limits |
138
+ | `InsufficientFundsError` | withdrawal larger than your balance (amount + fee) |
139
+ | `RateLimitError` | too many requests |
140
+ | `ServerError` / `NetworkError` | Paysawa busy or unreachable |
141
+ | `WaitTimeoutError` | `wait_for_transaction` gave up (`.transaction` has the latest state) |
142
+ | `WebhookVerificationError` | the webhook is not authentic: ignore it |
143
+
144
+ Payments (collect, withdraw) are **never retried automatically**, so a retry can never charge twice.
145
+ Reads are retried twice. If a payment call fails with a network error, look the payment up by your
146
+ `external_reference` before trying again.
147
+
148
+ ## 7. Sandbox numbers (test apps)
149
+
150
+ `670000001` succeeds, a number ending in `0000` fails, a number ending in `9999` stays pending.
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ pip install -e ".[dev]"
156
+ pytest
157
+ ```
@@ -0,0 +1,124 @@
1
+ # paysawa
2
+
3
+ Python SDK for the **Paysawa** mobile money gateway (MTN Mobile Money and Orange Money, XAF).
4
+ Collect payments, send money, create payment links and verify signed webhooks with a few lines of code.
5
+ It speaks Paysawa's Campay-style API, so the concepts are the same as Campay's.
6
+
7
+ ```bash
8
+ pip install paysawa
9
+ ```
10
+
11
+ ## 1. Get your credentials
12
+
13
+ In your Paysawa dashboard create an **app**. It gives you three values (you can show them again any time):
14
+
15
+ | Value | What it is |
16
+ |---|---|
17
+ | App username | like `pa_1a2b3c4d5e6f7a8b` |
18
+ | App password | secret, never put it in front-end code or in git |
19
+ | Webhook key | verifies that webhooks really come from Paysawa |
20
+
21
+ A **test** app uses the sandbox (no real money). A **live** app moves real money.
22
+
23
+ ## 2. Quick start
24
+
25
+ ```python
26
+ from paysawa import Client
27
+
28
+ client = Client(
29
+ app_username="pa_xxxxxxxxxxxxxxxx",
30
+ app_password="your-app-password",
31
+ webhook_key="whk_xxxxxxxx",
32
+ base_url="https://YOUR-PAYSAWA-URL/api/", # or set PAYSAWA_BASE_URL
33
+ )
34
+
35
+ # Ask a customer to pay: they get a prompt on their phone and approve with their PIN.
36
+ tx = client.collect(amount=5000, phone="670000001", description="Order 1001",
37
+ external_reference="order-1001", wait=True) # wait=True blocks until final
38
+ if tx.is_successful:
39
+ print("Paid!", tx.reference)
40
+ else:
41
+ print("Not paid:", tx.status)
42
+
43
+ print(client.get_balance().total_balance) # your Paysawa balance
44
+ client.withdraw(amount=1000, phone="690000001", description="Refund") # pay money out
45
+ link = client.get_payment_link(amount=2000, description="Order 1002", redirect_url="https://shop.example.com/thanks")
46
+ print(link.link) # send this to your customer
47
+ ```
48
+
49
+ Put the credentials in environment variables instead of code and use `Client.from_env()`:
50
+ `PAYSAWA_APP_USERNAME`, `PAYSAWA_APP_PASSWORD`, `PAYSAWA_WEBHOOK_KEY`, `PAYSAWA_BASE_URL`.
51
+
52
+ ## 3. Statuses
53
+
54
+ Every transaction is `PENDING`, `SUCCESSFUL` or `FAILED` (`tx.is_pending`, `tx.is_successful`, `tx.is_failed`, `tx.is_final`).
55
+ A collection stays `PENDING` until the customer approves. Without `wait=True`, check it later with
56
+ `client.get_transaction(reference)` or use `client.wait_for_transaction(reference, timeout=120)`, or rely on webhooks.
57
+
58
+ ## 4. Webhooks
59
+
60
+ Set your app's webhook URL in the dashboard. Paysawa calls it with a `GET` and query parameters plus a signed `signature`.
61
+ **Always verify it**:
62
+
63
+ ```python
64
+ # Flask
65
+ @app.get("/webhooks/paysawa")
66
+ def paysawa_webhook():
67
+ try:
68
+ event = client.verify_webhook(request.args.to_dict())
69
+ except WebhookVerificationError:
70
+ return "invalid", 401
71
+ if event.is_successful:
72
+ mark_order_paid(event.external_reference, event.amount)
73
+ return "ok"
74
+ ```
75
+
76
+ ```python
77
+ # FastAPI
78
+ @app.get("/webhooks/paysawa")
79
+ async def paysawa_webhook(request: Request):
80
+ event = client.verify_webhook(dict(request.query_params)) # raises WebhookVerificationError if not authentic
81
+ ...
82
+ ```
83
+
84
+ Django: `client.verify_webhook(request.GET.dict())`. Reply with any 2xx status; otherwise Paysawa retries.
85
+ Check `event.amount` against the order before you ship anything.
86
+
87
+ ## 5. Async
88
+
89
+ ```python
90
+ from paysawa import AsyncClient
91
+
92
+ async with AsyncClient.from_env() as client:
93
+ tx = await client.collect(5000, "670000001", "Order 1001", "order-1001", wait=True)
94
+ ```
95
+
96
+ ## 6. Errors
97
+
98
+ All errors inherit from `PaysawaError` (`.message`, `.status`, `.code`):
99
+
100
+ | Exception | When |
101
+ |---|---|
102
+ | `AuthenticationError` | wrong app username/password |
103
+ | `PermissionDeniedError` | live mode not enabled, account suspended |
104
+ | `InvalidRequestError` | bad amount or phone number, unknown reference, limits |
105
+ | `InsufficientFundsError` | withdrawal larger than your balance (amount + fee) |
106
+ | `RateLimitError` | too many requests |
107
+ | `ServerError` / `NetworkError` | Paysawa busy or unreachable |
108
+ | `WaitTimeoutError` | `wait_for_transaction` gave up (`.transaction` has the latest state) |
109
+ | `WebhookVerificationError` | the webhook is not authentic: ignore it |
110
+
111
+ Payments (collect, withdraw) are **never retried automatically**, so a retry can never charge twice.
112
+ Reads are retried twice. If a payment call fails with a network error, look the payment up by your
113
+ `external_reference` before trying again.
114
+
115
+ ## 7. Sandbox numbers (test apps)
116
+
117
+ `670000001` succeeds, a number ending in `0000` fails, a number ending in `9999` stays pending.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ pip install -e ".[dev]"
123
+ pytest
124
+ ```
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "paysawa"
7
+ dynamic = ["version"]
8
+ description = "Python SDK for the Paysawa mobile money gateway (MTN / Orange): collect, withdraw, payment links and signed webhooks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Paysawa"}]
13
+ keywords = ["paysawa", "mobile money", "payments", "cameroon", "mtn", "orange money", "campay", "xaf"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Office/Business :: Financial :: Point-Of-Sale",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["httpx>=0.25", "PyJWT>=2.4"]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "build>=1.0", "twine>=5.0"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/ChrinaerDev/paysawa-python"
34
+ Issues = "https://github.com/ChrinaerDev/paysawa-python/issues"
35
+ Changelog = "https://github.com/ChrinaerDev/paysawa-python/blob/main/CHANGELOG.md"
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = {attr = "paysawa._version.__version__"}
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.setuptools.package-data]
44
+ paysawa = ["py.typed"]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ """Paysawa Python SDK: collect and withdraw mobile money (MTN / Orange), payment links and webhooks."""
2
+ from ._version import __version__
3
+ from .client import DEFAULT_BASE_URL, AsyncClient, Client
4
+ from .errors import (
5
+ AuthenticationError,
6
+ InsufficientFundsError,
7
+ InvalidRequestError,
8
+ NetworkError,
9
+ PaysawaError,
10
+ PermissionDeniedError,
11
+ RateLimitError,
12
+ ServerError,
13
+ WaitTimeoutError,
14
+ WebhookVerificationError,
15
+ )
16
+ from .models import FAILED, PENDING, SUCCESSFUL, Result, Transaction, WebhookEvent
17
+ from .webhooks import verify_webhook
18
+
19
+ __all__ = [
20
+ "__version__", "Client", "AsyncClient", "DEFAULT_BASE_URL", "verify_webhook",
21
+ "PENDING", "SUCCESSFUL", "FAILED", "Result", "Transaction", "WebhookEvent",
22
+ "PaysawaError", "AuthenticationError", "PermissionDeniedError", "InvalidRequestError", "InsufficientFundsError",
23
+ "RateLimitError", "ServerError", "NetworkError", "WebhookVerificationError", "WaitTimeoutError",
24
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,322 @@
1
+ """Paysawa clients (synchronous `Client` and asynchronous `AsyncClient`).
2
+
3
+ from paysawa import Client
4
+
5
+ client = Client(app_username="pa_...", app_password="...", webhook_key="whk_...", base_url="https://your-paysawa/api/")
6
+ tx = client.collect(amount=5000, phone="670000001", description="Order 1001", external_reference="order-1001", wait=True)
7
+ if tx.is_successful: ...
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import threading
14
+ import time
15
+ from typing import Any, Callable, Mapping, Optional, Union
16
+
17
+ import httpx
18
+
19
+ from .errors import AuthenticationError, NetworkError, PaysawaError, WaitTimeoutError, error_from_response
20
+ from .models import Result, Transaction, WebhookEvent
21
+ from .webhooks import verify_webhook
22
+
23
+ # Change this one line once Paysawa is hosted, or set PAYSAWA_BASE_URL, or pass base_url= to the client.
24
+ DEFAULT_BASE_URL = "http://localhost:8000/api/"
25
+
26
+ _sleep = time.sleep # indirections so tests can run instantly
27
+ _monotonic = time.monotonic
28
+ _asleep = asyncio.sleep
29
+
30
+ Amount = Union[int, str]
31
+
32
+
33
+ def _base_url(value: Optional[str]) -> str:
34
+ return (value or os.environ.get("PAYSAWA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") + "/"
35
+
36
+
37
+ def _amount(value: Any) -> str:
38
+ """Amounts are whole XAF. Accepts 5000, "5000", 5000.0; rejects 0, negatives and fractions."""
39
+ try:
40
+ number = float(str(value).strip())
41
+ except ValueError:
42
+ raise ValueError(f"amount must be a number, got {value!r}") from None
43
+ if number <= 0 or number != int(number):
44
+ raise ValueError(f"amount must be a whole positive number of XAF, got {value!r}")
45
+ return str(int(number))
46
+
47
+
48
+ def _raise(response: Any) -> None:
49
+ try:
50
+ data = response.json()
51
+ except ValueError:
52
+ data = {}
53
+ raise error_from_response(response.status_code, data)
54
+
55
+
56
+ def _collect_body(amount: Amount, phone: str, description: str, external_reference: Optional[str], currency: str) -> dict:
57
+ return {"amount": _amount(amount), "currency": currency, "from": phone, "description": description, "external_reference": external_reference}
58
+
59
+
60
+ def _withdraw_body(amount: Amount, phone: str, description: str, external_reference: Optional[str], currency: str) -> dict:
61
+ return {"amount": _amount(amount), "currency": currency, "to": phone, "description": description, "external_reference": external_reference}
62
+
63
+
64
+ def _link_body(amount: Amount, description: str, external_reference: Optional[str], redirect_url: Optional[str], currency: str) -> dict:
65
+ body = {"amount": _amount(amount), "currency": currency, "description": description, "external_reference": external_reference}
66
+ if redirect_url:
67
+ body["redirect_url"] = redirect_url
68
+ return body
69
+
70
+
71
+ def _env(name: str, required: bool = True) -> str:
72
+ value = os.environ.get(name, "")
73
+ if required and not value:
74
+ raise ValueError(f"Environment variable {name} is not set.")
75
+ return value
76
+
77
+
78
+ class Client:
79
+ """Synchronous client. Use as a context manager (`with Client(...) as client:`) or call close() when done."""
80
+
81
+ def __init__(self, app_username: str, app_password: str, webhook_key: str = "", *, base_url: Optional[str] = None,
82
+ timeout: float = 30.0, max_retries: int = 2, http_client: Optional[httpx.Client] = None):
83
+ if not app_username or not app_password:
84
+ raise ValueError("app_username and app_password are required (create an app in your Paysawa dashboard).")
85
+ self.base_url = _base_url(base_url)
86
+ self.app_username = app_username
87
+ self._password = app_password
88
+ self.webhook_key = webhook_key
89
+ self.max_retries = max(0, int(max_retries))
90
+ self._owns_http = http_client is None
91
+ self._http = http_client or httpx.Client(timeout=timeout)
92
+ self._token: Optional[str] = None
93
+ self._expires_at = 0.0
94
+ self._lock = threading.Lock()
95
+
96
+ @classmethod
97
+ def from_env(cls, **kwargs: Any) -> "Client":
98
+ """Reads PAYSAWA_APP_USERNAME, PAYSAWA_APP_PASSWORD, PAYSAWA_WEBHOOK_KEY (optional) and PAYSAWA_BASE_URL."""
99
+ return cls(_env("PAYSAWA_APP_USERNAME"), _env("PAYSAWA_APP_PASSWORD"), _env("PAYSAWA_WEBHOOK_KEY", required=False), **kwargs)
100
+
101
+ def __enter__(self) -> "Client":
102
+ return self
103
+
104
+ def __exit__(self, *exc: Any) -> None:
105
+ self.close()
106
+
107
+ def close(self) -> None:
108
+ if self._owns_http:
109
+ self._http.close()
110
+
111
+ # ------------------------------------------------------------------ transport
112
+ def _send(self, method: str, url: str, json: Any = None, headers: Optional[dict] = None) -> Any:
113
+ try:
114
+ return self._http.request(method, url, json=json, headers=headers or {})
115
+ except httpx.TimeoutException:
116
+ raise NetworkError("Paysawa did not answer in time.", code="timeout") from None
117
+ except httpx.HTTPError:
118
+ raise NetworkError(f"Cannot reach Paysawa at {self.base_url}.", code="unreachable") from None
119
+
120
+ def _get_token(self, force: bool = False) -> str:
121
+ with self._lock:
122
+ if not force and self._token and _monotonic() < self._expires_at:
123
+ return self._token
124
+ response = self._send("POST", self.base_url + "token/", {"username": self.app_username, "password": self._password})
125
+ if response.status_code >= 400:
126
+ _raise(response)
127
+ data = response.json()
128
+ self._token = data["token"]
129
+ self._expires_at = _monotonic() + max(int(data.get("expires_in") or 300) - 30, 10)
130
+ return self._token
131
+
132
+ def _request(self, method: str, path: str, json: Any = None) -> dict:
133
+ """Payments (POST) are never retried automatically, so a retry cannot create a second payment. Reads are."""
134
+ retries_left = self.max_retries if method == "GET" else 0
135
+ force_token = False
136
+ while True:
137
+ token = self._get_token(force=force_token)
138
+ try:
139
+ response = self._send(method, self.base_url + path, json, {"Authorization": f"Token {token}"})
140
+ except NetworkError:
141
+ if retries_left <= 0:
142
+ raise
143
+ retries_left -= 1
144
+ _sleep(0.5 * (self.max_retries - retries_left))
145
+ continue
146
+ if response.status_code == 401 and not force_token:
147
+ force_token = True
148
+ continue
149
+ if response.status_code >= 500 and retries_left > 0:
150
+ retries_left -= 1
151
+ _sleep(0.5 * (self.max_retries - retries_left))
152
+ continue
153
+ if response.status_code >= 400:
154
+ _raise(response)
155
+ return response.json()
156
+
157
+ # ------------------------------------------------------------------ API
158
+ def collect(self, amount: Amount, phone: str, description: str = "", external_reference: Optional[str] = None, *,
159
+ currency: str = "XAF", wait: bool = False, timeout: float = 120.0, interval: float = 3.0) -> Transaction:
160
+ """Ask `phone` to pay `amount`. The customer approves on their phone. With wait=True this blocks until the payment
161
+ is SUCCESSFUL or FAILED (or raises WaitTimeoutError). Returns a Transaction (reference, status, operator, ussd_code)."""
162
+ tx = Transaction(self._request("POST", "collect/", _collect_body(amount, phone, description, external_reference, currency)))
163
+ if wait and not tx.is_final:
164
+ return self.wait_for_transaction(tx.reference, timeout=timeout, interval=interval)
165
+ return tx
166
+
167
+ def withdraw(self, amount: Amount, phone: str, description: str = "", external_reference: Optional[str] = None, *,
168
+ currency: str = "XAF", wait: bool = False, timeout: float = 120.0, interval: float = 3.0) -> Transaction:
169
+ """Send `amount` from your Paysawa balance to `phone`. Raises InsufficientFundsError if amount + fee exceeds the balance."""
170
+ tx = Transaction(self._request("POST", "withdraw/", _withdraw_body(amount, phone, description, external_reference, currency)))
171
+ if wait and not tx.is_final:
172
+ return self.wait_for_transaction(tx.reference, timeout=timeout, interval=interval)
173
+ return tx
174
+
175
+ def get_transaction(self, reference: str) -> Transaction:
176
+ """Latest state of a collection, withdrawal (col_..., pay_...) or payment link (cs_...)."""
177
+ return Transaction(self._request("GET", f"transaction/{reference}/"))
178
+
179
+ def wait_for_transaction(self, reference: str, *, timeout: float = 120.0, interval: float = 3.0,
180
+ on_update: Optional[Callable[[Transaction], None]] = None) -> Transaction:
181
+ deadline = _monotonic() + timeout
182
+ tx = self.get_transaction(reference)
183
+ while not tx.is_final:
184
+ if on_update:
185
+ on_update(tx)
186
+ if _monotonic() >= deadline:
187
+ raise WaitTimeoutError(f"Transaction {reference} is still pending after {timeout:g}s.", transaction=tx)
188
+ _sleep(max(0.0, min(interval, deadline - _monotonic())))
189
+ tx = self.get_transaction(reference)
190
+ return tx
191
+
192
+ def get_balance(self) -> Result:
193
+ """{'total_balance': ..., 'held_balance': ..., 'currency': 'XAF'}. One balance per merchant."""
194
+ return Result(self._request("GET", "balance/"))
195
+
196
+ def get_payment_link(self, amount: Amount, description: str = "", external_reference: Optional[str] = None,
197
+ redirect_url: Optional[str] = None, *, currency: str = "XAF") -> Result:
198
+ """Create a hosted payment page link. Returns {'link': ..., 'reference': 'cs_...'}."""
199
+ return Result(self._request("POST", "get_payment_link/", _link_body(amount, description, external_reference, redirect_url, currency)))
200
+
201
+ def verify_webhook(self, params: Mapping[str, Any]) -> WebhookEvent:
202
+ """Verify a webhook received on your webhook URL with this client's webhook_key."""
203
+ return verify_webhook(params, self.webhook_key)
204
+
205
+
206
+ class AsyncClient:
207
+ """Asynchronous client with the same methods. Use `async with AsyncClient(...) as client:`."""
208
+
209
+ def __init__(self, app_username: str, app_password: str, webhook_key: str = "", *, base_url: Optional[str] = None,
210
+ timeout: float = 30.0, max_retries: int = 2, http_client: Optional[httpx.AsyncClient] = None):
211
+ if not app_username or not app_password:
212
+ raise ValueError("app_username and app_password are required (create an app in your Paysawa dashboard).")
213
+ self.base_url = _base_url(base_url)
214
+ self.app_username = app_username
215
+ self._password = app_password
216
+ self.webhook_key = webhook_key
217
+ self.max_retries = max(0, int(max_retries))
218
+ self._owns_http = http_client is None
219
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
220
+ self._token: Optional[str] = None
221
+ self._expires_at = 0.0
222
+ self._lock: Optional[asyncio.Lock] = None
223
+
224
+ @classmethod
225
+ def from_env(cls, **kwargs: Any) -> "AsyncClient":
226
+ return cls(_env("PAYSAWA_APP_USERNAME"), _env("PAYSAWA_APP_PASSWORD"), _env("PAYSAWA_WEBHOOK_KEY", required=False), **kwargs)
227
+
228
+ async def __aenter__(self) -> "AsyncClient":
229
+ return self
230
+
231
+ async def __aexit__(self, *exc: Any) -> None:
232
+ await self.aclose()
233
+
234
+ async def aclose(self) -> None:
235
+ if self._owns_http:
236
+ await self._http.aclose()
237
+
238
+ async def _send(self, method: str, url: str, json: Any = None, headers: Optional[dict] = None) -> Any:
239
+ try:
240
+ return await self._http.request(method, url, json=json, headers=headers or {})
241
+ except httpx.TimeoutException:
242
+ raise NetworkError("Paysawa did not answer in time.", code="timeout") from None
243
+ except httpx.HTTPError:
244
+ raise NetworkError(f"Cannot reach Paysawa at {self.base_url}.", code="unreachable") from None
245
+
246
+ async def _get_token(self, force: bool = False) -> str:
247
+ if self._lock is None:
248
+ self._lock = asyncio.Lock()
249
+ async with self._lock:
250
+ if not force and self._token and _monotonic() < self._expires_at:
251
+ return self._token
252
+ response = await self._send("POST", self.base_url + "token/", {"username": self.app_username, "password": self._password})
253
+ if response.status_code >= 400:
254
+ _raise(response)
255
+ data = response.json()
256
+ self._token = data["token"]
257
+ self._expires_at = _monotonic() + max(int(data.get("expires_in") or 300) - 30, 10)
258
+ return self._token
259
+
260
+ async def _request(self, method: str, path: str, json: Any = None) -> dict:
261
+ retries_left = self.max_retries if method == "GET" else 0
262
+ force_token = False
263
+ while True:
264
+ token = await self._get_token(force=force_token)
265
+ try:
266
+ response = await self._send(method, self.base_url + path, json, {"Authorization": f"Token {token}"})
267
+ except NetworkError:
268
+ if retries_left <= 0:
269
+ raise
270
+ retries_left -= 1
271
+ await _asleep(0.5 * (self.max_retries - retries_left))
272
+ continue
273
+ if response.status_code == 401 and not force_token:
274
+ force_token = True
275
+ continue
276
+ if response.status_code >= 500 and retries_left > 0:
277
+ retries_left -= 1
278
+ await _asleep(0.5 * (self.max_retries - retries_left))
279
+ continue
280
+ if response.status_code >= 400:
281
+ _raise(response)
282
+ return response.json()
283
+
284
+ async def collect(self, amount: Amount, phone: str, description: str = "", external_reference: Optional[str] = None, *,
285
+ currency: str = "XAF", wait: bool = False, timeout: float = 120.0, interval: float = 3.0) -> Transaction:
286
+ tx = Transaction(await self._request("POST", "collect/", _collect_body(amount, phone, description, external_reference, currency)))
287
+ if wait and not tx.is_final:
288
+ return await self.wait_for_transaction(tx.reference, timeout=timeout, interval=interval)
289
+ return tx
290
+
291
+ async def withdraw(self, amount: Amount, phone: str, description: str = "", external_reference: Optional[str] = None, *,
292
+ currency: str = "XAF", wait: bool = False, timeout: float = 120.0, interval: float = 3.0) -> Transaction:
293
+ tx = Transaction(await self._request("POST", "withdraw/", _withdraw_body(amount, phone, description, external_reference, currency)))
294
+ if wait and not tx.is_final:
295
+ return await self.wait_for_transaction(tx.reference, timeout=timeout, interval=interval)
296
+ return tx
297
+
298
+ async def get_transaction(self, reference: str) -> Transaction:
299
+ return Transaction(await self._request("GET", f"transaction/{reference}/"))
300
+
301
+ async def wait_for_transaction(self, reference: str, *, timeout: float = 120.0, interval: float = 3.0,
302
+ on_update: Optional[Callable[[Transaction], None]] = None) -> Transaction:
303
+ deadline = _monotonic() + timeout
304
+ tx = await self.get_transaction(reference)
305
+ while not tx.is_final:
306
+ if on_update:
307
+ on_update(tx)
308
+ if _monotonic() >= deadline:
309
+ raise WaitTimeoutError(f"Transaction {reference} is still pending after {timeout:g}s.", transaction=tx)
310
+ await _asleep(max(0.0, min(interval, deadline - _monotonic())))
311
+ tx = await self.get_transaction(reference)
312
+ return tx
313
+
314
+ async def get_balance(self) -> Result:
315
+ return Result(await self._request("GET", "balance/"))
316
+
317
+ async def get_payment_link(self, amount: Amount, description: str = "", external_reference: Optional[str] = None,
318
+ redirect_url: Optional[str] = None, *, currency: str = "XAF") -> Result:
319
+ return Result(await self._request("POST", "get_payment_link/", _link_body(amount, description, external_reference, redirect_url, currency)))
320
+
321
+ def verify_webhook(self, params: Mapping[str, Any]) -> WebhookEvent:
322
+ return verify_webhook(params, self.webhook_key)
@@ -0,0 +1,78 @@
1
+ """Exceptions raised by the SDK. Catch PaysawaError to catch them all."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Optional
5
+
6
+
7
+ class PaysawaError(Exception):
8
+ """Base class. `status` is the HTTP status (None for network problems), `code` the machine-readable error code."""
9
+
10
+ def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None):
11
+ super().__init__(message)
12
+ self.message = message
13
+ self.status = status
14
+ self.code = code
15
+
16
+ def __str__(self) -> str:
17
+ prefix = f"[{self.code}] " if self.code else ""
18
+ return f"{prefix}{self.message}"
19
+
20
+
21
+ class AuthenticationError(PaysawaError):
22
+ """Wrong app username/password, or an expired/invalid token (HTTP 401)."""
23
+
24
+
25
+ class PermissionDeniedError(PaysawaError):
26
+ """The account cannot do this yet, e.g. live mode not enabled or account suspended (HTTP 403)."""
27
+
28
+
29
+ class InvalidRequestError(PaysawaError):
30
+ """The request was refused: bad amount, bad phone number, unknown reference, limits... (HTTP 400/404/409/410/422)."""
31
+
32
+
33
+ class InsufficientFundsError(InvalidRequestError):
34
+ """A withdrawal needs more than the available balance (amount + fee)."""
35
+
36
+
37
+ class RateLimitError(PaysawaError):
38
+ """Too many requests (HTTP 429)."""
39
+
40
+
41
+ class ServerError(PaysawaError):
42
+ """Paysawa had a problem or is busy (HTTP 5xx). Safe to retry reads; for payments check the transaction first."""
43
+
44
+
45
+ class NetworkError(PaysawaError):
46
+ """Could not reach Paysawa (timeout, DNS, connection refused)."""
47
+
48
+
49
+ class WebhookVerificationError(PaysawaError):
50
+ """A webhook did not pass signature verification. Do NOT trust it."""
51
+
52
+
53
+ class WaitTimeoutError(PaysawaError):
54
+ """wait_for_transaction() gave up while the transaction was still pending. `.transaction` holds its latest state."""
55
+
56
+ def __init__(self, message: str, *, transaction: Any = None):
57
+ super().__init__(message, code="wait_timeout")
58
+ self.transaction = transaction
59
+
60
+
61
+ def error_from_response(status: int, data: Any) -> PaysawaError:
62
+ """Builds the right exception from an error response (flat {message, code} or {error: {code, message}})."""
63
+ message, code = f"HTTP {status}", None
64
+ if isinstance(data, dict):
65
+ inner = data.get("error") if isinstance(data.get("error"), dict) else data
66
+ message = str(inner.get("message") or data.get("detail") or message)
67
+ code = inner.get("code")
68
+ if code == "insufficient_funds":
69
+ return InsufficientFundsError(message, status=status, code=code)
70
+ if status == 401:
71
+ return AuthenticationError(message, status=status, code=code)
72
+ if status == 403:
73
+ return PermissionDeniedError(message, status=status, code=code)
74
+ if status == 429:
75
+ return RateLimitError(message, status=status, code=code)
76
+ if status >= 500:
77
+ return ServerError(message, status=status, code=code)
78
+ return InvalidRequestError(message, status=status, code=code)
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ PENDING = "PENDING"
6
+ SUCCESSFUL = "SUCCESSFUL"
7
+ FAILED = "FAILED"
8
+
9
+
10
+ class Result(dict):
11
+ """A plain dict that also allows attribute access: result["reference"] == result.reference."""
12
+
13
+ __slots__ = ()
14
+
15
+ def __getattr__(self, name: str) -> Any:
16
+ try:
17
+ return self[name]
18
+ except KeyError:
19
+ raise AttributeError(name) from None
20
+
21
+
22
+ class Transaction(Result):
23
+ """A collection or withdrawal. `status` is PENDING, SUCCESSFUL or FAILED."""
24
+
25
+ __slots__ = ()
26
+
27
+ @property
28
+ def is_pending(self) -> bool:
29
+ return self.get("status") == PENDING
30
+
31
+ @property
32
+ def is_successful(self) -> bool:
33
+ return self.get("status") == SUCCESSFUL
34
+
35
+ @property
36
+ def is_failed(self) -> bool:
37
+ return self.get("status") == FAILED
38
+
39
+ @property
40
+ def is_final(self) -> bool:
41
+ return self.get("status") in (SUCCESSFUL, FAILED)
42
+
43
+
44
+ class WebhookEvent(Transaction):
45
+ """A verified webhook: status, reference, amount (int), currency, operator, external_reference..."""
46
+
47
+ __slots__ = ()
File without changes
@@ -0,0 +1,41 @@
1
+ """Webhook verification. Paysawa calls your app's webhook URL with a GET request: query parameters plus a `signature`
2
+ (a JWT signed with your app's webhook key). Always verify it before trusting the data."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ import jwt
8
+
9
+ from .errors import WebhookVerificationError
10
+ from .models import WebhookEvent
11
+
12
+
13
+ def verify_webhook(params: Mapping[str, Any], webhook_key: str) -> WebhookEvent:
14
+ """Verify a webhook and return it as a WebhookEvent. Raises WebhookVerificationError if it is not authentic.
15
+
16
+ `params` are the query parameters, as plain strings (Flask: request.args.to_dict(), FastAPI: dict(request.query_params),
17
+ Django: request.GET.dict()). Besides checking the signature, every signed field is compared with the received one, so a
18
+ valid signature cannot be replayed with other values (a different amount, for example).
19
+ """
20
+ if not webhook_key:
21
+ raise WebhookVerificationError("A webhook_key is required to verify webhooks.", code="no_webhook_key")
22
+ data = dict(params)
23
+ signature = data.get("signature")
24
+ if not signature:
25
+ raise WebhookVerificationError("The webhook has no signature.", code="missing_signature")
26
+ try:
27
+ signed = jwt.decode(signature, webhook_key, algorithms=["HS256"], leeway=10)
28
+ except jwt.ExpiredSignatureError:
29
+ raise WebhookVerificationError("The webhook signature has expired.", code="expired_signature") from None
30
+ except jwt.PyJWTError:
31
+ raise WebhookVerificationError("The webhook signature is not valid for this webhook key.", code="invalid_signature") from None
32
+ clean = {k: v for k, v in data.items() if k != "signature"}
33
+ for key, value in signed.items():
34
+ if key in ("iat", "exp"):
35
+ continue
36
+ if str(clean.get(key, "")) != str(value):
37
+ raise WebhookVerificationError(f"Parameter '{key}' does not match what was signed.", code="tampered")
38
+ amount = clean.get("amount")
39
+ if isinstance(amount, str) and amount.isdigit():
40
+ clean["amount"] = int(amount)
41
+ return WebhookEvent(clean)
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: paysawa
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Paysawa mobile money gateway (MTN / Orange): collect, withdraw, payment links and signed webhooks.
5
+ Author: Paysawa
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ChrinaerDev/paysawa-python
8
+ Project-URL: Issues, https://github.com/ChrinaerDev/paysawa-python/issues
9
+ Project-URL: Changelog, https://github.com/ChrinaerDev/paysawa-python/blob/main/CHANGELOG.md
10
+ Keywords: paysawa,mobile money,payments,cameroon,mtn,orange money,campay,xaf
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: httpx>=0.25
26
+ Requires-Dist: PyJWT>=2.4
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
+ Requires-Dist: build>=1.0; extra == "dev"
31
+ Requires-Dist: twine>=5.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # paysawa
35
+
36
+ Python SDK for the **Paysawa** mobile money gateway (MTN Mobile Money and Orange Money, XAF).
37
+ Collect payments, send money, create payment links and verify signed webhooks with a few lines of code.
38
+ It speaks Paysawa's Campay-style API, so the concepts are the same as Campay's.
39
+
40
+ ```bash
41
+ pip install paysawa
42
+ ```
43
+
44
+ ## 1. Get your credentials
45
+
46
+ In your Paysawa dashboard create an **app**. It gives you three values (you can show them again any time):
47
+
48
+ | Value | What it is |
49
+ |---|---|
50
+ | App username | like `pa_1a2b3c4d5e6f7a8b` |
51
+ | App password | secret, never put it in front-end code or in git |
52
+ | Webhook key | verifies that webhooks really come from Paysawa |
53
+
54
+ A **test** app uses the sandbox (no real money). A **live** app moves real money.
55
+
56
+ ## 2. Quick start
57
+
58
+ ```python
59
+ from paysawa import Client
60
+
61
+ client = Client(
62
+ app_username="pa_xxxxxxxxxxxxxxxx",
63
+ app_password="your-app-password",
64
+ webhook_key="whk_xxxxxxxx",
65
+ base_url="https://YOUR-PAYSAWA-URL/api/", # or set PAYSAWA_BASE_URL
66
+ )
67
+
68
+ # Ask a customer to pay: they get a prompt on their phone and approve with their PIN.
69
+ tx = client.collect(amount=5000, phone="670000001", description="Order 1001",
70
+ external_reference="order-1001", wait=True) # wait=True blocks until final
71
+ if tx.is_successful:
72
+ print("Paid!", tx.reference)
73
+ else:
74
+ print("Not paid:", tx.status)
75
+
76
+ print(client.get_balance().total_balance) # your Paysawa balance
77
+ client.withdraw(amount=1000, phone="690000001", description="Refund") # pay money out
78
+ link = client.get_payment_link(amount=2000, description="Order 1002", redirect_url="https://shop.example.com/thanks")
79
+ print(link.link) # send this to your customer
80
+ ```
81
+
82
+ Put the credentials in environment variables instead of code and use `Client.from_env()`:
83
+ `PAYSAWA_APP_USERNAME`, `PAYSAWA_APP_PASSWORD`, `PAYSAWA_WEBHOOK_KEY`, `PAYSAWA_BASE_URL`.
84
+
85
+ ## 3. Statuses
86
+
87
+ Every transaction is `PENDING`, `SUCCESSFUL` or `FAILED` (`tx.is_pending`, `tx.is_successful`, `tx.is_failed`, `tx.is_final`).
88
+ A collection stays `PENDING` until the customer approves. Without `wait=True`, check it later with
89
+ `client.get_transaction(reference)` or use `client.wait_for_transaction(reference, timeout=120)`, or rely on webhooks.
90
+
91
+ ## 4. Webhooks
92
+
93
+ Set your app's webhook URL in the dashboard. Paysawa calls it with a `GET` and query parameters plus a signed `signature`.
94
+ **Always verify it**:
95
+
96
+ ```python
97
+ # Flask
98
+ @app.get("/webhooks/paysawa")
99
+ def paysawa_webhook():
100
+ try:
101
+ event = client.verify_webhook(request.args.to_dict())
102
+ except WebhookVerificationError:
103
+ return "invalid", 401
104
+ if event.is_successful:
105
+ mark_order_paid(event.external_reference, event.amount)
106
+ return "ok"
107
+ ```
108
+
109
+ ```python
110
+ # FastAPI
111
+ @app.get("/webhooks/paysawa")
112
+ async def paysawa_webhook(request: Request):
113
+ event = client.verify_webhook(dict(request.query_params)) # raises WebhookVerificationError if not authentic
114
+ ...
115
+ ```
116
+
117
+ Django: `client.verify_webhook(request.GET.dict())`. Reply with any 2xx status; otherwise Paysawa retries.
118
+ Check `event.amount` against the order before you ship anything.
119
+
120
+ ## 5. Async
121
+
122
+ ```python
123
+ from paysawa import AsyncClient
124
+
125
+ async with AsyncClient.from_env() as client:
126
+ tx = await client.collect(5000, "670000001", "Order 1001", "order-1001", wait=True)
127
+ ```
128
+
129
+ ## 6. Errors
130
+
131
+ All errors inherit from `PaysawaError` (`.message`, `.status`, `.code`):
132
+
133
+ | Exception | When |
134
+ |---|---|
135
+ | `AuthenticationError` | wrong app username/password |
136
+ | `PermissionDeniedError` | live mode not enabled, account suspended |
137
+ | `InvalidRequestError` | bad amount or phone number, unknown reference, limits |
138
+ | `InsufficientFundsError` | withdrawal larger than your balance (amount + fee) |
139
+ | `RateLimitError` | too many requests |
140
+ | `ServerError` / `NetworkError` | Paysawa busy or unreachable |
141
+ | `WaitTimeoutError` | `wait_for_transaction` gave up (`.transaction` has the latest state) |
142
+ | `WebhookVerificationError` | the webhook is not authentic: ignore it |
143
+
144
+ Payments (collect, withdraw) are **never retried automatically**, so a retry can never charge twice.
145
+ Reads are retried twice. If a payment call fails with a network error, look the payment up by your
146
+ `external_reference` before trying again.
147
+
148
+ ## 7. Sandbox numbers (test apps)
149
+
150
+ `670000001` succeeds, a number ending in `0000` fails, a number ending in `9999` stays pending.
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ pip install -e ".[dev]"
156
+ pytest
157
+ ```
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/paysawa/__init__.py
5
+ src/paysawa/_version.py
6
+ src/paysawa/client.py
7
+ src/paysawa/errors.py
8
+ src/paysawa/models.py
9
+ src/paysawa/py.typed
10
+ src/paysawa/webhooks.py
11
+ src/paysawa.egg-info/PKG-INFO
12
+ src/paysawa.egg-info/SOURCES.txt
13
+ src/paysawa.egg-info/dependency_links.txt
14
+ src/paysawa.egg-info/requires.txt
15
+ src/paysawa.egg-info/top_level.txt
16
+ tests/test_client.py
17
+ tests/test_webhooks.py
@@ -0,0 +1,8 @@
1
+ httpx>=0.25
2
+ PyJWT>=2.4
3
+
4
+ [dev]
5
+ pytest>=8
6
+ pytest-asyncio>=0.23
7
+ build>=1.0
8
+ twine>=5.0
@@ -0,0 +1 @@
1
+ paysawa
@@ -0,0 +1,207 @@
1
+ import json
2
+ import os
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ import paysawa
8
+ from paysawa import (
9
+ AsyncClient, AuthenticationError, Client, InsufficientFundsError, InvalidRequestError, NetworkError,
10
+ PermissionDeniedError, RateLimitError, ServerError, Transaction, WaitTimeoutError,
11
+ )
12
+ import paysawa.client as client_module
13
+
14
+ client_module._sleep = lambda seconds: None # keep the tests instant
15
+
16
+
17
+ async def _no_sleep(seconds):
18
+ return None
19
+
20
+
21
+ client_module._asleep = _no_sleep
22
+ BASE = "http://paysawa.test/api/"
23
+
24
+
25
+ def make_server(**state):
26
+ state.setdefault("calls", [])
27
+ state.setdefault("tokens", 0)
28
+ state.setdefault("script", {}) # path suffix -> list of responses (last one repeats)
29
+
30
+ def respond(path):
31
+ queue = state["script"].get(path)
32
+ if not queue:
33
+ return None
34
+ item = queue.pop(0) if len(queue) > 1 else queue[0]
35
+ if isinstance(item, Exception):
36
+ raise item
37
+ return httpx.Response(item[0], json=item[1])
38
+
39
+ def handler(request):
40
+ path = request.url.path
41
+ body = json.loads(request.content) if request.content else {}
42
+ state["calls"].append((request.method, path, body, {k.lower(): v for k, v in request.headers.items()}))
43
+ if path.endswith("/token/"):
44
+ state["tokens"] += 1
45
+ if body.get("password") != "secret":
46
+ return httpx.Response(401, json={"message": "Invalid app username or password.", "code": "invalid_credentials"})
47
+ return httpx.Response(200, json={"token": f"T{state['tokens']}", "expires_in": 3600})
48
+ for suffix in state["script"]:
49
+ if path.endswith(suffix):
50
+ return respond(suffix)
51
+ return httpx.Response(404, json={"message": "no such endpoint", "code": "not_found"})
52
+
53
+ return handler, state
54
+
55
+
56
+ def make_client(**state):
57
+ handler, st = make_server(**state)
58
+ http = httpx.Client(transport=httpx.MockTransport(handler))
59
+ return Client("pa_demo", "secret", "whk_demo", base_url=BASE, http_client=http), st
60
+
61
+
62
+ def calls_to(state, suffix):
63
+ return [c for c in state["calls"] if c[1].endswith(suffix)]
64
+
65
+
66
+ def test_collect_sends_a_campay_style_request_and_caches_the_token():
67
+ client, st = make_client(script={"/collect/": [(200, {"reference": "col_1", "ussd_code": "*126#", "operator": "MTN", "status": "PENDING"})],
68
+ "/balance/": [(200, {"total_balance": 10, "held_balance": 0, "currency": "XAF"})]})
69
+ tx = client.collect(5000, "670000001", "Order 1001", "order-1001")
70
+ client.get_balance()
71
+ assert isinstance(tx, Transaction) and tx.reference == "col_1" and tx["operator"] == "MTN" and tx.is_pending and not tx.is_final
72
+ assert st["tokens"] == 1
73
+ method, path, body, headers = calls_to(st, "/collect/")[0]
74
+ assert headers["authorization"] == "Token T1"
75
+ assert body == {"amount": "5000", "currency": "XAF", "from": "670000001", "description": "Order 1001", "external_reference": "order-1001"}
76
+
77
+
78
+ def test_withdraw_uses_the_to_field_and_reports_status():
79
+ client, st = make_client(script={"/withdraw/": [(200, {"reference": "pay_1", "status": "SUCCESSFUL", "amount": 1000, "currency": "XAF", "operator": "ORANGE"})]})
80
+ tx = client.withdraw("1000", "690000001", "Refund")
81
+ assert tx.is_successful and tx.is_final and tx.operator == "ORANGE"
82
+ assert calls_to(st, "/withdraw/")[0][2]["to"] == "690000001"
83
+
84
+
85
+ def test_expired_token_is_replaced_once():
86
+ handler, st = make_server(script={"/balance/": [(401, {"message": "Invalid or expired token.", "code": "unauthorized"}),
87
+ (200, {"total_balance": 4875, "held_balance": 0, "currency": "XAF"})]})
88
+ client = Client("pa_demo", "secret", base_url=BASE, http_client=httpx.Client(transport=httpx.MockTransport(handler)))
89
+ assert client.get_balance().total_balance == 4875
90
+ assert st["tokens"] == 2
91
+
92
+
93
+ def test_wrong_credentials_raise_authentication_error():
94
+ handler, _ = make_server()
95
+ client = Client("pa_demo", "wrong", base_url=BASE, http_client=httpx.Client(transport=httpx.MockTransport(handler)))
96
+ with pytest.raises(AuthenticationError) as exc:
97
+ client.get_balance()
98
+ assert exc.value.code == "invalid_credentials" and exc.value.status == 401
99
+
100
+
101
+ def test_error_responses_become_specific_exceptions():
102
+ client, _ = make_client(script={
103
+ "/withdraw/": [(402, {"message": "Available balance is 0 XAF, 1025 XAF needed (amount + fee).", "code": "insufficient_funds"})],
104
+ "/collect/": [(422, {"message": "expected a Cameroonian mobile number", "code": "invalid_phone"})],
105
+ "/get_payment_link/": [(403, {"message": "Live mode is not enabled for this account yet.", "code": "live_not_enabled"})],
106
+ "/balance/": [(429, {"message": "Too many requests, slow down.", "code": "rate_limited"})],
107
+ })
108
+ with pytest.raises(InsufficientFundsError) as exc:
109
+ client.withdraw(1000, "690000001")
110
+ assert exc.value.code == "insufficient_funds" and isinstance(exc.value, InvalidRequestError)
111
+ with pytest.raises(InvalidRequestError) as exc:
112
+ client.collect(1000, "12345")
113
+ assert exc.value.code == "invalid_phone" and "[invalid_phone]" in str(exc.value)
114
+ with pytest.raises(PermissionDeniedError):
115
+ client.get_payment_link(2000)
116
+ with pytest.raises(RateLimitError):
117
+ client.get_balance()
118
+
119
+
120
+ def test_payments_are_never_retried_but_reads_are():
121
+ client, st = make_client(script={"/collect/": [(503, {"message": "busy", "code": "busy"})],
122
+ "/balance/": [(503, {"message": "busy", "code": "busy"}), (200, {"total_balance": 5, "held_balance": 0, "currency": "XAF"})]})
123
+ with pytest.raises(ServerError):
124
+ client.collect(1000, "670000001")
125
+ assert len(calls_to(st, "/collect/")) == 1 # a retry could have charged the customer twice
126
+ assert client.get_balance().total_balance == 5
127
+ assert len(calls_to(st, "/balance/")) == 2
128
+
129
+
130
+ def test_network_problems_are_wrapped():
131
+ client, st = make_client(script={"/collect/": [httpx.ConnectError("refused")], "/balance/": [httpx.ConnectError("refused")]})
132
+ with pytest.raises(NetworkError) as exc:
133
+ client.collect(1000, "670000001")
134
+ assert exc.value.code == "unreachable" and len(calls_to(st, "/collect/")) == 1
135
+ with pytest.raises(NetworkError):
136
+ client.get_balance()
137
+ assert len(calls_to(st, "/balance/")) == 3 # 1 try + 2 retries
138
+
139
+
140
+ def test_wait_for_transaction_polls_until_final():
141
+ pending = (200, {"reference": "col_1", "status": "PENDING"})
142
+ done = (200, {"reference": "col_1", "status": "SUCCESSFUL", "amount": 5000})
143
+ client, st = make_client(script={"/collect/": [(200, {"reference": "col_1", "status": "PENDING", "operator": "MTN", "ussd_code": "*126#"})],
144
+ "/transaction/col_1/": [pending, pending, done]})
145
+ tx = client.collect(5000, "670000001", wait=True)
146
+ assert tx.is_successful and tx.amount == 5000
147
+ assert len(calls_to(st, "/transaction/col_1/")) == 3
148
+
149
+
150
+ def test_wait_for_transaction_times_out_with_the_latest_state():
151
+ client, _ = make_client(script={"/transaction/col_1/": [(200, {"reference": "col_1", "status": "PENDING"})]})
152
+ clock = {"t": 0.0}
153
+ client_module._monotonic = lambda: clock.__setitem__("t", clock["t"] + 50.0) or clock["t"]
154
+ try:
155
+ with pytest.raises(WaitTimeoutError) as exc:
156
+ client.wait_for_transaction("col_1", timeout=120, interval=3)
157
+ finally:
158
+ import time
159
+ client_module._monotonic = time.monotonic
160
+ assert exc.value.transaction.is_pending and exc.value.code == "wait_timeout"
161
+
162
+
163
+ def test_balance_and_payment_link_and_attribute_access():
164
+ client, st = make_client(script={"/balance/": [(200, {"total_balance": 4875, "mtn_balance": None, "orange_balance": None, "held_balance": 0, "currency": "XAF"})],
165
+ "/get_payment_link/": [(200, {"link": "http://paysawa.test/pay/cs_1", "reference": "cs_1"})]})
166
+ assert client.get_balance().total_balance == 4875
167
+ link = client.get_payment_link(2000, "Order", "order-1", "https://shop.example.com/thanks")
168
+ assert link.link.endswith("/pay/cs_1") and link["reference"] == "cs_1"
169
+ assert calls_to(st, "/get_payment_link/")[0][2]["redirect_url"] == "https://shop.example.com/thanks"
170
+ with pytest.raises(AttributeError):
171
+ link.nothing_here
172
+
173
+
174
+ def test_bad_input_fails_before_any_request():
175
+ client, st = make_client()
176
+ for bad in (0, -5, 5.5, "abc"):
177
+ with pytest.raises(ValueError):
178
+ client.collect(bad, "670000001")
179
+ with pytest.raises(ValueError):
180
+ Client("", "x")
181
+ assert st["calls"] == []
182
+
183
+
184
+ def test_from_env():
185
+ os.environ.update({"PAYSAWA_APP_USERNAME": "pa_env", "PAYSAWA_APP_PASSWORD": "pw", "PAYSAWA_WEBHOOK_KEY": "whk_env", "PAYSAWA_BASE_URL": "http://x.test/api"})
186
+ try:
187
+ client = Client.from_env()
188
+ assert client.app_username == "pa_env" and client.webhook_key == "whk_env" and client.base_url == "http://x.test/api/"
189
+ os.environ.pop("PAYSAWA_APP_PASSWORD")
190
+ with pytest.raises(ValueError):
191
+ Client.from_env()
192
+ finally:
193
+ for key in ("PAYSAWA_APP_USERNAME", "PAYSAWA_APP_PASSWORD", "PAYSAWA_WEBHOOK_KEY", "PAYSAWA_BASE_URL"):
194
+ os.environ.pop(key, None)
195
+
196
+
197
+ async def test_async_client_collect_and_wait():
198
+ handler, st = make_server(script={"/collect/": [(200, {"reference": "col_7", "status": "PENDING"})],
199
+ "/transaction/col_7/": [(200, {"reference": "col_7", "status": "PENDING"}), (200, {"reference": "col_7", "status": "FAILED", "reason": "x"})]})
200
+ client = AsyncClient("pa_demo", "secret", "whk_demo", base_url=BASE, http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
201
+ tx = await client.collect(5000, "670000000", wait=True)
202
+ assert tx.is_failed and tx.reason == "x"
203
+ assert st["tokens"] == 1
204
+
205
+
206
+ def test_package_exports():
207
+ assert paysawa.__version__ and paysawa.PENDING == "PENDING" and callable(paysawa.verify_webhook)
@@ -0,0 +1,46 @@
1
+ import time
2
+
3
+ import jwt
4
+ import pytest
5
+
6
+ from paysawa import Client, WebhookVerificationError, verify_webhook
7
+
8
+ KEY = "whk_test_key"
9
+
10
+
11
+ def signed(params, key=KEY, exp_in=3600):
12
+ now = int(time.time())
13
+ return {**params, "signature": jwt.encode({**params, "iat": now, "exp": now + exp_in}, key, algorithm="HS256")}
14
+
15
+
16
+ PARAMS = {"status": "SUCCESSFUL", "reference": "col_1", "amount": "5000", "currency": "XAF", "operator": "MTN",
17
+ "code": "", "operator_reference": "op-1", "external_reference": "order-1001"}
18
+
19
+
20
+ def test_valid_webhook_becomes_an_event():
21
+ event = verify_webhook(signed(PARAMS), KEY)
22
+ assert event.is_successful and event.amount == 5000 and event.external_reference == "order-1001" and "signature" not in event
23
+
24
+
25
+ def test_client_verifies_with_its_own_key():
26
+ client = Client("pa_x", "pw", KEY, base_url="http://x.test/api/")
27
+ assert client.verify_webhook(signed(PARAMS)).reference == "col_1"
28
+
29
+
30
+ def test_changed_values_wrong_key_missing_and_expired_are_rejected():
31
+ good = signed(PARAMS)
32
+ with pytest.raises(WebhookVerificationError) as exc:
33
+ verify_webhook({**good, "amount": "1"}, KEY)
34
+ assert exc.value.code == "tampered"
35
+ with pytest.raises(WebhookVerificationError) as exc:
36
+ verify_webhook(signed(PARAMS, key="whk_other"), KEY)
37
+ assert exc.value.code == "invalid_signature"
38
+ with pytest.raises(WebhookVerificationError) as exc:
39
+ verify_webhook(PARAMS, KEY)
40
+ assert exc.value.code == "missing_signature"
41
+ with pytest.raises(WebhookVerificationError) as exc:
42
+ verify_webhook(signed(PARAMS, exp_in=-3600), KEY)
43
+ assert exc.value.code == "expired_signature"
44
+ with pytest.raises(WebhookVerificationError) as exc:
45
+ verify_webhook(good, "")
46
+ assert exc.value.code == "no_webhook_key"