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 +57 -0
- solvapay/_async_client.py +355 -0
- solvapay/_config.py +22 -0
- solvapay/_http.py +107 -0
- solvapay/client.py +352 -0
- solvapay/events.py +90 -0
- solvapay/exceptions.py +16 -0
- solvapay/fastapi.py +65 -0
- solvapay/langchain.py +67 -0
- solvapay/models.py +182 -0
- solvapay/paywall.py +130 -0
- solvapay/paywall_state.py +121 -0
- solvapay/webhooks.py +86 -0
- solvapay_python-0.6.0.dist-info/METADATA +268 -0
- solvapay_python-0.6.0.dist-info/RECORD +17 -0
- solvapay_python-0.6.0.dist-info/WHEEL +4 -0
- solvapay_python-0.6.0.dist-info/licenses/LICENSE +21 -0
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
|