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,340 @@
1
+ """
2
+ BillPay collection services — Order and Customer Control Numbers.
3
+
4
+ Sync: ``BillPayService`` — attach to :class:`~clickpesa.client.ClickPesaClient`.
5
+ Async: ``AsyncBillPayService`` — attach to :class:`~clickpesa.async_client.AsyncClickPesaClient`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import ClickPesaClient
14
+ from ..async_client import AsyncClickPesaClient
15
+
16
+ _BASE = "/third-parties/billpay"
17
+ _BULK_LIMIT = 50
18
+
19
+ BillPaymentMode = Literal["ALLOW_PARTIAL_AND_OVER_PAYMENT", "EXACT"]
20
+ BillStatus = Literal["ACTIVE", "INACTIVE"]
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Sync
25
+ # ---------------------------------------------------------------------------
26
+
27
+ class BillPayService:
28
+ """Synchronous BillPay control-number management."""
29
+
30
+ def __init__(self, client: "ClickPesaClient") -> None:
31
+ self._c = client
32
+
33
+ def create_order_control_number(
34
+ self,
35
+ bill_reference: str | None = None,
36
+ amount: float | None = None,
37
+ description: str | None = None,
38
+ payment_mode: BillPaymentMode | None = None,
39
+ ) -> dict[str, Any]:
40
+ """
41
+ Generate a one-time order control number.
42
+
43
+ All fields are optional. If ``bill_reference`` is omitted the API
44
+ auto-generates one. ``payment_mode`` is only applied when ``amount``
45
+ is also set.
46
+
47
+ Args:
48
+ bill_reference: Custom alphanumeric reference (must be unique).
49
+ amount: Bill amount.
50
+ description: Human-readable bill description.
51
+ payment_mode: ``"ALLOW_PARTIAL_AND_OVER_PAYMENT"`` (default) or ``"EXACT"``.
52
+
53
+ Returns:
54
+ Dict with ``billPayNumber``, ``billDescription``, ``billAmount``, etc.
55
+ """
56
+ payload: dict[str, Any] = {}
57
+ if bill_reference is not None:
58
+ payload["billReference"] = bill_reference
59
+ if amount is not None:
60
+ payload["billAmount"] = amount
61
+ if description is not None:
62
+ payload["billDescription"] = description
63
+ if payment_mode is not None:
64
+ payload["billPaymentMode"] = payment_mode
65
+ return self._c.request("POST", f"{_BASE}/create-order-control-number", json=payload)
66
+
67
+ def create_customer_control_number(
68
+ self,
69
+ customer_name: str,
70
+ phone: str | None = None,
71
+ email: str | None = None,
72
+ bill_reference: str | None = None,
73
+ amount: float | None = None,
74
+ description: str | None = None,
75
+ payment_mode: BillPaymentMode | None = None,
76
+ ) -> dict[str, Any]:
77
+ """
78
+ Generate a persistent control number tied to a specific customer.
79
+
80
+ Either ``phone`` or ``email`` (or both) must be supplied.
81
+
82
+ Args:
83
+ customer_name: Customer's full name.
84
+ phone: Phone with country code, no ``+`` (e.g. ``"255712345678"``).
85
+ email: Customer email address.
86
+ bill_reference: Custom alphanumeric reference (must be unique).
87
+ amount: Bill amount.
88
+ description: Human-readable bill description.
89
+ payment_mode: ``"ALLOW_PARTIAL_AND_OVER_PAYMENT"`` (default) or ``"EXACT"``.
90
+
91
+ Returns:
92
+ Dict with ``billPayNumber``, ``billCustomerName``, ``billAmount``, etc.
93
+ """
94
+ if phone is None and email is None:
95
+ raise ValueError("At least one of 'phone' or 'email' must be provided.")
96
+
97
+ payload: dict[str, Any] = {"customerName": customer_name}
98
+ if phone is not None:
99
+ payload["customerPhone"] = phone
100
+ if email is not None:
101
+ payload["customerEmail"] = email
102
+ if bill_reference is not None:
103
+ payload["billReference"] = bill_reference
104
+ if amount is not None:
105
+ payload["billAmount"] = amount
106
+ if description is not None:
107
+ payload["billDescription"] = description
108
+ if payment_mode is not None:
109
+ payload["billPaymentMode"] = payment_mode
110
+ return self._c.request("POST", f"{_BASE}/create-customer-control-number", json=payload)
111
+
112
+ def bulk_create_order_numbers(
113
+ self,
114
+ control_numbers: list[dict[str, Any]],
115
+ ) -> dict[str, Any]:
116
+ """
117
+ Bulk-create up to 50 order control numbers in a single request.
118
+
119
+ Each item may contain: ``billReference``, ``billAmount``,
120
+ ``billDescription``, ``billPaymentMode``.
121
+
122
+ Args:
123
+ control_numbers: List of order dicts (1–50 items).
124
+
125
+ Returns:
126
+ Dict with ``billPayNumbers``, ``created``, ``failed``, and
127
+ optional ``errors`` list.
128
+
129
+ Raises:
130
+ ValueError: If the list exceeds 50 items or is empty.
131
+ """
132
+ if not control_numbers:
133
+ raise ValueError("control_numbers must contain at least 1 item.")
134
+ if len(control_numbers) > _BULK_LIMIT:
135
+ raise ValueError(f"ClickPesa bulk limit is {_BULK_LIMIT} items per request.")
136
+ return self._c.request(
137
+ "POST",
138
+ f"{_BASE}/bulk-create-order-control-numbers",
139
+ json={"controlNumbers": control_numbers},
140
+ )
141
+
142
+ def bulk_create_customer_numbers(
143
+ self,
144
+ control_numbers: list[dict[str, Any]],
145
+ ) -> dict[str, Any]:
146
+ """
147
+ Bulk-create up to 50 customer control numbers in a single request.
148
+
149
+ Each item must contain ``customerName`` and at least one of
150
+ ``customerPhone`` / ``customerEmail``. Optional fields:
151
+ ``billReference``, ``billAmount``, ``billDescription``, ``billPaymentMode``.
152
+
153
+ Args:
154
+ control_numbers: List of customer dicts (1–50 items).
155
+
156
+ Returns:
157
+ Dict with ``billPayNumbers``, ``created``, ``failed``, and
158
+ optional ``errors`` list.
159
+
160
+ Raises:
161
+ ValueError: If the list exceeds 50 items or is empty.
162
+ """
163
+ if not control_numbers:
164
+ raise ValueError("control_numbers must contain at least 1 item.")
165
+ if len(control_numbers) > _BULK_LIMIT:
166
+ raise ValueError(f"ClickPesa bulk limit is {_BULK_LIMIT} items per request.")
167
+ return self._c.request(
168
+ "POST",
169
+ f"{_BASE}/bulk-create-customer-control-numbers",
170
+ json={"controlNumbers": control_numbers},
171
+ )
172
+
173
+ def get_details(self, bill_pay_number: str) -> dict[str, Any]:
174
+ """
175
+ Query details of a specific control number.
176
+
177
+ Returns:
178
+ Dict with ``billPayNumber``, ``billDescription``, ``billAmount``,
179
+ ``billPaymentMode``, and ``billCustomerName``.
180
+ """
181
+ return self._c.request("GET", f"{_BASE}/{bill_pay_number}")
182
+
183
+ def update_reference(
184
+ self,
185
+ bill_pay_number: str,
186
+ amount: float | None = None,
187
+ description: str | None = None,
188
+ status: BillStatus | None = None,
189
+ payment_mode: BillPaymentMode | None = None,
190
+ ) -> dict[str, Any]:
191
+ """
192
+ Partially update a BillPay reference.
193
+
194
+ At least one argument besides ``bill_pay_number`` must be provided.
195
+
196
+ Args:
197
+ bill_pay_number: The BillPay number to update.
198
+ amount: New bill amount (positive, max 2 decimal places).
199
+ description: New description (max 500 characters).
200
+ status: ``"ACTIVE"`` or ``"INACTIVE"``.
201
+ payment_mode: ``"ALLOW_PARTIAL_AND_OVER_PAYMENT"`` or ``"EXACT"``.
202
+
203
+ Returns:
204
+ Updated BillPay object.
205
+ """
206
+ data: dict[str, Any] = {}
207
+ if amount is not None:
208
+ data["billAmount"] = amount
209
+ if description is not None:
210
+ data["billDescription"] = description
211
+ if status is not None:
212
+ data["billStatus"] = status
213
+ if payment_mode is not None:
214
+ data["billPaymentMode"] = payment_mode
215
+ if not data:
216
+ raise ValueError("At least one field must be provided to update.")
217
+ return self._c.request("PATCH", f"{_BASE}/{bill_pay_number}", json=data)
218
+
219
+ def update_status(self, bill_pay_number: str, status: BillStatus) -> dict[str, Any]:
220
+ """
221
+ Convenience method to activate or deactivate a control number.
222
+
223
+ Args:
224
+ bill_pay_number: The BillPay number to update.
225
+ status: ``"ACTIVE"`` or ``"INACTIVE"``.
226
+ """
227
+ return self.update_reference(bill_pay_number, status=status)
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Async
232
+ # ---------------------------------------------------------------------------
233
+
234
+ class AsyncBillPayService:
235
+ """Asynchronous BillPay control-number management (mirrors :class:`BillPayService`)."""
236
+
237
+ def __init__(self, client: "AsyncClickPesaClient") -> None:
238
+ self._c = client
239
+
240
+ async def create_order_control_number(
241
+ self,
242
+ bill_reference: str | None = None,
243
+ amount: float | None = None,
244
+ description: str | None = None,
245
+ payment_mode: BillPaymentMode | None = None,
246
+ ) -> dict[str, Any]:
247
+ payload: dict[str, Any] = {}
248
+ if bill_reference is not None:
249
+ payload["billReference"] = bill_reference
250
+ if amount is not None:
251
+ payload["billAmount"] = amount
252
+ if description is not None:
253
+ payload["billDescription"] = description
254
+ if payment_mode is not None:
255
+ payload["billPaymentMode"] = payment_mode
256
+ return await self._c.request("POST", f"{_BASE}/create-order-control-number", json=payload)
257
+
258
+ async def create_customer_control_number(
259
+ self,
260
+ customer_name: str,
261
+ phone: str | None = None,
262
+ email: str | None = None,
263
+ bill_reference: str | None = None,
264
+ amount: float | None = None,
265
+ description: str | None = None,
266
+ payment_mode: BillPaymentMode | None = None,
267
+ ) -> dict[str, Any]:
268
+ if phone is None and email is None:
269
+ raise ValueError("At least one of 'phone' or 'email' must be provided.")
270
+ payload: dict[str, Any] = {"customerName": customer_name}
271
+ if phone is not None:
272
+ payload["customerPhone"] = phone
273
+ if email is not None:
274
+ payload["customerEmail"] = email
275
+ if bill_reference is not None:
276
+ payload["billReference"] = bill_reference
277
+ if amount is not None:
278
+ payload["billAmount"] = amount
279
+ if description is not None:
280
+ payload["billDescription"] = description
281
+ if payment_mode is not None:
282
+ payload["billPaymentMode"] = payment_mode
283
+ return await self._c.request(
284
+ "POST", f"{_BASE}/create-customer-control-number", json=payload
285
+ )
286
+
287
+ async def bulk_create_order_numbers(
288
+ self,
289
+ control_numbers: list[dict[str, Any]],
290
+ ) -> dict[str, Any]:
291
+ if not control_numbers:
292
+ raise ValueError("control_numbers must contain at least 1 item.")
293
+ if len(control_numbers) > _BULK_LIMIT:
294
+ raise ValueError(f"ClickPesa bulk limit is {_BULK_LIMIT} items per request.")
295
+ return await self._c.request(
296
+ "POST",
297
+ f"{_BASE}/bulk-create-order-control-numbers",
298
+ json={"controlNumbers": control_numbers},
299
+ )
300
+
301
+ async def bulk_create_customer_numbers(
302
+ self,
303
+ control_numbers: list[dict[str, Any]],
304
+ ) -> dict[str, Any]:
305
+ if not control_numbers:
306
+ raise ValueError("control_numbers must contain at least 1 item.")
307
+ if len(control_numbers) > _BULK_LIMIT:
308
+ raise ValueError(f"ClickPesa bulk limit is {_BULK_LIMIT} items per request.")
309
+ return await self._c.request(
310
+ "POST",
311
+ f"{_BASE}/bulk-create-customer-control-numbers",
312
+ json={"controlNumbers": control_numbers},
313
+ )
314
+
315
+ async def get_details(self, bill_pay_number: str) -> dict[str, Any]:
316
+ return await self._c.request("GET", f"{_BASE}/{bill_pay_number}")
317
+
318
+ async def update_reference(
319
+ self,
320
+ bill_pay_number: str,
321
+ amount: float | None = None,
322
+ description: str | None = None,
323
+ status: BillStatus | None = None,
324
+ payment_mode: BillPaymentMode | None = None,
325
+ ) -> dict[str, Any]:
326
+ data: dict[str, Any] = {}
327
+ if amount is not None:
328
+ data["billAmount"] = amount
329
+ if description is not None:
330
+ data["billDescription"] = description
331
+ if status is not None:
332
+ data["billStatus"] = status
333
+ if payment_mode is not None:
334
+ data["billPaymentMode"] = payment_mode
335
+ if not data:
336
+ raise ValueError("At least one field must be provided to update.")
337
+ return await self._c.request("PATCH", f"{_BASE}/{bill_pay_number}", json=data)
338
+
339
+ async def update_status(self, bill_pay_number: str, status: BillStatus) -> dict[str, Any]:
340
+ return await self.update_reference(bill_pay_number, status=status)
@@ -0,0 +1,74 @@
1
+ """
2
+ Exchange rate services.
3
+
4
+ Sync: ``ExchangeService`` — attach to :class:`~clickpesa.client.ClickPesaClient`.
5
+ Async: ``AsyncExchangeService`` — 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
+ _ENDPOINT = "/third-parties/exchange-rates/all"
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Sync
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class ExchangeService:
24
+ """Synchronous exchange rate methods."""
25
+
26
+ def __init__(self, client: "ClickPesaClient") -> None:
27
+ self._c = client
28
+
29
+ def get_rates(
30
+ self,
31
+ source: str | None = None,
32
+ target: str | None = None,
33
+ ) -> list[dict[str, Any]]:
34
+ """
35
+ Fetch the latest exchange rates.
36
+
37
+ Args:
38
+ source: ISO 4217 source currency code (e.g. ``"USD"``).
39
+ When omitted all available source currencies are returned.
40
+ target: ISO 4217 target currency code (e.g. ``"TZS"``).
41
+ When omitted all available target currencies are returned.
42
+
43
+ Returns:
44
+ List of ``{"source": "…", "target": "…", "rate": 2510, "date": "…"}`` dicts.
45
+ """
46
+ params: dict[str, Any] = {}
47
+ if source is not None:
48
+ params["source"] = source
49
+ if target is not None:
50
+ params["target"] = target
51
+ return self._c.request("GET", _ENDPOINT, params=params)
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Async
56
+ # ---------------------------------------------------------------------------
57
+
58
+ class AsyncExchangeService:
59
+ """Asynchronous exchange rate methods (mirrors :class:`ExchangeService`)."""
60
+
61
+ def __init__(self, client: "AsyncClickPesaClient") -> None:
62
+ self._c = client
63
+
64
+ async def get_rates(
65
+ self,
66
+ source: str | None = None,
67
+ target: str | None = None,
68
+ ) -> list[dict[str, Any]]:
69
+ params: dict[str, Any] = {}
70
+ if source is not None:
71
+ params["source"] = source
72
+ if target is not None:
73
+ params["target"] = target
74
+ return await self._c.request("GET", _ENDPOINT, params=params)
@@ -0,0 +1,169 @@
1
+ """
2
+ Hosted link generation services — Checkout and Payout Links.
3
+
4
+ Sync: ``LinkService`` — attach to :class:`~clickpesa.client.ClickPesaClient`.
5
+ Async: ``AsyncLinkService`` — attach to :class:`~clickpesa.async_client.AsyncClickPesaClient`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import ClickPesaClient
14
+ from ..async_client import AsyncClickPesaClient
15
+
16
+ _CHECKOUT = "/third-parties/checkout-link/generate-checkout-url"
17
+ _PAYOUT = "/third-parties/payout-link/generate-payout-url"
18
+
19
+ OrderCurrency = Literal["TZS", "USD"]
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Sync
24
+ # ---------------------------------------------------------------------------
25
+
26
+ class LinkService:
27
+ """Synchronous hosted-link generation methods."""
28
+
29
+ def __init__(self, client: "ClickPesaClient") -> None:
30
+ self._c = client
31
+
32
+ def generate_checkout(
33
+ self,
34
+ order_id: str,
35
+ order_currency: OrderCurrency,
36
+ total_price: str | None = None,
37
+ order_items: list[dict[str, Any]] | None = None,
38
+ customer_name: str | None = None,
39
+ customer_email: str | None = None,
40
+ customer_phone: str | None = None,
41
+ description: str | None = None,
42
+ ) -> dict[str, Any]:
43
+ """
44
+ Generate a hosted checkout payment page.
45
+
46
+ Supply **either** ``total_price`` **or** ``order_items`` — not both.
47
+
48
+ Args:
49
+ order_id: Unique alphanumeric order reference.
50
+ order_currency: ``"TZS"`` or ``"USD"``.
51
+ total_price: Order total as a string (Option 1).
52
+ order_items: List of ``{"name": …, "price": …, "quantity": …}``
53
+ dicts (Option 2).
54
+ customer_name: Pre-fill customer name on the checkout page.
55
+ customer_email: Pre-fill customer email.
56
+ customer_phone: Customer phone with country code, no ``+``.
57
+ description: Order description shown on the checkout page.
58
+
59
+ Returns:
60
+ Dict with ``checkoutLink`` (URL string) and ``clientId``.
61
+
62
+ Raises:
63
+ ValueError: If neither or both of ``total_price`` / ``order_items``
64
+ are provided.
65
+ """
66
+ if total_price is None and order_items is None:
67
+ raise ValueError("Provide either 'total_price' or 'order_items'.")
68
+ if total_price is not None and order_items is not None:
69
+ raise ValueError("Provide either 'total_price' or 'order_items', not both.")
70
+
71
+ payload: dict[str, Any] = {
72
+ "orderReference": order_id,
73
+ "orderCurrency": order_currency,
74
+ }
75
+ if total_price is not None:
76
+ payload["totalPrice"] = str(total_price)
77
+ if order_items is not None:
78
+ payload["orderItems"] = order_items
79
+ if customer_name is not None:
80
+ payload["customerName"] = customer_name
81
+ if customer_email is not None:
82
+ payload["customerEmail"] = customer_email
83
+ if customer_phone is not None:
84
+ payload["customerPhone"] = customer_phone
85
+ if description is not None:
86
+ payload["description"] = description
87
+
88
+ return self._c.request("POST", _CHECKOUT, json=payload)
89
+
90
+ def generate_payout(
91
+ self,
92
+ amount: str,
93
+ order_id: str,
94
+ ) -> dict[str, Any]:
95
+ """
96
+ Generate a hosted payout link.
97
+
98
+ The recipient uses the link to enter their own bank / mobile-money
99
+ details without you needing to collect them yourself.
100
+
101
+ Args:
102
+ amount: Payout amount.
103
+ order_id: Unique alphanumeric order reference.
104
+
105
+ Returns:
106
+ Dict with ``payoutLink`` (URL string) and ``clientId``.
107
+ """
108
+ payload: dict[str, Any] = {
109
+ "amount": str(amount),
110
+ "orderReference": order_id,
111
+ }
112
+ return self._c.request("POST", _PAYOUT, json=payload)
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Async
117
+ # ---------------------------------------------------------------------------
118
+
119
+ class AsyncLinkService:
120
+ """Asynchronous hosted-link generation methods (mirrors :class:`LinkService`)."""
121
+
122
+ def __init__(self, client: "AsyncClickPesaClient") -> None:
123
+ self._c = client
124
+
125
+ async def generate_checkout(
126
+ self,
127
+ order_id: str,
128
+ order_currency: OrderCurrency,
129
+ total_price: str | None = None,
130
+ order_items: list[dict[str, Any]] | None = None,
131
+ customer_name: str | None = None,
132
+ customer_email: str | None = None,
133
+ customer_phone: str | None = None,
134
+ description: str | None = None,
135
+ ) -> dict[str, Any]:
136
+ if total_price is None and order_items is None:
137
+ raise ValueError("Provide either 'total_price' or 'order_items'.")
138
+ if total_price is not None and order_items is not None:
139
+ raise ValueError("Provide either 'total_price' or 'order_items', not both.")
140
+
141
+ payload: dict[str, Any] = {
142
+ "orderReference": order_id,
143
+ "orderCurrency": order_currency,
144
+ }
145
+ if total_price is not None:
146
+ payload["totalPrice"] = str(total_price)
147
+ if order_items is not None:
148
+ payload["orderItems"] = order_items
149
+ if customer_name is not None:
150
+ payload["customerName"] = customer_name
151
+ if customer_email is not None:
152
+ payload["customerEmail"] = customer_email
153
+ if customer_phone is not None:
154
+ payload["customerPhone"] = customer_phone
155
+ if description is not None:
156
+ payload["description"] = description
157
+
158
+ return await self._c.request("POST", _CHECKOUT, json=payload)
159
+
160
+ async def generate_payout(
161
+ self,
162
+ amount: str,
163
+ order_id: str,
164
+ ) -> dict[str, Any]:
165
+ payload: dict[str, Any] = {
166
+ "amount": str(amount),
167
+ "orderReference": order_id,
168
+ }
169
+ return await self._c.request("POST", _PAYOUT, json=payload)