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/client.py ADDED
@@ -0,0 +1,352 @@
1
+ """Public SolvaPay client. Synchronous; mirrors @solvapay/core surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from solvapay._config import resolve_api_key, resolve_base_url
9
+ from solvapay._http import HttpClient
10
+ from solvapay.exceptions import SolvaPayAPIError
11
+ from solvapay.models import (
12
+ BalanceResponse,
13
+ CancelPurchaseRequest,
14
+ CheckLimitsRequest,
15
+ CheckoutSession,
16
+ CheckoutSessionRequest,
17
+ CloneProductRequest,
18
+ CreateCustomerRequest,
19
+ CreatePlanRequest,
20
+ CreateProductRequest,
21
+ Customer,
22
+ LimitResponse,
23
+ Merchant,
24
+ Plan,
25
+ PlatformConfig,
26
+ Product,
27
+ TrackUsageRequest,
28
+ UpdateCustomerRequest,
29
+ UpdatePlanRequest,
30
+ )
31
+
32
+
33
+ class SolvaPay:
34
+ """Synchronous SolvaPay API client.
35
+
36
+ Args:
37
+ api_key: SolvaPay secret key. Falls back to SOLVAPAY_SECRET_KEY env var.
38
+ base_url: API base URL. Falls back to SOLVAPAY_API_BASE_URL env var,
39
+ then to https://api.solvapay.com.
40
+ timeout: HTTP timeout in seconds. Default 30.
41
+
42
+ Example:
43
+ >>> from solvapay import SolvaPay
44
+ >>> sv = SolvaPay() # reads SOLVAPAY_SECRET_KEY
45
+ >>> session = sv.create_checkout_session(
46
+ ... customer_ref="cus_123", product_ref="prd_0QKI8NHF"
47
+ ... )
48
+ >>> print(session.checkout_url)
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ api_key: str | None = None,
54
+ *,
55
+ base_url: str | None = None,
56
+ timeout: float = 30.0,
57
+ ) -> None:
58
+ self._http = HttpClient(
59
+ api_key=resolve_api_key(api_key),
60
+ base_url=resolve_base_url(base_url),
61
+ timeout=timeout,
62
+ )
63
+
64
+ def close(self) -> None:
65
+ self._http.close()
66
+
67
+ def __enter__(self) -> SolvaPay:
68
+ return self
69
+
70
+ def __exit__(self, *_: object) -> None:
71
+ self.close()
72
+
73
+ def create_checkout_session(
74
+ self,
75
+ *,
76
+ customer_ref: str,
77
+ product_ref: str,
78
+ plan_ref: str | None = None,
79
+ return_url: str | None = None,
80
+ ) -> CheckoutSession:
81
+ """Create a hosted checkout session.
82
+
83
+ Maps to POST /v1/sdk/checkout-sessions.
84
+
85
+ Args:
86
+ customer_ref: Backend customer reference.
87
+ product_ref: SolvaPay product reference (e.g., "prd_0QKI8NHF").
88
+ plan_ref: Optional plan reference. Omit to show plan selector.
89
+ return_url: Optional URL to redirect after checkout.
90
+
91
+ Returns:
92
+ CheckoutSession with .session_id and .checkout_url.
93
+ """
94
+ req = CheckoutSessionRequest(
95
+ customer_ref=customer_ref,
96
+ product_ref=product_ref,
97
+ plan_ref=plan_ref,
98
+ return_url=return_url,
99
+ )
100
+ data = self._http.request(
101
+ "POST",
102
+ "/v1/sdk/checkout-sessions",
103
+ json=req.model_dump(by_alias=True, exclude_none=True),
104
+ )
105
+ return CheckoutSession.model_validate(data)
106
+
107
+ def ensure_customer(
108
+ self,
109
+ customer_ref: str,
110
+ external_ref: str | None = None,
111
+ *,
112
+ email: str | None = None,
113
+ name: str | None = None,
114
+ ) -> str:
115
+ """Idempotently create or look up a customer.
116
+
117
+ Tries GET /v1/sdk/customers?externalRef=... first. On 404, creates via
118
+ POST /v1/sdk/customers. Auto-generates a placeholder email if not provided.
119
+
120
+ Returns the SolvaPay backend customer reference string.
121
+ """
122
+ lookup_ref = external_ref or customer_ref
123
+ try:
124
+ existing = self._http.request(
125
+ "GET", "/v1/sdk/customers", params={"externalRef": lookup_ref}
126
+ )
127
+ if existing.get("customerRef"):
128
+ return str(existing["customerRef"])
129
+ except SolvaPayAPIError as exc:
130
+ if exc.status_code != 404:
131
+ raise
132
+
133
+ req = CreateCustomerRequest(
134
+ email=email or f"{customer_ref}-{int(time.time())}@auto-created.local",
135
+ external_ref=lookup_ref,
136
+ name=name,
137
+ )
138
+ created = self._http.request(
139
+ "POST",
140
+ "/v1/sdk/customers",
141
+ json=req.model_dump(by_alias=True, exclude_none=True),
142
+ )
143
+ return str(created["customerRef"])
144
+
145
+ def get_customer(
146
+ self,
147
+ customer_ref: str | None = None,
148
+ *,
149
+ external_ref: str | None = None,
150
+ email: str | None = None,
151
+ ) -> Customer:
152
+ """Retrieve a customer by ref, external_ref, or email."""
153
+ if customer_ref:
154
+ data = self._http.request("GET", f"/v1/sdk/customers/{customer_ref}")
155
+ elif external_ref:
156
+ data = self._http.request(
157
+ "GET", "/v1/sdk/customers", params={"externalRef": external_ref}
158
+ )
159
+ elif email:
160
+ data = self._http.request("GET", "/v1/sdk/customers", params={"email": email})
161
+ else:
162
+ raise ValueError("Must provide customer_ref, external_ref, or email")
163
+ return Customer.model_validate(data)
164
+
165
+ def check_limits(
166
+ self,
167
+ *,
168
+ customer_ref: str,
169
+ product_ref: str,
170
+ plan_ref: str | None = None,
171
+ meter_name: str | None = None,
172
+ usage_type: str | None = None,
173
+ ) -> LimitResponse:
174
+ """Check whether a customer is within their purchase/usage limits.
175
+
176
+ Maps to POST /v1/sdk/limits.
177
+ """
178
+ req = CheckLimitsRequest(
179
+ customer_ref=customer_ref,
180
+ product_ref=product_ref,
181
+ plan_ref=plan_ref,
182
+ meter_name=meter_name,
183
+ usage_type=usage_type,
184
+ )
185
+ data = self._http.request(
186
+ "POST",
187
+ "/v1/sdk/limits",
188
+ json=req.model_dump(by_alias=True, exclude_none=True),
189
+ )
190
+ return LimitResponse.model_validate(data)
191
+
192
+ def track_usage(
193
+ self,
194
+ *,
195
+ customer_ref: str,
196
+ product_ref: str,
197
+ meter_name: str,
198
+ units: float,
199
+ idempotency_key: str | None = None,
200
+ ) -> dict[str, Any]:
201
+ """Record usage against a meter. Maps to POST /v1/sdk/usages."""
202
+ req = TrackUsageRequest(
203
+ customer_ref=customer_ref,
204
+ product_ref=product_ref,
205
+ meter_name=meter_name,
206
+ units=units,
207
+ )
208
+ return self._http.request(
209
+ "POST",
210
+ "/v1/sdk/usages",
211
+ json=req.model_dump(by_alias=True, exclude_none=True),
212
+ idempotency_key=idempotency_key,
213
+ )
214
+
215
+ def update_customer(
216
+ self,
217
+ customer_ref: str,
218
+ *,
219
+ email: str | None = None,
220
+ name: str | None = None,
221
+ external_ref: str | None = None,
222
+ ) -> Customer:
223
+ """Update customer fields. Maps to PATCH /v1/sdk/customers/{ref}."""
224
+ req = UpdateCustomerRequest(email=email, name=name, external_ref=external_ref)
225
+ data = self._http.request(
226
+ "PATCH",
227
+ f"/v1/sdk/customers/{customer_ref}",
228
+ json=req.model_dump(by_alias=True, exclude_none=True),
229
+ )
230
+ return Customer.model_validate(data)
231
+
232
+ def get_customer_balance(self, customer_ref: str) -> BalanceResponse:
233
+ """Get credit balance for a customer. Maps to GET /v1/sdk/customers/{ref}/balance."""
234
+ data = self._http.request("GET", f"/v1/sdk/customers/{customer_ref}/balance")
235
+ return BalanceResponse.model_validate(data)
236
+
237
+ def cancel_purchase(self, purchase_ref: str, *, reason: str | None = None) -> dict[str, Any]:
238
+ """Cancel a purchase. Maps to POST /v1/sdk/purchases/{ref}/cancel."""
239
+ req = CancelPurchaseRequest(reason=reason)
240
+ return self._http.request(
241
+ "POST",
242
+ f"/v1/sdk/purchases/{purchase_ref}/cancel",
243
+ json=req.model_dump(by_alias=True, exclude_none=True),
244
+ )
245
+
246
+ def reactivate_purchase(self, purchase_ref: str) -> dict[str, Any]:
247
+ """Reactivate a cancelled purchase. Maps to POST /v1/sdk/purchases/{ref}/reactivate."""
248
+ return self._http.request("POST", f"/v1/sdk/purchases/{purchase_ref}/reactivate")
249
+
250
+ # --- Admin: Products ---
251
+
252
+ def list_products(self) -> list[Product]:
253
+ """List all products. Maps to GET /v1/sdk/products."""
254
+ data = self._http.request("GET", "/v1/sdk/products")
255
+ items: list[Any] = data if isinstance(data, list) else data.get("products", [])
256
+ return [Product.model_validate(p) for p in items]
257
+
258
+ def get_product(self, product_ref: str) -> Product:
259
+ """Get a product by ref. Maps to GET /v1/sdk/products/{ref}."""
260
+ data = self._http.request("GET", f"/v1/sdk/products/{product_ref}")
261
+ return Product.model_validate(data)
262
+
263
+ def create_product(self, *, name: str, type: str, default_currency: str) -> Product:
264
+ """Create a product. Maps to POST /v1/sdk/products."""
265
+ req = CreateProductRequest(name=name, type=type, default_currency=default_currency)
266
+ data = self._http.request(
267
+ "POST",
268
+ "/v1/sdk/products",
269
+ json=req.model_dump(by_alias=True, exclude_none=True),
270
+ )
271
+ return Product.model_validate(data)
272
+
273
+ def delete_product(self, product_ref: str) -> dict[str, Any]:
274
+ """Delete a product. Maps to DELETE /v1/sdk/products/{ref}."""
275
+ return self._http.request("DELETE", f"/v1/sdk/products/{product_ref}")
276
+
277
+ def clone_product(self, product_ref: str, *, new_name: str) -> Product:
278
+ """Clone a product with a new name. Maps to POST /v1/sdk/products/{ref}/clone."""
279
+ req = CloneProductRequest(new_name=new_name)
280
+ data = self._http.request(
281
+ "POST",
282
+ f"/v1/sdk/products/{product_ref}/clone",
283
+ json=req.model_dump(by_alias=True, exclude_none=True),
284
+ )
285
+ return Product.model_validate(data)
286
+
287
+ # --- Admin: Plans ---
288
+
289
+ def list_plans(self, product_ref: str) -> list[Plan]:
290
+ """List plans for a product. Maps to GET /v1/sdk/products/{ref}/plans."""
291
+ data = self._http.request("GET", f"/v1/sdk/products/{product_ref}/plans")
292
+ items: list[Any] = data if isinstance(data, list) else data.get("plans", [])
293
+ return [Plan.model_validate(p) for p in items]
294
+
295
+ def create_plan(
296
+ self,
297
+ product_ref: str,
298
+ *,
299
+ name: str,
300
+ type: str,
301
+ price: float | None = None,
302
+ currency: str | None = None,
303
+ interval: str | None = None,
304
+ ) -> Plan:
305
+ """Create a plan for a product. Maps to POST /v1/sdk/products/{ref}/plans."""
306
+ req = CreatePlanRequest(
307
+ name=name, type=type, price=price, currency=currency, interval=interval
308
+ )
309
+ data = self._http.request(
310
+ "POST",
311
+ f"/v1/sdk/products/{product_ref}/plans",
312
+ json=req.model_dump(by_alias=True, exclude_none=True),
313
+ )
314
+ return Plan.model_validate(data)
315
+
316
+ def update_plan(
317
+ self,
318
+ product_ref: str,
319
+ plan_ref: str,
320
+ *,
321
+ name: str | None = None,
322
+ type: str | None = None,
323
+ price: float | None = None,
324
+ currency: str | None = None,
325
+ interval: str | None = None,
326
+ ) -> Plan:
327
+ """Update a plan. Maps to PUT /v1/sdk/products/{ref}/plans/{ref}."""
328
+ req = UpdatePlanRequest(
329
+ name=name, type=type, price=price, currency=currency, interval=interval
330
+ )
331
+ data = self._http.request(
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
+ return Plan.model_validate(data)
337
+
338
+ def delete_plan(self, product_ref: str, plan_ref: str) -> dict[str, Any]:
339
+ """Delete a plan. Maps to DELETE /v1/sdk/products/{ref}/plans/{ref}."""
340
+ return self._http.request("DELETE", f"/v1/sdk/products/{product_ref}/plans/{plan_ref}")
341
+
342
+ # --- Admin: Merchant + Platform ---
343
+
344
+ def get_merchant(self) -> Merchant:
345
+ """Get merchant account details. Maps to GET /v1/sdk/merchant."""
346
+ data = self._http.request("GET", "/v1/sdk/merchant")
347
+ return Merchant.model_validate(data)
348
+
349
+ def get_platform_config(self) -> PlatformConfig:
350
+ """Get platform-level configuration. Maps to GET /v1/sdk/platform-config."""
351
+ data = self._http.request("GET", "/v1/sdk/platform-config")
352
+ return PlatformConfig.model_validate(data)
solvapay/events.py ADDED
@@ -0,0 +1,90 @@
1
+ """Typed pydantic models for SolvaPay webhook events.
2
+
3
+ Mirrors the 13 event types in @solvapay/server src/types/webhook.ts.
4
+ Use as a discriminated union with `verify_webhook(..., parse_as=WebhookEvent)`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Annotated, Any, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+
14
+ class _Event(BaseModel):
15
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
16
+ id: str
17
+ created: int
18
+ api_version: str
19
+ livemode: bool
20
+ data: dict[str, Any]
21
+
22
+
23
+ class PaymentSucceeded(_Event):
24
+ type: Literal["payment.succeeded"]
25
+
26
+
27
+ class PaymentFailed(_Event):
28
+ type: Literal["payment.failed"]
29
+
30
+
31
+ class PaymentRefunded(_Event):
32
+ type: Literal["payment.refunded"]
33
+
34
+
35
+ class PaymentRefundFailed(_Event):
36
+ type: Literal["payment.refund_failed"]
37
+
38
+
39
+ class PurchaseCreated(_Event):
40
+ type: Literal["purchase.created"]
41
+
42
+
43
+ class PurchaseUpdated(_Event):
44
+ type: Literal["purchase.updated"]
45
+
46
+
47
+ class PurchaseCancelled(_Event):
48
+ type: Literal["purchase.cancelled"]
49
+
50
+
51
+ class PurchaseExpired(_Event):
52
+ type: Literal["purchase.expired"]
53
+
54
+
55
+ class PurchaseSuspended(_Event):
56
+ type: Literal["purchase.suspended"]
57
+
58
+
59
+ class CustomerCreated(_Event):
60
+ type: Literal["customer.created"]
61
+
62
+
63
+ class CustomerUpdated(_Event):
64
+ type: Literal["customer.updated"]
65
+
66
+
67
+ class CustomerDeleted(_Event):
68
+ type: Literal["customer.deleted"]
69
+
70
+
71
+ class CheckoutSessionCreated(_Event):
72
+ type: Literal["checkout_session.created"]
73
+
74
+
75
+ WebhookEvent = Annotated[
76
+ PaymentSucceeded
77
+ | PaymentFailed
78
+ | PaymentRefunded
79
+ | PaymentRefundFailed
80
+ | PurchaseCreated
81
+ | PurchaseUpdated
82
+ | PurchaseCancelled
83
+ | PurchaseExpired
84
+ | PurchaseSuspended
85
+ | CustomerCreated
86
+ | CustomerUpdated
87
+ | CustomerDeleted
88
+ | CheckoutSessionCreated,
89
+ Field(discriminator="type"),
90
+ ]
solvapay/exceptions.py ADDED
@@ -0,0 +1,16 @@
1
+ """Exception hierarchy for SolvaPay SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SolvaPayError(Exception):
7
+ """Base exception for all SolvaPay SDK errors."""
8
+
9
+
10
+ class SolvaPayAPIError(SolvaPayError):
11
+ """Raised when the SolvaPay API returns a non-2xx response."""
12
+
13
+ def __init__(self, status_code: int, body: str, message: str | None = None) -> None:
14
+ self.status_code = status_code
15
+ self.body = body
16
+ super().__init__(message or f"SolvaPay API error {status_code}: {body}")
solvapay/fastapi.py ADDED
@@ -0,0 +1,65 @@
1
+ """Optional FastAPI integration. Requires `pip install solvapay[fastapi]`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any
7
+
8
+ try:
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ except ImportError as exc:
11
+ raise ImportError("FastAPI is not installed. Run: pip install solvapay[fastapi]") from exc
12
+
13
+ from solvapay.exceptions import SolvaPayError
14
+ from solvapay.webhooks import verify_webhook
15
+
16
+
17
+ def webhook_router(
18
+ *,
19
+ secret: str,
20
+ on_event: Callable[[dict[str, Any]], Awaitable[None]],
21
+ path: str = "/webhooks/solvapay",
22
+ ) -> APIRouter:
23
+ """Build an APIRouter that verifies and dispatches SolvaPay webhooks.
24
+
25
+ Handles signature verification automatically. Mount it on your FastAPI app
26
+ and implement your event logic in `on_event`.
27
+
28
+ Args:
29
+ secret: Webhook signing secret (starts with `whsec_`). Get from
30
+ SOLVAPAY_WEBHOOK_SECRET env or your SolvaPay dashboard.
31
+ on_event: Async callback that receives the verified event dict.
32
+ path: URL path for the webhook endpoint. Default "/webhooks/solvapay".
33
+
34
+ Returns:
35
+ An APIRouter ready for `app.include_router(...)`.
36
+
37
+ Example:
38
+ import os
39
+ from solvapay.fastapi import webhook_router
40
+
41
+ async def handle_event(event: dict) -> None:
42
+ if event["type"] == "purchase.created":
43
+ ... # grant access
44
+
45
+ app.include_router(
46
+ webhook_router(
47
+ secret=os.environ["SOLVAPAY_WEBHOOK_SECRET"],
48
+ on_event=handle_event,
49
+ )
50
+ )
51
+ """
52
+ router = APIRouter()
53
+
54
+ @router.post(path)
55
+ async def _solvapay_webhook(request: Request) -> dict[str, bool]:
56
+ body = (await request.body()).decode()
57
+ sig = request.headers.get("sv-signature", "")
58
+ try:
59
+ event = verify_webhook(body=body, signature=sig, secret=secret)
60
+ except SolvaPayError as exc:
61
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
62
+ await on_event(event)
63
+ return {"received": True}
64
+
65
+ return router
solvapay/langchain.py ADDED
@@ -0,0 +1,67 @@
1
+ """Optional LangChain integration. Requires `pip install solvapay[langchain]`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import wraps
6
+ from typing import Any
7
+
8
+ try:
9
+ from langchain_core.tools import BaseTool
10
+ except ImportError as exc:
11
+ raise ImportError("LangChain is not installed. Run: pip install solvapay[langchain]") from exc
12
+
13
+ from solvapay.client import SolvaPay
14
+ from solvapay.paywall_state import decide
15
+
16
+
17
+ def monetize_tool(
18
+ tool: BaseTool,
19
+ *,
20
+ product: str,
21
+ customer_ref_arg: str = "customer_ref",
22
+ client: SolvaPay | None = None,
23
+ ) -> BaseTool:
24
+ """Wrap a LangChain tool with SolvaPay paywall gating.
25
+
26
+ Checks check_limits before each invocation. On gate hit, returns a
27
+ structured dict with the checkout URL — does NOT raise — so LangChain
28
+ agents can surface the recovery action to the user.
29
+
30
+ Args:
31
+ tool: Any LangChain BaseTool (Tool, StructuredTool, etc.).
32
+ product: SolvaPay product reference (e.g., "prd_0QKI8NHF").
33
+ customer_ref_arg: Name of the kwarg carrying the customer ref.
34
+ client: Pre-configured SolvaPay instance. Constructs one per call if omitted.
35
+
36
+ Returns:
37
+ The same tool with its `.func` replaced by a gated wrapper.
38
+
39
+ Example:
40
+ from langchain_core.tools import Tool
41
+ from solvapay.langchain import monetize_tool
42
+
43
+ raw = Tool.from_function(name="search", func=do_search, description="Search the web.")
44
+ paid = monetize_tool(raw, product="prd_search")
45
+ """
46
+ sv = client or SolvaPay()
47
+ original_func = tool.func # type: ignore[attr-defined]
48
+
49
+ @wraps(original_func)
50
+ def gated(**kwargs: Any) -> Any:
51
+ customer_ref = kwargs.get(customer_ref_arg)
52
+ if not isinstance(customer_ref, str):
53
+ return {"error": f"Missing required argument: {customer_ref_arg}"}
54
+ limits = sv.check_limits(customer_ref=customer_ref, product_ref=product)
55
+ if not limits.within_limits:
56
+ d = decide(limits)
57
+ return {
58
+ "paywall_required": True,
59
+ "state": d.state.value,
60
+ "message": d.message,
61
+ "checkout_url": d.checkout_url,
62
+ "recovery_tool": d.recovery_tool,
63
+ }
64
+ return original_func(**kwargs)
65
+
66
+ tool.func = gated # type: ignore[attr-defined]
67
+ return tool