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/models.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Pydantic models for SolvaPay API request/response payloads.
|
|
2
|
+
|
|
3
|
+
Python fields are snake_case. API wire format is camelCase.
|
|
4
|
+
Field(alias="camelCase") handles the mapping; populate_by_name=True
|
|
5
|
+
lets callers use either form during construction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Base(BaseModel):
|
|
14
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Response models
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CheckoutSession(_Base):
|
|
23
|
+
"""Response from POST /v1/sdk/checkout-sessions."""
|
|
24
|
+
|
|
25
|
+
session_id: str = Field(alias="sessionId")
|
|
26
|
+
checkout_url: str = Field(alias="checkoutUrl")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Purchase(_Base):
|
|
30
|
+
reference: str
|
|
31
|
+
product_name: str | None = Field(default=None, alias="productName")
|
|
32
|
+
product_ref: str | None = Field(default=None, alias="productRef")
|
|
33
|
+
plan_ref: str | None = Field(default=None, alias="planRef")
|
|
34
|
+
status: str
|
|
35
|
+
start_date: str = Field(alias="startDate")
|
|
36
|
+
end_date: str | None = Field(default=None, alias="endDate")
|
|
37
|
+
amount: int | None = None
|
|
38
|
+
currency: str | None = None
|
|
39
|
+
cancelled_at: str | None = Field(default=None, alias="cancelledAt")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Customer(_Base):
|
|
43
|
+
customer_ref: str = Field(alias="customerRef")
|
|
44
|
+
email: str | None = None
|
|
45
|
+
name: str | None = None
|
|
46
|
+
external_ref: str | None = Field(default=None, alias="externalRef")
|
|
47
|
+
purchases: list[Purchase] | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LimitResponse(_Base):
|
|
51
|
+
within_limits: bool = Field(alias="withinLimits")
|
|
52
|
+
remaining: float = 0
|
|
53
|
+
plan: str | None = None
|
|
54
|
+
checkout_url: str | None = Field(default=None, alias="checkoutUrl")
|
|
55
|
+
meter_name: str | None = Field(default=None, alias="meterName")
|
|
56
|
+
credit_balance: float | None = Field(default=None, alias="creditBalance")
|
|
57
|
+
credits_per_unit: float | None = Field(default=None, alias="creditsPerUnit")
|
|
58
|
+
currency: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Request models — snake_case Python fields, camelCase wire via alias
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CheckoutSessionRequest(_Base):
|
|
67
|
+
customer_ref: str = Field(serialization_alias="customerRef")
|
|
68
|
+
product_ref: str = Field(serialization_alias="productRef")
|
|
69
|
+
plan_ref: str | None = Field(default=None, serialization_alias="planRef")
|
|
70
|
+
return_url: str | None = Field(default=None, serialization_alias="returnUrl")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CreateCustomerRequest(_Base):
|
|
74
|
+
email: str
|
|
75
|
+
external_ref: str = Field(serialization_alias="externalRef")
|
|
76
|
+
name: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CheckLimitsRequest(_Base):
|
|
80
|
+
customer_ref: str = Field(serialization_alias="customerRef")
|
|
81
|
+
product_ref: str = Field(serialization_alias="productRef")
|
|
82
|
+
plan_ref: str | None = Field(default=None, serialization_alias="planRef")
|
|
83
|
+
meter_name: str | None = Field(default=None, serialization_alias="meterName")
|
|
84
|
+
usage_type: str | None = Field(default=None, serialization_alias="usageType")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TrackUsageRequest(_Base):
|
|
88
|
+
customer_ref: str = Field(serialization_alias="customerRef")
|
|
89
|
+
product_ref: str = Field(serialization_alias="productRef")
|
|
90
|
+
meter_name: str = Field(serialization_alias="meterName")
|
|
91
|
+
units: float
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UpdateCustomerRequest(_Base):
|
|
95
|
+
email: str | None = None
|
|
96
|
+
name: str | None = None
|
|
97
|
+
external_ref: str | None = Field(default=None, serialization_alias="externalRef")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BalanceResponse(_Base):
|
|
101
|
+
customer_ref: str = Field(alias="customerRef")
|
|
102
|
+
balance: float
|
|
103
|
+
currency: str
|
|
104
|
+
plan: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CancelPurchaseRequest(_Base):
|
|
108
|
+
reason: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Admin response models
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Product(_Base):
|
|
117
|
+
"""A SolvaPay product."""
|
|
118
|
+
|
|
119
|
+
reference: str
|
|
120
|
+
name: str
|
|
121
|
+
type: str
|
|
122
|
+
status: str | None = None
|
|
123
|
+
default_currency: str | None = Field(default=None, alias="defaultCurrency")
|
|
124
|
+
created_at: str | None = Field(default=None, alias="createdAt")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Plan(_Base):
|
|
128
|
+
"""A SolvaPay plan attached to a product."""
|
|
129
|
+
|
|
130
|
+
reference: str
|
|
131
|
+
name: str
|
|
132
|
+
type: str
|
|
133
|
+
price: float | None = None
|
|
134
|
+
currency: str | None = None
|
|
135
|
+
interval: str | None = None
|
|
136
|
+
status: str | None = None
|
|
137
|
+
product_ref: str | None = Field(default=None, alias="productRef")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Merchant(_Base):
|
|
141
|
+
"""Merchant account details."""
|
|
142
|
+
|
|
143
|
+
merchant_ref: str | None = Field(default=None, alias="merchantRef")
|
|
144
|
+
name: str | None = None
|
|
145
|
+
email: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class PlatformConfig(_Base):
|
|
149
|
+
"""Platform-level configuration."""
|
|
150
|
+
|
|
151
|
+
currency: str | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Admin request models
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CreateProductRequest(_Base):
|
|
160
|
+
name: str
|
|
161
|
+
type: str
|
|
162
|
+
default_currency: str = Field(serialization_alias="defaultCurrency")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CreatePlanRequest(_Base):
|
|
166
|
+
name: str
|
|
167
|
+
type: str
|
|
168
|
+
price: float | None = None
|
|
169
|
+
currency: str | None = None
|
|
170
|
+
interval: str | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class UpdatePlanRequest(_Base):
|
|
174
|
+
name: str | None = None
|
|
175
|
+
type: str | None = None
|
|
176
|
+
price: float | None = None
|
|
177
|
+
currency: str | None = None
|
|
178
|
+
interval: str | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CloneProductRequest(_Base):
|
|
182
|
+
new_name: str = Field(serialization_alias="newName")
|
solvapay/paywall.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Paywall decorator — wrap any function to auto-check limits before running.
|
|
2
|
+
|
|
3
|
+
Mirrors the @solvapay/server `payable` philosophy in a single decorator.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from typing import ParamSpec, TypeVar
|
|
11
|
+
|
|
12
|
+
from solvapay.exceptions import SolvaPayError
|
|
13
|
+
|
|
14
|
+
P = ParamSpec("P")
|
|
15
|
+
R = TypeVar("R")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PaywallRequired(SolvaPayError):
|
|
19
|
+
"""Raised by @paywall.require when a customer has exceeded their limits."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, checkout_url: str | None, message: str = "Paywall: limit exceeded") -> None:
|
|
22
|
+
self.checkout_url = checkout_url
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require(
|
|
27
|
+
*,
|
|
28
|
+
product: str,
|
|
29
|
+
plan: str | None = None,
|
|
30
|
+
customer_ref_arg: str = "customer_ref",
|
|
31
|
+
client: object | None = None,
|
|
32
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
33
|
+
"""Decorate a function with a SolvaPay paywall check.
|
|
34
|
+
|
|
35
|
+
Before each call, checks `check_limits` for the customer. If
|
|
36
|
+
`within_limits` is False, raises `PaywallRequired` with the checkout URL
|
|
37
|
+
so the caller can redirect the user to upgrade.
|
|
38
|
+
|
|
39
|
+
The decorated function must accept a `customer_ref` keyword argument
|
|
40
|
+
(rename via `customer_ref_arg` if needed).
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
product: SolvaPay product reference (e.g., "prd_0QKI8NHF").
|
|
44
|
+
plan: Optional plan reference to check against.
|
|
45
|
+
customer_ref_arg: Name of the kwarg that carries the customer ref.
|
|
46
|
+
Defaults to "customer_ref".
|
|
47
|
+
client: Pre-configured SolvaPay instance. If omitted, a new client
|
|
48
|
+
is created per call (reads SOLVAPAY_SECRET_KEY from env).
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
sv = SolvaPay()
|
|
52
|
+
|
|
53
|
+
@paywall.require(product="prd_0QKI8NHF", client=sv)
|
|
54
|
+
def run_expensive_query(*, customer_ref: str, query: str) -> dict:
|
|
55
|
+
...
|
|
56
|
+
"""
|
|
57
|
+
from solvapay.client import SolvaPay
|
|
58
|
+
|
|
59
|
+
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
|
|
60
|
+
@wraps(fn)
|
|
61
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
62
|
+
customer_ref = kwargs.get(customer_ref_arg)
|
|
63
|
+
if not isinstance(customer_ref, str):
|
|
64
|
+
raise SolvaPayError(
|
|
65
|
+
f"@paywall.require expected str kwarg '{customer_ref_arg}', "
|
|
66
|
+
f"got {type(customer_ref).__name__}"
|
|
67
|
+
)
|
|
68
|
+
sv: SolvaPay = client if isinstance(client, SolvaPay) else SolvaPay()
|
|
69
|
+
limits = sv.check_limits(
|
|
70
|
+
customer_ref=customer_ref,
|
|
71
|
+
product_ref=product,
|
|
72
|
+
plan_ref=plan,
|
|
73
|
+
)
|
|
74
|
+
if not limits.within_limits:
|
|
75
|
+
raise PaywallRequired(checkout_url=limits.checkout_url)
|
|
76
|
+
return fn(*args, **kwargs)
|
|
77
|
+
|
|
78
|
+
return wrapper
|
|
79
|
+
|
|
80
|
+
return decorator
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def require_async(
|
|
84
|
+
*,
|
|
85
|
+
product: str,
|
|
86
|
+
plan: str | None = None,
|
|
87
|
+
customer_ref_arg: str = "customer_ref",
|
|
88
|
+
client: object | None = None,
|
|
89
|
+
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
|
90
|
+
"""Async equivalent of @paywall.require. Decorated function must be async.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
product: SolvaPay product reference (e.g., "prd_0QKI8NHF").
|
|
94
|
+
plan: Optional plan reference to check against.
|
|
95
|
+
customer_ref_arg: Name of the kwarg that carries the customer ref.
|
|
96
|
+
client: Pre-configured AsyncSolvaPay instance. If omitted, a new
|
|
97
|
+
client is created per call (reads SOLVAPAY_SECRET_KEY from env).
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
sv = AsyncSolvaPay()
|
|
101
|
+
|
|
102
|
+
@paywall.require_async(product="prd_0QKI8NHF", client=sv)
|
|
103
|
+
async def run_expensive_query(*, customer_ref: str, query: str) -> dict:
|
|
104
|
+
...
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def decorator(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
108
|
+
@wraps(fn)
|
|
109
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
110
|
+
from solvapay._async_client import AsyncSolvaPay
|
|
111
|
+
|
|
112
|
+
customer_ref = kwargs.get(customer_ref_arg)
|
|
113
|
+
if not isinstance(customer_ref, str):
|
|
114
|
+
raise SolvaPayError(
|
|
115
|
+
f"@paywall.require_async expected str kwarg '{customer_ref_arg}', "
|
|
116
|
+
f"got {type(customer_ref).__name__}"
|
|
117
|
+
)
|
|
118
|
+
sv: AsyncSolvaPay = client if isinstance(client, AsyncSolvaPay) else AsyncSolvaPay()
|
|
119
|
+
limits = await sv.check_limits(
|
|
120
|
+
customer_ref=customer_ref,
|
|
121
|
+
product_ref=product,
|
|
122
|
+
plan_ref=plan,
|
|
123
|
+
)
|
|
124
|
+
if not limits.within_limits:
|
|
125
|
+
raise PaywallRequired(checkout_url=limits.checkout_url)
|
|
126
|
+
return await fn(*args, **kwargs)
|
|
127
|
+
|
|
128
|
+
return wrapper
|
|
129
|
+
|
|
130
|
+
return decorator
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Pure-function paywall state machine. Mirrors TS classifyPaywallState.
|
|
2
|
+
|
|
3
|
+
Input: a LimitResponse (output of check_limits).
|
|
4
|
+
Output: a structured state + display copy + recovery action.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
from solvapay.models import LimitResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PaywallState(str, Enum):
|
|
16
|
+
OK = "ok"
|
|
17
|
+
ACTIVATION_REQUIRED = "activation_required"
|
|
18
|
+
TOPUP_REQUIRED = "topup_required"
|
|
19
|
+
UPGRADE_REQUIRED = "upgrade_required"
|
|
20
|
+
REACTIVATION_REQUIRED = "reactivation_required" # reserved; not yet returned by classify_state
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GateDecision(NamedTuple):
|
|
24
|
+
state: PaywallState
|
|
25
|
+
message: str
|
|
26
|
+
recovery_tool: str | None # "upgrade" | "topup" | "activate_plan" | "manage_account" | None
|
|
27
|
+
checkout_url: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_RECOVERY_TOOL: dict[PaywallState, str | None] = {
|
|
31
|
+
PaywallState.OK: None,
|
|
32
|
+
PaywallState.ACTIVATION_REQUIRED: "activate_plan",
|
|
33
|
+
PaywallState.TOPUP_REQUIRED: "topup",
|
|
34
|
+
PaywallState.UPGRADE_REQUIRED: "upgrade",
|
|
35
|
+
PaywallState.REACTIVATION_REQUIRED: "manage_account",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def classify_state(limits: LimitResponse) -> PaywallState:
|
|
40
|
+
"""Return the recovery state implied by a check_limits response.
|
|
41
|
+
|
|
42
|
+
Precedence mirrors TS classifyPaywallState (paywall-state.ts):
|
|
43
|
+
1. within_limits → OK
|
|
44
|
+
2. plan is None → activation_required (no active plan; proxy for activationRequired flag)
|
|
45
|
+
3. credit_balance present and exhausted → topup_required (usage-based, out of credits)
|
|
46
|
+
4. everything else → upgrade_required (recurring cap or unresolvable state)
|
|
47
|
+
"""
|
|
48
|
+
if limits.within_limits:
|
|
49
|
+
return PaywallState.OK
|
|
50
|
+
if limits.plan is None:
|
|
51
|
+
return PaywallState.ACTIVATION_REQUIRED
|
|
52
|
+
# Usage-based: credit_balance field signals usage-based billing in our LimitResponse.
|
|
53
|
+
# Mirrors TS: creditBalance === 0 on a usage-based plan → topup path.
|
|
54
|
+
if limits.credit_balance is not None and limits.credit_balance <= 0:
|
|
55
|
+
return PaywallState.TOPUP_REQUIRED
|
|
56
|
+
return PaywallState.UPGRADE_REQUIRED
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_gate_message(state: PaywallState, limits: LimitResponse) -> str:
|
|
60
|
+
"""Terminal-friendly gate copy. Mirrors TS buildGateMessage.
|
|
61
|
+
|
|
62
|
+
Inlines checkoutUrl when present so terminal-only MCP/CLI hosts can
|
|
63
|
+
open a browser directly, matching TS behaviour exactly.
|
|
64
|
+
"""
|
|
65
|
+
if state == PaywallState.OK:
|
|
66
|
+
return ""
|
|
67
|
+
url = limits.checkout_url or None
|
|
68
|
+
open_clause = f", or open {url} in a browser" if url else ""
|
|
69
|
+
if state == PaywallState.ACTIVATION_REQUIRED:
|
|
70
|
+
return (
|
|
71
|
+
f"Your plan needs activation before you can use this tool. "
|
|
72
|
+
f"Call the `activate_plan` tool to activate it{open_clause}."
|
|
73
|
+
)
|
|
74
|
+
if state == PaywallState.TOPUP_REQUIRED:
|
|
75
|
+
return f"You're out of credits. Call the `topup` tool to add more{open_clause}."
|
|
76
|
+
if state == PaywallState.UPGRADE_REQUIRED:
|
|
77
|
+
return (
|
|
78
|
+
f"You don't have an active plan for this tool. "
|
|
79
|
+
f"Call the `upgrade` tool to pick a plan{open_clause}."
|
|
80
|
+
)
|
|
81
|
+
# reactivation_required — two alternatives; URL not appended (mirrors TS)
|
|
82
|
+
return (
|
|
83
|
+
"Your previous plan is no longer active. "
|
|
84
|
+
"Call the `manage_account` tool to reactivate it, "
|
|
85
|
+
"or the `upgrade` tool to pick a new plan."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_nudge_message(state: PaywallState, limits: LimitResponse) -> str:
|
|
90
|
+
"""Low-balance / approaching-cap nudge copy. Mirrors TS buildNudgeMessage.
|
|
91
|
+
|
|
92
|
+
Only topup_required and upgrade_required produce actionable nudges on
|
|
93
|
+
successful calls; other states return an empty string.
|
|
94
|
+
"""
|
|
95
|
+
if state == PaywallState.OK:
|
|
96
|
+
return ""
|
|
97
|
+
url = limits.checkout_url or None
|
|
98
|
+
visit_clause = f", or visit {url}" if url else ""
|
|
99
|
+
if state == PaywallState.TOPUP_REQUIRED:
|
|
100
|
+
return (
|
|
101
|
+
f"Heads up — running low on credits. Call the `topup` tool to add more{visit_clause}."
|
|
102
|
+
)
|
|
103
|
+
if state == PaywallState.UPGRADE_REQUIRED:
|
|
104
|
+
return (
|
|
105
|
+
f"Heads up — approaching your plan's limit this period. "
|
|
106
|
+
f"Call the `upgrade` tool for more headroom{visit_clause}."
|
|
107
|
+
)
|
|
108
|
+
if state == PaywallState.ACTIVATION_REQUIRED:
|
|
109
|
+
return f"Heads up — this plan still needs activation. Call the `activate_plan` tool{visit_clause}."
|
|
110
|
+
return f"Heads up — your plan is no longer active. Call the `manage_account` tool to reactivate it{visit_clause}."
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def decide(limits: LimitResponse) -> GateDecision:
|
|
114
|
+
"""Full decision: state + gate message + recovery tool + checkout URL."""
|
|
115
|
+
state = classify_state(limits)
|
|
116
|
+
return GateDecision(
|
|
117
|
+
state=state,
|
|
118
|
+
message=build_gate_message(state, limits),
|
|
119
|
+
recovery_tool=_RECOVERY_TOOL[state],
|
|
120
|
+
checkout_url=limits.checkout_url,
|
|
121
|
+
)
|
solvapay/webhooks.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Webhook signature verification.
|
|
2
|
+
|
|
3
|
+
Mirrors @solvapay/server verifyWebhook. HMAC-SHA256 over "{timestamp}.{body}"
|
|
4
|
+
with header format "t={ts},v1={hmac}". 5-minute tolerance. Constant-time compare.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
from solvapay.exceptions import SolvaPayError
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_signature_header(signature: str) -> tuple[int, str]:
|
|
21
|
+
parts: dict[str, str] = {}
|
|
22
|
+
for chunk in signature.split(","):
|
|
23
|
+
if "=" in chunk:
|
|
24
|
+
k, _, v = chunk.partition("=")
|
|
25
|
+
parts[k.strip()] = v.strip()
|
|
26
|
+
if "t" not in parts or "v1" not in parts:
|
|
27
|
+
raise SolvaPayError("Webhook signature header malformed (missing t= or v1=)")
|
|
28
|
+
try:
|
|
29
|
+
ts = int(parts["t"])
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise SolvaPayError("Webhook signature timestamp not an integer") from exc
|
|
32
|
+
return ts, parts["v1"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def verify_webhook(
|
|
36
|
+
*,
|
|
37
|
+
body: str,
|
|
38
|
+
signature: str,
|
|
39
|
+
secret: str,
|
|
40
|
+
tolerance: int = 300,
|
|
41
|
+
parse_as: type[T] | None = None,
|
|
42
|
+
) -> dict[str, Any] | T:
|
|
43
|
+
"""Verify a SolvaPay webhook signature and return the parsed event.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
body: Raw request body string. Must be the exact bytes signed by
|
|
47
|
+
SolvaPay — do not reformat JSON or strip whitespace.
|
|
48
|
+
signature: Value of the sv-signature request header.
|
|
49
|
+
secret: Webhook signing secret (starts with whsec_).
|
|
50
|
+
tolerance: Max seconds since signature timestamp. Default 300 (5 min).
|
|
51
|
+
parse_as: Optional pydantic type to validate the event into. Pass
|
|
52
|
+
`WebhookEvent` for a typed discriminated union. Omit for
|
|
53
|
+
backwards-compatible dict return.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Parsed webhook event as dict (default) or instance of `parse_as`.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
SolvaPayError: Header malformed, timestamp too old, signature mismatch,
|
|
60
|
+
or body not valid JSON.
|
|
61
|
+
"""
|
|
62
|
+
timestamp, received = _parse_signature_header(signature)
|
|
63
|
+
|
|
64
|
+
age = abs(int(time.time()) - timestamp)
|
|
65
|
+
if age > tolerance:
|
|
66
|
+
raise SolvaPayError(f"Webhook signature timestamp too old (age={age}s)")
|
|
67
|
+
|
|
68
|
+
expected = hmac.new(
|
|
69
|
+
secret.encode(),
|
|
70
|
+
f"{timestamp}.{body}".encode(),
|
|
71
|
+
hashlib.sha256,
|
|
72
|
+
).hexdigest()
|
|
73
|
+
|
|
74
|
+
if not hmac.compare_digest(expected, received):
|
|
75
|
+
raise SolvaPayError("Webhook signature mismatch")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
event: dict[str, Any] = json.loads(body)
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
raise SolvaPayError("Webhook body is not valid JSON") from exc
|
|
81
|
+
|
|
82
|
+
if parse_as is None:
|
|
83
|
+
return event
|
|
84
|
+
from pydantic import TypeAdapter
|
|
85
|
+
|
|
86
|
+
return TypeAdapter(parse_as).validate_python(event)
|