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/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"]