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,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
+ ]