pesapal-python 1.0.0__py3-none-any.whl
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.
- pesapal/__init__.py +39 -0
- pesapal/client.py +367 -0
- pesapal/exceptions.py +53 -0
- pesapal/mobile_money.py +116 -0
- pesapal/models.py +86 -0
- pesapal/py.typed +0 -0
- pesapal_python-1.0.0.dist-info/METADATA +176 -0
- pesapal_python-1.0.0.dist-info/RECORD +11 -0
- pesapal_python-1.0.0.dist-info/WHEEL +5 -0
- pesapal_python-1.0.0.dist-info/licenses/LICENSE +21 -0
- pesapal_python-1.0.0.dist-info/top_level.txt +1 -0
pesapal/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Python SDK for the Pesapal API v3.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
from pesapal import PesapalClient, OrderRequest, BillingAddress
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .client import (
|
|
11
|
+
PRODUCTION_BASE_URL,
|
|
12
|
+
SANDBOX_BASE_URL,
|
|
13
|
+
PesapalClient,
|
|
14
|
+
)
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
PesapalAPIError,
|
|
17
|
+
PesapalAuthError,
|
|
18
|
+
PesapalConfigError,
|
|
19
|
+
PesapalError,
|
|
20
|
+
PesapalMobileMoneyError,
|
|
21
|
+
)
|
|
22
|
+
from .models import BillingAddress, MobileMoneyResult, OrderRequest
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PesapalClient",
|
|
28
|
+
"OrderRequest",
|
|
29
|
+
"BillingAddress",
|
|
30
|
+
"MobileMoneyResult",
|
|
31
|
+
"PesapalError",
|
|
32
|
+
"PesapalConfigError",
|
|
33
|
+
"PesapalAuthError",
|
|
34
|
+
"PesapalAPIError",
|
|
35
|
+
"PesapalMobileMoneyError",
|
|
36
|
+
"PRODUCTION_BASE_URL",
|
|
37
|
+
"SANDBOX_BASE_URL",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
pesapal/client.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Synchronous client for the Pesapal API v3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any, Dict, Optional, Union
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
PesapalAPIError,
|
|
13
|
+
PesapalAuthError,
|
|
14
|
+
PesapalConfigError,
|
|
15
|
+
)
|
|
16
|
+
from .models import MobileMoneyResult, OrderRequest
|
|
17
|
+
|
|
18
|
+
#: Base URL for the Pesapal production environment.
|
|
19
|
+
PRODUCTION_BASE_URL = "https://pay.pesapal.com/v3"
|
|
20
|
+
#: Base URL for the Pesapal sandbox/demo environment.
|
|
21
|
+
SANDBOX_BASE_URL = "https://cybqa.pesapal.com/pesapalv3"
|
|
22
|
+
|
|
23
|
+
PayloadType = Union[Dict[str, Any], OrderRequest]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PesapalClient:
|
|
27
|
+
"""A thin, typed wrapper around the Pesapal REST API.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
from pesapal import PesapalClient, OrderRequest, BillingAddress
|
|
32
|
+
|
|
33
|
+
client = PesapalClient("consumer_key", "consumer_secret", sandbox=True)
|
|
34
|
+
ipn = client.register_ipn("https://example.com/ipn", "POST")
|
|
35
|
+
order = OrderRequest(
|
|
36
|
+
amount=1000,
|
|
37
|
+
currency="UGX",
|
|
38
|
+
description="Test payment",
|
|
39
|
+
callback_url="https://example.com/callback",
|
|
40
|
+
notification_id=ipn["ipn_id"],
|
|
41
|
+
billing_address=BillingAddress(
|
|
42
|
+
email_address="customer@example.com",
|
|
43
|
+
phone_number="0775000000",
|
|
44
|
+
country_code="UG",
|
|
45
|
+
first_name="Jane",
|
|
46
|
+
last_name="Doe",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
submitted = client.submit_order(order)
|
|
50
|
+
status = client.get_transaction_status(submitted["order_tracking_id"])
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
consumer_key: str,
|
|
56
|
+
consumer_secret: str,
|
|
57
|
+
*,
|
|
58
|
+
sandbox: bool = False,
|
|
59
|
+
base_url: Optional[str] = None,
|
|
60
|
+
timeout: int = 30,
|
|
61
|
+
session: Optional[requests.Session] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
if not consumer_key or not consumer_secret:
|
|
64
|
+
raise PesapalConfigError(
|
|
65
|
+
"consumer_key and consumer_secret are required."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.consumer_key = consumer_key
|
|
69
|
+
self.consumer_secret = consumer_secret
|
|
70
|
+
self.base_url = (
|
|
71
|
+
base_url
|
|
72
|
+
or (SANDBOX_BASE_URL if sandbox else PRODUCTION_BASE_URL)
|
|
73
|
+
).rstrip("/")
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.session = session or requests.Session()
|
|
76
|
+
|
|
77
|
+
self._token: Optional[str] = None
|
|
78
|
+
self._token_expiry: Optional[datetime] = None
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Construction from environment
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_env(
|
|
85
|
+
cls,
|
|
86
|
+
*,
|
|
87
|
+
sandbox: bool = False,
|
|
88
|
+
dotenv_path: Optional[str] = None,
|
|
89
|
+
load_dotenv: bool = True,
|
|
90
|
+
**kwargs: Any,
|
|
91
|
+
) -> "PesapalClient":
|
|
92
|
+
"""Build a client from environment variables.
|
|
93
|
+
|
|
94
|
+
Reads the following variables (optionally loaded from a ``.env`` file)::
|
|
95
|
+
|
|
96
|
+
PESAPAL_CONSUMER_KEY
|
|
97
|
+
PESAPAL_CONSUMER_SECRET
|
|
98
|
+
PESAPAL_PRODUCTION_BASE_URL (optional)
|
|
99
|
+
PESAPAL_SANDBOX_BASE_URL (optional)
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
sandbox: Use the sandbox base URL when ``True``.
|
|
103
|
+
dotenv_path: Optional explicit path to a ``.env`` file.
|
|
104
|
+
load_dotenv: When ``True`` (default), attempt to load a ``.env``
|
|
105
|
+
file using ``python-dotenv`` if it is installed.
|
|
106
|
+
**kwargs: Forwarded to :class:`PesapalClient` (e.g. ``timeout``).
|
|
107
|
+
"""
|
|
108
|
+
if load_dotenv:
|
|
109
|
+
try:
|
|
110
|
+
from dotenv import load_dotenv as _load_dotenv
|
|
111
|
+
|
|
112
|
+
_load_dotenv(dotenv_path)
|
|
113
|
+
except ImportError:
|
|
114
|
+
if dotenv_path:
|
|
115
|
+
raise PesapalConfigError(
|
|
116
|
+
"python-dotenv is required to load a .env file. "
|
|
117
|
+
'Install it with: pip install python-dotenv'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
consumer_key = os.getenv("PESAPAL_CONSUMER_KEY")
|
|
121
|
+
consumer_secret = os.getenv("PESAPAL_CONSUMER_SECRET")
|
|
122
|
+
if not consumer_key or not consumer_secret:
|
|
123
|
+
raise PesapalConfigError(
|
|
124
|
+
"PESAPAL_CONSUMER_KEY and PESAPAL_CONSUMER_SECRET must be set "
|
|
125
|
+
"in the environment or a .env file."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
base_url = kwargs.pop("base_url", None)
|
|
129
|
+
if base_url is None:
|
|
130
|
+
if sandbox:
|
|
131
|
+
base_url = os.getenv("PESAPAL_SANDBOX_BASE_URL") or SANDBOX_BASE_URL
|
|
132
|
+
else:
|
|
133
|
+
base_url = (
|
|
134
|
+
os.getenv("PESAPAL_PRODUCTION_BASE_URL") or PRODUCTION_BASE_URL
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return cls(
|
|
138
|
+
consumer_key,
|
|
139
|
+
consumer_secret,
|
|
140
|
+
sandbox=sandbox,
|
|
141
|
+
base_url=base_url,
|
|
142
|
+
**kwargs,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Authentication
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
def authenticate(self) -> str:
|
|
149
|
+
"""Request a fresh bearer token and cache it. Returns the token."""
|
|
150
|
+
url = f"{self.base_url}/api/Auth/RequestToken"
|
|
151
|
+
try:
|
|
152
|
+
resp = self.session.post(
|
|
153
|
+
url,
|
|
154
|
+
json={
|
|
155
|
+
"consumer_key": self.consumer_key,
|
|
156
|
+
"consumer_secret": self.consumer_secret,
|
|
157
|
+
},
|
|
158
|
+
headers={
|
|
159
|
+
"Accept": "application/json",
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
},
|
|
162
|
+
timeout=self.timeout,
|
|
163
|
+
)
|
|
164
|
+
except requests.RequestException as exc:
|
|
165
|
+
raise PesapalAuthError(f"Authentication request failed: {exc}") from exc
|
|
166
|
+
|
|
167
|
+
data = self._safe_json(resp)
|
|
168
|
+
token = data.get("token")
|
|
169
|
+
if not token:
|
|
170
|
+
raise PesapalAuthError(
|
|
171
|
+
f"No token returned by Pesapal: {data.get('error') or data}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self._token = token
|
|
175
|
+
self._token_expiry = self._parse_expiry(data.get("expiryDate"))
|
|
176
|
+
return token
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def token(self) -> Optional[str]:
|
|
180
|
+
"""The currently cached token (may be ``None`` or expired)."""
|
|
181
|
+
return self._token
|
|
182
|
+
|
|
183
|
+
def _ensure_token(self) -> str:
|
|
184
|
+
"""Return a valid token, authenticating/refreshing if necessary."""
|
|
185
|
+
if self._token is None:
|
|
186
|
+
return self.authenticate()
|
|
187
|
+
if self._token_expiry is not None:
|
|
188
|
+
now = datetime.now(timezone.utc)
|
|
189
|
+
if now >= self._token_expiry - timedelta(seconds=60):
|
|
190
|
+
return self.authenticate()
|
|
191
|
+
return self._token
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
# IPN registration (needed to obtain a notification_id for orders)
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
def register_ipn(
|
|
197
|
+
self, url: str, ipn_notification_type: str = "POST"
|
|
198
|
+
) -> Dict[str, Any]:
|
|
199
|
+
"""Register an Instant Payment Notification (IPN) URL.
|
|
200
|
+
|
|
201
|
+
Returns the decoded response containing the ``ipn_id``.
|
|
202
|
+
"""
|
|
203
|
+
return self._request(
|
|
204
|
+
"POST",
|
|
205
|
+
"/api/URLSetup/RegisterIPN",
|
|
206
|
+
json={"url": url, "ipn_notification_type": ipn_notification_type},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def get_ipn_list(self) -> Any:
|
|
210
|
+
"""Return the list of IPN URLs registered for this merchant."""
|
|
211
|
+
return self._request("GET", "/api/URLSetup/GetIpnList")
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
# Orders
|
|
215
|
+
# ------------------------------------------------------------------
|
|
216
|
+
def submit_order(self, order: PayloadType) -> Dict[str, Any]:
|
|
217
|
+
"""Submit an order request.
|
|
218
|
+
|
|
219
|
+
Accepts an :class:`OrderRequest` or a raw ``dict``. Returns the decoded
|
|
220
|
+
response which includes ``order_tracking_id``, ``merchant_reference``
|
|
221
|
+
and ``redirect_url``.
|
|
222
|
+
"""
|
|
223
|
+
payload = order.to_dict() if isinstance(order, OrderRequest) else order
|
|
224
|
+
return self._request(
|
|
225
|
+
"POST", "/api/Transactions/SubmitOrderRequest", json=payload
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def get_transaction_status(self, order_tracking_id: str) -> Dict[str, Any]:
|
|
229
|
+
"""Fetch the status of a transaction by its order tracking id."""
|
|
230
|
+
return self._request(
|
|
231
|
+
"GET",
|
|
232
|
+
"/api/Transactions/GetTransactionStatus",
|
|
233
|
+
params={"orderTrackingId": order_tracking_id},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def refund(
|
|
237
|
+
self,
|
|
238
|
+
confirmation_code: str,
|
|
239
|
+
amount: Union[str, float],
|
|
240
|
+
username: str,
|
|
241
|
+
remarks: str,
|
|
242
|
+
) -> Dict[str, Any]:
|
|
243
|
+
"""Request a refund for a completed transaction."""
|
|
244
|
+
return self._request(
|
|
245
|
+
"POST",
|
|
246
|
+
"/api/Transactions/RefundRequest",
|
|
247
|
+
json={
|
|
248
|
+
"confirmation_code": confirmation_code,
|
|
249
|
+
"amount": str(amount),
|
|
250
|
+
"username": username,
|
|
251
|
+
"remarks": remarks,
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def cancel_order(self, order_tracking_id: str) -> Dict[str, Any]:
|
|
256
|
+
"""Cancel a pending order by its order tracking id."""
|
|
257
|
+
return self._request(
|
|
258
|
+
"POST",
|
|
259
|
+
"/api/Transactions/CancelOrder",
|
|
260
|
+
json={"order_tracking_id": order_tracking_id},
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Mobile money (Selenium helper, optional dependency)
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
def initiate_mobile_money_payment(
|
|
267
|
+
self, redirect_url: str, **kwargs: Any
|
|
268
|
+
) -> MobileMoneyResult:
|
|
269
|
+
"""Drive the hosted Pesapal iframe to trigger a mobile-money STK push.
|
|
270
|
+
|
|
271
|
+
Uses the ``redirect_url`` returned by :meth:`submit_order`. The phone
|
|
272
|
+
number is already prepopulated on the hosted form, so it is not filled
|
|
273
|
+
here. Requires the optional ``mobilemoney`` extra (Selenium + Chrome).
|
|
274
|
+
|
|
275
|
+
Additional keyword arguments are forwarded to
|
|
276
|
+
:func:`pesapal.mobile_money.initiate_mobile_money_payment`.
|
|
277
|
+
"""
|
|
278
|
+
from .mobile_money import initiate_mobile_money_payment
|
|
279
|
+
|
|
280
|
+
return initiate_mobile_money_payment(redirect_url, **kwargs)
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
# Internal helpers
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
def _request(
|
|
286
|
+
self,
|
|
287
|
+
method: str,
|
|
288
|
+
path: str,
|
|
289
|
+
*,
|
|
290
|
+
json: Optional[Dict[str, Any]] = None,
|
|
291
|
+
params: Optional[Dict[str, Any]] = None,
|
|
292
|
+
) -> Any:
|
|
293
|
+
token = self._ensure_token()
|
|
294
|
+
url = f"{self.base_url}{path}"
|
|
295
|
+
headers = {
|
|
296
|
+
"Accept": "application/json",
|
|
297
|
+
"Content-Type": "application/json",
|
|
298
|
+
"Authorization": f"Bearer {token}",
|
|
299
|
+
}
|
|
300
|
+
try:
|
|
301
|
+
resp = self.session.request(
|
|
302
|
+
method,
|
|
303
|
+
url,
|
|
304
|
+
json=json,
|
|
305
|
+
params=params,
|
|
306
|
+
headers=headers,
|
|
307
|
+
timeout=self.timeout,
|
|
308
|
+
)
|
|
309
|
+
except requests.RequestException as exc:
|
|
310
|
+
raise PesapalAPIError(f"Request to {path} failed: {exc}") from exc
|
|
311
|
+
|
|
312
|
+
data = self._safe_json(resp)
|
|
313
|
+
self._raise_for_pesapal_error(resp, data)
|
|
314
|
+
return data
|
|
315
|
+
|
|
316
|
+
def _safe_json(self, resp: requests.Response) -> Any:
|
|
317
|
+
try:
|
|
318
|
+
return resp.json()
|
|
319
|
+
except ValueError:
|
|
320
|
+
raise PesapalAPIError(
|
|
321
|
+
"Pesapal returned a non-JSON response.",
|
|
322
|
+
status_code=resp.status_code,
|
|
323
|
+
response=resp.text,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def _raise_for_pesapal_error(
|
|
327
|
+
self, resp: requests.Response, data: Any
|
|
328
|
+
) -> None:
|
|
329
|
+
# Pesapal returns HTTP 200 even for logical errors, signalling them via
|
|
330
|
+
# a non-null ``error`` object, so we inspect both.
|
|
331
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
332
|
+
has_error = bool(error) and any(
|
|
333
|
+
error.get(k) for k in ("error_type", "code", "message")
|
|
334
|
+
) if isinstance(error, dict) else bool(error)
|
|
335
|
+
|
|
336
|
+
if resp.status_code >= 400 or has_error:
|
|
337
|
+
message = "Pesapal API error"
|
|
338
|
+
if isinstance(error, dict) and error.get("message"):
|
|
339
|
+
message = str(error["message"])
|
|
340
|
+
elif isinstance(data, dict) and data.get("message"):
|
|
341
|
+
message = str(data["message"])
|
|
342
|
+
raise PesapalAPIError(
|
|
343
|
+
message,
|
|
344
|
+
status_code=resp.status_code,
|
|
345
|
+
error=error,
|
|
346
|
+
response=data,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _parse_expiry(value: Optional[str]) -> Optional[datetime]:
|
|
351
|
+
if not value:
|
|
352
|
+
return None
|
|
353
|
+
text = value.strip()
|
|
354
|
+
if text.endswith("Z"):
|
|
355
|
+
text = text[:-1] + "+00:00"
|
|
356
|
+
try:
|
|
357
|
+
dt = datetime.fromisoformat(text)
|
|
358
|
+
except ValueError:
|
|
359
|
+
# Trim fractional seconds beyond microsecond precision if present.
|
|
360
|
+
try:
|
|
361
|
+
head, _, tail = text.partition(".")
|
|
362
|
+
dt = datetime.fromisoformat(head)
|
|
363
|
+
except ValueError:
|
|
364
|
+
return None
|
|
365
|
+
if dt.tzinfo is None:
|
|
366
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
367
|
+
return dt
|
pesapal/exceptions.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Exceptions raised by the Pesapal SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PesapalError(Exception):
|
|
9
|
+
"""Base class for all Pesapal SDK errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PesapalConfigError(PesapalError):
|
|
13
|
+
"""Raised when the client is misconfigured (e.g. missing credentials)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PesapalAuthError(PesapalError):
|
|
17
|
+
"""Raised when authentication with the Pesapal API fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PesapalAPIError(PesapalError):
|
|
21
|
+
"""Raised when the Pesapal API returns an error response.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
message: Human readable error message.
|
|
25
|
+
status_code: HTTP status code of the response, if available.
|
|
26
|
+
error: The parsed ``error`` object returned by Pesapal, if any.
|
|
27
|
+
response: The raw decoded JSON body of the response, if any.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
status_code: Optional[int] = None,
|
|
34
|
+
error: Optional[Any] = None,
|
|
35
|
+
response: Optional[Any] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.message = message
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
self.error = error
|
|
41
|
+
self.response = response
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
44
|
+
parts = [self.message]
|
|
45
|
+
if self.status_code is not None:
|
|
46
|
+
parts.append(f"(HTTP {self.status_code})")
|
|
47
|
+
if self.error:
|
|
48
|
+
parts.append(f"error={self.error!r}")
|
|
49
|
+
return " ".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PesapalMobileMoneyError(PesapalError):
|
|
53
|
+
"""Raised when the Selenium mobile-money flow fails."""
|
pesapal/mobile_money.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Selenium-based mobile money helper.
|
|
2
|
+
|
|
3
|
+
The Pesapal mobile-money flow happens on a hosted iframe page (the
|
|
4
|
+
``redirect_url`` returned by ``SubmitOrderRequest``). There is no public JSON
|
|
5
|
+
endpoint to trigger the STK push directly, so this module drives a headless
|
|
6
|
+
browser to submit the prepopulated form and waits for Pesapal to redirect back
|
|
7
|
+
with a ``ResponseId``.
|
|
8
|
+
|
|
9
|
+
This module requires the optional ``mobilemoney`` extra::
|
|
10
|
+
|
|
11
|
+
pip install "pesapal-python[mobilemoney]"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from urllib.parse import parse_qs, urlparse
|
|
17
|
+
|
|
18
|
+
from .exceptions import PesapalMobileMoneyError
|
|
19
|
+
from .models import MobileMoneyResult
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from selenium import webdriver
|
|
23
|
+
from selenium.webdriver.common.by import By
|
|
24
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
25
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
26
|
+
|
|
27
|
+
_SELENIUM_AVAILABLE = True
|
|
28
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
29
|
+
_SELENIUM_AVAILABLE = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_driver(headless: bool):
|
|
33
|
+
options = webdriver.ChromeOptions()
|
|
34
|
+
if headless:
|
|
35
|
+
options.add_argument("--headless=new")
|
|
36
|
+
options.add_argument("--window-size=1280,800")
|
|
37
|
+
return webdriver.Chrome(options=options)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def initiate_mobile_money_payment(
|
|
41
|
+
redirect_url: str,
|
|
42
|
+
*,
|
|
43
|
+
headless: bool = True,
|
|
44
|
+
timeout: int = 60,
|
|
45
|
+
click_timeout: int = 5,
|
|
46
|
+
driver=None,
|
|
47
|
+
) -> MobileMoneyResult:
|
|
48
|
+
"""Submit the hosted Pesapal payment form and wait for the redirect.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
redirect_url: The ``redirect_url`` from ``submit_order``.
|
|
52
|
+
headless: Run Chrome without a visible window (default ``True``).
|
|
53
|
+
timeout: Seconds to wait for the post-submit redirect.
|
|
54
|
+
click_timeout: Seconds to wait for clickable elements (radios/button).
|
|
55
|
+
driver: Optional pre-built Selenium WebDriver to use instead of
|
|
56
|
+
creating one. When supplied, the caller is responsible for quitting.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A :class:`~pesapal.models.MobileMoneyResult`.
|
|
60
|
+
"""
|
|
61
|
+
if not _SELENIUM_AVAILABLE:
|
|
62
|
+
raise PesapalMobileMoneyError(
|
|
63
|
+
"Selenium is not installed. Install the optional extra: "
|
|
64
|
+
'pip install "pesapal-python[mobilemoney]"'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
owns_driver = driver is None
|
|
68
|
+
if owns_driver:
|
|
69
|
+
driver = _build_driver(headless)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
driver.get(redirect_url)
|
|
73
|
+
|
|
74
|
+
wait = WebDriverWait(driver, timeout)
|
|
75
|
+
click_wait = WebDriverWait(driver, click_timeout)
|
|
76
|
+
|
|
77
|
+
# The phone number is prepopulated on the hosted form; just locate the
|
|
78
|
+
# submit button and click it once it is interactable.
|
|
79
|
+
proceed_button = click_wait.until(
|
|
80
|
+
EC.element_to_be_clickable((By.ID, "submitFormBtn"))
|
|
81
|
+
)
|
|
82
|
+
driver.execute_script(
|
|
83
|
+
"arguments[0].scrollIntoView({block:'center'});", proceed_button
|
|
84
|
+
)
|
|
85
|
+
proceed_button.click()
|
|
86
|
+
|
|
87
|
+
# Pesapal processes the request and redirects back with a ResponseId.
|
|
88
|
+
wait.until(lambda drv: "ResponseId=" in drv.current_url)
|
|
89
|
+
|
|
90
|
+
final_url = driver.current_url
|
|
91
|
+
query = parse_qs(urlparse(final_url).query)
|
|
92
|
+
tracking_id = query.get("OrderTrackingId", [None])[0]
|
|
93
|
+
response_id = query.get("ResponseId", [None])[0]
|
|
94
|
+
|
|
95
|
+
return MobileMoneyResult(
|
|
96
|
+
success=True,
|
|
97
|
+
tracking_id=tracking_id,
|
|
98
|
+
response_id=response_id,
|
|
99
|
+
final_url=final_url,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
except Exception as exc: # noqa: BLE001 - surface as a typed result
|
|
103
|
+
current_url = None
|
|
104
|
+
try:
|
|
105
|
+
current_url = driver.current_url
|
|
106
|
+
except Exception: # pragma: no cover - driver may be dead
|
|
107
|
+
pass
|
|
108
|
+
return MobileMoneyResult(
|
|
109
|
+
success=False,
|
|
110
|
+
final_url=current_url,
|
|
111
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
finally:
|
|
115
|
+
if owns_driver:
|
|
116
|
+
driver.quit()
|
pesapal/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Typed request/response models for the Pesapal SDK.
|
|
2
|
+
|
|
3
|
+
These :mod:`dataclasses` are convenience helpers. Every client method also
|
|
4
|
+
accepts a plain ``dict`` if you prefer to build payloads yourself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _new_uuid() -> str:
|
|
15
|
+
return str(uuid.uuid4())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class BillingAddress:
|
|
20
|
+
"""Customer billing information attached to an order."""
|
|
21
|
+
|
|
22
|
+
email_address: Optional[str] = None
|
|
23
|
+
phone_number: Optional[str] = None
|
|
24
|
+
country_code: Optional[str] = None
|
|
25
|
+
first_name: Optional[str] = None
|
|
26
|
+
middle_name: Optional[str] = None
|
|
27
|
+
last_name: Optional[str] = None
|
|
28
|
+
line_1: Optional[str] = None
|
|
29
|
+
line_2: Optional[str] = None
|
|
30
|
+
city: Optional[str] = None
|
|
31
|
+
state: Optional[str] = None
|
|
32
|
+
postal_code: Optional[str] = None
|
|
33
|
+
zip_code: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
36
|
+
return {k: ("" if v is None else v) for k, v in asdict(self).items()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class OrderRequest:
|
|
41
|
+
"""Payload for ``SubmitOrderRequest``.
|
|
42
|
+
|
|
43
|
+
``notification_id`` is the IPN id returned by :meth:`PesapalClient.register_ipn`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
amount: float
|
|
47
|
+
currency: str
|
|
48
|
+
description: str
|
|
49
|
+
callback_url: str
|
|
50
|
+
notification_id: str
|
|
51
|
+
billing_address: BillingAddress
|
|
52
|
+
id: str = field(default_factory=_new_uuid)
|
|
53
|
+
redirect_mode: str = ""
|
|
54
|
+
branch: Optional[str] = None
|
|
55
|
+
cancellation_url: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
58
|
+
payload: Dict[str, Any] = {
|
|
59
|
+
"id": self.id,
|
|
60
|
+
"currency": self.currency,
|
|
61
|
+
"amount": self.amount,
|
|
62
|
+
"description": self.description,
|
|
63
|
+
"callback_url": self.callback_url,
|
|
64
|
+
"redirect_mode": self.redirect_mode,
|
|
65
|
+
"notification_id": self.notification_id,
|
|
66
|
+
"billing_address": self.billing_address.to_dict(),
|
|
67
|
+
}
|
|
68
|
+
if self.branch is not None:
|
|
69
|
+
payload["branch"] = self.branch
|
|
70
|
+
if self.cancellation_url is not None:
|
|
71
|
+
payload["cancellation_url"] = self.cancellation_url
|
|
72
|
+
return payload
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MobileMoneyResult:
|
|
77
|
+
"""Result of the Selenium-driven mobile money flow."""
|
|
78
|
+
|
|
79
|
+
success: bool
|
|
80
|
+
tracking_id: Optional[str] = None
|
|
81
|
+
response_id: Optional[str] = None
|
|
82
|
+
final_url: Optional[str] = None
|
|
83
|
+
error: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
86
|
+
return asdict(self)
|
pesapal/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pesapal-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for the Pesapal API v3 (authentication, orders, transaction status, refunds, cancellations) with an optional Selenium mobile-money helper.
|
|
5
|
+
Author: Pesapal SDK Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/robin-001/pesapal-py
|
|
8
|
+
Project-URL: Documentation, https://github.com/robin-001/pesapal-py#readme
|
|
9
|
+
Project-URL: Source, https://github.com/robin-001/pesapal-py
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/robin-001/pesapal-py/issues
|
|
11
|
+
Keywords: pesapal,payments,mobile money,uganda,kenya,api
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: requests>=2.25
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0
|
|
28
|
+
Provides-Extra: mobilemoney
|
|
29
|
+
Requires-Dist: selenium>=4.10; extra == "mobilemoney"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
32
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
33
|
+
Requires-Dist: build; extra == "dev"
|
|
34
|
+
Requires-Dist: twine; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# pesapal-python
|
|
38
|
+
|
|
39
|
+
A lightweight Python SDK for the [Pesapal API v3](https://developer.pesapal.com/),
|
|
40
|
+
covering authentication, order submission, transaction status, refunds and
|
|
41
|
+
cancellations, plus an optional Selenium helper to trigger the mobile-money
|
|
42
|
+
STK push from the hosted payment page.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Authenticate** and transparently cache/refresh the bearer token.
|
|
47
|
+
- **Register IPN** URLs and list them (needed to obtain a `notification_id`).
|
|
48
|
+
- **Submit orders** and get back the `redirect_url` / `order_tracking_id`.
|
|
49
|
+
- **Initiate mobile money** payment by driving the hosted iframe (optional).
|
|
50
|
+
- **Get transaction status** for an order.
|
|
51
|
+
- **Refund** completed transactions.
|
|
52
|
+
- **Cancel** pending orders.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install pesapal-python
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
To use the Selenium mobile-money helper (requires Google Chrome + chromedriver):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install "pesapal-python[mobilemoney]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configuration via `.env`
|
|
67
|
+
|
|
68
|
+
Copy `.env.example` to `.env` and fill in your credentials:
|
|
69
|
+
|
|
70
|
+
```dotenv
|
|
71
|
+
PESAPAL_CONSUMER_KEY=xxx
|
|
72
|
+
PESAPAL_CONSUMER_SECRET=xxx=
|
|
73
|
+
PESAPAL_PRODUCTION_BASE_URL=https://pay.pesapal.com/v3
|
|
74
|
+
PESAPAL_SANDBOX_BASE_URL=https://cybqa.pesapal.com/pesapalv3
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then build the client straight from the environment:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pesapal import PesapalClient
|
|
81
|
+
|
|
82
|
+
client = PesapalClient.from_env(sandbox=True) # loads .env automatically
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`from_env` reads `PESAPAL_CONSUMER_KEY` / `PESAPAL_CONSUMER_SECRET` and picks the
|
|
86
|
+
base URL from `PESAPAL_SANDBOX_BASE_URL` or `PESAPAL_PRODUCTION_BASE_URL`
|
|
87
|
+
depending on `sandbox`, falling back to the built-in defaults.
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pesapal import PesapalClient, OrderRequest, BillingAddress
|
|
93
|
+
|
|
94
|
+
client = PesapalClient(
|
|
95
|
+
consumer_key="YOUR_CONSUMER_KEY",
|
|
96
|
+
consumer_secret="YOUR_CONSUMER_SECRET",
|
|
97
|
+
sandbox=True, # omit or set False for production
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 1. Authenticate (optional — called automatically on first request)
|
|
101
|
+
client.authenticate()
|
|
102
|
+
|
|
103
|
+
# 2. Register an IPN URL (once) to obtain a notification_id
|
|
104
|
+
ipn = client.register_ipn("https://your-app.com/ipn", "POST")
|
|
105
|
+
notification_id = ipn["ipn_id"]
|
|
106
|
+
|
|
107
|
+
# 3. Submit an order
|
|
108
|
+
order = OrderRequest(
|
|
109
|
+
amount=1000,
|
|
110
|
+
currency="UGX",
|
|
111
|
+
description="Order #1234",
|
|
112
|
+
callback_url="https://your-app.com/callback",
|
|
113
|
+
notification_id=notification_id,
|
|
114
|
+
branch="HQ",
|
|
115
|
+
billing_address=BillingAddress(
|
|
116
|
+
email_address="customer@example.com",
|
|
117
|
+
phone_number="0775000000",
|
|
118
|
+
country_code="UG",
|
|
119
|
+
first_name="Jane",
|
|
120
|
+
last_name="Doe",
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
submitted = client.submit_order(order)
|
|
124
|
+
order_tracking_id = submitted["order_tracking_id"]
|
|
125
|
+
redirect_url = submitted["redirect_url"]
|
|
126
|
+
|
|
127
|
+
# 4. Initiate mobile money (drives the hosted form; phone is prepopulated)
|
|
128
|
+
result = client.initiate_mobile_money_payment(redirect_url, headless=True)
|
|
129
|
+
print(result.success, result.tracking_id, result.response_id)
|
|
130
|
+
|
|
131
|
+
# 5. Check transaction status
|
|
132
|
+
status = client.get_transaction_status(order_tracking_id)
|
|
133
|
+
print(status["payment_status_description"])
|
|
134
|
+
|
|
135
|
+
# 6. Refund a completed transaction
|
|
136
|
+
client.refund(
|
|
137
|
+
confirmation_code=status["confirmation_code"],
|
|
138
|
+
amount=1000,
|
|
139
|
+
username="Operator Name",
|
|
140
|
+
remarks="Service not offered",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 7. Cancel a pending order
|
|
144
|
+
client.cancel_order(order_tracking_id)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Environments
|
|
148
|
+
|
|
149
|
+
| Environment | Base URL |
|
|
150
|
+
| ----------- | ------------------------------------------ |
|
|
151
|
+
| Production | `https://pay.pesapal.com/v3` |
|
|
152
|
+
| Sandbox | `https://cybqa.pesapal.com/pesapalv3` |
|
|
153
|
+
|
|
154
|
+
Select with `PesapalClient(..., sandbox=True)` or pass a custom `base_url`.
|
|
155
|
+
|
|
156
|
+
## Error handling
|
|
157
|
+
|
|
158
|
+
All API failures raise a subclass of `PesapalError`:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from pesapal import PesapalAPIError, PesapalAuthError
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
client.submit_order(order)
|
|
165
|
+
except PesapalAuthError as exc:
|
|
166
|
+
... # bad credentials / token problem
|
|
167
|
+
except PesapalAPIError as exc:
|
|
168
|
+
print(exc.status_code, exc.error, exc.response)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The mobile-money helper does **not** raise on failure; it returns a
|
|
172
|
+
`MobileMoneyResult` with `success=False` and an `error` message.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pesapal/__init__.py,sha256=LguQyBanzYFgFWxY8ceg3I-1jJB8-t4HDc_vSD3Am6A,782
|
|
2
|
+
pesapal/client.py,sha256=y56liFM0pFuJiEvmDKEzbO8dOgXb_i0uo_y76QgLc9A,13170
|
|
3
|
+
pesapal/exceptions.py,sha256=P-SO8CcqT3Gha4NAkfLudPyjmrq1LI38t5dACQ-SZZM,1557
|
|
4
|
+
pesapal/mobile_money.py,sha256=aWs8JlC-gs6R_-polhxfKaBvSzvUsndkTH2-eMfFY6A,3858
|
|
5
|
+
pesapal/models.py,sha256=IjkoInd_Tz1KcgM20QGymWKcdBq7m2PlG7yi4lLQKIw,2493
|
|
6
|
+
pesapal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pesapal_python-1.0.0.dist-info/licenses/LICENSE,sha256=uZg89QLWGUDSijT7JTXecrHJjiglUPBHXcWay4Q1ap0,1081
|
|
8
|
+
pesapal_python-1.0.0.dist-info/METADATA,sha256=ZXbnMoLMpMqXttfpTuBL4ySuo5WzW5ULOOoKQbPtVZM,5657
|
|
9
|
+
pesapal_python-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
pesapal_python-1.0.0.dist-info/top_level.txt,sha256=80JDSH0Hu8HEr0jPh_9LNGythBU1UNFZQRFa9DtaGUc,8
|
|
11
|
+
pesapal_python-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pesapal SDK Contributors
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pesapal
|