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.
- solvapay/__init__.py +26 -0
- solvapay/_currencies.py +170 -0
- solvapay/_generated/__init__.py +1 -0
- solvapay/_generated/models.py +3643 -0
- solvapay/_generated/operations.py +1894 -0
- solvapay/_http.py +243 -0
- solvapay/_js.py +114 -0
- solvapay/_tax_ids.py +442 -0
- solvapay/agents/__init__.py +67 -0
- solvapay/agents/calls.py +302 -0
- solvapay/agents/identity.py +293 -0
- solvapay/agents/mcp.py +614 -0
- solvapay/agents/oauth.py +105 -0
- solvapay/business_details.py +124 -0
- solvapay/client.py +107 -0
- solvapay/errors.py +33 -0
- solvapay/mcp/__init__.py +90 -0
- solvapay/mcp/app.py +243 -0
- solvapay/mcp/auth.py +604 -0
- solvapay/mcp/bootstrap.py +494 -0
- solvapay/mcp/intent.py +474 -0
- solvapay/mcp/mcp-app.html +119 -0
- solvapay/mcp/narrate.py +1019 -0
- solvapay/mcp/response.py +220 -0
- solvapay/mcp/server.py +343 -0
- solvapay/mcp/tools.py +273 -0
- solvapay/mcp/transport.py +550 -0
- solvapay/money.py +61 -0
- solvapay/paywall/__init__.py +25 -0
- solvapay/paywall/decide.py +450 -0
- solvapay/paywall/gate.py +176 -0
- solvapay/paywall/state.py +371 -0
- solvapay/pricing.py +281 -0
- solvapay/py.typed +0 -0
- solvapay/solvapay.py +173 -0
- solvapay/webhooks.py +93 -0
- solvapay-0.1.0.dev4.dist-info/METADATA +183 -0
- solvapay-0.1.0.dev4.dist-info/RECORD +39 -0
- solvapay-0.1.0.dev4.dist-info/WHEEL +4 -0
|
@@ -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
|
solvapay/paywall/gate.py
ADDED
|
@@ -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
|