clickpesa-python-sdk 0.1.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.
@@ -0,0 +1,248 @@
1
+ """
2
+ Payment collection services — USSD Push and Card Payments.
3
+
4
+ Sync: ``PaymentService`` — attach to :class:`~clickpesa.client.ClickPesaClient`.
5
+ Async: ``AsyncPaymentService`` — attach to :class:`~clickpesa.async_client.AsyncClickPesaClient`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import ClickPesaClient
14
+ from ..async_client import AsyncClickPesaClient
15
+
16
+ _BASE = "/third-parties/payments"
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Sync
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class PaymentService:
24
+ """Synchronous payment collection methods."""
25
+
26
+ def __init__(self, client: "ClickPesaClient") -> None:
27
+ self._c = client
28
+
29
+ def preview_ussd_push(
30
+ self,
31
+ amount: str,
32
+ order_id: str,
33
+ phone: str | None = None,
34
+ currency: str = "TZS",
35
+ fetch_sender_details: bool = False,
36
+ ) -> dict[str, Any]:
37
+ """
38
+ Validate a USSD Push request and check available payment methods.
39
+
40
+ Args:
41
+ amount: Payment amount.
42
+ order_id: Unique alphanumeric order reference.
43
+ phone: Customer phone with country code, no ``+``
44
+ (e.g. ``"255712345678"``). Optional.
45
+ currency: Must be ``"TZS"`` (default).
46
+ fetch_sender_details: When ``True`` the response includes the
47
+ sender's name, number and provider.
48
+
49
+ Returns:
50
+ Dict with ``activeMethods`` list and optional ``sender`` object.
51
+ """
52
+ payload: dict[str, Any] = {
53
+ "amount": str(amount),
54
+ "currency": currency,
55
+ "orderReference": order_id,
56
+ "fetchSenderDetails": fetch_sender_details,
57
+ }
58
+ if phone is not None:
59
+ payload["phoneNumber"] = phone
60
+ return self._c.request("POST", f"{_BASE}/preview-ussd-push-request", json=payload)
61
+
62
+ def initiate_ussd_push(
63
+ self,
64
+ amount: str,
65
+ phone: str,
66
+ order_id: str,
67
+ currency: str = "TZS",
68
+ ) -> dict[str, Any]:
69
+ """
70
+ Trigger the USSD PIN prompt on the customer's phone.
71
+
72
+ Args:
73
+ amount: Payment amount.
74
+ phone: Customer phone with country code, no ``+``.
75
+ order_id: Unique alphanumeric order reference.
76
+ currency: Must be ``"TZS"`` (default).
77
+
78
+ Returns:
79
+ Transaction object with ``id``, ``status``, ``channel``, etc.
80
+ """
81
+ payload: dict[str, Any] = {
82
+ "amount": str(amount),
83
+ "phoneNumber": phone,
84
+ "currency": currency,
85
+ "orderReference": order_id,
86
+ }
87
+ return self._c.request("POST", f"{_BASE}/initiate-ussd-push-request", json=payload)
88
+
89
+ def preview_card(
90
+ self,
91
+ amount: str,
92
+ order_id: str,
93
+ currency: str = "USD",
94
+ ) -> dict[str, Any]:
95
+ """
96
+ Validate card payment details and check available card methods.
97
+
98
+ Args:
99
+ amount: Payment amount.
100
+ order_id: Unique alphanumeric order reference.
101
+ currency: Must be ``"USD"`` (default).
102
+
103
+ Returns:
104
+ Dict with ``activeMethods`` list (VISA / MASTER CARD).
105
+ """
106
+ payload: dict[str, Any] = {
107
+ "amount": str(amount),
108
+ "currency": currency,
109
+ "orderReference": order_id,
110
+ }
111
+ return self._c.request("POST", f"{_BASE}/preview-card-payment", json=payload)
112
+
113
+ def initiate_card(
114
+ self,
115
+ amount: str,
116
+ order_id: str,
117
+ customer: dict[str, str],
118
+ currency: str = "USD",
119
+ ) -> dict[str, Any]:
120
+ """
121
+ Generate a hosted card payment link for the customer.
122
+
123
+ Args:
124
+ amount: Payment amount.
125
+ order_id: Unique alphanumeric order reference.
126
+ customer: Either ``{"id": "…"}`` **or**
127
+ ``{"fullName": "…", "email": "…", "phoneNumber": "…"}``.
128
+ currency: Must be ``"USD"`` (default).
129
+
130
+ Returns:
131
+ Dict with ``cardPaymentLink`` and ``clientId``.
132
+ """
133
+ payload: dict[str, Any] = {
134
+ "amount": str(amount),
135
+ "orderReference": order_id,
136
+ "currency": currency,
137
+ "customer": customer,
138
+ }
139
+ return self._c.request("POST", f"{_BASE}/initiate-card-payment", json=payload)
140
+
141
+ def get_status(self, order_reference: str) -> list[dict[str, Any]]:
142
+ """
143
+ Query the status of a payment by its order reference.
144
+
145
+ Returns:
146
+ List of payment objects matching the reference.
147
+ """
148
+ return self._c.request("GET", f"{_BASE}/{order_reference}")
149
+
150
+ def list_all(self, **filters: Any) -> dict[str, Any]:
151
+ """
152
+ Query all payments with optional filtering and pagination.
153
+
154
+ Keyword Args:
155
+ startDate (str): ``YYYY-MM-DD`` or ``DD-MM-YYYY``.
156
+ endDate (str): ``YYYY-MM-DD`` or ``DD-MM-YYYY``.
157
+ status (str): ``SUCCESS`` | ``SETTLED`` | ``PROCESSING``
158
+ | ``PENDING`` | ``FAILED``.
159
+ collectedCurrency (str): e.g. ``"TZS"`` or ``"USD"``.
160
+ channel (str): Payment channel identifier.
161
+ orderReference (str): Filter by specific reference.
162
+ sortBy (str): Any response field (default ``createdAt``).
163
+ orderBy (str): ``ASC`` or ``DESC`` (default ``DESC``).
164
+ skip (int): Pagination offset (default ``0``).
165
+ limit (int): Page size (default ``20``).
166
+
167
+ Returns:
168
+ Dict with ``data`` (list) and ``totalCount`` (int).
169
+ """
170
+ return self._c.request("GET", f"{_BASE}/all", params=filters)
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Async
175
+ # ---------------------------------------------------------------------------
176
+
177
+ class AsyncPaymentService:
178
+ """Asynchronous payment collection methods (mirrors :class:`PaymentService`)."""
179
+
180
+ def __init__(self, client: "AsyncClickPesaClient") -> None:
181
+ self._c = client
182
+
183
+ async def preview_ussd_push(
184
+ self,
185
+ amount: str,
186
+ order_id: str,
187
+ phone: str | None = None,
188
+ currency: str = "TZS",
189
+ fetch_sender_details: bool = False,
190
+ ) -> dict[str, Any]:
191
+ payload: dict[str, Any] = {
192
+ "amount": str(amount),
193
+ "currency": currency,
194
+ "orderReference": order_id,
195
+ "fetchSenderDetails": fetch_sender_details,
196
+ }
197
+ if phone is not None:
198
+ payload["phoneNumber"] = phone
199
+ return await self._c.request("POST", f"{_BASE}/preview-ussd-push-request", json=payload)
200
+
201
+ async def initiate_ussd_push(
202
+ self,
203
+ amount: str,
204
+ phone: str,
205
+ order_id: str,
206
+ currency: str = "TZS",
207
+ ) -> dict[str, Any]:
208
+ payload: dict[str, Any] = {
209
+ "amount": str(amount),
210
+ "phoneNumber": phone,
211
+ "currency": currency,
212
+ "orderReference": order_id,
213
+ }
214
+ return await self._c.request("POST", f"{_BASE}/initiate-ussd-push-request", json=payload)
215
+
216
+ async def preview_card(
217
+ self,
218
+ amount: str,
219
+ order_id: str,
220
+ currency: str = "USD",
221
+ ) -> dict[str, Any]:
222
+ payload: dict[str, Any] = {
223
+ "amount": str(amount),
224
+ "currency": currency,
225
+ "orderReference": order_id,
226
+ }
227
+ return await self._c.request("POST", f"{_BASE}/preview-card-payment", json=payload)
228
+
229
+ async def initiate_card(
230
+ self,
231
+ amount: str,
232
+ order_id: str,
233
+ customer: dict[str, str],
234
+ currency: str = "USD",
235
+ ) -> dict[str, Any]:
236
+ payload: dict[str, Any] = {
237
+ "amount": str(amount),
238
+ "orderReference": order_id,
239
+ "currency": currency,
240
+ "customer": customer,
241
+ }
242
+ return await self._c.request("POST", f"{_BASE}/initiate-card-payment", json=payload)
243
+
244
+ async def get_status(self, order_reference: str) -> list[dict[str, Any]]:
245
+ return await self._c.request("GET", f"{_BASE}/{order_reference}")
246
+
247
+ async def list_all(self, **filters: Any) -> dict[str, Any]:
248
+ return await self._c.request("GET", f"{_BASE}/all", params=filters)
@@ -0,0 +1,299 @@
1
+ """
2
+ Disbursement services — Mobile Money and Bank Payouts.
3
+
4
+ Sync: ``PayoutService`` — attach to :class:`~clickpesa.client.ClickPesaClient`.
5
+ Async: ``AsyncPayoutService`` — attach to :class:`~clickpesa.async_client.AsyncClickPesaClient`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import ClickPesaClient
14
+ from ..async_client import AsyncClickPesaClient
15
+
16
+ _BASE = "/third-parties/payouts"
17
+ _BANKS = "/third-parties/list/banks"
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Sync
22
+ # ---------------------------------------------------------------------------
23
+
24
+ class PayoutService:
25
+ """Synchronous disbursement methods."""
26
+
27
+ def __init__(self, client: "ClickPesaClient") -> None:
28
+ self._c = client
29
+
30
+ # --- Mobile Money ---
31
+
32
+ def preview_mobile_money(
33
+ self,
34
+ amount: float,
35
+ phone: str,
36
+ order_id: str,
37
+ currency: str = "TZS",
38
+ ) -> dict[str, Any]:
39
+ """
40
+ Check fees and account balance before disbursing to a mobile wallet.
41
+
42
+ Args:
43
+ amount: Payout amount.
44
+ phone: Recipient phone with country code, no ``+``.
45
+ order_id: Unique alphanumeric order reference.
46
+ currency: Source currency — ``"TZS"`` or ``"USD"`` (default ``"TZS"``).
47
+ Recipient always receives funds in TZS.
48
+
49
+ Returns:
50
+ Preview dict with ``amount``, ``balance``, ``fee``, ``receiver``, etc.
51
+ """
52
+ payload: dict[str, Any] = {
53
+ "amount": amount,
54
+ "phoneNumber": phone,
55
+ "currency": currency,
56
+ "orderReference": order_id,
57
+ }
58
+ return self._c.request("POST", f"{_BASE}/preview-mobile-money-payout", json=payload)
59
+
60
+ def create_mobile_money(
61
+ self,
62
+ amount: float,
63
+ phone: str,
64
+ order_id: str,
65
+ currency: str = "TZS",
66
+ ) -> dict[str, Any]:
67
+ """
68
+ Disburse funds to a mobile money wallet.
69
+
70
+ Args:
71
+ amount: Payout amount.
72
+ phone: Recipient phone with country code, no ``+``.
73
+ order_id: Unique alphanumeric order reference.
74
+ currency: Source currency — ``"TZS"`` or ``"USD"`` (default ``"TZS"``).
75
+
76
+ Returns:
77
+ Transaction object with ``id``, ``status``, ``beneficiary``, etc.
78
+ """
79
+ payload: dict[str, Any] = {
80
+ "amount": amount,
81
+ "phoneNumber": phone,
82
+ "currency": currency,
83
+ "orderReference": order_id,
84
+ }
85
+ return self._c.request("POST", f"{_BASE}/create-mobile-money-payout", json=payload)
86
+
87
+ # --- Bank Payouts ---
88
+
89
+ def preview_bank(
90
+ self,
91
+ amount: float,
92
+ account_number: str,
93
+ bic: str,
94
+ order_id: str,
95
+ transfer_type: str = "ACH",
96
+ currency: str = "TZS",
97
+ account_currency: str = "TZS",
98
+ ) -> dict[str, Any]:
99
+ """
100
+ Validate bank details and check fees before an ACH / RTGS transfer.
101
+
102
+ Args:
103
+ amount: Payout amount.
104
+ account_number: Beneficiary bank account number.
105
+ bic: Bank identifier code — fetch via :meth:`get_banks`.
106
+ order_id: Unique alphanumeric order reference.
107
+ transfer_type: ``"ACH"`` (default) or ``"RTGS"``.
108
+ currency: Source currency — ``"TZS"`` or ``"USD"`` (default ``"TZS"``).
109
+ account_currency: Receiving currency — currently only ``"TZS"`` (default).
110
+
111
+ Returns:
112
+ Preview dict with ``amount``, ``balance``, ``fee``, ``receiver``, etc.
113
+ """
114
+ payload: dict[str, Any] = {
115
+ "amount": amount,
116
+ "accountNumber": account_number,
117
+ "bic": bic,
118
+ "orderReference": order_id,
119
+ "transferType": transfer_type,
120
+ "currency": currency,
121
+ "accountCurrency": account_currency,
122
+ }
123
+ return self._c.request("POST", f"{_BASE}/preview-bank-payout", json=payload)
124
+
125
+ def create_bank(
126
+ self,
127
+ amount: float,
128
+ account_number: str,
129
+ account_name: str,
130
+ bic: str,
131
+ order_id: str,
132
+ transfer_type: str = "ACH",
133
+ currency: str = "TZS",
134
+ account_currency: str = "TZS",
135
+ ) -> dict[str, Any]:
136
+ """
137
+ Disburse funds to a bank account.
138
+
139
+ Args:
140
+ amount: Payout amount.
141
+ account_number: Beneficiary bank account number.
142
+ account_name: Beneficiary name as registered with the bank.
143
+ bic: Bank identifier code — fetch via :meth:`get_banks`.
144
+ order_id: Unique alphanumeric order reference.
145
+ transfer_type: ``"ACH"`` (default) or ``"RTGS"``.
146
+ currency: Source currency — ``"TZS"`` or ``"USD"`` (default ``"TZS"``).
147
+ account_currency: Receiving currency — currently only ``"TZS"`` (default).
148
+
149
+ Returns:
150
+ Transaction object with ``id``, ``status``, ``beneficiary``, etc.
151
+ """
152
+ payload: dict[str, Any] = {
153
+ "amount": amount,
154
+ "accountNumber": account_number,
155
+ "accountName": account_name,
156
+ "bic": bic,
157
+ "orderReference": order_id,
158
+ "transferType": transfer_type,
159
+ "currency": currency,
160
+ "accountCurrency": account_currency,
161
+ }
162
+ return self._c.request("POST", f"{_BASE}/create-bank-payout", json=payload)
163
+
164
+ # --- Utilities ---
165
+
166
+ def get_banks(self) -> list[dict[str, Any]]:
167
+ """
168
+ Fetch the list of supported banks and their BIC codes.
169
+
170
+ Returns:
171
+ List of ``{"name": "…", "bic": "…"}`` dicts.
172
+ """
173
+ return self._c.request("GET", _BANKS)
174
+
175
+ def get_status(self, order_reference: str) -> list[dict[str, Any]]:
176
+ """
177
+ Query the status of a payout by its order reference.
178
+
179
+ Returns:
180
+ List of payout objects matching the reference.
181
+ """
182
+ return self._c.request("GET", f"{_BASE}/{order_reference}")
183
+
184
+ def list_all(self, **filters: Any) -> dict[str, Any]:
185
+ """
186
+ Query payout history with optional filtering and pagination.
187
+
188
+ Keyword Args:
189
+ startDate (str): ``YYYY-MM-DD`` or ``DD-MM-YYYY``.
190
+ endDate (str): ``YYYY-MM-DD`` or ``DD-MM-YYYY``.
191
+ channel (str): ``"BANK TRANSFER"`` | ``"MOBILE MONEY"``.
192
+ currency (str): e.g. ``"TZS"`` or ``"USD"``.
193
+ orderReference (str): Filter by specific reference.
194
+ status (str): ``SUCCESS`` | ``PROCESSING`` | ``PENDING``
195
+ | ``FAILED`` | ``REFUNDED`` | ``REVERSED``.
196
+ transferType (str): ``"ACH"`` | ``"RTGS"``.
197
+ sortBy (str): Any response field (default ``createdAt``).
198
+ orderBy (str): ``ASC`` or ``DESC`` (default ``DESC``).
199
+ skip (int): Pagination offset (default ``0``).
200
+ limit (int): Page size (default ``20``).
201
+
202
+ Returns:
203
+ Dict with ``data`` (list) and ``totalCount`` (int).
204
+ """
205
+ return self._c.request("GET", f"{_BASE}/all", params=filters)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Async
210
+ # ---------------------------------------------------------------------------
211
+
212
+ class AsyncPayoutService:
213
+ """Asynchronous disbursement methods (mirrors :class:`PayoutService`)."""
214
+
215
+ def __init__(self, client: "AsyncClickPesaClient") -> None:
216
+ self._c = client
217
+
218
+ async def preview_mobile_money(
219
+ self,
220
+ amount: float,
221
+ phone: str,
222
+ order_id: str,
223
+ currency: str = "TZS",
224
+ ) -> dict[str, Any]:
225
+ payload: dict[str, Any] = {
226
+ "amount": amount,
227
+ "phoneNumber": phone,
228
+ "currency": currency,
229
+ "orderReference": order_id,
230
+ }
231
+ return await self._c.request("POST", f"{_BASE}/preview-mobile-money-payout", json=payload)
232
+
233
+ async def create_mobile_money(
234
+ self,
235
+ amount: float,
236
+ phone: str,
237
+ order_id: str,
238
+ currency: str = "TZS",
239
+ ) -> dict[str, Any]:
240
+ payload: dict[str, Any] = {
241
+ "amount": amount,
242
+ "phoneNumber": phone,
243
+ "currency": currency,
244
+ "orderReference": order_id,
245
+ }
246
+ return await self._c.request("POST", f"{_BASE}/create-mobile-money-payout", json=payload)
247
+
248
+ async def preview_bank(
249
+ self,
250
+ amount: float,
251
+ account_number: str,
252
+ bic: str,
253
+ order_id: str,
254
+ transfer_type: str = "ACH",
255
+ currency: str = "TZS",
256
+ account_currency: str = "TZS",
257
+ ) -> dict[str, Any]:
258
+ payload: dict[str, Any] = {
259
+ "amount": amount,
260
+ "accountNumber": account_number,
261
+ "bic": bic,
262
+ "orderReference": order_id,
263
+ "transferType": transfer_type,
264
+ "currency": currency,
265
+ "accountCurrency": account_currency,
266
+ }
267
+ return await self._c.request("POST", f"{_BASE}/preview-bank-payout", json=payload)
268
+
269
+ async def create_bank(
270
+ self,
271
+ amount: float,
272
+ account_number: str,
273
+ account_name: str,
274
+ bic: str,
275
+ order_id: str,
276
+ transfer_type: str = "ACH",
277
+ currency: str = "TZS",
278
+ account_currency: str = "TZS",
279
+ ) -> dict[str, Any]:
280
+ payload: dict[str, Any] = {
281
+ "amount": amount,
282
+ "accountNumber": account_number,
283
+ "accountName": account_name,
284
+ "bic": bic,
285
+ "orderReference": order_id,
286
+ "transferType": transfer_type,
287
+ "currency": currency,
288
+ "accountCurrency": account_currency,
289
+ }
290
+ return await self._c.request("POST", f"{_BASE}/create-bank-payout", json=payload)
291
+
292
+ async def get_banks(self) -> list[dict[str, Any]]:
293
+ return await self._c.request("GET", _BANKS)
294
+
295
+ async def get_status(self, order_reference: str) -> list[dict[str, Any]]:
296
+ return await self._c.request("GET", f"{_BASE}/{order_reference}")
297
+
298
+ async def list_all(self, **filters: Any) -> dict[str, Any]:
299
+ return await self._c.request("GET", f"{_BASE}/all", params=filters)
clickpesa/webhooks.py ADDED
@@ -0,0 +1,42 @@
1
+ """
2
+ Webhook signature verification helpers.
3
+
4
+ Usage::
5
+
6
+ from clickpesa import WebhookValidator
7
+
8
+ is_valid = WebhookValidator.verify(
9
+ payload=request.json(),
10
+ signature=request.headers["X-ClickPesa-Signature"],
11
+ checksum_key="your-checksum-secret",
12
+ )
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .security import SecurityManager
18
+
19
+
20
+ class WebhookValidator:
21
+ """Static helper for validating ClickPesa webhook payloads."""
22
+
23
+ @staticmethod
24
+ def verify(payload: dict, signature: str, checksum_key: str) -> bool:
25
+ """
26
+ Verify that an incoming webhook was genuinely sent by ClickPesa.
27
+
28
+ Uses constant-time comparison (``hmac.compare_digest``) to prevent
29
+ timing-based attacks.
30
+
31
+ Args:
32
+ payload: Parsed JSON body of the webhook request.
33
+ signature: Value of the ``X-ClickPesa-Signature`` header.
34
+ checksum_key: Your application's checksum secret key.
35
+
36
+ Returns:
37
+ ``True`` if the signature is valid, ``False`` otherwise.
38
+ """
39
+ return SecurityManager.verify_webhook(checksum_key, payload, signature)
40
+
41
+
42
+ __all__ = ["WebhookValidator"]