lunipay 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.
@@ -0,0 +1,33 @@
1
+ # Node
2
+ node_modules/
3
+ dist/
4
+ *.tgz
5
+ .npm/
6
+ .pnpm-store/
7
+
8
+ # Python
9
+ __pycache__/
10
+ *.py[cod]
11
+ *$py.class
12
+ .venv/
13
+ .pytest_cache/
14
+ *.egg-info/
15
+ build/
16
+ sdist/
17
+ wheelhouse/
18
+
19
+ # Editors & OS
20
+ .DS_Store
21
+ .vscode/
22
+ .idea/
23
+ *.swp
24
+ *.swo
25
+
26
+ # Logs
27
+ *.log
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+
31
+ # Env
32
+ .env
33
+ .env.local
lunipay-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: lunipay
3
+ Version: 0.1.0
4
+ Summary: The official LuniPay Python SDK.
5
+ Project-URL: Homepage, https://lunipay.io/docs/sdks/python
6
+ Project-URL: Documentation, https://lunipay.io/docs/sdks/python
7
+ Project-URL: Repository, https://github.com/SammarieoBrown/lunipay-sdk
8
+ Project-URL: Issues, https://github.com/SammarieoBrown/lunipay-sdk/issues
9
+ Author: LuniPay
10
+ License: MIT
11
+ Keywords: api,caribbean,checkout,lunipay,payments,sdk,stripe-alternative
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
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: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Office/Business :: Financial
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.8
25
+ Requires-Dist: requests>=2.20
26
+ Requires-Dist: typing-extensions>=4.0; python_version < '3.11'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # lunipay
30
+
31
+ The official LuniPay Python SDK.
32
+
33
+ ```bash
34
+ pip install lunipay
35
+ ```
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ import lunipay
41
+
42
+ lunipay.api_key = "sk_test_YOUR_KEY"
43
+
44
+ session = lunipay.CheckoutSession.create(
45
+ amount=5000,
46
+ currency="usd",
47
+ success_url="https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
48
+ )
49
+
50
+ print(session["url"])
51
+ ```
52
+
53
+ ## Resources
54
+
55
+ Every LuniPay resource is a top-level class on the module:
56
+
57
+ ```python
58
+ lunipay.Customer.create(email="ada@example.com", first_name="Ada", last_name="Lovelace")
59
+ lunipay.Customer.retrieve("cus_01JRZK...")
60
+ lunipay.Customer.modify("cus_01JRZK...", phone="+18765559999")
61
+ lunipay.Customer.delete("cus_01JRZK...")
62
+
63
+ for customer in lunipay.Customer.list().auto_paging_iter():
64
+ print(customer["email"])
65
+ ```
66
+
67
+ Supported resources: `CheckoutSession`, `Customer`, `Invoice`, `Payment`, `PaymentLink`, `WebhookEndpoint`, `Event`.
68
+
69
+ ## Webhooks
70
+
71
+ ```python
72
+ import lunipay
73
+
74
+ @app.post("/webhook")
75
+ def webhook():
76
+ payload = request.get_data()
77
+ sig = request.headers.get("LuniPay-Signature")
78
+ try:
79
+ event = lunipay.Webhook.construct_event(
80
+ payload=payload,
81
+ sig_header=sig,
82
+ secret="whsec_your_secret",
83
+ )
84
+ except lunipay.error.SignatureVerificationError:
85
+ return "bad signature", 400
86
+
87
+ if event["type"] == "checkout.session.completed":
88
+ # fulfill the order
89
+ pass
90
+
91
+ return "", 200
92
+ ```
93
+
94
+ ## Errors
95
+
96
+ All LuniPay API errors raise subclasses of `lunipay.error.LuniPayError`:
97
+
98
+ ```python
99
+ try:
100
+ lunipay.Customer.create(email="bad", first_name="x", last_name="y")
101
+ except lunipay.error.InvalidRequestError as e:
102
+ print(e.code, e.param, str(e))
103
+ ```
104
+
105
+ Subclasses: `AuthenticationError`, `PermissionError`, `InvalidRequestError`, `IdempotencyError`, `RateLimitError`, `APIError`, `APIConnectionError`.
106
+
107
+ ## Idempotency
108
+
109
+ Pass `idempotency_key` to any mutating request:
110
+
111
+ ```python
112
+ import uuid
113
+
114
+ key = str(uuid.uuid4())
115
+ session = lunipay.CheckoutSession.create(
116
+ amount=5000,
117
+ currency="usd",
118
+ success_url="https://example.com/thanks",
119
+ idempotency_key=key,
120
+ )
121
+ ```
122
+
123
+ ## Full documentation
124
+
125
+ See [lunipay.io/docs/sdks/python](https://lunipay.io/docs/sdks/python).
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,101 @@
1
+ # lunipay
2
+
3
+ The official LuniPay Python SDK.
4
+
5
+ ```bash
6
+ pip install lunipay
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ import lunipay
13
+
14
+ lunipay.api_key = "sk_test_YOUR_KEY"
15
+
16
+ session = lunipay.CheckoutSession.create(
17
+ amount=5000,
18
+ currency="usd",
19
+ success_url="https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
20
+ )
21
+
22
+ print(session["url"])
23
+ ```
24
+
25
+ ## Resources
26
+
27
+ Every LuniPay resource is a top-level class on the module:
28
+
29
+ ```python
30
+ lunipay.Customer.create(email="ada@example.com", first_name="Ada", last_name="Lovelace")
31
+ lunipay.Customer.retrieve("cus_01JRZK...")
32
+ lunipay.Customer.modify("cus_01JRZK...", phone="+18765559999")
33
+ lunipay.Customer.delete("cus_01JRZK...")
34
+
35
+ for customer in lunipay.Customer.list().auto_paging_iter():
36
+ print(customer["email"])
37
+ ```
38
+
39
+ Supported resources: `CheckoutSession`, `Customer`, `Invoice`, `Payment`, `PaymentLink`, `WebhookEndpoint`, `Event`.
40
+
41
+ ## Webhooks
42
+
43
+ ```python
44
+ import lunipay
45
+
46
+ @app.post("/webhook")
47
+ def webhook():
48
+ payload = request.get_data()
49
+ sig = request.headers.get("LuniPay-Signature")
50
+ try:
51
+ event = lunipay.Webhook.construct_event(
52
+ payload=payload,
53
+ sig_header=sig,
54
+ secret="whsec_your_secret",
55
+ )
56
+ except lunipay.error.SignatureVerificationError:
57
+ return "bad signature", 400
58
+
59
+ if event["type"] == "checkout.session.completed":
60
+ # fulfill the order
61
+ pass
62
+
63
+ return "", 200
64
+ ```
65
+
66
+ ## Errors
67
+
68
+ All LuniPay API errors raise subclasses of `lunipay.error.LuniPayError`:
69
+
70
+ ```python
71
+ try:
72
+ lunipay.Customer.create(email="bad", first_name="x", last_name="y")
73
+ except lunipay.error.InvalidRequestError as e:
74
+ print(e.code, e.param, str(e))
75
+ ```
76
+
77
+ Subclasses: `AuthenticationError`, `PermissionError`, `InvalidRequestError`, `IdempotencyError`, `RateLimitError`, `APIError`, `APIConnectionError`.
78
+
79
+ ## Idempotency
80
+
81
+ Pass `idempotency_key` to any mutating request:
82
+
83
+ ```python
84
+ import uuid
85
+
86
+ key = str(uuid.uuid4())
87
+ session = lunipay.CheckoutSession.create(
88
+ amount=5000,
89
+ currency="usd",
90
+ success_url="https://example.com/thanks",
91
+ idempotency_key=key,
92
+ )
93
+ ```
94
+
95
+ ## Full documentation
96
+
97
+ See [lunipay.io/docs/sdks/python](https://lunipay.io/docs/sdks/python).
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,46 @@
1
+ """
2
+ LuniPay Python SDK.
3
+
4
+ import lunipay
5
+ lunipay.api_key = "sk_test_..."
6
+ session = lunipay.CheckoutSession.create(amount=5000, currency="usd", ...)
7
+ """
8
+
9
+ from typing import Optional
10
+
11
+ api_key: Optional[str] = None
12
+ api_base: str = "https://lunipay.io/api"
13
+ api_version: str = "2026-04-14"
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ # Resource classes
18
+ from .api_resources.checkout_session import CheckoutSession # noqa: E402
19
+ from .api_resources.customer import Customer # noqa: E402
20
+ from .api_resources.invoice import Invoice # noqa: E402
21
+ from .api_resources.payment_link import PaymentLink # noqa: E402
22
+ from .api_resources.payment import Payment # noqa: E402
23
+ from .api_resources.webhook_endpoint import WebhookEndpoint # noqa: E402
24
+ from .api_resources.event import Event # noqa: E402
25
+
26
+ # Webhook helpers
27
+ from .webhook import Webhook # noqa: E402
28
+
29
+ # Error namespace — users catch `lunipay.error.InvalidRequestError`, etc.
30
+ from . import error # noqa: E402
31
+
32
+ __all__ = [
33
+ "api_key",
34
+ "api_base",
35
+ "api_version",
36
+ "__version__",
37
+ "CheckoutSession",
38
+ "Customer",
39
+ "Invoice",
40
+ "PaymentLink",
41
+ "Payment",
42
+ "WebhookEndpoint",
43
+ "Event",
44
+ "Webhook",
45
+ "error",
46
+ ]
@@ -0,0 +1,131 @@
1
+ """
2
+ Thin HTTP client wrapper for LuniPay API calls.
3
+
4
+ All resource classes route through `APIRequestor.request()`, which
5
+ handles auth headers, body serialization, error envelope parsing, and
6
+ exception mapping.
7
+ """
8
+
9
+ from typing import Any, Dict, Optional
10
+
11
+ import requests
12
+
13
+ from . import error as err
14
+
15
+ _USER_AGENT = "lunipay-python/0.1.0"
16
+ _DEFAULT_TIMEOUT = 30 # seconds
17
+
18
+
19
+ def request(
20
+ method: str,
21
+ url: str,
22
+ params: Optional[Dict[str, Any]] = None,
23
+ api_key: Optional[str] = None,
24
+ idempotency_key: Optional[str] = None,
25
+ ) -> Any:
26
+ """
27
+ Make an HTTP request to the LuniPay API.
28
+
29
+ Args:
30
+ method: 'get' | 'post' | 'patch' | 'delete'
31
+ url: path only (e.g. '/v1/customers')
32
+ params: request body (POST/PATCH) or query params (GET/DELETE)
33
+ api_key: per-request override. Falls back to lunipay.api_key.
34
+ idempotency_key: optional Idempotency-Key header value.
35
+
36
+ Returns:
37
+ The parsed JSON response body.
38
+
39
+ Raises:
40
+ lunipay.error.AuthenticationError
41
+ lunipay.error.PermissionError
42
+ lunipay.error.RateLimitError
43
+ lunipay.error.InvalidRequestError
44
+ lunipay.error.IdempotencyError
45
+ lunipay.error.APIError
46
+ lunipay.error.APIConnectionError
47
+ """
48
+ # Late import so consumer `import lunipay` doesn't fail if the config
49
+ # is mutated after the module loads.
50
+ import lunipay
51
+
52
+ effective_key = api_key or lunipay.api_key
53
+ if not effective_key:
54
+ raise err.AuthenticationError(
55
+ "No API key provided. Set `lunipay.api_key` or pass `api_key=` "
56
+ "to the request."
57
+ )
58
+
59
+ full_url = lunipay.api_base.rstrip("/") + url
60
+
61
+ headers = {
62
+ "Authorization": f"Bearer {effective_key}",
63
+ "Accept": "application/json",
64
+ "User-Agent": _USER_AGENT,
65
+ "LuniPay-Version": lunipay.api_version,
66
+ }
67
+ if idempotency_key:
68
+ headers["Idempotency-Key"] = idempotency_key
69
+
70
+ method_lower = method.lower()
71
+ json_body: Optional[Dict[str, Any]] = None
72
+ query: Optional[Dict[str, Any]] = None
73
+
74
+ if method_lower in ("get", "delete"):
75
+ query = params or None
76
+ else:
77
+ headers["Content-Type"] = "application/json"
78
+ json_body = params or {}
79
+
80
+ try:
81
+ resp = requests.request(
82
+ method_lower.upper(),
83
+ full_url,
84
+ headers=headers,
85
+ params=query,
86
+ json=json_body,
87
+ timeout=_DEFAULT_TIMEOUT,
88
+ )
89
+ except requests.exceptions.Timeout as e:
90
+ raise err.APIConnectionError(
91
+ f"Request timed out after {_DEFAULT_TIMEOUT}s: {e}"
92
+ )
93
+ except requests.exceptions.ConnectionError as e:
94
+ raise err.APIConnectionError(f"Failed to connect to LuniPay API: {e}")
95
+ except requests.exceptions.RequestException as e:
96
+ raise err.APIConnectionError(f"Request failed: {e}")
97
+
98
+ try:
99
+ body = resp.json() if resp.content else {}
100
+ except ValueError:
101
+ body = {}
102
+
103
+ if 200 <= resp.status_code < 300:
104
+ return body
105
+
106
+ _raise_for_error(resp.status_code, body)
107
+ return None # unreachable — _raise_for_error always raises
108
+
109
+
110
+ def _raise_for_error(status: int, body: Any) -> None:
111
+ envelope = body.get("error", {}) if isinstance(body, dict) else {}
112
+ message = envelope.get("message") or f"HTTP {status}"
113
+ err_type = envelope.get("type") or ""
114
+ code = envelope.get("code")
115
+ param = envelope.get("param")
116
+
117
+ if status == 401:
118
+ raise err.AuthenticationError(message, http_status=status, json_body=body)
119
+ if status == 403:
120
+ raise err.PermissionError(message, http_status=status, json_body=body)
121
+ if status == 429:
122
+ raise err.RateLimitError(message, http_status=status, json_body=body)
123
+ if status == 409 and err_type == "idempotency_error":
124
+ raise err.IdempotencyError(
125
+ message, param=param, code=code, http_status=status, json_body=body
126
+ )
127
+ if 400 <= status < 500:
128
+ raise err.InvalidRequestError(
129
+ message, param=param, code=code, http_status=status, json_body=body
130
+ )
131
+ raise err.APIError(message, http_status=status, json_body=body)
@@ -0,0 +1 @@
1
+ """LuniPay API resource classes."""
@@ -0,0 +1,161 @@
1
+ """
2
+ Resource base classes and mixins. Resource classes compose these to expose
3
+ `create / retrieve / list / modify / delete` + custom actions.
4
+
5
+ Mirrors Stripe's Python SDK pattern so developers migrating from Stripe
6
+ feel immediately at home.
7
+ """
8
+
9
+ from typing import Any, Dict, Iterator, Optional
10
+
11
+ from .._api_requestor import request as _api_request
12
+
13
+
14
+ class ListObject:
15
+ """
16
+ Cursor-paginated list response. Supports `auto_paging_iter()` for
17
+ seamless iteration across pages.
18
+
19
+ for customer in lunipay.Customer.list().auto_paging_iter():
20
+ print(customer["email"])
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ data: list,
26
+ has_more: bool,
27
+ url: str,
28
+ resource_cls: type,
29
+ api_key: Optional[str],
30
+ ) -> None:
31
+ self.data = data
32
+ self.has_more = has_more
33
+ self.url = url
34
+ self._resource_cls = resource_cls
35
+ self._api_key = api_key
36
+
37
+ def __iter__(self) -> Iterator[Any]:
38
+ return iter(self.data)
39
+
40
+ def __len__(self) -> int:
41
+ return len(self.data)
42
+
43
+ def auto_paging_iter(self) -> Iterator[Any]:
44
+ page: "ListObject" = self
45
+ while True:
46
+ for item in page.data:
47
+ yield item
48
+ if not page.has_more or not page.data:
49
+ return
50
+ last = page.data[-1]
51
+ last_id = last.get("id") if isinstance(last, dict) else None
52
+ if not last_id:
53
+ return
54
+ page = self._resource_cls.list(
55
+ api_key=self._api_key, starting_after=last_id
56
+ )
57
+
58
+
59
+ class APIResource:
60
+ """Base class for all API resources."""
61
+
62
+ OBJECT_NAME: str = ""
63
+ RESOURCE_URL: str = ""
64
+
65
+ @classmethod
66
+ def _static_request(
67
+ cls,
68
+ method: str,
69
+ url: str,
70
+ params: Optional[Dict[str, Any]] = None,
71
+ api_key: Optional[str] = None,
72
+ idempotency_key: Optional[str] = None,
73
+ ) -> Any:
74
+ return _api_request(
75
+ method=method,
76
+ url=url,
77
+ params=params,
78
+ api_key=api_key,
79
+ idempotency_key=idempotency_key,
80
+ )
81
+
82
+ @classmethod
83
+ def retrieve(cls, id: str, api_key: Optional[str] = None) -> Any:
84
+ return cls._static_request(
85
+ "get", f"{cls.RESOURCE_URL}/{id}", api_key=api_key
86
+ )
87
+
88
+
89
+ class CreateableResource:
90
+ """Mixin: POST {RESOURCE_URL} → create."""
91
+
92
+ @classmethod
93
+ def create(
94
+ cls,
95
+ api_key: Optional[str] = None,
96
+ idempotency_key: Optional[str] = None,
97
+ **params: Any,
98
+ ) -> Any:
99
+ return cls._static_request( # type: ignore[attr-defined]
100
+ "post",
101
+ cls.RESOURCE_URL, # type: ignore[attr-defined]
102
+ params=params,
103
+ api_key=api_key,
104
+ idempotency_key=idempotency_key,
105
+ )
106
+
107
+
108
+ class ListableResource:
109
+ """Mixin: GET {RESOURCE_URL} → list with cursor pagination."""
110
+
111
+ @classmethod
112
+ def list(
113
+ cls,
114
+ api_key: Optional[str] = None,
115
+ **params: Any,
116
+ ) -> ListObject:
117
+ body = cls._static_request( # type: ignore[attr-defined]
118
+ "get",
119
+ cls.RESOURCE_URL, # type: ignore[attr-defined]
120
+ params=params,
121
+ api_key=api_key,
122
+ )
123
+ return ListObject(
124
+ data=body.get("data", []),
125
+ has_more=bool(body.get("has_more", False)),
126
+ url=body.get("url", cls.RESOURCE_URL), # type: ignore[attr-defined]
127
+ resource_cls=cls,
128
+ api_key=api_key,
129
+ )
130
+
131
+
132
+ class UpdateableResource:
133
+ """Mixin: PATCH {RESOURCE_URL}/{id} → modify."""
134
+
135
+ @classmethod
136
+ def modify(
137
+ cls,
138
+ id: str,
139
+ api_key: Optional[str] = None,
140
+ idempotency_key: Optional[str] = None,
141
+ **params: Any,
142
+ ) -> Any:
143
+ return cls._static_request( # type: ignore[attr-defined]
144
+ "patch",
145
+ f"{cls.RESOURCE_URL}/{id}", # type: ignore[attr-defined]
146
+ params=params,
147
+ api_key=api_key,
148
+ idempotency_key=idempotency_key,
149
+ )
150
+
151
+
152
+ class DeleteableResource:
153
+ """Mixin: DELETE {RESOURCE_URL}/{id}."""
154
+
155
+ @classmethod
156
+ def delete(cls, id: str, api_key: Optional[str] = None) -> Any:
157
+ return cls._static_request( # type: ignore[attr-defined]
158
+ "delete",
159
+ f"{cls.RESOURCE_URL}/{id}", # type: ignore[attr-defined]
160
+ api_key=api_key,
161
+ )
@@ -0,0 +1,22 @@
1
+ from typing import Any, Optional
2
+
3
+ from ._abstract import APIResource, CreateableResource, ListableResource
4
+
5
+
6
+ class CheckoutSession(CreateableResource, ListableResource, APIResource):
7
+ OBJECT_NAME = "checkout_session"
8
+ RESOURCE_URL = "/v1/checkout/sessions"
9
+
10
+ @classmethod
11
+ def expire(
12
+ cls,
13
+ id: str,
14
+ api_key: Optional[str] = None,
15
+ idempotency_key: Optional[str] = None,
16
+ ) -> Any:
17
+ return cls._static_request(
18
+ "post",
19
+ f"{cls.RESOURCE_URL}/{id}/expire",
20
+ api_key=api_key,
21
+ idempotency_key=idempotency_key,
22
+ )
@@ -0,0 +1,18 @@
1
+ from ._abstract import (
2
+ APIResource,
3
+ CreateableResource,
4
+ DeleteableResource,
5
+ ListableResource,
6
+ UpdateableResource,
7
+ )
8
+
9
+
10
+ class Customer(
11
+ CreateableResource,
12
+ ListableResource,
13
+ UpdateableResource,
14
+ DeleteableResource,
15
+ APIResource,
16
+ ):
17
+ OBJECT_NAME = "customer"
18
+ RESOURCE_URL = "/v1/customers"
@@ -0,0 +1,6 @@
1
+ from ._abstract import APIResource, ListableResource
2
+
3
+
4
+ class Event(ListableResource, APIResource):
5
+ OBJECT_NAME = "event"
6
+ RESOURCE_URL = "/v1/events"
@@ -0,0 +1,48 @@
1
+ from typing import Any, Optional
2
+
3
+ from ._abstract import (
4
+ APIResource,
5
+ CreateableResource,
6
+ DeleteableResource,
7
+ ListableResource,
8
+ UpdateableResource,
9
+ )
10
+
11
+
12
+ class Invoice(
13
+ CreateableResource,
14
+ ListableResource,
15
+ UpdateableResource,
16
+ DeleteableResource,
17
+ APIResource,
18
+ ):
19
+ OBJECT_NAME = "invoice"
20
+ RESOURCE_URL = "/v1/invoices"
21
+
22
+ @classmethod
23
+ def send(
24
+ cls,
25
+ id: str,
26
+ api_key: Optional[str] = None,
27
+ idempotency_key: Optional[str] = None,
28
+ ) -> Any:
29
+ return cls._static_request(
30
+ "post",
31
+ f"{cls.RESOURCE_URL}/{id}/send",
32
+ api_key=api_key,
33
+ idempotency_key=idempotency_key,
34
+ )
35
+
36
+ @classmethod
37
+ def void(
38
+ cls,
39
+ id: str,
40
+ api_key: Optional[str] = None,
41
+ idempotency_key: Optional[str] = None,
42
+ ) -> Any:
43
+ return cls._static_request(
44
+ "post",
45
+ f"{cls.RESOURCE_URL}/{id}/void",
46
+ api_key=api_key,
47
+ idempotency_key=idempotency_key,
48
+ )
@@ -0,0 +1,27 @@
1
+ from typing import Any, Optional
2
+
3
+ from ._abstract import APIResource, ListableResource
4
+
5
+
6
+ class Payment(ListableResource, APIResource):
7
+ OBJECT_NAME = "payment"
8
+ RESOURCE_URL = "/v1/payments"
9
+
10
+ @classmethod
11
+ def refund(
12
+ cls,
13
+ id: str,
14
+ amount: Optional[int] = None,
15
+ api_key: Optional[str] = None,
16
+ idempotency_key: Optional[str] = None,
17
+ ) -> Any:
18
+ params = {}
19
+ if amount is not None:
20
+ params["amount"] = amount
21
+ return cls._static_request(
22
+ "post",
23
+ f"{cls.RESOURCE_URL}/{id}/refund",
24
+ params=params,
25
+ api_key=api_key,
26
+ idempotency_key=idempotency_key,
27
+ )
@@ -0,0 +1,18 @@
1
+ from ._abstract import (
2
+ APIResource,
3
+ CreateableResource,
4
+ DeleteableResource,
5
+ ListableResource,
6
+ UpdateableResource,
7
+ )
8
+
9
+
10
+ class PaymentLink(
11
+ CreateableResource,
12
+ ListableResource,
13
+ UpdateableResource,
14
+ DeleteableResource,
15
+ APIResource,
16
+ ):
17
+ OBJECT_NAME = "payment_link"
18
+ RESOURCE_URL = "/v1/payment-links"
@@ -0,0 +1,18 @@
1
+ from ._abstract import (
2
+ APIResource,
3
+ CreateableResource,
4
+ DeleteableResource,
5
+ ListableResource,
6
+ UpdateableResource,
7
+ )
8
+
9
+
10
+ class WebhookEndpoint(
11
+ CreateableResource,
12
+ ListableResource,
13
+ UpdateableResource,
14
+ DeleteableResource,
15
+ APIResource,
16
+ ):
17
+ OBJECT_NAME = "webhook_endpoint"
18
+ RESOURCE_URL = "/v1/webhook-endpoints"
@@ -0,0 +1,75 @@
1
+ """
2
+ LuniPay SDK exception hierarchy.
3
+
4
+ try:
5
+ lunipay.CheckoutSession.create(amount=-1, currency="usd", ...)
6
+ except lunipay.error.InvalidRequestError as e:
7
+ print(e.param, e.code, e.message)
8
+ """
9
+
10
+ from typing import Any, Optional
11
+
12
+
13
+ class LuniPayError(Exception):
14
+ """Base class for all SDK errors."""
15
+
16
+ def __init__(
17
+ self,
18
+ message: Optional[str] = None,
19
+ http_status: Optional[int] = None,
20
+ json_body: Optional[Any] = None,
21
+ ) -> None:
22
+ self.message = message or "An unknown error occurred"
23
+ self.http_status = http_status
24
+ self.json_body = json_body
25
+ super().__init__(self.message)
26
+
27
+ def __str__(self) -> str:
28
+ return self.message
29
+
30
+
31
+ class APIError(LuniPayError):
32
+ """Server-side (5xx) error from the LuniPay API."""
33
+
34
+
35
+ class APIConnectionError(LuniPayError):
36
+ """Network-level error — timeout, DNS failure, TLS error, etc."""
37
+
38
+
39
+ class AuthenticationError(LuniPayError):
40
+ """Invalid or missing API key."""
41
+
42
+
43
+ class PermissionError(LuniPayError): # noqa: A001 — intentionally shadowing built-in
44
+ """API key lacks permission (e.g., publishable key on server endpoint)."""
45
+
46
+
47
+ class RateLimitError(LuniPayError):
48
+ """Rate limit exceeded (HTTP 429)."""
49
+
50
+
51
+ class InvalidRequestError(LuniPayError):
52
+ """
53
+ Bad request — invalid params, resource not found, or invalid state
54
+ transition. Carries `param` and `code` from the API error envelope.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ message: Optional[str] = None,
60
+ param: Optional[str] = None,
61
+ code: Optional[str] = None,
62
+ http_status: Optional[int] = None,
63
+ json_body: Optional[Any] = None,
64
+ ) -> None:
65
+ self.param = param
66
+ self.code = code
67
+ super().__init__(message, http_status=http_status, json_body=json_body)
68
+
69
+
70
+ class IdempotencyError(InvalidRequestError):
71
+ """Idempotency key reused with different request parameters (HTTP 409)."""
72
+
73
+
74
+ class SignatureVerificationError(LuniPayError):
75
+ """Webhook signature verification failed."""
File without changes
@@ -0,0 +1,120 @@
1
+ """
2
+ Webhook signature verification.
3
+
4
+ Mirrors the signing scheme in `src/lib/utils/crypto.ts`:
5
+
6
+ header = f"t={timestamp},v1={hex_hmac_sha256}"
7
+ signed = f"{timestamp}.{payload}"
8
+ hmac = HMAC-SHA256(signed_with=secret).hex()
9
+
10
+ Developers verify inbound webhook requests like so:
11
+
12
+ event = lunipay.Webhook.construct_event(
13
+ payload=request.get_data(),
14
+ sig_header=request.headers["LuniPay-Signature"],
15
+ secret="whsec_your_secret",
16
+ )
17
+ """
18
+
19
+ import hashlib
20
+ import hmac
21
+ import json
22
+ import time
23
+ from typing import Any, Mapping, Union
24
+
25
+ from .error import SignatureVerificationError
26
+
27
+ DEFAULT_TOLERANCE_SECONDS = 300 # 5 minutes — matches the server default.
28
+
29
+
30
+ class Webhook:
31
+ @staticmethod
32
+ def construct_event(
33
+ payload: Union[bytes, str],
34
+ sig_header: str,
35
+ secret: str,
36
+ tolerance: int = DEFAULT_TOLERANCE_SECONDS,
37
+ ) -> Any:
38
+ """
39
+ Verify a webhook signature and return the parsed event payload.
40
+
41
+ Args:
42
+ payload: Raw request body, bytes or str — MUST be the exact
43
+ bytes LuniPay sent. Do not re-serialize.
44
+ sig_header: Value of the `LuniPay-Signature` header.
45
+ secret: The endpoint's signing secret (whsec_...).
46
+ tolerance: Maximum age of the timestamp in seconds.
47
+
48
+ Returns:
49
+ The parsed JSON event dict (with `id`, `type`, `data`, etc.).
50
+
51
+ Raises:
52
+ lunipay.error.SignatureVerificationError on any failure.
53
+ """
54
+ if isinstance(payload, bytes):
55
+ payload_str = payload.decode("utf-8", errors="strict")
56
+ else:
57
+ payload_str = payload
58
+
59
+ if not sig_header:
60
+ raise SignatureVerificationError("Missing LuniPay-Signature header")
61
+
62
+ timestamp, signatures = _parse_header(sig_header)
63
+ if timestamp is None:
64
+ raise SignatureVerificationError(
65
+ "Unable to extract timestamp from LuniPay-Signature header"
66
+ )
67
+ if not signatures:
68
+ raise SignatureVerificationError(
69
+ "Unable to extract v1 signatures from LuniPay-Signature header"
70
+ )
71
+
72
+ signed = f"{timestamp}.{payload_str}"
73
+ expected = hmac.new(
74
+ secret.encode("utf-8"), signed.encode("utf-8"), hashlib.sha256
75
+ ).hexdigest()
76
+
77
+ if not any(hmac.compare_digest(expected, sig) for sig in signatures):
78
+ raise SignatureVerificationError(
79
+ "No valid signature found for the payload"
80
+ )
81
+
82
+ now = int(time.time())
83
+ if abs(now - timestamp) > tolerance:
84
+ raise SignatureVerificationError(
85
+ f"Timestamp outside tolerance window "
86
+ f"(tolerance={tolerance}s, drift={abs(now - timestamp)}s)"
87
+ )
88
+
89
+ try:
90
+ return json.loads(payload_str)
91
+ except json.JSONDecodeError as e:
92
+ raise SignatureVerificationError(f"Invalid JSON payload: {e}")
93
+
94
+
95
+ def _parse_header(
96
+ header: str,
97
+ ) -> "tuple[int | None, list[str]]":
98
+ """Parse `t=<ts>,v1=<sig>[,v1=<sig2>...]` into (timestamp, [signatures])."""
99
+ timestamp: "int | None" = None
100
+ signatures: list[str] = []
101
+ for part in header.split(","):
102
+ part = part.strip()
103
+ if "=" not in part:
104
+ continue
105
+ k, _, v = part.partition("=")
106
+ k = k.strip()
107
+ v = v.strip()
108
+ if k == "t":
109
+ try:
110
+ timestamp = int(v)
111
+ except ValueError:
112
+ return None, []
113
+ elif k == "v1":
114
+ signatures.append(v)
115
+ return timestamp, signatures
116
+
117
+
118
+ # Silence an unused-import warning under strict type checkers; Mapping is
119
+ # imported for potential future typed-event support.
120
+ _ = Mapping
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lunipay"
7
+ version = "0.1.0"
8
+ description = "The official LuniPay Python SDK."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "LuniPay" }]
13
+ dependencies = [
14
+ "requests>=2.20",
15
+ "typing_extensions>=4.0; python_version<'3.11'",
16
+ ]
17
+ keywords = ["lunipay", "payments", "checkout", "stripe-alternative", "caribbean", "api", "sdk"]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.8",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Topic :: Office/Business :: Financial",
30
+ "Typing :: Typed",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://lunipay.io/docs/sdks/python"
35
+ Documentation = "https://lunipay.io/docs/sdks/python"
36
+ Repository = "https://github.com/SammarieoBrown/lunipay-sdk"
37
+ Issues = "https://github.com/SammarieoBrown/lunipay-sdk/issues"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["lunipay"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = [
44
+ "lunipay",
45
+ "README.md",
46
+ "LICENSE",
47
+ "pyproject.toml",
48
+ ]