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,494 @@
|
|
|
1
|
+
"""The snapshot behind the ``account`` tool, ported from ``@solvapay/mcp-core``.
|
|
2
|
+
|
|
3
|
+
``build_bootstrap_payload`` reads the merchant, product, plans and the
|
|
4
|
+
signed-in customer's purchases, card, balance, limits and portal link in
|
|
5
|
+
parallel, and shapes them as the TypeScript SDK does (``BootstrapPayload``).
|
|
6
|
+
It calls the generated API client directly where TypeScript goes through its
|
|
7
|
+
``*Core`` request helpers. The merchant and the product must load; a failed
|
|
8
|
+
customer read leaves its field ``null`` and is logged.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Awaitable, Iterable, Mapping
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Literal, TypeVar
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from .._js import UNDEFINED, coalesce, get, js_round, number
|
|
23
|
+
from ..errors import SolvaPayError
|
|
24
|
+
from ..money import format_minor
|
|
25
|
+
from ..paywall import classify_paywall_state, credit_signals, next_action_for
|
|
26
|
+
from ..solvapay import SolvaPay
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("solvapay.mcp")
|
|
29
|
+
|
|
30
|
+
Json = dict[str, Any]
|
|
31
|
+
ViewKind = Literal["checkout", "account", "topup", "auto-recharge"]
|
|
32
|
+
ADVERTISED_VIEWS: tuple[ViewKind, ...] = ("checkout", "account", "topup")
|
|
33
|
+
_VIEW_PRIORITY: tuple[ViewKind, ...] = ("checkout", "topup", "account")
|
|
34
|
+
|
|
35
|
+
PORTAL_AUTO_RECHARGE_QUERY = "tab=credits&intent=autorecharge"
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_date(iso: Any) -> datetime | None:
|
|
41
|
+
"""``new Date(iso)`` for the ISO 8601 strings the API sends; None when it is not one.
|
|
42
|
+
A time without an offset is read as UTC."""
|
|
43
|
+
if not isinstance(iso, str) or not iso:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
parsed = datetime.fromisoformat(re.sub(r"Z$", "+00:00", iso))
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---- active purchase --------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_plan_purchase(purchase: Mapping[str, Any]) -> bool:
|
|
56
|
+
"""A plan purchase, not a credit top-up."""
|
|
57
|
+
if get(purchase, "origin") == "credit_topup":
|
|
58
|
+
return False
|
|
59
|
+
if get(get(purchase, "metadata"), "purpose") == "credit_topup":
|
|
60
|
+
return False
|
|
61
|
+
snapshot = get(purchase, "planSnapshot")
|
|
62
|
+
return snapshot is not UNDEFINED and snapshot is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _start_time(purchase: Mapping[str, Any]) -> float:
|
|
66
|
+
start = get(purchase, "startDate")
|
|
67
|
+
if not isinstance(start, str) or not start:
|
|
68
|
+
return 0
|
|
69
|
+
parsed = parse_date(start)
|
|
70
|
+
return parsed.timestamp() if parsed else 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def select_active_plan_purchase(
|
|
74
|
+
purchases: Iterable[Any] | None, product_ref: str | None = None
|
|
75
|
+
) -> Json | None:
|
|
76
|
+
"""The customer's current plan purchase: active, on ``product_ref``, not a top-up.
|
|
77
|
+
The newest start wins; paid beats free only on the same start."""
|
|
78
|
+
candidates = []
|
|
79
|
+
for purchase in purchases or []:
|
|
80
|
+
if not isinstance(purchase, Mapping):
|
|
81
|
+
continue
|
|
82
|
+
status = get(purchase, "status")
|
|
83
|
+
if isinstance(status, str) and status and status != "active":
|
|
84
|
+
continue
|
|
85
|
+
if not is_plan_purchase(purchase):
|
|
86
|
+
continue
|
|
87
|
+
if product_ref and get(purchase, "productRef") != product_ref:
|
|
88
|
+
continue
|
|
89
|
+
candidates.append(dict(purchase))
|
|
90
|
+
if not candidates:
|
|
91
|
+
return None
|
|
92
|
+
# A stable sort, as Array.prototype.sort is: equal keys keep their order.
|
|
93
|
+
ranked = sorted(
|
|
94
|
+
candidates,
|
|
95
|
+
key=lambda p: (-_start_time(p), -int((coalesce(get(p, "amount"), 0) or 0) > 0)),
|
|
96
|
+
)
|
|
97
|
+
return ranked[0]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def derive_default_view(data: Mapping[str, Any], enabled: Iterable[ViewKind]) -> ViewKind:
|
|
101
|
+
"""The view to land on when ``view`` is omitted: no plan → checkout, a top-up gate →
|
|
102
|
+
topup, else account; the first enabled view in that order when it is turned off."""
|
|
103
|
+
enabled_views = set(enabled)
|
|
104
|
+
customer = get(data, "customer")
|
|
105
|
+
purchases = get(get(customer, "purchase"), "purchases")
|
|
106
|
+
product_ref = get(data, "productRef")
|
|
107
|
+
has_plan = (
|
|
108
|
+
select_active_plan_purchase(
|
|
109
|
+
purchases if isinstance(purchases, list) else None,
|
|
110
|
+
product_ref if isinstance(product_ref, str) else None,
|
|
111
|
+
)
|
|
112
|
+
is not None
|
|
113
|
+
)
|
|
114
|
+
preferred: ViewKind
|
|
115
|
+
if not has_plan:
|
|
116
|
+
preferred = "checkout"
|
|
117
|
+
elif get(customer, "nextAction") == "topup":
|
|
118
|
+
preferred = "topup"
|
|
119
|
+
else:
|
|
120
|
+
preferred = "account"
|
|
121
|
+
if preferred in enabled_views:
|
|
122
|
+
return preferred
|
|
123
|
+
for view in _VIEW_PRIORITY:
|
|
124
|
+
if view in enabled_views:
|
|
125
|
+
return view
|
|
126
|
+
raise SolvaPayError(
|
|
127
|
+
"derive_default_view: no enabled views, so the account tool should not be registered",
|
|
128
|
+
code="invalid_argument",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---- shaping ----------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _minor_units(amount: Any, currency: Any) -> str | None:
|
|
136
|
+
"""``Intl`` currency format with fixed digits; None where ``Intl`` would throw."""
|
|
137
|
+
if amount is None or amount is UNDEFINED or not isinstance(currency, str) or not currency:
|
|
138
|
+
return None
|
|
139
|
+
if not re.fullmatch(r"[A-Za-z]{3}", currency):
|
|
140
|
+
return None
|
|
141
|
+
return format_minor(amount, currency)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _number(value: Any) -> Any:
|
|
145
|
+
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
146
|
+
return value if ok else UNDEFINED
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def enrich_purchase(purchase: Mapping[str, Any]) -> Json:
|
|
150
|
+
"""A purchase plus ``priceDisplay`` / ``priceUsdDisplay`` strings, and the snapshot's
|
|
151
|
+
``priceDisplay``, so a model reading the JSON need not reason about minor units."""
|
|
152
|
+
amount = _number(get(purchase, "amount"))
|
|
153
|
+
original = _number(get(purchase, "originalAmount"))
|
|
154
|
+
currency = get(purchase, "currency")
|
|
155
|
+
currency = currency if isinstance(currency, str) else None
|
|
156
|
+
price_display = _minor_units(original, currency) or _minor_units(amount, "USD")
|
|
157
|
+
usd_display = _minor_units(amount, "USD") if currency and currency.upper() != "USD" else None
|
|
158
|
+
out: Json = dict(purchase)
|
|
159
|
+
if price_display:
|
|
160
|
+
out["priceDisplay"] = price_display
|
|
161
|
+
if usd_display:
|
|
162
|
+
out["priceUsdDisplay"] = usd_display
|
|
163
|
+
snapshot = get(purchase, "planSnapshot")
|
|
164
|
+
if isinstance(snapshot, Mapping):
|
|
165
|
+
snap_currency = get(snapshot, "currency")
|
|
166
|
+
snap_display = _minor_units(
|
|
167
|
+
_number(get(snapshot, "price")),
|
|
168
|
+
snap_currency if isinstance(snap_currency, str) else None,
|
|
169
|
+
)
|
|
170
|
+
out["planSnapshot"] = (
|
|
171
|
+
{**snapshot, "priceDisplay": snap_display} if snap_display else snapshot
|
|
172
|
+
)
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def auto_recharge_url_from(customer_url: Any) -> str | None:
|
|
177
|
+
"""The portal URL opened on the auto-recharge form; None without an http(s) URL."""
|
|
178
|
+
if not isinstance(customer_url, str) or not re.match(r"^https?://", customer_url, re.I):
|
|
179
|
+
return None
|
|
180
|
+
separator = "&" if "?" in customer_url else "?"
|
|
181
|
+
return f"{customer_url}{separator}{PORTAL_AUTO_RECHARGE_QUERY}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def derive_usage_snapshot(
|
|
185
|
+
*,
|
|
186
|
+
period_start: Any = UNDEFINED,
|
|
187
|
+
period_end: Any = UNDEFINED,
|
|
188
|
+
purchase_ref: Any = UNDEFINED,
|
|
189
|
+
limits: Mapping[str, Any] | None,
|
|
190
|
+
) -> Json:
|
|
191
|
+
"""``deriveUsageSnapshot``: consumption and cap from a limits response.
|
|
192
|
+
``limits=None`` means the cap is unknown, not unlimited."""
|
|
193
|
+
extra: Json = {}
|
|
194
|
+
if isinstance(period_start, str) and period_start:
|
|
195
|
+
extra["periodStart"] = period_start
|
|
196
|
+
if isinstance(period_end, str) and period_end:
|
|
197
|
+
extra["periodEnd"] = period_end
|
|
198
|
+
if isinstance(purchase_ref, str) and purchase_ref:
|
|
199
|
+
extra["purchaseRef"] = purchase_ref
|
|
200
|
+
if not limits:
|
|
201
|
+
return {
|
|
202
|
+
"meterRef": None,
|
|
203
|
+
"total": None,
|
|
204
|
+
"used": None,
|
|
205
|
+
"remaining": None,
|
|
206
|
+
"percentUsed": None,
|
|
207
|
+
**extra,
|
|
208
|
+
}
|
|
209
|
+
remaining = limits["remaining"] if number(get(limits, "remaining")) >= 0 else None
|
|
210
|
+
limit = _number(get(limits, "limit"))
|
|
211
|
+
total = limit if limit is not UNDEFINED and limit > 0 else None
|
|
212
|
+
used_value = _number(get(limits, "used"))
|
|
213
|
+
if used_value is not UNDEFINED:
|
|
214
|
+
used: Any = used_value
|
|
215
|
+
elif total is not None and remaining is not None:
|
|
216
|
+
used = max(0, total - remaining)
|
|
217
|
+
else:
|
|
218
|
+
used = None
|
|
219
|
+
percent = (
|
|
220
|
+
min(100, js_round(used / total * 10000) / 100)
|
|
221
|
+
if total is not None and total > 0 and used is not None
|
|
222
|
+
else None
|
|
223
|
+
)
|
|
224
|
+
if isinstance(percent, float) and percent.is_integer():
|
|
225
|
+
percent = int(percent)
|
|
226
|
+
return {
|
|
227
|
+
"meterRef": coalesce(get(limits, "meterName"), None),
|
|
228
|
+
"total": total,
|
|
229
|
+
"used": used,
|
|
230
|
+
"remaining": remaining,
|
|
231
|
+
"percentUsed": percent,
|
|
232
|
+
**extra,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _dump(model: Any) -> Any:
|
|
237
|
+
if isinstance(model, BaseModel):
|
|
238
|
+
return model.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
239
|
+
return model
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _unwrap_plan(plan: Mapping[str, Any]) -> Json:
|
|
243
|
+
"""``listPlans`` spreads a plan's ``data`` under it, as the TypeScript client does."""
|
|
244
|
+
data = plan.get("data")
|
|
245
|
+
data = data if isinstance(data, Mapping) else {}
|
|
246
|
+
price = coalesce(get(plan, "price"), get(data, "price"))
|
|
247
|
+
out: Json = {**data, **plan}
|
|
248
|
+
if price is not UNDEFINED:
|
|
249
|
+
out["price"] = price
|
|
250
|
+
out.pop("data", None)
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def signed_in_customer_ref(ref: Any) -> str | None:
|
|
255
|
+
"""A signed-in customer, or None. ``anonymous`` is no customer."""
|
|
256
|
+
if not isinstance(ref, str):
|
|
257
|
+
return None
|
|
258
|
+
trimmed = ref.strip()
|
|
259
|
+
return trimmed if trimmed and trimmed != "anonymous" else None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
_PROVIDER_NOT_FOUND = "\n".join(
|
|
263
|
+
[
|
|
264
|
+
"Provider account not found on this SolvaPay deployment.",
|
|
265
|
+
"",
|
|
266
|
+
"The Worker secret key authenticates against SolvaPay, but no merchant",
|
|
267
|
+
"record exists for it. This usually means the secret key was created",
|
|
268
|
+
"manually (without running `solvapay init`) or the merchant was deleted.",
|
|
269
|
+
"",
|
|
270
|
+
"To recover:",
|
|
271
|
+
" 1. Run `npx solvapay init` in the project root. It will create the",
|
|
272
|
+
" merchant on the backend and write a valid secret key to `.env`.",
|
|
273
|
+
" 2. Redeploy with `npm run deploy` to push the corrected secret to",
|
|
274
|
+
" the Worker.",
|
|
275
|
+
"",
|
|
276
|
+
"No tool calls will succeed until the merchant exists.",
|
|
277
|
+
]
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def _optional(name: str, call: Awaitable[T]) -> T | None:
|
|
282
|
+
"""A customer read that may fail: logged, and left out of the payload."""
|
|
283
|
+
try:
|
|
284
|
+
return await call
|
|
285
|
+
except SolvaPayError as error:
|
|
286
|
+
logger.warning(
|
|
287
|
+
"bootstrap: %s failed; omitted from the payload (%s, status %s)",
|
|
288
|
+
name,
|
|
289
|
+
error.message,
|
|
290
|
+
error.status,
|
|
291
|
+
)
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _none() -> None:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def build_bootstrap_payload(
|
|
300
|
+
solvapay: SolvaPay,
|
|
301
|
+
*,
|
|
302
|
+
view: ViewKind,
|
|
303
|
+
product_ref: str,
|
|
304
|
+
public_base_url: str,
|
|
305
|
+
customer_ref: str | None,
|
|
306
|
+
) -> Json:
|
|
307
|
+
"""The ``BootstrapPayload`` for ``view``. Raises ``SolvaPayError`` (with the API's
|
|
308
|
+
status) when the merchant or the product cannot load."""
|
|
309
|
+
api = solvapay.api
|
|
310
|
+
customer_ref = signed_in_customer_ref(customer_ref)
|
|
311
|
+
signed_in = customer_ref is not None
|
|
312
|
+
ref = customer_ref or ""
|
|
313
|
+
|
|
314
|
+
async def merchant() -> Json:
|
|
315
|
+
try:
|
|
316
|
+
return _dump(await api.get_merchant()) # type: ignore[no-any-return]
|
|
317
|
+
except SolvaPayError as error:
|
|
318
|
+
details = (
|
|
319
|
+
_PROVIDER_NOT_FOUND
|
|
320
|
+
if error.status == 404
|
|
321
|
+
else f"bootstrap: merchant lookup failed: {error.message}"
|
|
322
|
+
)
|
|
323
|
+
raise SolvaPayError(details, code=error.code, status=error.status) from error
|
|
324
|
+
|
|
325
|
+
async def product() -> Json:
|
|
326
|
+
try:
|
|
327
|
+
return _dump(await api.get_product(product_ref=product_ref)) # type: ignore[no-any-return]
|
|
328
|
+
except SolvaPayError as error:
|
|
329
|
+
raise SolvaPayError(
|
|
330
|
+
f"bootstrap: product lookup failed: {error.message}",
|
|
331
|
+
code=error.code,
|
|
332
|
+
status=error.status,
|
|
333
|
+
) from error
|
|
334
|
+
|
|
335
|
+
async def publishable_key() -> Any:
|
|
336
|
+
config = await _optional("getPlatformConfig", api.get_platform_config())
|
|
337
|
+
return coalesce(get(_dump(config), "stripePublishableKey"), None)
|
|
338
|
+
|
|
339
|
+
async def plans() -> list[Json]:
|
|
340
|
+
response = await _optional("listPlans", api.list_plans(product_ref=product_ref))
|
|
341
|
+
listed = coalesce(get(_dump(response), "plans"), [])
|
|
342
|
+
return [_unwrap_plan(plan) for plan in listed if isinstance(plan, Mapping)]
|
|
343
|
+
|
|
344
|
+
async def customer() -> Json | None:
|
|
345
|
+
if not signed_in:
|
|
346
|
+
return None
|
|
347
|
+
return _dump(await _optional("getCustomer", api.get_customer(reference=ref))) # type: ignore[no-any-return]
|
|
348
|
+
|
|
349
|
+
async def limits() -> Json | None:
|
|
350
|
+
if not signed_in:
|
|
351
|
+
return None
|
|
352
|
+
body = {"customerRef": ref, "productRef": product_ref, "meterName": "requests"}
|
|
353
|
+
return _dump(await _optional("checkLimits", api.check_limits(body=body))) # type: ignore[no-any-return]
|
|
354
|
+
|
|
355
|
+
def customer_read(name: str, call: Awaitable[Any]) -> Awaitable[Any]:
|
|
356
|
+
return _optional(name, call) if signed_in else _none()
|
|
357
|
+
|
|
358
|
+
results = await asyncio.gather(
|
|
359
|
+
publishable_key(),
|
|
360
|
+
merchant(),
|
|
361
|
+
product(),
|
|
362
|
+
plans(),
|
|
363
|
+
customer(),
|
|
364
|
+
customer_read("getPaymentMethod", api.get_payment_method(customer_ref=ref))
|
|
365
|
+
if signed_in
|
|
366
|
+
else _none(),
|
|
367
|
+
customer_read("getCustomerBalance", api.get_customer_balance(reference=ref))
|
|
368
|
+
if signed_in
|
|
369
|
+
else _none(),
|
|
370
|
+
limits(),
|
|
371
|
+
customer_read(
|
|
372
|
+
"createCustomerSession", api.create_customer_session(body={"customerRef": ref})
|
|
373
|
+
)
|
|
374
|
+
if signed_in
|
|
375
|
+
else _none(),
|
|
376
|
+
return_exceptions=True,
|
|
377
|
+
)
|
|
378
|
+
for result in results:
|
|
379
|
+
if isinstance(result, BaseException):
|
|
380
|
+
raise result
|
|
381
|
+
values: list[Any] = list(results)
|
|
382
|
+
(
|
|
383
|
+
stripe_key,
|
|
384
|
+
merchant_data,
|
|
385
|
+
product_data,
|
|
386
|
+
plan_list,
|
|
387
|
+
customer_data,
|
|
388
|
+
payment_method,
|
|
389
|
+
balance,
|
|
390
|
+
limits_data,
|
|
391
|
+
portal,
|
|
392
|
+
) = values
|
|
393
|
+
|
|
394
|
+
# checkLimits creates the free default purchase, so the parallel customer read
|
|
395
|
+
# can predate it. Read again once when limits names a purchase it lacks.
|
|
396
|
+
needed = get(limits_data, "purchaseRef")
|
|
397
|
+
if signed_in and isinstance(needed, str) and needed:
|
|
398
|
+
listed = coalesce(get(customer_data, "purchases"), [])
|
|
399
|
+
if not any(get(p, "reference") == needed for p in listed):
|
|
400
|
+
customer_data = await customer()
|
|
401
|
+
|
|
402
|
+
purpose = view == "topup" or (
|
|
403
|
+
view not in ("checkout", "auto-recharge")
|
|
404
|
+
and get(limits_data, "paywallReason") == "topup_required"
|
|
405
|
+
)
|
|
406
|
+
checkout = None
|
|
407
|
+
if signed_in:
|
|
408
|
+
session_body: Json = {"productRef": product_ref, "customerRef": ref}
|
|
409
|
+
if purpose:
|
|
410
|
+
session_body["purpose"] = "credit_topup"
|
|
411
|
+
checkout = _dump(
|
|
412
|
+
await _optional("createCheckoutSession", api.create_checkout_session(body=session_body))
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
purchase: Json | None = None
|
|
416
|
+
if customer_data is not None:
|
|
417
|
+
purchases = [
|
|
418
|
+
enrich_purchase(p)
|
|
419
|
+
for p in coalesce(get(customer_data, "purchases"), [])
|
|
420
|
+
if get(p, "status") == "active"
|
|
421
|
+
]
|
|
422
|
+
purchase = {"customerRef": coalesce(get(customer_data, "reference"), ref)}
|
|
423
|
+
for key in ("email", "name"):
|
|
424
|
+
if get(customer_data, key) is not UNDEFINED:
|
|
425
|
+
purchase[key] = customer_data[key]
|
|
426
|
+
purchase["purchases"] = purchases
|
|
427
|
+
|
|
428
|
+
payload_customer: Json | None = None
|
|
429
|
+
if signed_in:
|
|
430
|
+
active = select_active_plan_purchase(
|
|
431
|
+
purchase["purchases"] if purchase else None, product_ref
|
|
432
|
+
)
|
|
433
|
+
usage = active and get(active, "usage")
|
|
434
|
+
signals = credit_signals(limits_data) if limits_data else None
|
|
435
|
+
email, name = get(purchase, "email"), get(purchase, "name")
|
|
436
|
+
payload_customer = {
|
|
437
|
+
"ref": ref,
|
|
438
|
+
"email": email.strip() if isinstance(email, str) and email.strip() else None,
|
|
439
|
+
"name": name.strip() if isinstance(name, str) and name.strip() else None,
|
|
440
|
+
"purchase": purchase,
|
|
441
|
+
"paymentMethod": _dump(payment_method),
|
|
442
|
+
"balance": _dump(balance),
|
|
443
|
+
"usage": derive_usage_snapshot(
|
|
444
|
+
period_start=get(usage, "periodStart"),
|
|
445
|
+
period_end=get(usage, "periodEnd"),
|
|
446
|
+
purchase_ref=get(active, "reference"),
|
|
447
|
+
limits=limits_data,
|
|
448
|
+
),
|
|
449
|
+
"limits": limits_data,
|
|
450
|
+
}
|
|
451
|
+
if limits_data:
|
|
452
|
+
payload_customer["canCall"] = get(limits_data, "withinLimits") is True
|
|
453
|
+
if signals:
|
|
454
|
+
for key, source in (
|
|
455
|
+
("remainingCalls", "remainingCalls"),
|
|
456
|
+
("creditsPerCall", "creditsPerCall"),
|
|
457
|
+
("shortfallCredits", "shortfallCredits"),
|
|
458
|
+
):
|
|
459
|
+
if signals.get(source, UNDEFINED) is not UNDEFINED:
|
|
460
|
+
payload_customer[key] = signals[source]
|
|
461
|
+
payload_customer["isCreditBased"] = signals["isCreditBased"]
|
|
462
|
+
if get(limits_data, "autoRecharge") is not UNDEFINED:
|
|
463
|
+
payload_customer["autoRecharge"] = limits_data["autoRecharge"]
|
|
464
|
+
if get(limits_data, "withinLimits") is False:
|
|
465
|
+
payload_customer["nextAction"] = next_action_for(classify_paywall_state(limits_data))
|
|
466
|
+
|
|
467
|
+
portal_url = coalesce(get(_dump(portal), "customerUrl"), None)
|
|
468
|
+
return {
|
|
469
|
+
"view": view,
|
|
470
|
+
"productRef": product_ref,
|
|
471
|
+
"stripePublishableKey": stripe_key,
|
|
472
|
+
"returnUrl": public_base_url,
|
|
473
|
+
"merchant": merchant_data,
|
|
474
|
+
"product": product_data,
|
|
475
|
+
"plans": plan_list,
|
|
476
|
+
"customer": payload_customer,
|
|
477
|
+
"checkoutUrl": coalesce(get(checkout, "checkoutUrl"), None),
|
|
478
|
+
"portalUrl": portal_url,
|
|
479
|
+
"autoRechargeUrl": auto_recharge_url_from(portal_url),
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
__all__ = [
|
|
484
|
+
"ADVERTISED_VIEWS",
|
|
485
|
+
"ViewKind",
|
|
486
|
+
"auto_recharge_url_from",
|
|
487
|
+
"build_bootstrap_payload",
|
|
488
|
+
"derive_default_view",
|
|
489
|
+
"derive_usage_snapshot",
|
|
490
|
+
"enrich_purchase",
|
|
491
|
+
"is_plan_purchase",
|
|
492
|
+
"select_active_plan_purchase",
|
|
493
|
+
"signed_in_customer_ref",
|
|
494
|
+
]
|