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 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."""
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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