lenapi 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.
- lenapi/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/payments.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Optional sync and async payment-provider plugins for LenApi.
|
|
2
|
+
|
|
3
|
+
The adapters create hosted checkout sessions and verify transactions. They do
|
|
4
|
+
not store orders or mark them paid; that remains application business logic.
|
|
5
|
+
Install the transport with ``pip install 'lenapi[payments]'``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from decimal import Decimal, InvalidOperation
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _minor_units(amount: int | float | Decimal | str, currency: str) -> int:
|
|
20
|
+
"""Convert a major-unit amount to a provider's integer minor units."""
|
|
21
|
+
try:
|
|
22
|
+
value = Decimal(str(amount))
|
|
23
|
+
except (InvalidOperation, ValueError) as exc:
|
|
24
|
+
raise ValueError("amount must be a valid decimal number") from exc
|
|
25
|
+
if not value.is_finite() or value <= 0:
|
|
26
|
+
raise ValueError("amount must be a positive finite number")
|
|
27
|
+
zero_decimal = {"BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"}
|
|
28
|
+
three_decimal = {"BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"}
|
|
29
|
+
exponent = 0 if currency.upper() in zero_decimal else 3 if currency.upper() in three_decimal else 2
|
|
30
|
+
scaled = value * (10 ** exponent)
|
|
31
|
+
if scaled != scaled.to_integral_value():
|
|
32
|
+
raise ValueError(f"amount has more than {exponent} decimal places for {currency.upper()}")
|
|
33
|
+
return int(scaled)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PaymentPlugin(ABC):
|
|
37
|
+
"""Base class for async hosted-checkout provider plugins."""
|
|
38
|
+
|
|
39
|
+
provider = "provider"
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, timeout: float = 30, client=None, sync_client=None,
|
|
42
|
+
base_url: str | None = None) -> None:
|
|
43
|
+
if timeout <= 0:
|
|
44
|
+
raise ValueError("timeout must be positive")
|
|
45
|
+
self._provided_async_client = client
|
|
46
|
+
self._async_client = client
|
|
47
|
+
self._provided_sync_client = sync_client
|
|
48
|
+
self._sync_client_instance = sync_client
|
|
49
|
+
if client is None or sync_client is None:
|
|
50
|
+
try:
|
|
51
|
+
import httpx
|
|
52
|
+
except ImportError as exc:
|
|
53
|
+
raise ImportError("Payment plugins require `pip install 'lenapi[payments]'`") from exc
|
|
54
|
+
self._timeout = timeout
|
|
55
|
+
self.base_url = (base_url or self.default_base_url).rstrip("/")
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def _client(self):
|
|
59
|
+
if self._async_client is None:
|
|
60
|
+
import httpx
|
|
61
|
+
self._async_client = httpx.AsyncClient(timeout=self._timeout)
|
|
62
|
+
return self._async_client
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def _sync_client(self):
|
|
66
|
+
if self._sync_client_instance is None:
|
|
67
|
+
import httpx
|
|
68
|
+
self._sync_client_instance = httpx.Client(timeout=self._timeout)
|
|
69
|
+
return self._sync_client_instance
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def default_base_url(self) -> str:
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
async def close(self) -> None:
|
|
77
|
+
if self._provided_async_client is None and self._async_client is not None:
|
|
78
|
+
await self._async_client.aclose()
|
|
79
|
+
self._async_client = None
|
|
80
|
+
if self._provided_sync_client is None and self._sync_client_instance is not None:
|
|
81
|
+
self._sync_client_instance.close()
|
|
82
|
+
self._sync_client_instance = None
|
|
83
|
+
|
|
84
|
+
def close_sync(self) -> None:
|
|
85
|
+
"""Close the synchronous HTTP client, if it was created."""
|
|
86
|
+
if self._provided_sync_client is None and self._sync_client_instance is not None:
|
|
87
|
+
self._sync_client_instance.close()
|
|
88
|
+
self._sync_client_instance = None
|
|
89
|
+
|
|
90
|
+
def __enter__(self):
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *_exc):
|
|
94
|
+
self.close_sync()
|
|
95
|
+
|
|
96
|
+
async def __aenter__(self):
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
async def __aexit__(self, *_exc):
|
|
100
|
+
await self.close()
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
async def create_checkout(self, *, amount, currency: str, email: str,
|
|
104
|
+
reference: str, success_url: str | None = None,
|
|
105
|
+
cancel_url: str | None = None,
|
|
106
|
+
metadata: dict[str, Any] | None = None, **options) -> dict:
|
|
107
|
+
"""Create a hosted payment session. Amount is in major currency units."""
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
async def verify(self, reference: str) -> dict:
|
|
111
|
+
"""Ask the payment provider for the current transaction status."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Paystack(PaymentPlugin):
|
|
115
|
+
"""Paystack hosted checkout adapter."""
|
|
116
|
+
|
|
117
|
+
provider = "paystack"
|
|
118
|
+
default_base_url = "https://api.paystack.co"
|
|
119
|
+
|
|
120
|
+
def __init__(self, secret_key: str | None = None, **kwargs) -> None:
|
|
121
|
+
self.secret_key = secret_key or os.getenv("PAYSTACK_SECRET_KEY")
|
|
122
|
+
if not self.secret_key:
|
|
123
|
+
raise ValueError("Pass secret_key or set PAYSTACK_SECRET_KEY")
|
|
124
|
+
super().__init__(**kwargs)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def _headers(self):
|
|
128
|
+
return {"Authorization": f"Bearer {self.secret_key}"}
|
|
129
|
+
|
|
130
|
+
async def create_checkout(self, *, amount, currency="NGN", email: str,
|
|
131
|
+
reference: str, success_url=None, cancel_url=None,
|
|
132
|
+
metadata=None, **options) -> dict:
|
|
133
|
+
payload = {"amount": str(_minor_units(amount, currency)), "currency": currency.upper(),
|
|
134
|
+
"email": email, "reference": reference, **options}
|
|
135
|
+
if success_url:
|
|
136
|
+
payload["callback_url"] = success_url
|
|
137
|
+
if metadata is not None:
|
|
138
|
+
payload["metadata"] = metadata
|
|
139
|
+
response = await self._client.post(f"{self.base_url}/transaction/initialize",
|
|
140
|
+
headers=self._headers, json=payload)
|
|
141
|
+
response.raise_for_status()
|
|
142
|
+
return response.json()
|
|
143
|
+
|
|
144
|
+
def create_checkout_sync(self, *, amount, currency="NGN", email: str,
|
|
145
|
+
reference: str, success_url=None, cancel_url=None,
|
|
146
|
+
metadata=None, **options) -> dict:
|
|
147
|
+
payload = {"amount": str(_minor_units(amount, currency)), "currency": currency.upper(),
|
|
148
|
+
"email": email, "reference": reference, **options}
|
|
149
|
+
if success_url:
|
|
150
|
+
payload["callback_url"] = success_url
|
|
151
|
+
if metadata is not None:
|
|
152
|
+
payload["metadata"] = metadata
|
|
153
|
+
response = self._sync_client.post(f"{self.base_url}/transaction/initialize",
|
|
154
|
+
headers=self._headers, json=payload)
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
return response.json()
|
|
157
|
+
|
|
158
|
+
async def verify(self, reference: str) -> dict:
|
|
159
|
+
response = await self._client.get(f"{self.base_url}/transaction/verify/{reference}",
|
|
160
|
+
headers=self._headers)
|
|
161
|
+
response.raise_for_status()
|
|
162
|
+
return response.json()
|
|
163
|
+
|
|
164
|
+
def verify_sync(self, reference: str) -> dict:
|
|
165
|
+
response = self._sync_client.get(f"{self.base_url}/transaction/verify/{reference}",
|
|
166
|
+
headers=self._headers)
|
|
167
|
+
response.raise_for_status()
|
|
168
|
+
return response.json()
|
|
169
|
+
|
|
170
|
+
def verify_webhook(self, raw_body: bytes, signature: str) -> bool:
|
|
171
|
+
expected = hmac.new(self.secret_key.encode(), raw_body, hashlib.sha512).hexdigest()
|
|
172
|
+
return hmac.compare_digest(expected, signature)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Flutterwave(PaymentPlugin):
|
|
176
|
+
"""Flutterwave Standard checkout adapter."""
|
|
177
|
+
|
|
178
|
+
provider = "flutterwave"
|
|
179
|
+
default_base_url = "https://api.flutterwave.com/v3"
|
|
180
|
+
|
|
181
|
+
def __init__(self, secret_key: str | None = None, **kwargs) -> None:
|
|
182
|
+
self.secret_key = secret_key or os.getenv("FLW_SECRET_KEY") or os.getenv("FLUTTERWAVE_SECRET_KEY")
|
|
183
|
+
if not self.secret_key:
|
|
184
|
+
raise ValueError("Pass secret_key or set FLW_SECRET_KEY")
|
|
185
|
+
super().__init__(**kwargs)
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def _headers(self):
|
|
189
|
+
return {"Authorization": f"Bearer {self.secret_key}"}
|
|
190
|
+
|
|
191
|
+
async def create_checkout(self, *, amount, currency="NGN", email: str,
|
|
192
|
+
reference: str, success_url=None, cancel_url=None,
|
|
193
|
+
metadata=None, **options) -> dict:
|
|
194
|
+
customer = {"email": email}
|
|
195
|
+
payload = {"tx_ref": reference, "amount": str(amount), "currency": currency.upper(),
|
|
196
|
+
"customer": customer, **options}
|
|
197
|
+
if success_url:
|
|
198
|
+
payload["redirect_url"] = success_url
|
|
199
|
+
if metadata is not None:
|
|
200
|
+
payload["meta"] = metadata
|
|
201
|
+
response = await self._client.post(f"{self.base_url}/payments",
|
|
202
|
+
headers=self._headers, json=payload)
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
return response.json()
|
|
205
|
+
|
|
206
|
+
def create_checkout_sync(self, *, amount, currency="NGN", email: str,
|
|
207
|
+
reference: str, success_url=None, cancel_url=None,
|
|
208
|
+
metadata=None, **options) -> dict:
|
|
209
|
+
payload = {"tx_ref": reference, "amount": str(amount), "currency": currency.upper(),
|
|
210
|
+
"customer": {"email": email}, **options}
|
|
211
|
+
if success_url:
|
|
212
|
+
payload["redirect_url"] = success_url
|
|
213
|
+
if metadata is not None:
|
|
214
|
+
payload["meta"] = metadata
|
|
215
|
+
response = self._sync_client.post(f"{self.base_url}/payments",
|
|
216
|
+
headers=self._headers, json=payload)
|
|
217
|
+
response.raise_for_status()
|
|
218
|
+
return response.json()
|
|
219
|
+
|
|
220
|
+
async def verify(self, transaction_id: str) -> dict:
|
|
221
|
+
response = await self._client.get(f"{self.base_url}/transactions/{transaction_id}/verify",
|
|
222
|
+
headers=self._headers)
|
|
223
|
+
response.raise_for_status()
|
|
224
|
+
return response.json()
|
|
225
|
+
|
|
226
|
+
def verify_sync(self, transaction_id: str) -> dict:
|
|
227
|
+
response = self._sync_client.get(f"{self.base_url}/transactions/{transaction_id}/verify",
|
|
228
|
+
headers=self._headers)
|
|
229
|
+
response.raise_for_status()
|
|
230
|
+
return response.json()
|
|
231
|
+
|
|
232
|
+
def verify_webhook(self, raw_body: bytes, signature: str) -> bool:
|
|
233
|
+
secret_hash = os.getenv("FLW_WEBHOOK_SECRET_HASH", "")
|
|
234
|
+
return bool(secret_hash) and hmac.compare_digest(secret_hash, signature)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class Stripe(PaymentPlugin):
|
|
238
|
+
"""Stripe Checkout Sessions adapter (hosted checkout)."""
|
|
239
|
+
|
|
240
|
+
provider = "stripe"
|
|
241
|
+
default_base_url = "https://api.stripe.com/v1"
|
|
242
|
+
|
|
243
|
+
def __init__(self, secret_key: str | None = None, **kwargs) -> None:
|
|
244
|
+
self.secret_key = secret_key or os.getenv("STRIPE_SECRET_KEY")
|
|
245
|
+
if not self.secret_key:
|
|
246
|
+
raise ValueError("Pass secret_key or set STRIPE_SECRET_KEY")
|
|
247
|
+
super().__init__(**kwargs)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def _auth(self):
|
|
251
|
+
return (self.secret_key, "")
|
|
252
|
+
|
|
253
|
+
async def create_checkout(self, *, amount, currency="usd", email: str,
|
|
254
|
+
reference: str, success_url=None, cancel_url=None,
|
|
255
|
+
metadata=None, **options) -> dict:
|
|
256
|
+
if not success_url or not cancel_url:
|
|
257
|
+
raise ValueError("Stripe Checkout requires success_url and cancel_url")
|
|
258
|
+
currency = currency.lower()
|
|
259
|
+
payload: dict[str, Any] = {
|
|
260
|
+
"mode": "payment", "success_url": success_url, "cancel_url": cancel_url,
|
|
261
|
+
"customer_email": email, "client_reference_id": reference,
|
|
262
|
+
"line_items[0][quantity]": "1",
|
|
263
|
+
"line_items[0][price_data][currency]": currency,
|
|
264
|
+
"line_items[0][price_data][unit_amount]": str(_minor_units(amount, currency)),
|
|
265
|
+
"line_items[0][price_data][product_data][name]": options.pop("product_name", "Payment"),
|
|
266
|
+
}
|
|
267
|
+
for key, value in (metadata or {}).items():
|
|
268
|
+
payload[f"metadata[{key}]"] = str(value)
|
|
269
|
+
payload.update(options)
|
|
270
|
+
response = await self._client.post(f"{self.base_url}/checkout/sessions",
|
|
271
|
+
auth=self._auth, data=payload)
|
|
272
|
+
response.raise_for_status()
|
|
273
|
+
return response.json()
|
|
274
|
+
|
|
275
|
+
def create_checkout_sync(self, *, amount, currency="usd", email: str,
|
|
276
|
+
reference: str, success_url=None, cancel_url=None,
|
|
277
|
+
metadata=None, **options) -> dict:
|
|
278
|
+
if not success_url or not cancel_url:
|
|
279
|
+
raise ValueError("Stripe Checkout requires success_url and cancel_url")
|
|
280
|
+
currency = currency.lower()
|
|
281
|
+
payload: dict[str, Any] = {
|
|
282
|
+
"mode": "payment", "success_url": success_url, "cancel_url": cancel_url,
|
|
283
|
+
"customer_email": email, "client_reference_id": reference,
|
|
284
|
+
"line_items[0][quantity]": "1",
|
|
285
|
+
"line_items[0][price_data][currency]": currency,
|
|
286
|
+
"line_items[0][price_data][unit_amount]": str(_minor_units(amount, currency)),
|
|
287
|
+
"line_items[0][price_data][product_data][name]": options.pop("product_name", "Payment"),
|
|
288
|
+
}
|
|
289
|
+
for key, value in (metadata or {}).items():
|
|
290
|
+
payload[f"metadata[{key}]"] = str(value)
|
|
291
|
+
payload.update(options)
|
|
292
|
+
response = self._sync_client.post(f"{self.base_url}/checkout/sessions",
|
|
293
|
+
auth=self._auth, data=payload)
|
|
294
|
+
response.raise_for_status()
|
|
295
|
+
return response.json()
|
|
296
|
+
|
|
297
|
+
async def verify(self, session_id: str) -> dict:
|
|
298
|
+
response = await self._client.get(f"{self.base_url}/checkout/sessions/{session_id}",
|
|
299
|
+
auth=self._auth)
|
|
300
|
+
response.raise_for_status()
|
|
301
|
+
return response.json()
|
|
302
|
+
|
|
303
|
+
def verify_sync(self, session_id: str) -> dict:
|
|
304
|
+
response = self._sync_client.get(f"{self.base_url}/checkout/sessions/{session_id}",
|
|
305
|
+
auth=self._auth)
|
|
306
|
+
response.raise_for_status()
|
|
307
|
+
return response.json()
|
|
308
|
+
|
|
309
|
+
def verify_webhook(self, raw_body: bytes, signature: str, *, tolerance_seconds: int = 300) -> bool:
|
|
310
|
+
"""Verify Stripe-Signature header value using Stripe's timestamped HMAC."""
|
|
311
|
+
webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
|
312
|
+
if not webhook_secret:
|
|
313
|
+
return False
|
|
314
|
+
parts = [part.split("=", 1) for part in signature.split(",") if "=" in part]
|
|
315
|
+
values: dict[str, list[str]] = {}
|
|
316
|
+
for key, value in parts:
|
|
317
|
+
values.setdefault(key, []).append(value)
|
|
318
|
+
try:
|
|
319
|
+
timestamp = int(values["t"][0])
|
|
320
|
+
except (KeyError, ValueError, IndexError):
|
|
321
|
+
return False
|
|
322
|
+
if abs(time.time() - timestamp) > tolerance_seconds:
|
|
323
|
+
return False
|
|
324
|
+
signed = str(timestamp).encode() + b"." + raw_body
|
|
325
|
+
expected = hmac.new(webhook_secret.encode(), signed, hashlib.sha256).hexdigest()
|
|
326
|
+
return any(hmac.compare_digest(expected, candidate) for candidate in values.get("v1", []))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Nomba(PaymentPlugin):
|
|
330
|
+
"""Nomba hosted checkout adapter using client-credentials access tokens."""
|
|
331
|
+
|
|
332
|
+
provider = "nomba"
|
|
333
|
+
default_base_url = "https://api.nomba.com"
|
|
334
|
+
|
|
335
|
+
def __init__(self, client_id: str | None = None, client_secret: str | None = None,
|
|
336
|
+
account_id: str | None = None, **kwargs) -> None:
|
|
337
|
+
self.client_id = client_id or os.getenv("NOMBA_CLIENT_ID")
|
|
338
|
+
self.client_secret = client_secret or os.getenv("NOMBA_CLIENT_SECRET")
|
|
339
|
+
self.account_id = account_id or os.getenv("NOMBA_ACCOUNT_ID")
|
|
340
|
+
if not all((self.client_id, self.client_secret, self.account_id)):
|
|
341
|
+
raise ValueError("Pass client_id, client_secret, account_id or set NOMBA_CLIENT_ID, NOMBA_CLIENT_SECRET and NOMBA_ACCOUNT_ID")
|
|
342
|
+
self._token: str | None = None
|
|
343
|
+
self._token_expires_at = 0.0
|
|
344
|
+
super().__init__(**kwargs)
|
|
345
|
+
|
|
346
|
+
async def _access_token(self) -> str:
|
|
347
|
+
if self._token and time.monotonic() < self._token_expires_at - 30:
|
|
348
|
+
return self._token
|
|
349
|
+
response = await self._client.post(
|
|
350
|
+
f"{self.base_url}/v1/auth/token/issue", headers={"accountId": self.account_id},
|
|
351
|
+
json={"grant_type": "client_credentials", "client_id": self.client_id,
|
|
352
|
+
"client_secret": self.client_secret},
|
|
353
|
+
)
|
|
354
|
+
response.raise_for_status()
|
|
355
|
+
result = response.json()
|
|
356
|
+
data = result.get("data", result)
|
|
357
|
+
self._token = data.get("access_token") or data.get("token")
|
|
358
|
+
if not self._token:
|
|
359
|
+
raise ValueError("Nomba token response did not include an access token")
|
|
360
|
+
self._token_expires_at = time.monotonic() + float(data.get("expires_in", 3600))
|
|
361
|
+
return self._token
|
|
362
|
+
|
|
363
|
+
def _access_token_sync(self) -> str:
|
|
364
|
+
if self._token and time.monotonic() < self._token_expires_at - 30:
|
|
365
|
+
return self._token
|
|
366
|
+
response = self._sync_client.post(
|
|
367
|
+
f"{self.base_url}/v1/auth/token/issue", headers={"accountId": self.account_id},
|
|
368
|
+
json={"grant_type": "client_credentials", "client_id": self.client_id,
|
|
369
|
+
"client_secret": self.client_secret},
|
|
370
|
+
)
|
|
371
|
+
response.raise_for_status()
|
|
372
|
+
result = response.json()
|
|
373
|
+
data = result.get("data", result)
|
|
374
|
+
self._token = data.get("access_token") or data.get("token")
|
|
375
|
+
if not self._token:
|
|
376
|
+
raise ValueError("Nomba token response did not include an access token")
|
|
377
|
+
self._token_expires_at = time.monotonic() + float(data.get("expires_in", 3600))
|
|
378
|
+
return self._token
|
|
379
|
+
|
|
380
|
+
async def create_checkout(self, *, amount, currency="NGN", email: str,
|
|
381
|
+
reference: str, success_url=None, cancel_url=None,
|
|
382
|
+
metadata=None, **options) -> dict:
|
|
383
|
+
token = await self._access_token()
|
|
384
|
+
order = {"customerEmail": email, "amount": str(amount), "currency": currency.upper(),
|
|
385
|
+
"orderReference": reference, "accountId": self.account_id, **options}
|
|
386
|
+
if success_url:
|
|
387
|
+
order["callbackUrl"] = success_url
|
|
388
|
+
if metadata is not None:
|
|
389
|
+
order["orderMetaData"] = metadata
|
|
390
|
+
response = await self._client.post(
|
|
391
|
+
f"{self.base_url}/v1/checkout/order",
|
|
392
|
+
headers={"Authorization": f"Bearer {token}", "accountId": self.account_id},
|
|
393
|
+
json={"order": order},
|
|
394
|
+
)
|
|
395
|
+
response.raise_for_status()
|
|
396
|
+
return response.json()
|
|
397
|
+
|
|
398
|
+
def create_checkout_sync(self, *, amount, currency="NGN", email: str,
|
|
399
|
+
reference: str, success_url=None, cancel_url=None,
|
|
400
|
+
metadata=None, **options) -> dict:
|
|
401
|
+
token = self._access_token_sync()
|
|
402
|
+
order = {"customerEmail": email, "amount": str(amount), "currency": currency.upper(),
|
|
403
|
+
"orderReference": reference, "accountId": self.account_id, **options}
|
|
404
|
+
if success_url:
|
|
405
|
+
order["callbackUrl"] = success_url
|
|
406
|
+
if metadata is not None:
|
|
407
|
+
order["orderMetaData"] = metadata
|
|
408
|
+
response = self._sync_client.post(
|
|
409
|
+
f"{self.base_url}/v1/checkout/order",
|
|
410
|
+
headers={"Authorization": f"Bearer {token}", "accountId": self.account_id},
|
|
411
|
+
json={"order": order},
|
|
412
|
+
)
|
|
413
|
+
response.raise_for_status()
|
|
414
|
+
return response.json()
|
|
415
|
+
|
|
416
|
+
async def verify(self, reference: str) -> dict:
|
|
417
|
+
token = await self._access_token()
|
|
418
|
+
response = await self._client.get(
|
|
419
|
+
f"{self.base_url}/v1/checkout/transaction",
|
|
420
|
+
headers={"Authorization": f"Bearer {token}", "accountId": self.account_id},
|
|
421
|
+
params={"idType": "ORDER_REFERENCE", "id": reference},
|
|
422
|
+
)
|
|
423
|
+
response.raise_for_status()
|
|
424
|
+
return response.json()
|
|
425
|
+
|
|
426
|
+
def verify_sync(self, reference: str) -> dict:
|
|
427
|
+
token = self._access_token_sync()
|
|
428
|
+
response = self._sync_client.get(
|
|
429
|
+
f"{self.base_url}/v1/checkout/transaction",
|
|
430
|
+
headers={"Authorization": f"Bearer {token}", "accountId": self.account_id},
|
|
431
|
+
params={"idType": "ORDER_REFERENCE", "id": reference},
|
|
432
|
+
)
|
|
433
|
+
response.raise_for_status()
|
|
434
|
+
return response.json()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
_PROVIDERS = {"nomba": Nomba, "flutterwave": Flutterwave, "stripe": Stripe, "paystack": Paystack}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def payment_provider(name: str, **config) -> PaymentPlugin:
|
|
441
|
+
"""Load a registered provider plugin by name.
|
|
442
|
+
|
|
443
|
+
Example: ``gateway = payment_provider("paystack")``. Applications can
|
|
444
|
+
register custom adapters by extending ``PaymentPlugin`` and calling
|
|
445
|
+
``register_payment_provider``.
|
|
446
|
+
"""
|
|
447
|
+
try:
|
|
448
|
+
plugin = _PROVIDERS[name.lower()]
|
|
449
|
+
except KeyError as exc:
|
|
450
|
+
raise ValueError(f"Unknown payment provider {name!r}; choose from {', '.join(sorted(_PROVIDERS))}") from exc
|
|
451
|
+
return plugin(**config)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class Payments:
|
|
455
|
+
"""Developer-facing facade over LenApi's payment provider plugins.
|
|
456
|
+
|
|
457
|
+
Choose a built-in provider by name, then use the same checkout and
|
|
458
|
+
verification methods for every provider::
|
|
459
|
+
|
|
460
|
+
with Payments("paystack") as payments:
|
|
461
|
+
checkout = payments.create_checkout(...)
|
|
462
|
+
status = payments.verify("order-123")
|
|
463
|
+
|
|
464
|
+
async with Payments("paystack") as payments:
|
|
465
|
+
checkout = await payments.acreate_checkout(...)
|
|
466
|
+
status = await payments.averify("order-123")
|
|
467
|
+
|
|
468
|
+
Provider-specific credentials can be passed as keyword arguments or read
|
|
469
|
+
from environment variables. No provider class is needed in application code.
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
def __init__(self, provider: str, **config) -> None:
|
|
473
|
+
self.provider = provider.strip().lower()
|
|
474
|
+
self._plugin = payment_provider(self.provider, **config)
|
|
475
|
+
|
|
476
|
+
def create_checkout(self, *, amount, currency: str, email: str,
|
|
477
|
+
reference: str, success_url: str | None = None,
|
|
478
|
+
cancel_url: str | None = None,
|
|
479
|
+
metadata: dict[str, Any] | None = None, **options) -> dict:
|
|
480
|
+
"""Synchronously create a hosted checkout session."""
|
|
481
|
+
return self._plugin.create_checkout_sync(
|
|
482
|
+
amount=amount, currency=currency, email=email, reference=reference,
|
|
483
|
+
success_url=success_url, cancel_url=cancel_url, metadata=metadata,
|
|
484
|
+
**options,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
async def acreate_checkout(self, *, amount, currency: str, email: str,
|
|
488
|
+
reference: str, success_url: str | None = None,
|
|
489
|
+
cancel_url: str | None = None,
|
|
490
|
+
metadata: dict[str, Any] | None = None, **options) -> dict:
|
|
491
|
+
"""Asynchronously create a hosted checkout session."""
|
|
492
|
+
return await self._plugin.create_checkout(
|
|
493
|
+
amount=amount, currency=currency, email=email, reference=reference,
|
|
494
|
+
success_url=success_url, cancel_url=cancel_url, metadata=metadata,
|
|
495
|
+
**options,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def verify(self, reference: str) -> dict:
|
|
499
|
+
"""Synchronously retrieve transaction status from the provider."""
|
|
500
|
+
return self._plugin.verify_sync(reference)
|
|
501
|
+
|
|
502
|
+
async def averify(self, reference: str) -> dict:
|
|
503
|
+
"""Asynchronously retrieve transaction status from the provider."""
|
|
504
|
+
return await self._plugin.verify(reference)
|
|
505
|
+
|
|
506
|
+
def verify_webhook(self, raw_body: bytes, signature: str, **options) -> bool:
|
|
507
|
+
"""Check a provider webhook signature when that adapter supports it."""
|
|
508
|
+
verifier = getattr(self._plugin, "verify_webhook", None)
|
|
509
|
+
if verifier is None:
|
|
510
|
+
raise NotImplementedError(f"{self.provider} webhook signature verification is not implemented")
|
|
511
|
+
return verifier(raw_body, signature, **options)
|
|
512
|
+
|
|
513
|
+
async def close(self) -> None:
|
|
514
|
+
"""Close the underlying HTTP connection pool."""
|
|
515
|
+
await self._plugin.close()
|
|
516
|
+
|
|
517
|
+
def close_sync(self) -> None:
|
|
518
|
+
"""Close the synchronous HTTP connection pool."""
|
|
519
|
+
self._plugin.close_sync()
|
|
520
|
+
|
|
521
|
+
def __enter__(self):
|
|
522
|
+
return self
|
|
523
|
+
|
|
524
|
+
def __exit__(self, *_exc):
|
|
525
|
+
self.close_sync()
|
|
526
|
+
|
|
527
|
+
async def __aenter__(self):
|
|
528
|
+
return self
|
|
529
|
+
|
|
530
|
+
async def __aexit__(self, *_exc):
|
|
531
|
+
await self.close()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def register_payment_provider(name: str, plugin: type[PaymentPlugin]) -> None:
|
|
535
|
+
"""Register a custom provider adapter for ``payment_provider``."""
|
|
536
|
+
key = name.strip().lower()
|
|
537
|
+
if not key or not issubclass(plugin, PaymentPlugin):
|
|
538
|
+
raise ValueError("name must be non-empty and plugin must subclass PaymentPlugin")
|
|
539
|
+
_PROVIDERS[key] = plugin
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
__all__ = ["Payments", "PaymentPlugin", "Nomba", "Flutterwave", "Stripe", "Paystack",
|
|
543
|
+
"payment_provider", "register_payment_provider"]
|