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 +21 -0
- paysawa-0.1.0/PKG-INFO +157 -0
- paysawa-0.1.0/README.md +124 -0
- paysawa-0.1.0/pyproject.toml +48 -0
- paysawa-0.1.0/setup.cfg +4 -0
- paysawa-0.1.0/src/paysawa/__init__.py +24 -0
- paysawa-0.1.0/src/paysawa/_version.py +1 -0
- paysawa-0.1.0/src/paysawa/client.py +322 -0
- paysawa-0.1.0/src/paysawa/errors.py +78 -0
- paysawa-0.1.0/src/paysawa/models.py +47 -0
- paysawa-0.1.0/src/paysawa/py.typed +0 -0
- paysawa-0.1.0/src/paysawa/webhooks.py +41 -0
- paysawa-0.1.0/src/paysawa.egg-info/PKG-INFO +157 -0
- paysawa-0.1.0/src/paysawa.egg-info/SOURCES.txt +17 -0
- paysawa-0.1.0/src/paysawa.egg-info/dependency_links.txt +1 -0
- paysawa-0.1.0/src/paysawa.egg-info/requires.txt +8 -0
- paysawa-0.1.0/src/paysawa.egg-info/top_level.txt +1 -0
- paysawa-0.1.0/tests/test_client.py +207 -0
- paysawa-0.1.0/tests/test_webhooks.py +46 -0
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
|
+
```
|
paysawa-0.1.0/README.md
ADDED
|
@@ -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"]
|
paysawa-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"
|