bila 0.1.0__tar.gz
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.
- bila-0.1.0/.gitignore +47 -0
- bila-0.1.0/PKG-INFO +63 -0
- bila-0.1.0/README.md +38 -0
- bila-0.1.0/pyproject.toml +40 -0
- bila-0.1.0/src/bila/__init__.py +229 -0
bila-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
/coverage
|
|
15
|
+
|
|
16
|
+
# next.js
|
|
17
|
+
/.next/
|
|
18
|
+
/out/
|
|
19
|
+
|
|
20
|
+
# production
|
|
21
|
+
/build
|
|
22
|
+
|
|
23
|
+
# misc
|
|
24
|
+
.DS_Store
|
|
25
|
+
*.pem
|
|
26
|
+
|
|
27
|
+
# debug
|
|
28
|
+
npm-debug.log*
|
|
29
|
+
yarn-debug.log*
|
|
30
|
+
yarn-error.log*
|
|
31
|
+
.pnpm-debug.log*
|
|
32
|
+
|
|
33
|
+
# env files (can opt-in for committing if needed)
|
|
34
|
+
.env*
|
|
35
|
+
|
|
36
|
+
# vercel
|
|
37
|
+
.vercel
|
|
38
|
+
|
|
39
|
+
# typescript
|
|
40
|
+
*.tsbuildinfo
|
|
41
|
+
next-env.d.ts
|
|
42
|
+
.env.local
|
|
43
|
+
.env*.local
|
|
44
|
+
|
|
45
|
+
# Playwright MCP — local screenshots & dev logs
|
|
46
|
+
/.playwright-mcp/
|
|
47
|
+
/bila-*.png
|
bila-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bila
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Bila Python SDK — Stripe-style payment aggregator for Somalia.
|
|
5
|
+
Project-URL: Homepage, https://bila.so
|
|
6
|
+
Project-URL: Documentation, https://bila.so/docs/sdks
|
|
7
|
+
Project-URL: Source, https://github.com/Saakuut/bila
|
|
8
|
+
Author-email: Tabsamo Group <info@tabsamo.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: bila,edahab,payments,somalia,stripe,waafipay
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: requests>=2.31
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# bila — Python SDK
|
|
27
|
+
|
|
28
|
+
Official Python SDK for [Bila](https://bila.so), the Stripe-style payment aggregator for Somalia.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install bila
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from bila import Bila
|
|
36
|
+
|
|
37
|
+
bila = Bila(api_key="sk_live_...")
|
|
38
|
+
|
|
39
|
+
charge = bila.charges.create(
|
|
40
|
+
amount=10000,
|
|
41
|
+
currency="USD",
|
|
42
|
+
rail="auto",
|
|
43
|
+
identifier="+252611234567",
|
|
44
|
+
description="Order #1234",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if charge["status"] == "requires_action":
|
|
48
|
+
redirect_url = charge["requires_action_payload"]["redirect_to"]
|
|
49
|
+
# send the payer to redirect_url
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Webhook verification:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from bila import verify_webhook_signature
|
|
56
|
+
|
|
57
|
+
signature = request.headers["Bila-Signature"]
|
|
58
|
+
body = request.get_data().decode()
|
|
59
|
+
if not verify_webhook_signature(body, signature, secret="whsec_..."):
|
|
60
|
+
return "invalid signature", 400
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Full docs at <https://bila.so/docs/sdks>.
|
bila-0.1.0/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# bila — Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [Bila](https://bila.so), the Stripe-style payment aggregator for Somalia.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install bila
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from bila import Bila
|
|
11
|
+
|
|
12
|
+
bila = Bila(api_key="sk_live_...")
|
|
13
|
+
|
|
14
|
+
charge = bila.charges.create(
|
|
15
|
+
amount=10000,
|
|
16
|
+
currency="USD",
|
|
17
|
+
rail="auto",
|
|
18
|
+
identifier="+252611234567",
|
|
19
|
+
description="Order #1234",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if charge["status"] == "requires_action":
|
|
23
|
+
redirect_url = charge["requires_action_payload"]["redirect_to"]
|
|
24
|
+
# send the payer to redirect_url
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Webhook verification:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from bila import verify_webhook_signature
|
|
31
|
+
|
|
32
|
+
signature = request.headers["Bila-Signature"]
|
|
33
|
+
body = request.get_data().decode()
|
|
34
|
+
if not verify_webhook_signature(body, signature, secret="whsec_..."):
|
|
35
|
+
return "invalid signature", 400
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Full docs at <https://bila.so/docs/sdks>.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bila"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Bila Python SDK — Stripe-style payment aggregator for Somalia."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Tabsamo Group", email = "info@tabsamo.com" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["bila", "payments", "somalia", "waafipay", "edahab", "stripe"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Office/Business :: Financial",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests>=2.31",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://bila.so"
|
|
33
|
+
Documentation = "https://bila.so/docs/sdks"
|
|
34
|
+
Source = "https://github.com/Saakuut/bila"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/bila"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/bila", "README.md", "pyproject.toml"]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Official Bila Python SDK.
|
|
2
|
+
|
|
3
|
+
Stripe-style: one client, resources hanging off the client, every method
|
|
4
|
+
returns a dict with the API response shape.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from bila import Bila
|
|
8
|
+
bila = Bila(api_key="sk_live_...")
|
|
9
|
+
charge = bila.charges.create(amount=10000, currency="USD", rail="auto",
|
|
10
|
+
identifier="+252611234567")
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__all__ = ["Bila", "BilaError", "verify_webhook_signature"]
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://api.bila.so"
|
|
28
|
+
DEFAULT_VERSION = "2026-05-17"
|
|
29
|
+
DEFAULT_TIMEOUT = 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BilaError(Exception):
|
|
33
|
+
"""Raised when the Bila API returns a non-2xx response."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, status: int, body: Dict[str, Any]):
|
|
36
|
+
super().__init__(body.get("message") or f"Bila {status}")
|
|
37
|
+
self.status = status
|
|
38
|
+
self.type = body.get("type")
|
|
39
|
+
self.code = body.get("code")
|
|
40
|
+
self.param = body.get("param")
|
|
41
|
+
self.request_id = body.get("request_id")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Bila:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
bila_version: str = DEFAULT_VERSION,
|
|
51
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise ValueError("Bila: api_key is required.")
|
|
55
|
+
self._api_key = api_key
|
|
56
|
+
self._base_url = base_url.rstrip("/")
|
|
57
|
+
self._bila_version = bila_version
|
|
58
|
+
self._timeout = timeout
|
|
59
|
+
self._session = requests.Session()
|
|
60
|
+
|
|
61
|
+
# Resources
|
|
62
|
+
self.charges = _Charges(self)
|
|
63
|
+
self.refunds = _Refunds(self)
|
|
64
|
+
self.customers = _Customers(self)
|
|
65
|
+
self.payment_methods = _PaymentMethods(self)
|
|
66
|
+
self.webhook_endpoints = _WebhookEndpoints(self)
|
|
67
|
+
self.balance = _Balance(self)
|
|
68
|
+
self.events = _Events(self)
|
|
69
|
+
|
|
70
|
+
def request(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
75
|
+
*,
|
|
76
|
+
idempotency_key: Optional[str] = None,
|
|
77
|
+
bila_version: Optional[str] = None,
|
|
78
|
+
request_id: Optional[str] = None,
|
|
79
|
+
) -> Dict[str, Any]:
|
|
80
|
+
url = f"{self._base_url}{path}"
|
|
81
|
+
basic = base64.b64encode(f"{self._api_key}:".encode()).decode()
|
|
82
|
+
headers = {
|
|
83
|
+
"Authorization": f"Basic {basic}",
|
|
84
|
+
"Bila-Version": bila_version or self._bila_version,
|
|
85
|
+
"Accept": "application/json",
|
|
86
|
+
}
|
|
87
|
+
if json_body is not None:
|
|
88
|
+
headers["Content-Type"] = "application/json"
|
|
89
|
+
if method.upper() not in ("GET", "HEAD"):
|
|
90
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
91
|
+
if request_id:
|
|
92
|
+
headers["X-Request-Id"] = request_id
|
|
93
|
+
|
|
94
|
+
res = self._session.request(
|
|
95
|
+
method,
|
|
96
|
+
url,
|
|
97
|
+
data=json.dumps(json_body) if json_body is not None else None,
|
|
98
|
+
headers=headers,
|
|
99
|
+
timeout=self._timeout,
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
data = res.json()
|
|
103
|
+
except ValueError:
|
|
104
|
+
data = {}
|
|
105
|
+
if not res.ok:
|
|
106
|
+
err = (data or {}).get("error") or {"type": "api_error", "message": f"HTTP {res.status_code}"}
|
|
107
|
+
raise BilaError(res.status_code, err)
|
|
108
|
+
return data
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Resource:
|
|
112
|
+
def __init__(self, client: Bila) -> None:
|
|
113
|
+
self._client = client
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _qs(params: Dict[str, Any]) -> str:
|
|
117
|
+
items = [(k, str(v)) for k, v in params.items() if v is not None]
|
|
118
|
+
if not items:
|
|
119
|
+
return ""
|
|
120
|
+
from urllib.parse import urlencode
|
|
121
|
+
return "?" + urlencode(items)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Charges(_Resource):
|
|
125
|
+
def create(self, *, amount: int, currency: str, rail: str, **kw: Any) -> Dict[str, Any]:
|
|
126
|
+
body = {"amount": amount, "currency": currency, "rail": rail, **kw}
|
|
127
|
+
opts = {k: body.pop(k) for k in ("idempotency_key", "bila_version", "request_id") if k in body}
|
|
128
|
+
return self._client.request("POST", "/v1/charges", body, **opts)
|
|
129
|
+
|
|
130
|
+
def retrieve(self, charge_id: str, **opts: Any) -> Dict[str, Any]:
|
|
131
|
+
return self._client.request("GET", f"/v1/charges/{charge_id}", **opts)
|
|
132
|
+
|
|
133
|
+
def list(self, *, limit: Optional[int] = None, starting_after: Optional[str] = None, **opts: Any) -> Dict[str, Any]:
|
|
134
|
+
q = _qs({"limit": limit, "starting_after": starting_after})
|
|
135
|
+
return self._client.request("GET", f"/v1/charges{q}", **opts)
|
|
136
|
+
|
|
137
|
+
def cancel(self, charge_id: str, **opts: Any) -> Dict[str, Any]:
|
|
138
|
+
return self._client.request("POST", f"/v1/charges/{charge_id}/cancel", {}, **opts)
|
|
139
|
+
|
|
140
|
+
def confirm(self, charge_id: str, **opts: Any) -> Dict[str, Any]:
|
|
141
|
+
return self._client.request("POST", f"/v1/charges/{charge_id}/confirm", {}, **opts)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _Refunds(_Resource):
|
|
145
|
+
def create(self, *, charge: str, amount: Optional[int] = None, reason: Optional[str] = None, **opts: Any) -> Dict[str, Any]:
|
|
146
|
+
body: Dict[str, Any] = {"charge": charge}
|
|
147
|
+
if amount is not None:
|
|
148
|
+
body["amount"] = amount
|
|
149
|
+
if reason is not None:
|
|
150
|
+
body["reason"] = reason
|
|
151
|
+
return self._client.request("POST", "/v1/refunds", body, **opts)
|
|
152
|
+
|
|
153
|
+
def retrieve(self, refund_id: str, **opts: Any) -> Dict[str, Any]:
|
|
154
|
+
return self._client.request("GET", f"/v1/refunds/{refund_id}", **opts)
|
|
155
|
+
|
|
156
|
+
def list(self, *, limit: Optional[int] = None, starting_after: Optional[str] = None, charge: Optional[str] = None, **opts: Any) -> Dict[str, Any]:
|
|
157
|
+
q = _qs({"limit": limit, "starting_after": starting_after, "charge": charge})
|
|
158
|
+
return self._client.request("GET", f"/v1/refunds{q}", **opts)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _Customers(_Resource):
|
|
162
|
+
def create(self, **params: Any) -> Dict[str, Any]:
|
|
163
|
+
opts = {k: params.pop(k) for k in ("idempotency_key", "bila_version", "request_id") if k in params}
|
|
164
|
+
return self._client.request("POST", "/v1/customers", params, **opts)
|
|
165
|
+
|
|
166
|
+
def retrieve(self, customer_id: str, **opts: Any) -> Dict[str, Any]:
|
|
167
|
+
return self._client.request("GET", f"/v1/customers/{customer_id}", **opts)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _PaymentMethods(_Resource):
|
|
171
|
+
def create(self, *, rail: str, kind: str, **params: Any) -> Dict[str, Any]:
|
|
172
|
+
body = {"rail": rail, "kind": kind, **params}
|
|
173
|
+
opts = {k: body.pop(k) for k in ("idempotency_key", "bila_version", "request_id") if k in body}
|
|
174
|
+
return self._client.request("POST", "/v1/payment_methods", body, **opts)
|
|
175
|
+
|
|
176
|
+
def retrieve(self, pm_id: str, **opts: Any) -> Dict[str, Any]:
|
|
177
|
+
return self._client.request("GET", f"/v1/payment_methods/{pm_id}", **opts)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _WebhookEndpoints(_Resource):
|
|
181
|
+
def create(self, *, url: str, **params: Any) -> Dict[str, Any]:
|
|
182
|
+
body = {"url": url, **params}
|
|
183
|
+
opts = {k: body.pop(k) for k in ("idempotency_key", "bila_version", "request_id") if k in body}
|
|
184
|
+
return self._client.request("POST", "/v1/webhook_endpoints", body, **opts)
|
|
185
|
+
|
|
186
|
+
def retrieve(self, endpoint_id: str, **opts: Any) -> Dict[str, Any]:
|
|
187
|
+
return self._client.request("GET", f"/v1/webhook_endpoints/{endpoint_id}", **opts)
|
|
188
|
+
|
|
189
|
+
def list(self, **opts: Any) -> Dict[str, Any]:
|
|
190
|
+
return self._client.request("GET", "/v1/webhook_endpoints", **opts)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _Balance(_Resource):
|
|
194
|
+
def retrieve(self, **opts: Any) -> Dict[str, Any]:
|
|
195
|
+
return self._client.request("GET", "/v1/balance", **opts)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _Events(_Resource):
|
|
199
|
+
def list(self, *, limit: Optional[int] = None, starting_after: Optional[str] = None, type: Optional[str] = None, **opts: Any) -> Dict[str, Any]:
|
|
200
|
+
q = _qs({"limit": limit, "starting_after": starting_after, "type": type})
|
|
201
|
+
return self._client.request("GET", f"/v1/events{q}", **opts)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def verify_webhook_signature(
|
|
205
|
+
body: str,
|
|
206
|
+
header: str,
|
|
207
|
+
secret: str,
|
|
208
|
+
tolerance_seconds: int = 300,
|
|
209
|
+
) -> bool:
|
|
210
|
+
"""Verify a Bila-Signature header against the raw request body.
|
|
211
|
+
|
|
212
|
+
Header format: 't=<unix-timestamp>,v1=<hex-hmac-sha256>'
|
|
213
|
+
"""
|
|
214
|
+
parts: Dict[str, str] = {}
|
|
215
|
+
for piece in header.split(","):
|
|
216
|
+
if "=" in piece:
|
|
217
|
+
k, v = piece.split("=", 1)
|
|
218
|
+
parts[k.strip()] = v.strip()
|
|
219
|
+
try:
|
|
220
|
+
t = int(parts.get("t", ""))
|
|
221
|
+
except ValueError:
|
|
222
|
+
return False
|
|
223
|
+
sig = parts.get("v1", "")
|
|
224
|
+
if not sig:
|
|
225
|
+
return False
|
|
226
|
+
if abs(int(time.time()) - t) > tolerance_seconds:
|
|
227
|
+
return False
|
|
228
|
+
expected = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
229
|
+
return hmac.compare_digest(expected, sig)
|