solvapay-python 0.6.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.
solvapay/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """SolvaPay community Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from solvapay import paywall
6
+ from solvapay._async_client import AsyncSolvaPay
7
+ from solvapay.client import SolvaPay
8
+ from solvapay.events import (
9
+ CheckoutSessionCreated,
10
+ CustomerCreated,
11
+ CustomerDeleted,
12
+ CustomerUpdated,
13
+ PaymentFailed,
14
+ PaymentRefunded,
15
+ PaymentRefundFailed,
16
+ PaymentSucceeded,
17
+ PurchaseCancelled,
18
+ PurchaseCreated,
19
+ PurchaseExpired,
20
+ PurchaseSuspended,
21
+ PurchaseUpdated,
22
+ WebhookEvent,
23
+ )
24
+ from solvapay.exceptions import SolvaPayAPIError, SolvaPayError
25
+ from solvapay.models import BalanceResponse, Merchant, Plan, PlatformConfig, Product
26
+ from solvapay.paywall import PaywallRequired
27
+ from solvapay.webhooks import verify_webhook
28
+
29
+ __all__ = [
30
+ "AsyncSolvaPay",
31
+ "BalanceResponse",
32
+ "CheckoutSessionCreated",
33
+ "CustomerCreated",
34
+ "CustomerDeleted",
35
+ "CustomerUpdated",
36
+ "Merchant",
37
+ "PaymentFailed",
38
+ "PaymentRefundFailed",
39
+ "PaymentRefunded",
40
+ "PaymentSucceeded",
41
+ "PaywallRequired",
42
+ "Plan",
43
+ "PlatformConfig",
44
+ "Product",
45
+ "PurchaseCancelled",
46
+ "PurchaseCreated",
47
+ "PurchaseExpired",
48
+ "PurchaseSuspended",
49
+ "PurchaseUpdated",
50
+ "SolvaPay",
51
+ "SolvaPayAPIError",
52
+ "SolvaPayError",
53
+ "WebhookEvent",
54
+ "paywall",
55
+ "verify_webhook",
56
+ ]
57
+ __version__ = "0.6.0"
@@ -0,0 +1,355 @@
1
+ """Async SolvaPay client. Mirrors SolvaPay sync surface 1:1.
2
+
3
+ Constructor signature identical to `SolvaPay`. All ops are `async def`.
4
+ Use `async with AsyncSolvaPay() as sv: ...` for proper teardown.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any
11
+
12
+ from solvapay._config import resolve_api_key, resolve_base_url
13
+ from solvapay._http import AsyncHttpClient, _RequestSpec
14
+ from solvapay.exceptions import SolvaPayAPIError
15
+ from solvapay.models import (
16
+ BalanceResponse,
17
+ CancelPurchaseRequest,
18
+ CheckLimitsRequest,
19
+ CheckoutSession,
20
+ CheckoutSessionRequest,
21
+ CloneProductRequest,
22
+ CreateCustomerRequest,
23
+ CreatePlanRequest,
24
+ CreateProductRequest,
25
+ Customer,
26
+ LimitResponse,
27
+ Merchant,
28
+ Plan,
29
+ PlatformConfig,
30
+ Product,
31
+ TrackUsageRequest,
32
+ UpdateCustomerRequest,
33
+ UpdatePlanRequest,
34
+ )
35
+
36
+
37
+ class AsyncSolvaPay:
38
+ """Async SolvaPay API client.
39
+
40
+ Args:
41
+ api_key: SolvaPay secret key. Falls back to SOLVAPAY_SECRET_KEY env var.
42
+ base_url: API base URL. Falls back to SOLVAPAY_API_BASE_URL env var,
43
+ then to https://api.solvapay.com.
44
+ timeout: HTTP timeout in seconds. Default 30.
45
+
46
+ Example:
47
+ >>> async with AsyncSolvaPay() as sv:
48
+ ... session = await sv.create_checkout_session(
49
+ ... customer_ref="cus_123", product_ref="prd_0QKI8NHF"
50
+ ... )
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ api_key: str | None = None,
56
+ *,
57
+ base_url: str | None = None,
58
+ timeout: float = 30.0,
59
+ ) -> None:
60
+ self._http = AsyncHttpClient(
61
+ api_key=resolve_api_key(api_key),
62
+ base_url=resolve_base_url(base_url),
63
+ timeout=timeout,
64
+ )
65
+
66
+ async def aclose(self) -> None:
67
+ await self._http.aclose()
68
+
69
+ async def __aenter__(self) -> AsyncSolvaPay:
70
+ return self
71
+
72
+ async def __aexit__(self, *_: object) -> None:
73
+ await self.aclose()
74
+
75
+ async def create_checkout_session(
76
+ self,
77
+ *,
78
+ customer_ref: str,
79
+ product_ref: str,
80
+ plan_ref: str | None = None,
81
+ return_url: str | None = None,
82
+ ) -> CheckoutSession:
83
+ req = CheckoutSessionRequest(
84
+ customer_ref=customer_ref,
85
+ product_ref=product_ref,
86
+ plan_ref=plan_ref,
87
+ return_url=return_url,
88
+ )
89
+ data = await self._http.send(
90
+ _RequestSpec(
91
+ "POST",
92
+ "/v1/sdk/checkout-sessions",
93
+ json=req.model_dump(by_alias=True, exclude_none=True),
94
+ )
95
+ )
96
+ return CheckoutSession.model_validate(data)
97
+
98
+ async def ensure_customer(
99
+ self,
100
+ customer_ref: str,
101
+ external_ref: str | None = None,
102
+ *,
103
+ email: str | None = None,
104
+ name: str | None = None,
105
+ ) -> str:
106
+ lookup_ref = external_ref or customer_ref
107
+ try:
108
+ existing = await self._http.send(
109
+ _RequestSpec("GET", "/v1/sdk/customers", params={"externalRef": lookup_ref})
110
+ )
111
+ if existing.get("customerRef"):
112
+ return str(existing["customerRef"])
113
+ except SolvaPayAPIError as exc:
114
+ if exc.status_code != 404:
115
+ raise
116
+
117
+ req = CreateCustomerRequest(
118
+ email=email or f"{customer_ref}-{int(time.time())}@auto-created.local",
119
+ external_ref=lookup_ref,
120
+ name=name,
121
+ )
122
+ created = await self._http.send(
123
+ _RequestSpec(
124
+ "POST", "/v1/sdk/customers", json=req.model_dump(by_alias=True, exclude_none=True)
125
+ )
126
+ )
127
+ return str(created["customerRef"])
128
+
129
+ async def get_customer(
130
+ self,
131
+ customer_ref: str | None = None,
132
+ *,
133
+ external_ref: str | None = None,
134
+ email: str | None = None,
135
+ ) -> Customer:
136
+ if customer_ref:
137
+ data = await self._http.send(_RequestSpec("GET", f"/v1/sdk/customers/{customer_ref}"))
138
+ elif external_ref:
139
+ data = await self._http.send(
140
+ _RequestSpec("GET", "/v1/sdk/customers", params={"externalRef": external_ref})
141
+ )
142
+ elif email:
143
+ data = await self._http.send(
144
+ _RequestSpec("GET", "/v1/sdk/customers", params={"email": email})
145
+ )
146
+ else:
147
+ raise ValueError("Must provide customer_ref, external_ref, or email")
148
+ return Customer.model_validate(data)
149
+
150
+ async def check_limits(
151
+ self,
152
+ *,
153
+ customer_ref: str,
154
+ product_ref: str,
155
+ plan_ref: str | None = None,
156
+ meter_name: str | None = None,
157
+ usage_type: str | None = None,
158
+ ) -> LimitResponse:
159
+ req = CheckLimitsRequest(
160
+ customer_ref=customer_ref,
161
+ product_ref=product_ref,
162
+ plan_ref=plan_ref,
163
+ meter_name=meter_name,
164
+ usage_type=usage_type,
165
+ )
166
+ data = await self._http.send(
167
+ _RequestSpec(
168
+ "POST", "/v1/sdk/limits", json=req.model_dump(by_alias=True, exclude_none=True)
169
+ )
170
+ )
171
+ return LimitResponse.model_validate(data)
172
+
173
+ async def track_usage(
174
+ self,
175
+ *,
176
+ customer_ref: str,
177
+ product_ref: str,
178
+ meter_name: str,
179
+ units: float,
180
+ idempotency_key: str | None = None,
181
+ ) -> dict[str, Any]:
182
+ """Record usage against a meter. Maps to POST /v1/sdk/usages."""
183
+ req = TrackUsageRequest(
184
+ customer_ref=customer_ref,
185
+ product_ref=product_ref,
186
+ meter_name=meter_name,
187
+ units=units,
188
+ )
189
+ return await self._http.send(
190
+ _RequestSpec(
191
+ "POST",
192
+ "/v1/sdk/usages",
193
+ json=req.model_dump(by_alias=True, exclude_none=True),
194
+ idempotency_key=idempotency_key,
195
+ )
196
+ )
197
+
198
+ async def update_customer(
199
+ self,
200
+ customer_ref: str,
201
+ *,
202
+ email: str | None = None,
203
+ name: str | None = None,
204
+ external_ref: str | None = None,
205
+ ) -> Customer:
206
+ """Update customer fields. Maps to PATCH /v1/sdk/customers/{ref}."""
207
+ req = UpdateCustomerRequest(email=email, name=name, external_ref=external_ref)
208
+ data = await self._http.send(
209
+ _RequestSpec(
210
+ "PATCH",
211
+ f"/v1/sdk/customers/{customer_ref}",
212
+ json=req.model_dump(by_alias=True, exclude_none=True),
213
+ )
214
+ )
215
+ return Customer.model_validate(data)
216
+
217
+ async def get_customer_balance(self, customer_ref: str) -> BalanceResponse:
218
+ """Get credit balance for a customer. Maps to GET /v1/sdk/customers/{ref}/balance."""
219
+ data = await self._http.send(
220
+ _RequestSpec("GET", f"/v1/sdk/customers/{customer_ref}/balance")
221
+ )
222
+ return BalanceResponse.model_validate(data)
223
+
224
+ async def cancel_purchase(
225
+ self, purchase_ref: str, *, reason: str | None = None
226
+ ) -> dict[str, Any]:
227
+ """Cancel a purchase. Maps to POST /v1/sdk/purchases/{ref}/cancel."""
228
+ req = CancelPurchaseRequest(reason=reason)
229
+ return await self._http.send(
230
+ _RequestSpec(
231
+ "POST",
232
+ f"/v1/sdk/purchases/{purchase_ref}/cancel",
233
+ json=req.model_dump(by_alias=True, exclude_none=True),
234
+ )
235
+ )
236
+
237
+ async def reactivate_purchase(self, purchase_ref: str) -> dict[str, Any]:
238
+ """Reactivate a cancelled purchase. Maps to POST /v1/sdk/purchases/{ref}/reactivate."""
239
+ return await self._http.send(
240
+ _RequestSpec("POST", f"/v1/sdk/purchases/{purchase_ref}/reactivate")
241
+ )
242
+
243
+ # --- Admin: Products ---
244
+
245
+ async def list_products(self) -> list[Product]:
246
+ """List all products. Maps to GET /v1/sdk/products."""
247
+ data = await self._http.send(_RequestSpec("GET", "/v1/sdk/products"))
248
+ items: list[Any] = data if isinstance(data, list) else data.get("products", [])
249
+ return [Product.model_validate(p) for p in items]
250
+
251
+ async def get_product(self, product_ref: str) -> Product:
252
+ """Get a product by ref. Maps to GET /v1/sdk/products/{ref}."""
253
+ data = await self._http.send(_RequestSpec("GET", f"/v1/sdk/products/{product_ref}"))
254
+ return Product.model_validate(data)
255
+
256
+ async def create_product(self, *, name: str, type: str, default_currency: str) -> Product:
257
+ """Create a product. Maps to POST /v1/sdk/products."""
258
+ req = CreateProductRequest(name=name, type=type, default_currency=default_currency)
259
+ data = await self._http.send(
260
+ _RequestSpec(
261
+ "POST",
262
+ "/v1/sdk/products",
263
+ json=req.model_dump(by_alias=True, exclude_none=True),
264
+ )
265
+ )
266
+ return Product.model_validate(data)
267
+
268
+ async def delete_product(self, product_ref: str) -> dict[str, Any]:
269
+ """Delete a product. Maps to DELETE /v1/sdk/products/{ref}."""
270
+ return await self._http.send(_RequestSpec("DELETE", f"/v1/sdk/products/{product_ref}"))
271
+
272
+ async def clone_product(self, product_ref: str, *, new_name: str) -> Product:
273
+ """Clone a product with a new name. Maps to POST /v1/sdk/products/{ref}/clone."""
274
+ req = CloneProductRequest(new_name=new_name)
275
+ data = await self._http.send(
276
+ _RequestSpec(
277
+ "POST",
278
+ f"/v1/sdk/products/{product_ref}/clone",
279
+ json=req.model_dump(by_alias=True, exclude_none=True),
280
+ )
281
+ )
282
+ return Product.model_validate(data)
283
+
284
+ # --- Admin: Plans ---
285
+
286
+ async def list_plans(self, product_ref: str) -> list[Plan]:
287
+ """List plans for a product. Maps to GET /v1/sdk/products/{ref}/plans."""
288
+ data = await self._http.send(_RequestSpec("GET", f"/v1/sdk/products/{product_ref}/plans"))
289
+ items: list[Any] = data if isinstance(data, list) else data.get("plans", [])
290
+ return [Plan.model_validate(p) for p in items]
291
+
292
+ async def create_plan(
293
+ self,
294
+ product_ref: str,
295
+ *,
296
+ name: str,
297
+ type: str,
298
+ price: float | None = None,
299
+ currency: str | None = None,
300
+ interval: str | None = None,
301
+ ) -> Plan:
302
+ """Create a plan for a product. Maps to POST /v1/sdk/products/{ref}/plans."""
303
+ req = CreatePlanRequest(
304
+ name=name, type=type, price=price, currency=currency, interval=interval
305
+ )
306
+ data = await self._http.send(
307
+ _RequestSpec(
308
+ "POST",
309
+ f"/v1/sdk/products/{product_ref}/plans",
310
+ json=req.model_dump(by_alias=True, exclude_none=True),
311
+ )
312
+ )
313
+ return Plan.model_validate(data)
314
+
315
+ async def update_plan(
316
+ self,
317
+ product_ref: str,
318
+ plan_ref: str,
319
+ *,
320
+ name: str | None = None,
321
+ type: str | None = None,
322
+ price: float | None = None,
323
+ currency: str | None = None,
324
+ interval: str | None = None,
325
+ ) -> Plan:
326
+ """Update a plan. Maps to PUT /v1/sdk/products/{ref}/plans/{ref}."""
327
+ req = UpdatePlanRequest(
328
+ name=name, type=type, price=price, currency=currency, interval=interval
329
+ )
330
+ data = await self._http.send(
331
+ _RequestSpec(
332
+ "PUT",
333
+ f"/v1/sdk/products/{product_ref}/plans/{plan_ref}",
334
+ json=req.model_dump(by_alias=True, exclude_none=True),
335
+ )
336
+ )
337
+ return Plan.model_validate(data)
338
+
339
+ async def delete_plan(self, product_ref: str, plan_ref: str) -> dict[str, Any]:
340
+ """Delete a plan. Maps to DELETE /v1/sdk/products/{ref}/plans/{ref}."""
341
+ return await self._http.send(
342
+ _RequestSpec("DELETE", f"/v1/sdk/products/{product_ref}/plans/{plan_ref}")
343
+ )
344
+
345
+ # --- Admin: Merchant + Platform ---
346
+
347
+ async def get_merchant(self) -> Merchant:
348
+ """Get merchant account details. Maps to GET /v1/sdk/merchant."""
349
+ data = await self._http.send(_RequestSpec("GET", "/v1/sdk/merchant"))
350
+ return Merchant.model_validate(data)
351
+
352
+ async def get_platform_config(self) -> PlatformConfig:
353
+ """Get platform-level configuration. Maps to GET /v1/sdk/platform-config."""
354
+ data = await self._http.send(_RequestSpec("GET", "/v1/sdk/platform-config"))
355
+ return PlatformConfig.model_validate(data)
solvapay/_config.py ADDED
@@ -0,0 +1,22 @@
1
+ """Environment configuration loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ DEFAULT_BASE_URL = "https://api.solvapay.com"
8
+
9
+
10
+ def resolve_api_key(explicit: str | None) -> str:
11
+ key = explicit or os.environ.get("SOLVAPAY_SECRET_KEY")
12
+ if not key:
13
+ from solvapay.exceptions import SolvaPayError
14
+
15
+ raise SolvaPayError(
16
+ "SolvaPay API key not provided. Pass api_key=... or set SOLVAPAY_SECRET_KEY env var."
17
+ )
18
+ return key
19
+
20
+
21
+ def resolve_base_url(explicit: str | None) -> str:
22
+ return explicit or os.environ.get("SOLVAPAY_API_BASE_URL") or DEFAULT_BASE_URL
solvapay/_http.py ADDED
@@ -0,0 +1,107 @@
1
+ """Internal HTTP transport. Not part of public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from solvapay.exceptions import SolvaPayAPIError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class _RequestSpec:
15
+ method: str
16
+ path: str
17
+ json: dict[str, Any] | None = None
18
+ params: dict[str, Any] | None = None
19
+ idempotency_key: str | None = None
20
+
21
+ def headers(self) -> dict[str, str]:
22
+ return {"Idempotency-Key": self.idempotency_key} if self.idempotency_key else {}
23
+
24
+
25
+ def _handle(response: httpx.Response) -> dict[str, Any]:
26
+ if not response.is_success:
27
+ raise SolvaPayAPIError(response.status_code, response.text)
28
+ if response.status_code == 204 or not response.content:
29
+ return {}
30
+ return response.json() # type: ignore[no-any-return]
31
+
32
+
33
+ class HttpClient:
34
+ def __init__(self, *, api_key: str, base_url: str, timeout: float = 30.0) -> None:
35
+ self._client = httpx.Client(
36
+ base_url=base_url,
37
+ headers={
38
+ "Authorization": f"Bearer {api_key}",
39
+ "Content-Type": "application/json",
40
+ "User-Agent": "solvapay-python/0.4.0",
41
+ },
42
+ timeout=timeout,
43
+ )
44
+
45
+ def close(self) -> None:
46
+ self._client.close()
47
+
48
+ def __enter__(self) -> HttpClient:
49
+ return self
50
+
51
+ def __exit__(self, *_: object) -> None:
52
+ self.close()
53
+
54
+ def send(self, spec: _RequestSpec) -> dict[str, Any]:
55
+ return _handle(
56
+ self._client.request(
57
+ spec.method,
58
+ spec.path,
59
+ json=spec.json,
60
+ params=spec.params,
61
+ headers=spec.headers(),
62
+ )
63
+ )
64
+
65
+ def request(
66
+ self,
67
+ method: str,
68
+ path: str,
69
+ *,
70
+ json: dict[str, Any] | None = None,
71
+ params: dict[str, Any] | None = None,
72
+ idempotency_key: str | None = None,
73
+ ) -> dict[str, Any]:
74
+ return self.send(_RequestSpec(method, path, json, params, idempotency_key))
75
+
76
+
77
+ class AsyncHttpClient:
78
+ def __init__(self, *, api_key: str, base_url: str, timeout: float = 30.0) -> None:
79
+ self._client = httpx.AsyncClient(
80
+ base_url=base_url,
81
+ headers={
82
+ "Authorization": f"Bearer {api_key}",
83
+ "Content-Type": "application/json",
84
+ "User-Agent": "solvapay-python/0.4.0",
85
+ },
86
+ timeout=timeout,
87
+ )
88
+
89
+ async def aclose(self) -> None:
90
+ await self._client.aclose()
91
+
92
+ async def __aenter__(self) -> AsyncHttpClient:
93
+ return self
94
+
95
+ async def __aexit__(self, *_: object) -> None:
96
+ await self.aclose()
97
+
98
+ async def send(self, spec: _RequestSpec) -> dict[str, Any]:
99
+ return _handle(
100
+ await self._client.request(
101
+ spec.method,
102
+ spec.path,
103
+ json=spec.json,
104
+ params=spec.params,
105
+ headers=spec.headers(),
106
+ )
107
+ )