solvapay 0.1.0.dev4__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.
@@ -0,0 +1,450 @@
1
+ """Whether a paid call may go ahead, ported from ``@solvapay/server``'s ``paywall.ts``.
2
+
3
+ ``Paywall.decide`` resolves the customer, checks the limits (from a short
4
+ cache, or the API), and answers ``allow`` or ``gate``. ``Paywall.run_allow``
5
+ runs the handler for an ``allow`` and records the usage. The rules about
6
+ plans, credits and limits stay in the API; this module only carries the
7
+ answer to the caller.
8
+
9
+ Deliberate differences from the TypeScript SDK (see conformance/README.md):
10
+
11
+ - Errors are told apart by status and code, never by message text.
12
+ - A lookup error other than 404 fails, instead of being logged and skipped.
13
+ - A 409 on customer creation is resolved by one lookup by ``externalRef``;
14
+ if that finds nothing it fails with ``customer_conflict``. The platform
15
+ links an email-matched customer itself, so the TypeScript SDK's retry
16
+ with a generated email (which could create a duplicate) is not ported.
17
+ - The customer cache belongs to the client instance, so two clients with
18
+ different keys in one process never share a customer.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ import re
26
+ import secrets
27
+ import string
28
+ import time
29
+ from collections.abc import Awaitable, Callable
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime, timezone
32
+ from typing import Any, Literal, TypeVar
33
+
34
+ from .._js import get, truthy
35
+ from ..client import AsyncApiClient
36
+ from ..errors import SolvaPayError
37
+ from .gate import build_paywall_gate
38
+
39
+ logger = logging.getLogger("solvapay")
40
+
41
+ T = TypeVar("T")
42
+ Json = dict[str, Any]
43
+
44
+ DEFAULT_LIMITS_CACHE_TTL_SECONDS = 10.0
45
+ _ALPHABET = string.ascii_lowercase + string.digits
46
+
47
+
48
+ FREE_METER_PATTERN = r"^free-[a-z0-9-]+$"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class FreeLimit:
53
+ """A free allowance the platform meters: ``cap`` calls per ``scope``.
54
+
55
+ ``meter`` must match ``free-[a-z0-9-]+``. A ``rolling_window`` scope needs
56
+ ``window_days``.
57
+ """
58
+
59
+ cap: int
60
+ scope: Literal["rolling_window", "lifetime"]
61
+ meter: str = "free-requests"
62
+ window_days: int | None = None
63
+
64
+ def __post_init__(self) -> None:
65
+ object.__setattr__(self, "meter", self.meter.lower())
66
+ if not re.fullmatch(FREE_METER_PATTERN, self.meter):
67
+ raise SolvaPayError(
68
+ f"Free meter {self.meter!r} must match {FREE_METER_PATTERN}",
69
+ code="invalid_free_limit",
70
+ )
71
+ if self.cap <= 0:
72
+ raise SolvaPayError("A free limit's cap must be above 0", code="invalid_free_limit")
73
+ if self.scope == "rolling_window" and not self.window_days:
74
+ raise SolvaPayError(
75
+ "A rolling_window free limit needs window_days", code="invalid_free_limit"
76
+ )
77
+
78
+ def wire(self) -> Json:
79
+ out: Json = {"meter": self.meter, "cap": self.cap, "scope": self.scope}
80
+ if self.window_days is not None:
81
+ out["windowDays"] = self.window_days
82
+ return out
83
+
84
+
85
+ @dataclass
86
+ class PaywallDecision:
87
+ """``allow``: run the handler. ``gate``: return ``gate`` to the caller instead."""
88
+
89
+ outcome: Literal["allow", "gate"]
90
+ customer_ref: str
91
+ request_id: str
92
+ limits: Json | None
93
+ gate: Json | None = None
94
+ consequence: Literal["overage"] | None = None
95
+
96
+ @property
97
+ def allowed(self) -> bool:
98
+ return self.outcome == "allow"
99
+
100
+
101
+ class PaywallError(SolvaPayError):
102
+ """Raised when a paid call is gated. ``gate`` is the paywall to show the customer."""
103
+
104
+ def __init__(self, gate: Json) -> None:
105
+ super().__init__(str(gate.get("message") or "Payment required"), code=str(gate["kind"]))
106
+ self.gate = gate
107
+
108
+
109
+ @dataclass
110
+ class _CacheEntry:
111
+ remaining: int
112
+ timestamp: float
113
+ limits: Json
114
+
115
+
116
+ @dataclass
117
+ class _Usage:
118
+ meter_name: str | None = None
119
+ usage_class: Literal["included", "overage"] | None = None
120
+ extra: Json = field(default_factory=dict)
121
+
122
+
123
+ def _request_id() -> str:
124
+ random = "".join(secrets.choice(_ALPHABET) for _ in range(9))
125
+ return f"solvapay_{int(time.time() * 1000)}_{random}"
126
+
127
+
128
+ class Paywall:
129
+ """Decides paid calls for one API client."""
130
+
131
+ def __init__(
132
+ self,
133
+ api: AsyncApiClient,
134
+ *,
135
+ limits_cache_ttl: float = DEFAULT_LIMITS_CACHE_TTL_SECONDS,
136
+ ) -> None:
137
+ self.api = api
138
+ self.limits_cache_ttl = limits_cache_ttl
139
+ self._customers: dict[str, str] = {}
140
+ self._customer_lookups: dict[str, asyncio.Future[str]] = {}
141
+ self._limits: dict[str, _CacheEntry] = {}
142
+ self._limits_fetches: dict[str, asyncio.Future[Json]] = {}
143
+ self._claims: dict[str, int] = {}
144
+
145
+ # Customers ---------------------------------------------------------------
146
+
147
+ async def ensure_customer(
148
+ self,
149
+ customer_ref: str,
150
+ external_ref: str | None = None,
151
+ *,
152
+ email: str | None = None,
153
+ name: str | None = None,
154
+ ) -> str:
155
+ """The SolvaPay customer (``cus_…``) for your user, created on first use."""
156
+ if customer_ref == "anonymous" or customer_ref.startswith("cus_"):
157
+ return customer_ref
158
+ if customer_ref in self._customers:
159
+ return self._customers[customer_ref]
160
+ key = external_ref or customer_ref
161
+ pending = self._customer_lookups.get(key)
162
+ if pending is None:
163
+ pending = asyncio.ensure_future(
164
+ self._resolve_customer(customer_ref, external_ref, email, name)
165
+ )
166
+ self._customer_lookups[key] = pending
167
+ pending.add_done_callback(lambda _: self._customer_lookups.pop(key, None))
168
+ ref = await asyncio.shield(pending)
169
+ self._customers[customer_ref] = ref
170
+ return ref
171
+
172
+ async def _find_by_external_ref(self, external_ref: str) -> str | None:
173
+ try:
174
+ customer = await self.api.get_customer_by_query(external_ref=external_ref)
175
+ except SolvaPayError as error:
176
+ if error.status == 404:
177
+ return None
178
+ raise
179
+ return customer.reference
180
+
181
+ async def _resolve_customer(
182
+ self, customer_ref: str, external_ref: str | None, email: str | None, name: str | None
183
+ ) -> str:
184
+ if external_ref:
185
+ found = await self._find_by_external_ref(external_ref)
186
+ if found:
187
+ return found
188
+ body: Json = {
189
+ "email": email or f"{customer_ref}-{int(time.time() * 1000)}@auto-created.local",
190
+ "metadata": {},
191
+ }
192
+ if name:
193
+ body["name"] = name
194
+ if external_ref:
195
+ body["externalRef"] = external_ref
196
+ try:
197
+ created = await self.api.create_customer(body=body)
198
+ except SolvaPayError as error:
199
+ if error.status != 409:
200
+ raise
201
+ found = await self._find_by_external_ref(external_ref) if external_ref else None
202
+ if found:
203
+ return found
204
+ raise SolvaPayError(
205
+ f"A customer for {customer_ref} already exists and is not linked to it: "
206
+ f"{error.message}. Link the existing customer to this externalRef.",
207
+ code="customer_conflict",
208
+ status=409,
209
+ body=error.body,
210
+ ) from error
211
+ return created.reference
212
+
213
+ # Limits ------------------------------------------------------------------
214
+
215
+ def invalidate_limits(self, customer_ref: str, product: str | None = None) -> None:
216
+ """Drops cached limits for a customer (every product, or one)."""
217
+ aliases = {customer_ref}
218
+ if customer_ref in self._customers:
219
+ aliases.add(self._customers[customer_ref])
220
+ if not customer_ref.startswith("cus_"):
221
+ aliases.add(f"cus_{customer_ref}")
222
+ for key in list(self._limits):
223
+ ref, _, rest = key.partition(":")
224
+ if ref in aliases and (product is None or rest.split(":", 1)[0] == product):
225
+ del self._limits[key]
226
+
227
+ async def _fetch_limits(self, key: str, body: Json) -> Json:
228
+ pending = self._limits_fetches.get(key)
229
+ if pending is None:
230
+ self._claims[key] = 0
231
+
232
+ async def fetch() -> Json:
233
+ response = await self.api.check_limits(body=body)
234
+ return response.model_dump(mode="json", by_alias=True, exclude_unset=True)
235
+
236
+ pending = asyncio.ensure_future(fetch())
237
+ self._limits_fetches[key] = pending
238
+ pending.add_done_callback(lambda _: self._limits_fetches.pop(key, None))
239
+ return await asyncio.shield(pending)
240
+
241
+ async def decide(
242
+ self,
243
+ *,
244
+ customer_ref: str,
245
+ product: str,
246
+ meter: str | None = None,
247
+ free_limit: FreeLimit | None = None,
248
+ tool_name: str | None = None,
249
+ ) -> PaywallDecision:
250
+ """``allow`` or ``gate`` for one paid call by ``customer_ref``.
251
+
252
+ A gate records a ``paywall`` usage event; an allow records nothing
253
+ until ``run_allow`` has run the handler.
254
+ """
255
+ if not product:
256
+ raise SolvaPayError("A product reference is required", code="missing_product")
257
+ usage_type = (free_limit.meter if free_limit else None) or meter or "requests"
258
+ request_id = _request_id()
259
+ started = time.monotonic()
260
+
261
+ if free_limit and (not customer_ref.strip() or customer_ref.strip() == "anonymous"):
262
+ raise SolvaPayError("identity required", code="identity_required", status=401)
263
+
264
+ backend_ref = await self.ensure_customer(customer_ref, customer_ref)
265
+ key = f"{backend_ref}:{product}:{usage_type}"
266
+ cached = self._limits.get(key)
267
+ now = time.monotonic()
268
+
269
+ limits: Json
270
+ if cached and now - cached.timestamp < self.limits_cache_ttl:
271
+ limits = cached.limits
272
+ if cached.remaining > 0:
273
+ cached.remaining -= 1
274
+ if cached.remaining <= 0:
275
+ del self._limits[key]
276
+ within = True
277
+ else:
278
+ # The last unit was used: block one call from the cache, then re-check.
279
+ within = False
280
+ del self._limits[key]
281
+ else:
282
+ self._limits.pop(key, None)
283
+ body: Json = {
284
+ "customerRef": backend_ref,
285
+ "productRef": product,
286
+ "meterName": usage_type,
287
+ "includeCheckoutSession": True,
288
+ }
289
+ if free_limit:
290
+ body["freeAllowance"] = free_limit.wire()
291
+ limits = await self._fetch_limits(key, body)
292
+ remaining = limits["remaining"]
293
+ if not limits["withinLimits"]:
294
+ within = False
295
+ elif remaining == -1:
296
+ within = True
297
+ elif remaining > 0:
298
+ # Callers sharing one fetch each claim a distinct unit.
299
+ claimed = self._claims.get(key, 0) + 1
300
+ self._claims[key] = claimed
301
+ if claimed <= remaining:
302
+ within = True
303
+ self._limits[key] = _CacheEntry(
304
+ remaining=remaining - claimed, timestamp=time.monotonic(), limits=limits
305
+ )
306
+ else:
307
+ within = False
308
+ else:
309
+ # withinLimits with nothing remaining is overage: the API allows it.
310
+ within = True
311
+
312
+ if not within:
313
+ await self.track_usage(
314
+ customer_ref=backend_ref,
315
+ product=product,
316
+ action=str(get(limits, "meterName") or usage_type),
317
+ outcome="paywall",
318
+ request_id=request_id,
319
+ duration_ms=int((time.monotonic() - started) * 1000),
320
+ tool_name=tool_name,
321
+ usage=_Usage(meter_name=free_limit.meter if free_limit else None),
322
+ )
323
+ # Gating on our own count (the last cached unit is used, or more
324
+ # parallel callers than units shared one check): the limits we
325
+ # counted down from still say units remain. The gate says none do.
326
+ gate_limits = (
327
+ {**limits, "withinLimits": False, "remaining": 0}
328
+ if limits.get("withinLimits")
329
+ else limits
330
+ )
331
+ return PaywallDecision(
332
+ outcome="gate",
333
+ customer_ref=backend_ref,
334
+ request_id=request_id,
335
+ limits=limits,
336
+ gate=build_paywall_gate(product, gate_limits),
337
+ )
338
+
339
+ return PaywallDecision(
340
+ outcome="allow",
341
+ customer_ref=backend_ref,
342
+ request_id=request_id,
343
+ limits=limits,
344
+ consequence="overage" if truthy(get(limits, "overage")) else None,
345
+ )
346
+
347
+ async def run_allow(
348
+ self,
349
+ decision: PaywallDecision,
350
+ handler: Callable[[], Awaitable[T]],
351
+ *,
352
+ product: str,
353
+ meter: str | None = None,
354
+ free_limit: FreeLimit | None = None,
355
+ tool_name: str | None = None,
356
+ ) -> T:
357
+ """Runs the handler for an ``allow`` and records ``success`` or ``fail``."""
358
+ if not decision.allowed:
359
+ raise SolvaPayError("run_allow needs an allow decision", code="invalid_argument")
360
+ usage_type = (free_limit.meter if free_limit else None) or meter or "requests"
361
+ action = str(get(decision.limits, "meterName") or usage_type)
362
+ started = time.monotonic()
363
+ try:
364
+ result = await handler()
365
+ except PaywallError:
366
+ raise
367
+ except BaseException:
368
+ await self.track_usage(
369
+ customer_ref=decision.customer_ref,
370
+ product=product,
371
+ action=action,
372
+ outcome="fail",
373
+ request_id=decision.request_id,
374
+ duration_ms=int((time.monotonic() - started) * 1000),
375
+ tool_name=tool_name,
376
+ usage=_Usage(meter_name=free_limit.meter if free_limit else None),
377
+ )
378
+ raise
379
+ await self.track_usage(
380
+ customer_ref=decision.customer_ref,
381
+ product=product,
382
+ action=action,
383
+ outcome="success",
384
+ request_id=decision.request_id,
385
+ duration_ms=int((time.monotonic() - started) * 1000),
386
+ tool_name=tool_name,
387
+ usage=_Usage(
388
+ meter_name=free_limit.meter if free_limit else None,
389
+ usage_class=None
390
+ if free_limit
391
+ else ("overage" if decision.consequence == "overage" else "included"),
392
+ ),
393
+ )
394
+ return result
395
+
396
+ # Usage -------------------------------------------------------------------
397
+
398
+ async def track_usage(
399
+ self,
400
+ *,
401
+ customer_ref: str,
402
+ product: str,
403
+ action: str,
404
+ outcome: Literal["success", "paywall", "fail"],
405
+ request_id: str,
406
+ duration_ms: int,
407
+ tool_name: str | None = None,
408
+ usage: _Usage | None = None,
409
+ ) -> None:
410
+ """Records one usage event. Logs a failure instead of failing the paid call.
411
+
412
+ The event is keyed ``{request_id}:{outcome}``, so a retry never counts twice.
413
+ A 404 (a customer created moments ago and not yet visible) is retried twice.
414
+ """
415
+ metadata: Json = {"action": action or "api_requests", "requestId": request_id}
416
+ if tool_name:
417
+ metadata["toolName"] = tool_name
418
+ if usage and usage.meter_name:
419
+ metadata["meterName"] = usage.meter_name
420
+ if usage and usage.usage_class:
421
+ metadata["usageClass"] = usage.usage_class
422
+ body: Json = {
423
+ "customerRef": customer_ref,
424
+ "actionType": "api_call",
425
+ "units": 1,
426
+ "outcome": outcome,
427
+ "productRef": product,
428
+ "duration": duration_ms,
429
+ "idempotencyKey": f"{request_id}:{outcome}",
430
+ "metadata": metadata,
431
+ "timestamp": datetime.now(timezone.utc)
432
+ .isoformat(timespec="milliseconds")
433
+ .replace("+00:00", "Z"),
434
+ }
435
+ for attempt in range(3):
436
+ try:
437
+ await self.api.record_usage(body=body)
438
+ return
439
+ except SolvaPayError as error:
440
+ if error.status == 404 and attempt < 2:
441
+ await asyncio.sleep(0.5)
442
+ continue
443
+ logger.error(
444
+ "SolvaPay usage tracking failed for %s (%s): %s [%s]",
445
+ customer_ref,
446
+ outcome,
447
+ error.message,
448
+ error.code,
449
+ )
450
+ return
@@ -0,0 +1,176 @@
1
+ """``build_paywall_gate``, ported from ``@solvapay/server``'s ``paywall-gate.ts``.
2
+
3
+ Turns a limits response into the paywall a customer sees: its kind, the
4
+ machine-readable recovery fields, and the message. JSON in, JSON out, with the
5
+ API's camelCase keys, so it can go straight into an MCP tool result's
6
+ ``structuredContent``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from .._js import UNDEFINED, coalesce, compact, get, number, truthy
14
+ from .state import (
15
+ PaywallStateKind,
16
+ build_gate_message,
17
+ classify_paywall_state,
18
+ credit_signals,
19
+ next_action_for,
20
+ )
21
+
22
+ Json = dict[str, Any]
23
+
24
+
25
+ def _all_paid_plans_are_payg(plans: Any) -> bool:
26
+ """True when every plan that takes payment is pay-as-you-go."""
27
+ if not plans:
28
+ return False
29
+ paid = [p for p in plans if get(p, "requiresPayment") is not False]
30
+ if not paid:
31
+ return False
32
+ return all(get(p, "type") in ("usage-based", "hybrid") for p in paid)
33
+
34
+
35
+ def _active_plan_ref(limits: Json) -> Any:
36
+ ref = coalesce(get(limits, "planRef"), get(limits, "plan"))
37
+ return ref if truthy(ref) and len(ref) > 0 else UNDEFINED
38
+
39
+
40
+ def _active_plan(limits: Json) -> Any:
41
+ ref = _active_plan_ref(limits)
42
+ if ref is UNDEFINED:
43
+ return UNDEFINED
44
+ for plan in coalesce(get(limits, "plans"), []):
45
+ if get(plan, "reference") == ref:
46
+ return plan
47
+ return UNDEFINED
48
+
49
+
50
+ def _included(limits: Json) -> Json | None:
51
+ used, limit = get(limits, "used"), get(limits, "limit")
52
+ remaining = get(limits, "remaining")
53
+ if used is not UNDEFINED and limit is not UNDEFINED:
54
+ return compact({"total": limit, "used": used, "remaining": remaining})
55
+ total = get(_active_plan(limits), "freeUnits")
56
+ if total is None or total is UNDEFINED or total == 0:
57
+ return None
58
+ used_count = max(0, number(total) - number(remaining))
59
+ if isinstance(total, int) and isinstance(remaining, int):
60
+ used_count = int(used_count)
61
+ return compact({"total": total, "used": used_count, "remaining": remaining})
62
+
63
+
64
+ def _links(limits: Json) -> Json | None:
65
+ links: Json = {}
66
+ checkout_url = get(limits, "checkoutUrl")
67
+ if truthy(checkout_url):
68
+ if get(limits, "paywallReason") == "topup_required":
69
+ links["topup"] = checkout_url
70
+ else:
71
+ links["checkout"] = checkout_url
72
+ if truthy(get(limits, "confirmationUrl")):
73
+ links["manage"] = limits["confirmationUrl"]
74
+ return links or None
75
+
76
+
77
+ def _recovery_fields(limits: Json, kind: PaywallStateKind) -> Json:
78
+ plan = _active_plan(limits)
79
+ included = _included(limits)
80
+ signals = credit_signals(limits)
81
+ plan_ref = _active_plan_ref(limits)
82
+ links = _links(limits)
83
+ balance = get(limits, "balance")
84
+
85
+ price: Json = {}
86
+ unit_price = get(plan, "perUnitChargeMinor")
87
+ if unit_price is not None and unit_price is not UNDEFINED:
88
+ price = compact({"unitPriceMinor": unit_price, "currency": get(plan, "currency")})
89
+ elif truthy(get(limits, "currency")):
90
+ price = {"currency": limits["currency"]}
91
+
92
+ fields: Json = {}
93
+ if truthy(plan_ref):
94
+ fields["planRef"] = plan_ref
95
+ fields.update(
96
+ compact(
97
+ {
98
+ "planName": get(limits, "planName"),
99
+ "plans": get(limits, "plans"),
100
+ "meterName": get(limits, "meterName"),
101
+ }
102
+ )
103
+ )
104
+ fields.update(price)
105
+ if included:
106
+ fields["included"] = included
107
+ fields.update(compact({"creditBalance": signals["creditBalance"]}))
108
+ fields["reason"] = kind
109
+ fields["nextAction"] = next_action_for(kind)
110
+ fields.update(
111
+ compact(
112
+ {
113
+ "creditsPerCall": signals["creditsPerCall"],
114
+ "shortfallCredits": signals["shortfallCredits"],
115
+ "remainingCalls": signals["remainingCalls"],
116
+ "creditsPerMinorUnit": get(balance, "creditsPerMinorUnit"),
117
+ "displayExchangeRate": get(balance, "displayExchangeRate"),
118
+ "purchaseRef": get(limits, "purchaseRef"),
119
+ "autoRecharge": get(limits, "autoRecharge"),
120
+ }
121
+ )
122
+ )
123
+ if links:
124
+ fields["links"] = links
125
+ return fields
126
+
127
+
128
+ def build_paywall_gate(product_ref: str, limits: Json) -> Json:
129
+ """The paywall for a limits response: ``activation_required`` or ``payment_required``.
130
+
131
+ When the customer is out of credits and every paid plan is pay-as-you-go,
132
+ the gate is ``activation_required`` with the plans attached, so clients
133
+ show top-up copy; the message still names the top-up.
134
+ """
135
+ checkout_url = get(limits, "checkoutUrl")
136
+ kind = classify_paywall_state(limits)
137
+ use_activation_for_topup = (
138
+ not truthy(get(limits, "activationRequired"))
139
+ and kind == "topup_required"
140
+ and _all_paid_plans_are_payg(get(limits, "plans"))
141
+ )
142
+ recovery = _recovery_fields(limits, kind)
143
+ common = compact(
144
+ {
145
+ "balance": get(limits, "balance"),
146
+ "productDetails": get(limits, "product"),
147
+ }
148
+ )
149
+ if truthy(get(limits, "activationRequired")) or use_activation_for_topup:
150
+ confirmation_url = get(limits, "confirmationUrl")
151
+ gate: Json = {
152
+ "kind": "activation_required",
153
+ "product": product_ref,
154
+ "message": "",
155
+ "checkoutUrl": (
156
+ confirmation_url
157
+ if truthy(confirmation_url)
158
+ else checkout_url
159
+ if truthy(checkout_url)
160
+ else ""
161
+ ),
162
+ **compact({"confirmationUrl": confirmation_url}),
163
+ **common,
164
+ **recovery,
165
+ }
166
+ else:
167
+ gate = {
168
+ "kind": "payment_required",
169
+ "product": product_ref,
170
+ "checkoutUrl": checkout_url if truthy(checkout_url) else "",
171
+ "message": "",
172
+ **common,
173
+ **recovery,
174
+ }
175
+ gate["message"] = build_gate_message(kind, gate)
176
+ return gate