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/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)