webirr 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.
webirr/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Official Python client library for WeBirr Payment Gateway APIs."""
2
+
3
+ from .client import WeBirrClient
4
+ from .models import (
5
+ ApiResponse,
6
+ Bill,
7
+ BillResponse,
8
+ PaymentDetail,
9
+ PaymentResponse,
10
+ PaymentStatus,
11
+ Stat,
12
+ )
13
+
14
+ __all__ = [
15
+ "ApiResponse",
16
+ "Bill",
17
+ "BillResponse",
18
+ "PaymentDetail",
19
+ "PaymentResponse",
20
+ "PaymentStatus",
21
+ "Stat",
22
+ "WeBirrClient",
23
+ ]
24
+
25
+ __version__ = "1.0.0"
webirr/client.py ADDED
@@ -0,0 +1,182 @@
1
+ """HTTP client for WeBirr Payment Gateway APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Callable, TypeVar
7
+ from urllib.parse import urljoin
8
+
9
+ import requests
10
+
11
+ from .models import (
12
+ ApiResponse,
13
+ Bill,
14
+ BillResponse,
15
+ PaymentResponse,
16
+ PaymentStatus,
17
+ Stat,
18
+ list_of,
19
+ )
20
+
21
+ T = TypeVar("T")
22
+
23
+
24
+ class WeBirrClient:
25
+ """Client for WeBirr merchant APIs."""
26
+
27
+ TEST_BASE_URL = "https://api.webirr.net"
28
+ PROD_BASE_URL = "https://api.webirr.net:8080"
29
+
30
+ def __init__(
31
+ self,
32
+ merchant_id: str,
33
+ api_key: str,
34
+ is_test_env: bool = True,
35
+ session: requests.Session | None = None,
36
+ base_url: str | None = None,
37
+ ) -> None:
38
+ self.merchant_id = merchant_id or ""
39
+ self.api_key = api_key or ""
40
+ self.base_url = (base_url or (self.TEST_BASE_URL if is_test_env else self.PROD_BASE_URL)).rstrip("/")
41
+ self.session = session or requests.Session()
42
+ self.session.headers.setdefault("Accept", "application/json")
43
+
44
+ def create_bill(self, bill: Bill) -> ApiResponse[str]:
45
+ """Create a new bill and return the payment code in ``response.res``."""
46
+
47
+ self._prepare_bill(bill)
48
+ return self._send("POST", "einvoice/api/bill", json_body=bill.to_dict())
49
+
50
+ def update_bill(self, bill: Bill) -> ApiResponse[str]:
51
+ """Update an existing unpaid bill."""
52
+
53
+ self._prepare_bill(bill)
54
+ return self._send("PUT", "einvoice/api/bill", json_body=bill.to_dict())
55
+
56
+ def delete_bill(self, payment_code: str) -> ApiResponse[str]:
57
+ """Delete an existing unpaid bill by payment code."""
58
+
59
+ return self._send("DELETE", "einvoice/api/bill", params={"wbc_code": payment_code}, json_body={})
60
+
61
+ def get_payment_status(self, payment_code: str) -> ApiResponse[PaymentStatus]:
62
+ """Get single payment status by payment code."""
63
+
64
+ return self._send(
65
+ "GET",
66
+ "einvoice/api/paymentStatus",
67
+ params={"wbc_code": payment_code},
68
+ res_factory=PaymentStatus.from_dict,
69
+ )
70
+
71
+ def get_bill_by_reference(self, bill_reference: str) -> ApiResponse[BillResponse]:
72
+ """Get a bill by merchant bill reference."""
73
+
74
+ return self._send(
75
+ "GET",
76
+ "einvoice/api/bill",
77
+ params={"bill_reference": bill_reference},
78
+ res_factory=BillResponse.from_dict,
79
+ )
80
+
81
+ def get_bill_by_payment_code(self, payment_code: str) -> ApiResponse[BillResponse]:
82
+ """Get a bill by WeBirr payment code / WBC code."""
83
+
84
+ return self._send(
85
+ "GET",
86
+ "einvoice/api/bill",
87
+ params={"wbc_code": payment_code},
88
+ res_factory=BillResponse.from_dict,
89
+ )
90
+
91
+ def get_bills(
92
+ self,
93
+ payment_status: int = -1,
94
+ last_time_stamp: str = "",
95
+ limit: int = 100,
96
+ ) -> ApiResponse[list[BillResponse]]:
97
+ """List bills updated after a timestamp cursor."""
98
+
99
+ return self._send(
100
+ "GET",
101
+ "einvoice/api/bills",
102
+ params={
103
+ "payment_status": payment_status,
104
+ "last_timestamp": last_time_stamp,
105
+ "limit": limit,
106
+ },
107
+ res_factory=list_of(BillResponse.from_dict),
108
+ )
109
+
110
+ def get_payments(
111
+ self,
112
+ last_time_stamp: str = "",
113
+ limit: int = 100,
114
+ ) -> ApiResponse[list[PaymentResponse]]:
115
+ """Poll payment updates after a timestamp cursor."""
116
+
117
+ return self._send(
118
+ "GET",
119
+ "einvoice/api/payments",
120
+ params={"last_timestamp": last_time_stamp, "limit": limit},
121
+ res_factory=list_of(PaymentResponse.from_dict),
122
+ )
123
+
124
+ def get_stat(self, date_from: str, date_to: str) -> ApiResponse[Stat]:
125
+ """Get basic merchant statistics for a date range."""
126
+
127
+ return self._send(
128
+ "GET",
129
+ "merchant/stat",
130
+ params={"date_from": date_from, "date_to": date_to},
131
+ res_factory=Stat.from_dict,
132
+ )
133
+
134
+ def _prepare_bill(self, bill: Bill) -> Bill:
135
+ if self.merchant_id:
136
+ bill.merchant_id = self.merchant_id
137
+ return bill
138
+
139
+ def _query(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
140
+ query: dict[str, Any] = {"api_key": self.api_key}
141
+ if self.merchant_id:
142
+ query["merchant_id"] = self.merchant_id
143
+ if params:
144
+ query.update(params)
145
+ return query
146
+
147
+ def _send(
148
+ self,
149
+ method: str,
150
+ path: str,
151
+ params: dict[str, Any] | None = None,
152
+ json_body: Any | None = None,
153
+ res_factory: Callable[[Any], T] | None = None,
154
+ ) -> ApiResponse[T]:
155
+ url = urljoin(f"{self.base_url}/", path)
156
+ response = self.session.request(
157
+ method,
158
+ url,
159
+ params=self._query(params),
160
+ json=json_body,
161
+ timeout=30,
162
+ )
163
+ return self._decode_response(response, res_factory)
164
+
165
+ def _decode_response(
166
+ self,
167
+ response: requests.Response,
168
+ res_factory: Callable[[Any], T] | None = None,
169
+ ) -> ApiResponse[T]:
170
+ if not 200 <= response.status_code < 300:
171
+ reason = getattr(response, "reason", "") or ""
172
+ return ApiResponse(error=f"http error {response.status_code} {reason}".strip())
173
+
174
+ try:
175
+ payload = response.json()
176
+ except (ValueError, json.JSONDecodeError):
177
+ return ApiResponse(error="invalid json response")
178
+
179
+ if not isinstance(payload, dict):
180
+ return ApiResponse(error="invalid response shape")
181
+
182
+ return ApiResponse.from_dict(payload, res_factory)
webirr/models.py ADDED
@@ -0,0 +1,293 @@
1
+ """Models used by the WeBirr Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable, Generic, Iterable, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ def _lookup(data: dict[str, Any], *keys: str, default: Any = "") -> Any:
12
+ for key in keys:
13
+ if key in data and data[key] is not None:
14
+ return data[key]
15
+ return default
16
+
17
+
18
+ def _to_dict(data: Any) -> dict[str, Any]:
19
+ if data is None:
20
+ return {}
21
+ if isinstance(data, dict):
22
+ return data
23
+ return vars(data)
24
+
25
+
26
+ @dataclass
27
+ class ApiResponse(Generic[T]):
28
+ """Common WeBirr API response wrapper."""
29
+
30
+ error: str | None = None
31
+ res: T | None = None
32
+ error_code: str | None = None
33
+
34
+ @property
35
+ def errorCode(self) -> str | None:
36
+ """Gateway-style alias for callers that prefer the wire name."""
37
+
38
+ return self.error_code
39
+
40
+ @classmethod
41
+ def from_dict(
42
+ cls,
43
+ data: dict[str, Any],
44
+ res_factory: Callable[[Any], T] | None = None,
45
+ ) -> "ApiResponse[T]":
46
+ raw_res = data.get("res")
47
+ res = res_factory(raw_res) if res_factory and raw_res is not None else raw_res
48
+ return cls(
49
+ error=data.get("error"),
50
+ res=res,
51
+ error_code=data.get("errorCode") or data.get("error_code"),
52
+ )
53
+
54
+
55
+ @dataclass
56
+ class Bill:
57
+ """Create/update request model for WeBirr bills."""
58
+
59
+ amount: str = ""
60
+ customer_code: str = ""
61
+ customer_name: str = ""
62
+ customer_phone: str = ""
63
+ time: str = ""
64
+ description: str = ""
65
+ bill_reference: str = ""
66
+ merchant_id: str = ""
67
+ extras: dict[str, Any] = field(default_factory=dict)
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "amount": self.amount,
72
+ "customerCode": self.customer_code,
73
+ "customerName": self.customer_name,
74
+ "customerPhone": self.customer_phone,
75
+ "time": self.time,
76
+ "description": self.description,
77
+ "billReference": self.bill_reference,
78
+ "merchantID": self.merchant_id,
79
+ "extras": self.extras or {},
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: Any) -> "Bill":
84
+ source = _to_dict(data)
85
+ return cls(
86
+ amount=str(_lookup(source, "amount")),
87
+ customer_code=str(_lookup(source, "customerCode", "customer_code")),
88
+ customer_name=str(_lookup(source, "customerName", "customer_name")),
89
+ customer_phone=str(_lookup(source, "customerPhone", "customer_phone")),
90
+ time=str(_lookup(source, "time")),
91
+ description=str(_lookup(source, "description")),
92
+ bill_reference=str(_lookup(source, "billReference", "bill_reference")),
93
+ merchant_id=str(_lookup(source, "merchantID", "merchant_id")),
94
+ extras=dict(_lookup(source, "extras", default={}) or {}),
95
+ )
96
+
97
+
98
+ @dataclass
99
+ class BillResponse(Bill):
100
+ """Bill retrieval/list response model."""
101
+
102
+ wbc_code: str = ""
103
+ payment_status: int | None = None
104
+ update_time_stamp: str = ""
105
+
106
+ @classmethod
107
+ def from_dict(cls, data: Any) -> "BillResponse":
108
+ source = _to_dict(data)
109
+ bill = Bill.from_dict(source)
110
+ return cls(
111
+ **bill.__dict__,
112
+ wbc_code=str(_lookup(source, "wbcCode", "wbc_code")),
113
+ payment_status=_lookup(source, "paymentStatus", "payment_status", default=None),
114
+ update_time_stamp=str(_lookup(source, "updateTimeStamp", "update_time_stamp")),
115
+ )
116
+
117
+ @property
118
+ def wbcCode(self) -> str:
119
+ return self.wbc_code
120
+
121
+ @property
122
+ def paymentStatus(self) -> int | None:
123
+ return self.payment_status
124
+
125
+ @property
126
+ def updateTimeStamp(self) -> str:
127
+ return self.update_time_stamp
128
+
129
+
130
+ @dataclass
131
+ class PaymentDetail:
132
+ """Payment detail returned by single payment status."""
133
+
134
+ status: int | None = None
135
+ id: int | str | None = None
136
+ bank_id: str = ""
137
+ payment_reference: str = ""
138
+ payment_date: str = ""
139
+ time: str = ""
140
+ confirmed: bool | None = None
141
+ confirmed_time: str = ""
142
+ amount: str = ""
143
+ wbc_code: str = ""
144
+ update_time_stamp: str = ""
145
+
146
+ @classmethod
147
+ def from_dict(cls, data: Any) -> "PaymentDetail":
148
+ source = _to_dict(data)
149
+ payment_date = str(_lookup(source, "paymentDate", "payment_date", "time"))
150
+ time = str(_lookup(source, "time", "paymentDate", "payment_date"))
151
+ return cls(
152
+ status=_lookup(source, "status", default=None),
153
+ id=_lookup(source, "id", default=None),
154
+ bank_id=str(_lookup(source, "bankID", "bank_id")),
155
+ payment_reference=str(_lookup(source, "paymentReference", "payment_reference")),
156
+ payment_date=payment_date,
157
+ time=time,
158
+ confirmed=_lookup(source, "confirmed", default=None),
159
+ confirmed_time=str(_lookup(source, "confirmedTime", "confirmed_time")),
160
+ amount=str(_lookup(source, "amount")),
161
+ wbc_code=str(_lookup(source, "wbcCode", "wbc_code")),
162
+ update_time_stamp=str(_lookup(source, "updateTimeStamp", "update_time_stamp")),
163
+ )
164
+
165
+ @property
166
+ def bankID(self) -> str:
167
+ return self.bank_id
168
+
169
+ @property
170
+ def paymentReference(self) -> str:
171
+ return self.payment_reference
172
+
173
+ @property
174
+ def paymentDate(self) -> str:
175
+ return self.payment_date
176
+
177
+ @property
178
+ def confirmedTime(self) -> str:
179
+ return self.confirmed_time
180
+
181
+ @property
182
+ def wbcCode(self) -> str:
183
+ return self.wbc_code
184
+
185
+ @property
186
+ def updateTimeStamp(self) -> str:
187
+ return self.update_time_stamp
188
+
189
+
190
+ @dataclass
191
+ class PaymentStatus:
192
+ """Single payment status wrapper."""
193
+
194
+ status: int | None = None
195
+ data: PaymentDetail | None = None
196
+
197
+ @property
198
+ def is_paid(self) -> bool:
199
+ return self.status == 2
200
+
201
+ @classmethod
202
+ def from_dict(cls, data: Any) -> "PaymentStatus":
203
+ source = _to_dict(data)
204
+ raw_data = source.get("data")
205
+ return cls(
206
+ status=_lookup(source, "status", default=None),
207
+ data=PaymentDetail.from_dict(raw_data) if raw_data else None,
208
+ )
209
+
210
+
211
+ @dataclass
212
+ class PaymentResponse(PaymentDetail):
213
+ """Payment item returned by bulk polling and webhook payloads."""
214
+
215
+ canceled: bool | None = None
216
+ canceled_time: str = ""
217
+
218
+ @property
219
+ def is_paid(self) -> bool:
220
+ return self.status == 2
221
+
222
+ @property
223
+ def is_reversed(self) -> bool:
224
+ return self.status == 3
225
+
226
+ @classmethod
227
+ def from_dict(cls, data: Any) -> "PaymentResponse":
228
+ source = _to_dict(data)
229
+ detail = PaymentDetail.from_dict(source)
230
+ return cls(
231
+ **detail.__dict__,
232
+ canceled=_lookup(source, "canceled", default=None),
233
+ canceled_time=str(_lookup(source, "canceledTime", "canceled_time")),
234
+ )
235
+
236
+ @property
237
+ def canceledTime(self) -> str:
238
+ return self.canceled_time
239
+
240
+
241
+ @dataclass
242
+ class Stat:
243
+ """Basic statistics for bills and payments."""
244
+
245
+ n_bills: int | None = None
246
+ n_bills_paid: int | None = None
247
+ n_bills_unpaid: int | None = None
248
+ amount_bills: str = ""
249
+ amount_paid: str = ""
250
+ amount_unpaid: str = ""
251
+
252
+ @classmethod
253
+ def from_dict(cls, data: Any) -> "Stat":
254
+ source = _to_dict(data)
255
+ return cls(
256
+ n_bills=_lookup(source, "NBills", "nBills", "n_bills", default=None),
257
+ n_bills_paid=_lookup(source, "NBillsPaid", "nBillsPaid", "n_bills_paid", default=None),
258
+ n_bills_unpaid=_lookup(source, "NBillsUnpaid", "nBillsUnpaid", "n_bills_unpaid", default=None),
259
+ amount_bills=str(_lookup(source, "AmountBills", "amountBills", "amount_bills")),
260
+ amount_paid=str(_lookup(source, "AmountPaid", "amountPaid", "amount_paid")),
261
+ amount_unpaid=str(_lookup(source, "AmountUnpaid", "amountUnpaid", "amount_unpaid")),
262
+ )
263
+
264
+ @property
265
+ def nBills(self) -> int | None:
266
+ return self.n_bills
267
+
268
+ @property
269
+ def nBillsPaid(self) -> int | None:
270
+ return self.n_bills_paid
271
+
272
+ @property
273
+ def nBillsUnpaid(self) -> int | None:
274
+ return self.n_bills_unpaid
275
+
276
+ @property
277
+ def amountBills(self) -> str:
278
+ return self.amount_bills
279
+
280
+ @property
281
+ def amountPaid(self) -> str:
282
+ return self.amount_paid
283
+
284
+ @property
285
+ def amountUnpaid(self) -> str:
286
+ return self.amount_unpaid
287
+
288
+
289
+ def list_of(factory: Callable[[Any], T]) -> Callable[[Iterable[Any]], list[T]]:
290
+ def parse(items: Iterable[Any]) -> list[T]:
291
+ return [factory(item) for item in items or []]
292
+
293
+ return parse
webirr/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,493 @@
1
+ Metadata-Version: 2.4
2
+ Name: webirr
3
+ Version: 1.0.0
4
+ Summary: Official Python Client Library for WeBirr Payment Gateway APIs
5
+ Author: WeBirr
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/webirr/webirr-api-python-client
8
+ Project-URL: Source, https://github.com/webirr/webirr-api-python-client
9
+ Project-URL: Issues, https://github.com/webirr/webirr-api-python-client/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: requests>=2.28
22
+ Dynamic: license-file
23
+
24
+ Official Python Client Library for WeBirr Payment Gateway APIs
25
+
26
+ This Client Library provides convenient access to WeBirr Payment Gateway APIs from Python Applications.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ $ pip install webirr
32
+ ```
33
+
34
+ For local development from this repository:
35
+
36
+ ```bash
37
+ $ pip install -e .
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ The library needs to be configured with a *merchant Id* & *API key*. You can get it by contacting [webirr.com](https://webirr.net)
43
+
44
+ > You can use this library for production or test environments. you will need to set is_test_env=True for test, and False for production apps when creating objects of class WeBirrClient
45
+
46
+ Examples assume the WeBirr TestEnv and read credentials from environment variables:
47
+
48
+ ```bash
49
+ export WEBIRR_TEST_ENV_MERCHANT_ID="YOUR_TEST_MERCHANT_ID"
50
+ export WEBIRR_TEST_ENV_API_KEY="YOUR_TEST_API_KEY"
51
+ ```
52
+
53
+ Create the client with merchant ID, API key, and environment once. The client automatically sets `Bill.merchant_id` before sending bill create/update requests, so application code and examples should not set `merchant_id` on the bill object.
54
+
55
+ ## Example
56
+
57
+ The examples below keep the PHP and .NET README flow: create the client, call the API, check `error`, handle the success branch, and print `error_code` on failure.
58
+
59
+ ### Creating a new Bill / Updating an existing Bill on WeBirr Servers
60
+
61
+ ```python
62
+ import os
63
+ from datetime import datetime
64
+
65
+ from webirr import Bill, WeBirrClient
66
+
67
+
68
+ def main():
69
+ api_key = os.getenv("WEBIRR_TEST_ENV_API_KEY", "")
70
+ merchant_id = os.getenv("WEBIRR_TEST_ENV_MERCHANT_ID", "")
71
+
72
+ # api_key = "YOUR_API_KEY"
73
+ # merchant_id = "YOUR_MERCHANT_ID"
74
+
75
+ api = WeBirrClient(merchant_id, api_key, True)
76
+
77
+ bill = Bill()
78
+ bill.amount = "270.90"
79
+ bill.customer_code = "cc01" # it can be email address or phone number if you dont have customer code
80
+ bill.customer_name = "Elias Haileselassie"
81
+ bill.customer_phone = "0911000000" # optional; used for SMS notification when enabled for the merchant
82
+ bill.time = "2021-07-22 22:14" # your bill time, always in this format
83
+ bill.description = "hotel booking"
84
+ bill.bill_reference = "python/example/" + datetime.now().strftime("%Y%m%d%H%M%S") # your unique reference number
85
+
86
+ print("\nCreating Bill...")
87
+ res = api.create_bill(bill)
88
+
89
+ if not res.error:
90
+ # success
91
+ payment_code = res.res # returns paymentcode such as 429 723 975
92
+ print(f"\nPayment Code = {payment_code}") # we may want to save payment code in local db.
93
+ else:
94
+ # fail
95
+ print(f"\nerror: {res.error}")
96
+ print(f"\nerrorCode: {res.error_code}") # can be used to handle specific business error such as ERROR_INVALID_INPUT_DUP_REF
97
+
98
+ # Update existing bill if it is not paid
99
+ bill.amount = "278.00"
100
+ bill.customer_name = "Elias python"
101
+ # bill.bill_reference = "WE CAN NOT CHANGE THIS"
102
+
103
+ print("\nUpdating Bill...")
104
+ res = api.update_bill(bill)
105
+
106
+ if not res.error:
107
+ # success
108
+ print("\nbill is updated succesfully") # res.res will be 'OK' no need to check here!
109
+ else:
110
+ # fail
111
+ print(f"\nerror: {res.error}")
112
+ print(f"\nerrorCode: {res.error_code}") # can be used to handle specific business error such as ERROR_INVALID_INPUT
113
+
114
+
115
+ main()
116
+ ```
117
+
118
+ ### Getting a Bill and Listing Bills
119
+
120
+ ```python
121
+ import os
122
+
123
+ from webirr import WeBirrClient
124
+
125
+
126
+ def main():
127
+ api_key = os.getenv("WEBIRR_TEST_ENV_API_KEY", "")
128
+ merchant_id = os.getenv("WEBIRR_TEST_ENV_MERCHANT_ID", "")
129
+
130
+ api = WeBirrClient(merchant_id, api_key, True)
131
+
132
+ bill_reference = "YOUR_BILL_REFERENCE" # BILL_REFERENCE_YOU_SAVED_AFTER_CREATING_A_NEW_BILL
133
+ payment_code = "YOUR_PAYMENT_CODE" # PAYMENT_CODE_YOU_SAVED_AFTER_CREATING_A_NEW_BILL
134
+
135
+ print("\nGetting bill by reference...")
136
+ res = api.get_bill_by_reference(bill_reference)
137
+ if not res.error:
138
+ # success
139
+ print("\nBill found by reference.")
140
+ print(f"\nBill Reference: {res.res.bill_reference}")
141
+ print(f"\nPayment Code: {res.res.wbc_code}")
142
+ print(f"\nAmount: {res.res.amount}")
143
+ print(f"\nPayment Status: {res.res.payment_status}")
144
+ print(f"\nUpdate Timestamp: {res.res.update_time_stamp}")
145
+ else:
146
+ # fail
147
+ print(f"\nError: {res.error}")
148
+ print(f"\nError Code: {res.error_code}")
149
+
150
+ print("\nGetting bill by payment code...")
151
+ res = api.get_bill_by_payment_code(payment_code)
152
+ if not res.error:
153
+ # success
154
+ print("\nBill found by payment code.")
155
+ print(f"\nBill Reference: {res.res.bill_reference}")
156
+ print(f"\nPayment Code: {res.res.wbc_code}")
157
+ else:
158
+ # fail
159
+ print(f"\nError: {res.error}")
160
+ print(f"\nError Code: {res.error_code}")
161
+
162
+ print("\nListing bills...")
163
+ payment_status = -1 # -1 all, 0 pending, 1 unconfirmed payment, 2 paid.
164
+ last_time_stamp = "20251231" # Date-only cursor; use "20251231235959" when you need time precision.
165
+ limit = 10
166
+
167
+ res = api.get_bills(payment_status, last_time_stamp, limit)
168
+ if not res.error:
169
+ # success
170
+ print(f"\nBills returned: {len(res.res)}")
171
+ for bill in res.res:
172
+ print("\n-----------------------------")
173
+ print(f"\nBill Reference: {bill.bill_reference}")
174
+ print(f"\nPayment Code: {bill.wbc_code}")
175
+ print(f"\nAmount: {bill.amount}")
176
+ print(f"\nPayment Status: {bill.payment_status}")
177
+ print(f"\nUpdate Timestamp: {bill.update_time_stamp}")
178
+ else:
179
+ # fail
180
+ print(f"\nError: {res.error}")
181
+ print(f"\nError Code: {res.error_code}")
182
+
183
+
184
+ main()
185
+ ```
186
+
187
+ Timestamp cursors can be date-only (`yyyyMMdd`) or include time (`yyyyMMddHHmmss`). Use empty string only when you intentionally want all history from the beginning.
188
+
189
+ ### Getting Payment status of an existing Bill from WeBirr Servers
190
+
191
+ ```python
192
+ import os
193
+
194
+ from webirr import WeBirrClient
195
+
196
+
197
+ def main():
198
+ api_key = os.getenv("WEBIRR_TEST_ENV_API_KEY", "")
199
+ merchant_id = os.getenv("WEBIRR_TEST_ENV_MERCHANT_ID", "")
200
+
201
+ # api_key = "YOUR_API_KEY"
202
+ # merchant_id = "YOUR_MERCHANT_ID"
203
+
204
+ api = WeBirrClient(merchant_id, api_key, True)
205
+ payment_code = "PAYMENT_CODE_YOU_SAVED_AFTER_CREATING_A_NEW_BILL"
206
+
207
+ print("\nGetting Payment Status...")
208
+ res = api.get_payment_status(payment_code)
209
+
210
+ if not res.error:
211
+ # success
212
+ if res.res and res.res.is_paid:
213
+ payment = res.res.data
214
+ print("\nbill is paid")
215
+ print("\nbill payment detail")
216
+ print(f"\nBank: {payment.bank_id}")
217
+ print(f"\nBank Reference Number: {payment.payment_reference}")
218
+ print(f"\nAmount Paid: {payment.amount}")
219
+ print(f"\nPayment Date: {payment.payment_date}")
220
+ else:
221
+ print("\nbill is pending payment")
222
+ else:
223
+ # fail
224
+ print(f"\nerror: {res.error}")
225
+ print(f"\nerrorCode: {res.error_code}") # can be used to handle specific business error such as ERROR_INVALID_INPUT
226
+
227
+
228
+ main()
229
+ ```
230
+
231
+ *Sample object returned from getPaymentStatus()*
232
+
233
+ ```python
234
+ sample = {
235
+ "error": None,
236
+ "res": {
237
+ "status": 2,
238
+ "data": {
239
+ "status": 2,
240
+ "id": 111219507,
241
+ "bankID": "cbe_mobile",
242
+ "paymentReference": "TX70e78862148f4c249606",
243
+ "paymentDate": "2025-02-26 22:17:19",
244
+ "confirmed": True,
245
+ "confirmedTime": "2025-02-26 22:17:19",
246
+ "amount": "278",
247
+ "wbcCode": "149 233 514",
248
+ "updateTimeStamp": "2025022622171981338",
249
+ },
250
+ },
251
+ "errorCode": None,
252
+ }
253
+ ```
254
+
255
+ Use `payment_date` as the payment time field. `time` remains available as a deprecated backward-compatible alias.
256
+
257
+ ### Deleting an existing Bill from WeBirr Servers (if it is not paid)
258
+
259
+ ```python
260
+ import os
261
+
262
+ from webirr import WeBirrClient
263
+
264
+
265
+ def main():
266
+ api_key = os.getenv("WEBIRR_TEST_ENV_API_KEY", "")
267
+ merchant_id = os.getenv("WEBIRR_TEST_ENV_MERCHANT_ID", "")
268
+
269
+ # api_key = "YOUR_API_KEY"
270
+ # merchant_id = "YOUR_MERCHANT_ID"
271
+
272
+ api = WeBirrClient(merchant_id, api_key, True)
273
+ payment_code = "PAYMENT_CODE_YOU_SAVED_AFTER_CREATING_A_NEW_BILL"
274
+
275
+ print("\nDeleting Bill...")
276
+ res = api.delete_bill(payment_code)
277
+
278
+ if not res.error:
279
+ # success
280
+ print("\nbill is deleted succesfully") # res.res will be 'OK' no need to check here!
281
+ else:
282
+ # fail
283
+ print(f"\nerror: {res.error}")
284
+ print(f"\nerrorCode: {res.error_code}") # can be used to handle specific business error such as ERROR_INVALID_INPUT
285
+
286
+
287
+ main()
288
+ ```
289
+
290
+ ### Getting list of Payments and process them with Bulk Polling Consumer
291
+
292
+ ```python
293
+ import os
294
+ import time
295
+
296
+ from webirr import WeBirrClient
297
+
298
+
299
+ class PaymentProcessor:
300
+ def __init__(self):
301
+ api_key = os.getenv("WEBIRR_TEST_ENV_API_KEY", "")
302
+ merchant_id = os.getenv("WEBIRR_TEST_ENV_MERCHANT_ID", "")
303
+ self.api = WeBirrClient(merchant_id, api_key, True)
304
+ self.last_time_stamp = "20251231" # Example cursor. Save the last processed payment updateTimeStamp in your database.
305
+
306
+ def run_once(self):
307
+ print("\nRetrieving Payments...")
308
+ self.fetch_and_process_payments()
309
+
310
+ def run_forever(self):
311
+ while True:
312
+ self.run_once()
313
+ print("\nSleeping for 5 seconds...")
314
+ time.sleep(5)
315
+
316
+ def fetch_and_process_payments(self):
317
+ limit = 100 # Number of records to retrieve depending on your processing requirement & capacity
318
+ response = self.api.get_payments(self.last_time_stamp, limit)
319
+
320
+ if not response.error:
321
+ # success
322
+ if len(response.res) == 0:
323
+ print("\nNo new payments found.")
324
+ for payment in response.res:
325
+ self.process_payment(payment)
326
+ print("\n-----------------------------")
327
+
328
+ if len(response.res) > 0:
329
+ self.last_time_stamp = response.res[-1].update_time_stamp
330
+ print(f"\nLast Timestamp: {self.last_time_stamp}") # save updateTimeStamp to your database for the next get_payments() call
331
+ else:
332
+ # fail
333
+ print(f"\nerror: {response.error}")
334
+ print(f"\nerrorCode: {response.error_code}") # can be used to handle specific business error such as ERROR_INVALID_INPUT
335
+
336
+ def process_payment(self, payment):
337
+ # Process Payment should be implemented as idempotent operation for production use cases.
338
+ # This method and logic can be shared among all payment processing consumers: 1. bulk polling, 2. webhook, 3. single payment polling.
339
+ print(f"\nPayment Status: {payment.status}")
340
+ if payment.is_paid:
341
+ print("\nPayment Status Text: Paid.")
342
+ if payment.is_reversed:
343
+ print("\nPayment Status Text: Reversed.")
344
+ print(f"\nBank: {payment.bank_id}")
345
+ print(f"\nBank Reference Number: {payment.payment_reference}")
346
+ print(f"\nAmount Paid: {payment.amount}")
347
+ print(f"\nPayment Date: {payment.payment_date}")
348
+ print(f"\nReversal/Cancel Date: {payment.canceled_time}")
349
+ print(f"\nUpdate Timestamp: {payment.update_time_stamp}")
350
+
351
+
352
+ PaymentProcessor().run_once()
353
+ ```
354
+
355
+ Bulk polling should persist `updateTimeStamp` only after processing the batch successfully. Polling processors should be idempotent because duplicate/redundant reads are possible.
356
+
357
+ ### Webhooks - Payment processing using Webhook Callbacks
358
+
359
+ ```python
360
+ import hmac
361
+ import json
362
+ import os
363
+
364
+ from webirr import PaymentResponse
365
+
366
+
367
+ class Webhook:
368
+ # Webhook handler for processing payment updates from WeBirr.
369
+ # This endpoint should be hosted on a secure server with HTTPS enabled.
370
+ def handle_request(self, method, provided_auth_key, raw_payload):
371
+ # Validate request method is POST
372
+ if method.upper() != "POST":
373
+ return self.json_response(405, {"error": "Method Not Allowed. POST required."})
374
+
375
+ # Authenticate using authKey query string parameter.
376
+ if not self.is_authenticated(provided_auth_key):
377
+ return self.json_response(403, {"error": "Unauthorized access. Invalid authKey."})
378
+
379
+ if not raw_payload:
380
+ return self.json_response(400, {"error": "Empty request body."})
381
+
382
+ try:
383
+ payload = json.loads(raw_payload)
384
+ except json.JSONDecodeError:
385
+ return self.json_response(400, {"error": "Invalid JSON format."})
386
+
387
+ payment_data = payload.get("data", payload)
388
+ if not payment_data:
389
+ return self.json_response(400, {"error": "Invalid payment data."})
390
+
391
+ payment = PaymentResponse.from_dict(payment_data)
392
+ self.process_payment(payment)
393
+
394
+ return self.json_response(200, {"success": True, "message": "Payment received and queued for processing"})
395
+
396
+ def is_authenticated(self, provided_auth_key):
397
+ expected_auth_key = os.getenv("WEBIRR_WEBHOOK_AUTH_KEY", "YOUR_WEBHOOK_AUTH_KEY")
398
+ return bool(expected_auth_key) and hmac.compare_digest(expected_auth_key, provided_auth_key or "")
399
+
400
+ def process_payment(self, payment):
401
+ # Process Payment should be implemented as idempotent operation for production use cases.
402
+ # This method and logic can be shared among all payment processing consumers: 1. bulk polling, 2. webhook, 3. single payment polling.
403
+ print(f"\nPayment Status: {payment.status}")
404
+ if payment.is_paid:
405
+ print("\nPayment Status Text: Paid.")
406
+ if payment.is_reversed:
407
+ print("\nPayment Status Text: Reversed.")
408
+ print(f"\nBank: {payment.bank_id}")
409
+ print(f"\nBank Reference Number: {payment.payment_reference}")
410
+ print(f"\nAmount Paid: {payment.amount}")
411
+ print(f"\nPayment Date: {payment.payment_date}")
412
+ print(f"\nReversal/Cancel Date: {payment.canceled_time}")
413
+ print(f"\nUpdate Timestamp: {payment.update_time_stamp}")
414
+
415
+ def json_response(self, status_code, body):
416
+ return {"status_code": status_code, "content_type": "application/json", "body": json.dumps(body)}
417
+
418
+
419
+ # Once hosted, the webhook URL needs to be shared with WeBirr for configuration.
420
+ ```
421
+
422
+ ### Gettting basic Statistics about bills created and payments received for a date range
423
+
424
+ ```python
425
+ import os
426
+
427
+ from webirr import WeBirrClient
428
+
429
+
430
+ def main():
431
+ api_key = os.getenv("WEBIRR_TEST_ENV_API_KEY", "")
432
+ merchant_id = os.getenv("WEBIRR_TEST_ENV_MERCHANT_ID", "")
433
+
434
+ # api_key = "YOUR_API_KEY"
435
+ # merchant_id = "YOUR_MERCHANT_ID"
436
+
437
+ api = WeBirrClient(merchant_id, api_key, True)
438
+
439
+ date_from = "2025-01-01" # YYYY-MM-DD
440
+ date_to = "2030-01-31" # YYYY-MM-DD
441
+
442
+ print("\nRetrieving Statistics...")
443
+ print(f"\nDate From: {date_from}")
444
+ print(f"\nDate To: {date_to}")
445
+
446
+ response = api.get_stat(date_from, date_to)
447
+
448
+ if not response.error:
449
+ # success
450
+ stat = response.res
451
+ print(f"\nNumber of Bills Created: {stat.n_bills}")
452
+ print(f"\nNumber of Paid Bills: {stat.n_bills_paid}")
453
+ print(f"\nNumber of Unpaid Bills: {stat.n_bills_unpaid}")
454
+ print(f"\nAmount of Bills: {stat.amount_bills}")
455
+ print(f"\nAmount Paid: {stat.amount_paid}")
456
+ print(f"\nAmount Unpaid: {stat.amount_unpaid}")
457
+ else:
458
+ # fail
459
+ print(f"\nError: {response.error}")
460
+ print(f"\nError Code: {response.error_code}")
461
+
462
+
463
+ main()
464
+ ```
465
+
466
+ ## Examples
467
+
468
+ The `examples` directory includes separate workflows matching the PHP SDK examples:
469
+
470
+ ```bash
471
+ python examples/example1_create_update_bill.py
472
+ python examples/example2_payment_status_single_poll.py
473
+ python examples/example3_delete_bill.py
474
+ python examples/example4_payment_status_bulk_poll.py
475
+ python examples/example5_stat_report.py
476
+ python examples/example6_payment_status_webhook.py
477
+ python examples/example7_get_bill_and_list_bills.py
478
+ ```
479
+
480
+ ## Reusable HTTP Session
481
+
482
+ For batch or mass bill workloads, pass a configured `requests.Session` so your application can reuse connections, configure adapters, and apply its own retry policy:
483
+
484
+ ```python
485
+ import requests
486
+
487
+ from webirr import WeBirrClient
488
+
489
+ session = requests.Session()
490
+ api = WeBirrClient(merchant_id, api_key, True, session=session)
491
+ ```
492
+
493
+ The SDK does not silently retry bill creation. Configure retry behavior in your application so duplicate create/update processing remains under your control.
@@ -0,0 +1,9 @@
1
+ webirr/__init__.py,sha256=fGFaAdCTk092PtmF6j-c5GuP0UtZm-15vGahzcEObGQ,428
2
+ webirr/client.py,sha256=B_U5oo1zp4FCEcX0SOeebmZDEULByramR22MkY5XlIw,5708
3
+ webirr/models.py,sha256=Qw6j1fTtxjES_WYMGWK1Ouyv8vT99J4a5TrdxJDNcNI,8641
4
+ webirr/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ webirr-1.0.0.dist-info/licenses/LICENSE,sha256=YAsTWjq1dNhMcg_G_cdQNDp4Y7ZsfYGWqJmohMZiMt4,1058
6
+ webirr-1.0.0.dist-info/METADATA,sha256=2Yfj9eZ6qGAtbVaceadb0etpLd8ZBDuKK9o7yCjevEw,17208
7
+ webirr-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ webirr-1.0.0.dist-info/top_level.txt,sha256=VKdC2Vutf6dR68mk5x3zSRQZTziIohdGU1sL74BNxOA,7
9
+ webirr-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) WeBirr
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
+ webirr