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
solvapay/mcp/narrate.py
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
"""Narration for the built-in ``account`` and ``activate_plan`` tools, ported from
|
|
2
|
+
``@solvapay/mcp-core``'s ``narrate.ts``.
|
|
3
|
+
|
|
4
|
+
Each narrator renders the bootstrap payload as sparse markdown a text-only
|
|
5
|
+
host can show as is: a bold title, ``Label: value`` rows, and the recovery as
|
|
6
|
+
a named tool call. The copy matches the TypeScript SDK byte for byte; the
|
|
7
|
+
conformance suite (``conformance/cases/narration``) checks it.
|
|
8
|
+
|
|
9
|
+
A row whose value is missing is left out, never written as ``Balance: —``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
from .._js import UNDEFINED, coalesce, get, num_str, number, to_locale_string, truthy
|
|
22
|
+
from ..errors import SolvaPayError
|
|
23
|
+
from ..money import credits_to_display_minor_units, format_minor
|
|
24
|
+
from ..paywall import credit_signals
|
|
25
|
+
from ..pricing import (
|
|
26
|
+
billing_cycle,
|
|
27
|
+
charges,
|
|
28
|
+
counts_usage,
|
|
29
|
+
credits_per_unit_from_balance,
|
|
30
|
+
headline_charges,
|
|
31
|
+
included_units,
|
|
32
|
+
meter_name,
|
|
33
|
+
plan_pricing_shape,
|
|
34
|
+
tier_bands,
|
|
35
|
+
tier_meters,
|
|
36
|
+
trial_days,
|
|
37
|
+
usage_rate,
|
|
38
|
+
)
|
|
39
|
+
from .bootstrap import parse_date, select_active_plan_purchase
|
|
40
|
+
|
|
41
|
+
Json = dict[str, Any]
|
|
42
|
+
ViewKind = Literal["checkout", "account", "topup", "auto-recharge"]
|
|
43
|
+
|
|
44
|
+
VIEWER_TOOL_NAME = "account"
|
|
45
|
+
ACTIVATE_PLAN_TOOL_NAME = "activate_plan"
|
|
46
|
+
CHECKOUT_TTL = "expires in 15 minutes"
|
|
47
|
+
MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class NarratorOutput:
|
|
52
|
+
text: str
|
|
53
|
+
links: list[Json] = field(default_factory=list)
|
|
54
|
+
"""``{"uri", "name"}`` for each ``resource_link`` block that follows the text."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---- formatting -------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _str(value: Any) -> str | None:
|
|
61
|
+
return value if isinstance(value, str) else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def format_money(amount_minor: Any, currency: Any) -> str | None:
|
|
65
|
+
if amount_minor is None or amount_minor is UNDEFINED or not truthy(currency):
|
|
66
|
+
return None
|
|
67
|
+
return format_minor(number(amount_minor), str(currency))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_compact_money(amount_minor: Any, currency: Any) -> str | None:
|
|
71
|
+
money = format_money(amount_minor, currency)
|
|
72
|
+
return re.sub(r"\.00(?!\d)", "", money, count=1) if money else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_count(value: Any) -> str:
|
|
76
|
+
return to_locale_string(number(value))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_short_date(iso: Any) -> str | None:
|
|
83
|
+
"""``toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })``."""
|
|
84
|
+
date = parse_date(iso)
|
|
85
|
+
if date is None:
|
|
86
|
+
return None
|
|
87
|
+
utc = date.astimezone(timezone.utc)
|
|
88
|
+
return f"{_MONTHS[utc.month - 1]} {utc.day}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _epoch_ms(date: datetime) -> int:
|
|
92
|
+
"""``date.getTime()``: whole milliseconds, so the arithmetic below is exact."""
|
|
93
|
+
return (date - datetime(1970, 1, 1, tzinfo=timezone.utc)) // timedelta(milliseconds=1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def days_until(iso: str, now: datetime) -> int | None:
|
|
97
|
+
end = parse_date(iso)
|
|
98
|
+
if end is None:
|
|
99
|
+
return None
|
|
100
|
+
millis = _epoch_ms(end) - _epoch_ms(now)
|
|
101
|
+
return max(0, -(-millis // MS_PER_DAY))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def meter_unit(meter: Any, count: Any) -> str:
|
|
105
|
+
if truthy(meter) and meter != "requests":
|
|
106
|
+
text = str(meter)
|
|
107
|
+
return _singular(text) if count == 1 else text
|
|
108
|
+
return "call" if count == 1 else "calls"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _singular(word: str) -> str:
|
|
112
|
+
"""One unit of a plural meter name: ``searches`` → ``search``, ``queries`` → ``query``."""
|
|
113
|
+
if re.search(r"[^aeiou]ies$", word, re.IGNORECASE):
|
|
114
|
+
return f"{word[:-3]}y"
|
|
115
|
+
if re.search(r"(ch|sh|ss|x|z)es$", word, re.IGNORECASE):
|
|
116
|
+
return word[:-2]
|
|
117
|
+
if re.search(r"[^s]s$", word, re.IGNORECASE):
|
|
118
|
+
return word[:-1]
|
|
119
|
+
return word
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _positive(value: Any) -> bool:
|
|
123
|
+
return truthy(value) and number(value) > 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---- payload readers --------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _customer(data: Json) -> Any:
|
|
130
|
+
return coalesce(get(data, "customer"), None)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _plans(data: Json) -> list[Json]:
|
|
134
|
+
plans = coalesce(get(data, "plans"), [])
|
|
135
|
+
return [dict(plan) for plan in plans if isinstance(plan, Mapping)]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _active_purchase(data: Json) -> Json | None:
|
|
139
|
+
purchases = get(get(_customer(data), "purchase"), "purchases")
|
|
140
|
+
return select_active_plan_purchase(
|
|
141
|
+
purchases if isinstance(purchases, list) else None, _str(get(data, "productRef"))
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _credits_unused(customer: Any) -> bool:
|
|
146
|
+
limits = get(customer, "limits")
|
|
147
|
+
return not credit_signals(dict(limits) if isinstance(limits, Mapping) else None)[
|
|
148
|
+
"isCreditBased"
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _prefer_limits_plan(purchase: Json | None, limits: Any) -> bool:
|
|
153
|
+
limits_ref = get(limits, "planRef")
|
|
154
|
+
if not truthy(limits_ref):
|
|
155
|
+
return False
|
|
156
|
+
return bool(
|
|
157
|
+
limits_ref
|
|
158
|
+
!= coalesce(get(purchase, "planRef"), get(get(purchase, "planSnapshot"), "reference"))
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def product_name(data: Json) -> str:
|
|
163
|
+
name = get(get(data, "product"), "name")
|
|
164
|
+
return name if isinstance(name, str) and name else "SolvaPay"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def balance_summary(customer: Any) -> str | None:
|
|
168
|
+
row = _balance_row(customer)
|
|
169
|
+
return re.sub(r"^Balance:\s*", "", row) if row else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _identity_row(customer: Any) -> str | None:
|
|
173
|
+
ref = get(customer, "ref")
|
|
174
|
+
ref = ref.strip() if isinstance(ref, str) else ""
|
|
175
|
+
if not ref:
|
|
176
|
+
return None
|
|
177
|
+
email = get(customer, "email")
|
|
178
|
+
email = email.strip() if isinstance(email, str) else ""
|
|
179
|
+
return f"Signed in as: {email} · {ref}" if email else f"Signed in as: {ref}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _balance_row(customer: Any) -> str | None:
|
|
183
|
+
balance = get(customer, "balance")
|
|
184
|
+
if not truthy(balance):
|
|
185
|
+
return None
|
|
186
|
+
credits = coalesce(get(balance, "credits"), 0)
|
|
187
|
+
currency = get(balance, "displayCurrency")
|
|
188
|
+
per_minor = get(balance, "creditsPerMinorUnit")
|
|
189
|
+
display_minor = None
|
|
190
|
+
if (
|
|
191
|
+
truthy(currency)
|
|
192
|
+
and isinstance(per_minor, (int, float))
|
|
193
|
+
and not isinstance(per_minor, bool)
|
|
194
|
+
and per_minor > 0
|
|
195
|
+
):
|
|
196
|
+
display_minor = credits_to_display_minor_units(
|
|
197
|
+
credits=number(credits),
|
|
198
|
+
credits_per_minor_unit=per_minor,
|
|
199
|
+
display_exchange_rate=number(coalesce(get(balance, "displayExchangeRate"), 1)),
|
|
200
|
+
display_currency=str(currency),
|
|
201
|
+
)
|
|
202
|
+
money = format_money(display_minor, coalesce(currency, None))
|
|
203
|
+
fmt = format_count(credits)
|
|
204
|
+
return f"Balance: {fmt} credits (~{money})" if money else f"Balance: {fmt} credits"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _recovery_line(views: list[str]) -> str:
|
|
208
|
+
calls = " or ".join(f'`{VIEWER_TOOL_NAME}` with view: "{view}"' for view in views)
|
|
209
|
+
return f"To continue, call {calls}."
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---- plans ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _format_plan_prices(plan: Any) -> str:
|
|
216
|
+
shape = plan_pricing_shape(plan)
|
|
217
|
+
headline = headline_charges(plan)
|
|
218
|
+
if headline:
|
|
219
|
+
prices = [format_money(c["amountMinor"], c["currency"]) for c in headline]
|
|
220
|
+
return " · ".join(p for p in prices if p is not None)
|
|
221
|
+
rate = shape["rate"] or usage_rate(plan)
|
|
222
|
+
if rate and rate["amountMinor"] > 0:
|
|
223
|
+
money = format_money(rate["amountMinor"], rate["currency"])
|
|
224
|
+
if money is not None:
|
|
225
|
+
unit = coalesce(rate.get("meter", UNDEFINED), meter_name(plan), "unit")
|
|
226
|
+
return f"{'from ' if rate['tiered'] else ''}{money} / {unit}"
|
|
227
|
+
money = format_money(get(plan, "price"), get(plan, "currency"))
|
|
228
|
+
return money if money is not None else ""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_free_plan(plan: Any) -> bool:
|
|
232
|
+
return get(plan, "requiresPayment") is False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _plan_type_label(plan: Any) -> str:
|
|
236
|
+
if _is_free_plan(plan):
|
|
237
|
+
return "no payment required"
|
|
238
|
+
shape = plan_pricing_shape(plan)["shape"]
|
|
239
|
+
return {
|
|
240
|
+
"usage": "pay as you go",
|
|
241
|
+
"hybrid": "subscription + usage",
|
|
242
|
+
"oneTime": "one-time",
|
|
243
|
+
}.get(shape, "recurring")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _format_cycle(plan: Any) -> str:
|
|
247
|
+
cycle = plan_pricing_shape(plan)["cycle"] or billing_cycle(plan)
|
|
248
|
+
if not cycle:
|
|
249
|
+
return ""
|
|
250
|
+
count = cycle.get("count")
|
|
251
|
+
return f"/{num_str(count)} {cycle['interval']}s" if count else f"/{cycle['interval']}"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _plans_list_lines(plans: list[Json]) -> list[str]:
|
|
255
|
+
lines = []
|
|
256
|
+
for plan in plans:
|
|
257
|
+
parts = [str(coalesce(get(plan, "name"), "Plan")), _plan_type_label(plan)]
|
|
258
|
+
price = _format_plan_prices(plan)
|
|
259
|
+
if price and not _is_free_plan(plan):
|
|
260
|
+
parts.append(f"{price}{_format_cycle(plan)}")
|
|
261
|
+
trial = trial_days(plan)
|
|
262
|
+
if truthy(trial):
|
|
263
|
+
parts.append(f"{num_str(trial)}-day trial")
|
|
264
|
+
if truthy(get(plan, "reference")):
|
|
265
|
+
parts.append(f"planRef: {plan['reference']}")
|
|
266
|
+
lines.append(" · ".join(parts))
|
|
267
|
+
return lines
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---- links ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _https_url(value: Any) -> str | None:
|
|
274
|
+
if isinstance(value, str) and re.match(r"^https?://", value, re.IGNORECASE):
|
|
275
|
+
return value
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _checkout_url(data: Json) -> str | None:
|
|
280
|
+
return _https_url(get(data, "checkoutUrl"))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _checkout_link_label(url: str) -> str:
|
|
284
|
+
return "Add credits" if "/checkout/topup" in url else "Open checkout"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _named_checkout_markdown(url: str) -> str:
|
|
288
|
+
return f"[{_checkout_link_label(url)}]({url})"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _checkout_row(data: Json) -> str | None:
|
|
292
|
+
url = _checkout_url(data)
|
|
293
|
+
return f"Checkout: {_named_checkout_markdown(url)} ({CHECKOUT_TTL})" if url else None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _named_manage_markdown(url: str) -> str:
|
|
297
|
+
return f"[Manage account]({url})"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _manage_row(data: Json) -> str | None:
|
|
301
|
+
url = _https_url(get(data, "portalUrl"))
|
|
302
|
+
return f"Manage: {_named_manage_markdown(url)} ({CHECKOUT_TTL})" if url else None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _checkout_link(data: Json) -> Json | None:
|
|
306
|
+
url = _checkout_url(data)
|
|
307
|
+
return {"uri": url, "name": _checkout_link_label(url)} if url else None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _hosted_portal_link(data: Json) -> Json | None:
|
|
311
|
+
url = _https_url(get(data, "portalUrl"))
|
|
312
|
+
return {"uri": url, "name": "Manage account"} if url else None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---- account state ----------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _is_paid_plan(plan: Any) -> bool:
|
|
319
|
+
if get(plan, "requiresPayment") is False:
|
|
320
|
+
return False
|
|
321
|
+
if any(charge["amountMinor"] > 0 for charge in charges(plan)):
|
|
322
|
+
return True
|
|
323
|
+
# A plan priced only in tier bands has no flat or per-unit charge.
|
|
324
|
+
if any(
|
|
325
|
+
tier["charge"]["amountMinor"] > 0
|
|
326
|
+
for meter in tier_meters(plan)
|
|
327
|
+
for tier in tier_bands(plan, meter)
|
|
328
|
+
):
|
|
329
|
+
return True
|
|
330
|
+
return number(coalesce(get(plan, "price"), 0)) > 0
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _has_readable_options(plan: Any) -> bool:
|
|
334
|
+
options = get(plan, "options")
|
|
335
|
+
return isinstance(options, list) and len(options) > 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _plan_shape(plan: Any) -> str | None:
|
|
339
|
+
"""free, trial, usage-based, recurring-unlimited, recurring-metered or paid-unknown."""
|
|
340
|
+
if not truthy(plan):
|
|
341
|
+
return None
|
|
342
|
+
if number(coalesce(trial_days(plan), 0)) > 0:
|
|
343
|
+
return "trial"
|
|
344
|
+
if not _is_paid_plan(plan):
|
|
345
|
+
return "free"
|
|
346
|
+
metered = counts_usage(plan)
|
|
347
|
+
unlimited_cap = included_units(plan) == 0
|
|
348
|
+
readable = _has_readable_options(plan)
|
|
349
|
+
if billing_cycle(plan):
|
|
350
|
+
if metered and not unlimited_cap:
|
|
351
|
+
return "recurring-metered"
|
|
352
|
+
if unlimited_cap or (readable and not metered):
|
|
353
|
+
return "recurring-unlimited"
|
|
354
|
+
return "paid-unknown"
|
|
355
|
+
if metered and not unlimited_cap:
|
|
356
|
+
return "usage-based"
|
|
357
|
+
if unlimited_cap or (readable and not metered):
|
|
358
|
+
return "recurring-unlimited"
|
|
359
|
+
return "paid-unknown"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _merge_plan(snapshot: Any, catalog: Any) -> Json | None:
|
|
363
|
+
if not truthy(snapshot) and not truthy(catalog):
|
|
364
|
+
return None
|
|
365
|
+
if not truthy(catalog):
|
|
366
|
+
return dict(snapshot) if truthy(snapshot) else None
|
|
367
|
+
if not truthy(snapshot):
|
|
368
|
+
return dict(catalog)
|
|
369
|
+
snapshot_options = get(snapshot, "options")
|
|
370
|
+
has_options = isinstance(snapshot_options, list) and len(snapshot_options) > 0
|
|
371
|
+
merged: Json = {**catalog, **snapshot}
|
|
372
|
+
merged["options"] = snapshot_options if has_options else get(catalog, "options")
|
|
373
|
+
merged["requiresPayment"] = coalesce(
|
|
374
|
+
get(snapshot, "requiresPayment"), get(catalog, "requiresPayment")
|
|
375
|
+
)
|
|
376
|
+
merged["isMetered"] = get(snapshot, "isMetered")
|
|
377
|
+
return {key: value for key, value in merged.items() if value is not UNDEFINED}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _find_catalog_plan(plans: list[Json], snapshot: Any, plan_ref: Any) -> Json | None:
|
|
381
|
+
ref = coalesce(get(snapshot, "reference"), plan_ref)
|
|
382
|
+
if not truthy(ref):
|
|
383
|
+
return None
|
|
384
|
+
return next((plan for plan in plans if get(plan, "reference") == ref), None)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _is_at_finite_cap(limits: Any) -> bool:
|
|
388
|
+
if not truthy(limits) or get(limits, "withinLimits") is not False:
|
|
389
|
+
return False
|
|
390
|
+
remaining = get(limits, "remaining")
|
|
391
|
+
if remaining is None or remaining is UNDEFINED or remaining == -1:
|
|
392
|
+
return False
|
|
393
|
+
return number(remaining) <= 0
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _is_cancelled_not_expired(purchase: Any, now: datetime) -> bool:
|
|
397
|
+
if not truthy(get(purchase, "cancelledAt")) or not truthy(get(purchase, "endDate")):
|
|
398
|
+
return False
|
|
399
|
+
end = parse_date(purchase["endDate"])
|
|
400
|
+
return end is not None and end > now
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _account_state(purchase: Json | None, limits: Any, plan: Any, now: datetime) -> str:
|
|
404
|
+
if truthy(get(limits, "activationRequired")):
|
|
405
|
+
return "H"
|
|
406
|
+
if _is_cancelled_not_expired(purchase, now):
|
|
407
|
+
return "J"
|
|
408
|
+
if truthy(get(limits, "overage")):
|
|
409
|
+
return "I"
|
|
410
|
+
if truthy(get(limits, "needsTopUp")):
|
|
411
|
+
return "D"
|
|
412
|
+
shape = _plan_shape(plan)
|
|
413
|
+
if purchase is None and shape is None:
|
|
414
|
+
return "A"
|
|
415
|
+
at_cap = _is_at_finite_cap(limits)
|
|
416
|
+
if shape == "usage-based":
|
|
417
|
+
return "D" if at_cap or get(limits, "withinLimits") is False else "B"
|
|
418
|
+
if at_cap:
|
|
419
|
+
return "F"
|
|
420
|
+
if shape in ("free", "trial"):
|
|
421
|
+
return "E"
|
|
422
|
+
if shape in ("recurring-metered", "recurring-unlimited"):
|
|
423
|
+
return "C"
|
|
424
|
+
return "C" if purchase is not None else "A"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _cadence_phrase(plan: Any) -> str | None:
|
|
428
|
+
"""How often a plan bills, for a sentence with no price: ``every year``, ``every 3 months``."""
|
|
429
|
+
cycle = billing_cycle(plan)
|
|
430
|
+
if not cycle:
|
|
431
|
+
return None
|
|
432
|
+
count = cycle.get("count", 1)
|
|
433
|
+
return f"every {cycle['interval']}" if count == 1 else f"every {count} {cycle['interval']}s"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _interval_phrase(plan: Any) -> str | None:
|
|
437
|
+
cycle = billing_cycle(plan)
|
|
438
|
+
if not cycle:
|
|
439
|
+
return None
|
|
440
|
+
count = cycle.get("count", 1)
|
|
441
|
+
if count == 1:
|
|
442
|
+
return f"a {cycle['interval']}"
|
|
443
|
+
return f"every {num_str(count)} {cycle['interval']}s"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _plan_price_phrase(plan: Any) -> str | None:
|
|
447
|
+
headline = headline_charges(plan)
|
|
448
|
+
if headline:
|
|
449
|
+
prices = [format_compact_money(c["amountMinor"], c["currency"]) for c in headline]
|
|
450
|
+
present = [p for p in prices if p is not None]
|
|
451
|
+
if present:
|
|
452
|
+
return " · ".join(present)
|
|
453
|
+
return format_compact_money(get(plan, "price"), get(plan, "currency"))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _plan_price_bit(plan: Any) -> str:
|
|
457
|
+
price = _plan_price_phrase(plan)
|
|
458
|
+
cycle = _interval_phrase(plan)
|
|
459
|
+
if not billing_cycle(plan):
|
|
460
|
+
return f", {price} once" if price else ""
|
|
461
|
+
if price and cycle:
|
|
462
|
+
return f", {price} {cycle}"
|
|
463
|
+
return f", {price}" if price else ""
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _credits_rate_phrase(plan: Any, customer: Any) -> str | None:
|
|
467
|
+
rate = usage_rate(plan)
|
|
468
|
+
if not rate or rate["amountMinor"] <= 0:
|
|
469
|
+
return None
|
|
470
|
+
floor = "from " if rate["tiered"] else ""
|
|
471
|
+
credits = credits_per_unit_from_balance(plan, get(customer, "balance"))
|
|
472
|
+
if credits is None:
|
|
473
|
+
money = format_compact_money(rate["amountMinor"], rate["currency"])
|
|
474
|
+
if not money:
|
|
475
|
+
return None
|
|
476
|
+
unit = meter_unit(coalesce(rate.get("meter", UNDEFINED), meter_name(plan)), 1)
|
|
477
|
+
return f"{floor}{money} per {unit}"
|
|
478
|
+
return f"{floor}{format_count(credits)} credits per call"
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _catalog_fragment(plan: Json, customer: Any) -> str:
|
|
482
|
+
name = coalesce(get(plan, "name"), "Plan")
|
|
483
|
+
cap = included_units(plan)
|
|
484
|
+
cycle = _interval_phrase(plan)
|
|
485
|
+
price = _plan_price_phrase(plan)
|
|
486
|
+
rate = _credits_rate_phrase(plan, customer)
|
|
487
|
+
shape = _plan_shape(plan)
|
|
488
|
+
trial = trial_days(plan)
|
|
489
|
+
cycle_bit = f" {cycle}" if cycle else ""
|
|
490
|
+
|
|
491
|
+
if shape == "free" or (not _is_paid_plan(plan) and shape != "trial"):
|
|
492
|
+
core = (
|
|
493
|
+
f"{name} gives {format_count(cap)} {meter_unit(meter_name(plan), cap)}{cycle_bit}"
|
|
494
|
+
if _positive(cap)
|
|
495
|
+
else f"{name} requires no payment"
|
|
496
|
+
)
|
|
497
|
+
elif shape == "usage-based":
|
|
498
|
+
core = f"{name} is {rate}" if rate else f"{name} is pay as you go"
|
|
499
|
+
elif not billing_cycle(plan):
|
|
500
|
+
allowance = (
|
|
501
|
+
" for unlimited"
|
|
502
|
+
if cap == 0 or cap is None
|
|
503
|
+
else f" for {format_count(cap)} {meter_unit(meter_name(plan), cap)}"
|
|
504
|
+
)
|
|
505
|
+
core = f"{name} is {price} once{allowance}" if price else f"{name} is one-time"
|
|
506
|
+
else:
|
|
507
|
+
if cap == 0:
|
|
508
|
+
allowance = " for unlimited"
|
|
509
|
+
elif _positive(cap):
|
|
510
|
+
allowance = f" for {format_count(cap)} {meter_unit(meter_name(plan), cap)}"
|
|
511
|
+
else:
|
|
512
|
+
allowance = ""
|
|
513
|
+
if price:
|
|
514
|
+
core = f"{name} is {price}{cycle_bit}{allowance}"
|
|
515
|
+
else:
|
|
516
|
+
core = (
|
|
517
|
+
f"{name} is billed {_cadence_phrase(plan) or cycle}{allowance}"
|
|
518
|
+
if cycle
|
|
519
|
+
else str(name)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
if truthy(trial):
|
|
523
|
+
core += f" · {num_str(trial)}-day trial"
|
|
524
|
+
if truthy(get(plan, "reference")):
|
|
525
|
+
core += f" · planRef: {plan['reference']}"
|
|
526
|
+
return core
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _carry_on_fragment(plan: Json, customer: Any) -> str:
|
|
530
|
+
name = coalesce(get(plan, "name"), "Plan")
|
|
531
|
+
if _plan_shape(plan) == "usage-based":
|
|
532
|
+
credits = get(get(customer, "balance"), "credits")
|
|
533
|
+
core = (
|
|
534
|
+
f"{name} starts now using your existing {format_count(credits)} credits"
|
|
535
|
+
if _positive(credits)
|
|
536
|
+
else f"{name} starts now"
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
price = _plan_price_phrase(plan)
|
|
540
|
+
cycle = _interval_phrase(plan)
|
|
541
|
+
if not billing_cycle(plan):
|
|
542
|
+
core = f"{name} is {price} once" if price else str(name)
|
|
543
|
+
else:
|
|
544
|
+
core = f"{name} is {price} {cycle}" if price and cycle else str(name)
|
|
545
|
+
return f"{core} · planRef: {plan['reference']}" if truthy(get(plan, "reference")) else core
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _join_or(parts: list[str]) -> str:
|
|
549
|
+
if len(parts) <= 1:
|
|
550
|
+
return parts[0] if parts else ""
|
|
551
|
+
if len(parts) == 2:
|
|
552
|
+
return f"{parts[0]}, or {parts[1]}"
|
|
553
|
+
return f"{', '.join(parts[:-1])}, or {parts[-1]}"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _remaining_of_total(usage: Any) -> str | None:
|
|
557
|
+
remaining, total = get(usage, "remaining"), get(usage, "total")
|
|
558
|
+
if remaining is None or remaining is UNDEFINED or total is None or total is UNDEFINED:
|
|
559
|
+
return None
|
|
560
|
+
if number(remaining) < 0:
|
|
561
|
+
return None
|
|
562
|
+
noun = meter_unit(get(usage, "meterRef"), total)
|
|
563
|
+
return f"{format_count(remaining)} of {format_count(total)} {noun}"
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _used_of_total(usage: Any) -> str | None:
|
|
567
|
+
used, total = get(usage, "used"), get(usage, "total")
|
|
568
|
+
if used is None or used is UNDEFINED or total is None or total is UNDEFINED:
|
|
569
|
+
return None
|
|
570
|
+
noun = meter_unit(get(usage, "meterRef"), total)
|
|
571
|
+
return f"{format_count(used)} of {format_count(total)} {noun}"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _claimable_free_plan(plans: list[Json]) -> Json | None:
|
|
575
|
+
for plan in plans:
|
|
576
|
+
if get(plan, "requiresPayment") is not False:
|
|
577
|
+
continue
|
|
578
|
+
cap = included_units(plan)
|
|
579
|
+
if cap is not None and cap > 0:
|
|
580
|
+
return plan
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _activate_recovery(plan_ref: Any) -> str:
|
|
585
|
+
return f'To continue, call `{ACTIVATE_PLAN_TOOL_NAME}` with planRef: "{plan_ref}".'
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _recovery_for_state(state: str, free_plan_ref: Any) -> str:
|
|
589
|
+
if state == "B":
|
|
590
|
+
return _recovery_line(["topup"])
|
|
591
|
+
if state == "D":
|
|
592
|
+
return _recovery_line(["topup", "checkout"])
|
|
593
|
+
if state == "H":
|
|
594
|
+
return (
|
|
595
|
+
_activate_recovery(free_plan_ref)
|
|
596
|
+
if truthy(free_plan_ref)
|
|
597
|
+
else _recovery_line(["checkout"])
|
|
598
|
+
)
|
|
599
|
+
if state == "J":
|
|
600
|
+
return _recovery_line(["account"])
|
|
601
|
+
return _recovery_line(["checkout"])
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _account_body(
|
|
605
|
+
state: str,
|
|
606
|
+
product: str,
|
|
607
|
+
plan: Any,
|
|
608
|
+
plan_shape: str | None,
|
|
609
|
+
purchase: Json | None,
|
|
610
|
+
customer: Any,
|
|
611
|
+
plans: list[Json],
|
|
612
|
+
now: datetime,
|
|
613
|
+
) -> str:
|
|
614
|
+
plan_name = coalesce(get(plan, "name"), "plan")
|
|
615
|
+
usage = coalesce(get(customer, "usage"), None)
|
|
616
|
+
credits = coalesce(get(get(customer, "balance"), "credits"), 0)
|
|
617
|
+
view = f"Call `{VIEWER_TOOL_NAME}` with view:"
|
|
618
|
+
|
|
619
|
+
if state == "A":
|
|
620
|
+
fragments = [_catalog_fragment(item, customer) for item in plans]
|
|
621
|
+
catalog = f" {', '.join(fragments)}." if fragments else ""
|
|
622
|
+
return f"{product} has no plan yet.{catalog} Reply with a plan name to activate it."
|
|
623
|
+
|
|
624
|
+
if state == "H":
|
|
625
|
+
free = _claimable_free_plan(plans)
|
|
626
|
+
cap = included_units(free) if free else None
|
|
627
|
+
cycle = _interval_phrase(free)
|
|
628
|
+
unit = meter_unit(meter_name(free), cap if _positive(cap) else 2)
|
|
629
|
+
ready = (
|
|
630
|
+
f"{format_count(cap)} {unit}{f' {cycle}' if cycle else ''}, no card"
|
|
631
|
+
if _positive(cap)
|
|
632
|
+
else "no card"
|
|
633
|
+
)
|
|
634
|
+
return (
|
|
635
|
+
f"{product} has a free plan ready: {ready}. "
|
|
636
|
+
f"Call `{ACTIVATE_PLAN_TOOL_NAME}` with a `planRef` to activate it."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
if state == "B":
|
|
640
|
+
rate = _credits_rate_phrase(plan, customer)
|
|
641
|
+
per_call = credits_per_unit_from_balance(plan, get(customer, "balance")) if plan else None
|
|
642
|
+
runway = (
|
|
643
|
+
f", about {format_count(math.floor(number(credits) / number(per_call)))} calls"
|
|
644
|
+
if _positive(per_call)
|
|
645
|
+
else ""
|
|
646
|
+
)
|
|
647
|
+
rate_bit = f", {rate}" if rate else ""
|
|
648
|
+
return (
|
|
649
|
+
f"{product} is on {plan_name}{rate_bit}. "
|
|
650
|
+
f"Balance {format_count(credits)} credits{runway}. "
|
|
651
|
+
f"{view} 'topup' to add credits."
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if state == "D":
|
|
655
|
+
per_call = credits_per_unit_from_balance(plan, get(customer, "balance")) if plan else None
|
|
656
|
+
if _positive(per_call):
|
|
657
|
+
shortfall = max(0, number(per_call) - number(credits))
|
|
658
|
+
cost_bit = (
|
|
659
|
+
f"; this call costs {format_count(per_call)} credits"
|
|
660
|
+
f" — {format_count(shortfall)} short"
|
|
661
|
+
)
|
|
662
|
+
else:
|
|
663
|
+
cost_bit = f" and {plan_name} needs credits"
|
|
664
|
+
return (
|
|
665
|
+
f"{product} is on {plan_name}. Balance {format_count(credits)} credits{cost_bit}. "
|
|
666
|
+
f"{view} 'topup' to add credits, or with view: 'checkout' to switch to a plan "
|
|
667
|
+
"that does not use credits."
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if state == "C":
|
|
671
|
+
price_bit = _plan_price_bit(plan)
|
|
672
|
+
one_time = not billing_cycle(plan)
|
|
673
|
+
left = _remaining_of_total(usage)
|
|
674
|
+
date = format_short_date(coalesce(get(usage, "periodEnd"), get(purchase, "endDate")))
|
|
675
|
+
renewing = f", renewing {date}" if date else ""
|
|
676
|
+
unlimited = (
|
|
677
|
+
get(usage, "remaining") == -1
|
|
678
|
+
or included_units(plan) == 0
|
|
679
|
+
or plan_shape == "recurring-unlimited"
|
|
680
|
+
)
|
|
681
|
+
if left:
|
|
682
|
+
position = f"{left} left" if one_time else f"{left} left this period{renewing}"
|
|
683
|
+
elif unlimited:
|
|
684
|
+
position = "Unlimited calls" if one_time else f"Unlimited calls{renewing}"
|
|
685
|
+
elif date and not one_time:
|
|
686
|
+
position = f"Renews {date}"
|
|
687
|
+
else:
|
|
688
|
+
position = "After your first call"
|
|
689
|
+
tail = (
|
|
690
|
+
f"Credits are not used on this plan. {view} 'checkout' to switch."
|
|
691
|
+
if _credits_unused(customer)
|
|
692
|
+
else f"{view} 'checkout' to switch."
|
|
693
|
+
)
|
|
694
|
+
return f"{product} is on {plan_name}{price_bit}. {position}. {tail}"
|
|
695
|
+
|
|
696
|
+
if state == "E":
|
|
697
|
+
left = _remaining_of_total(usage)
|
|
698
|
+
date = format_short_date(get(usage, "periodEnd"))
|
|
699
|
+
period = billing_cycle(plan)
|
|
700
|
+
interval = period["interval"] if period else "period"
|
|
701
|
+
if left:
|
|
702
|
+
left_bit = f"{left} left this {interval}{f', resetting {date}' if date else ''}"
|
|
703
|
+
else:
|
|
704
|
+
left_bit = f"resets {date}" if date else "After your first call"
|
|
705
|
+
return (
|
|
706
|
+
f"{product} is on the free plan: {left_bit}. "
|
|
707
|
+
f"Credits are not used on {plan_name}. {view} 'checkout' for more calls."
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
if state == "F":
|
|
711
|
+
cap = coalesce(get(usage, "total"), included_units(plan))
|
|
712
|
+
unit = meter_unit(
|
|
713
|
+
coalesce(get(usage, "meterRef"), meter_name(plan)), cap if _positive(cap) else 2
|
|
714
|
+
)
|
|
715
|
+
date = format_short_date(get(usage, "periodEnd"))
|
|
716
|
+
cap_bit = (
|
|
717
|
+
f"{format_count(cap)} {unit} are used up" if _positive(cap) else "allowance is used up"
|
|
718
|
+
)
|
|
719
|
+
consequence = (
|
|
720
|
+
f" Further calls fail until {date}"
|
|
721
|
+
if date
|
|
722
|
+
else " Further calls fail until the allowance resets"
|
|
723
|
+
)
|
|
724
|
+
current = coalesce(get(plan, "reference"), get(purchase, "planRef"))
|
|
725
|
+
others = [
|
|
726
|
+
item
|
|
727
|
+
for item in plans
|
|
728
|
+
if not (truthy(get(item, "reference")) and get(item, "reference") == current)
|
|
729
|
+
and _plan_shape(item) not in ("free", "trial")
|
|
730
|
+
]
|
|
731
|
+
carry_on = (
|
|
732
|
+
f" {_join_or([_carry_on_fragment(item, customer) for item in others])}."
|
|
733
|
+
if others
|
|
734
|
+
else ""
|
|
735
|
+
)
|
|
736
|
+
anti_trap = (
|
|
737
|
+
f" Adding credits will not help, because {plan_name} does not spend them."
|
|
738
|
+
if _credits_unused(customer)
|
|
739
|
+
else ""
|
|
740
|
+
)
|
|
741
|
+
price_bit = _plan_price_bit(plan)
|
|
742
|
+
return (
|
|
743
|
+
f"{product} is on {plan_name}{price_bit}. {cap_bit}.{consequence}.{anti_trap}"
|
|
744
|
+
f"{carry_on} {view} 'checkout' to switch plan."
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if state == "I":
|
|
748
|
+
used = _used_of_total(usage)
|
|
749
|
+
used_bit = f": {used} used" if used else ""
|
|
750
|
+
return (
|
|
751
|
+
f"{product} is over its {plan_name} allowance{used_bit}. "
|
|
752
|
+
"Calls still work, paid from your credits. "
|
|
753
|
+
f"{view} 'checkout' for a higher limit."
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
end_date = get(purchase, "endDate")
|
|
757
|
+
date = format_short_date(end_date)
|
|
758
|
+
days = days_until(end_date, now) if truthy(end_date) else None
|
|
759
|
+
left = _remaining_of_total(usage)
|
|
760
|
+
until = f"runs until {date}" if date else "is cancelled"
|
|
761
|
+
days_bit = f", {days} days away" if days is not None else ""
|
|
762
|
+
left_bit = f", with {left} left" if left else ""
|
|
763
|
+
return (
|
|
764
|
+
f"{product}'s {plan_name} plan is cancelled and {until}{days_bit}{left_bit}. "
|
|
765
|
+
f"Calls stop after that. {view} 'account' to reactivate it."
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ---- narrators --------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
ActivateStatus = Literal[
|
|
773
|
+
"activated",
|
|
774
|
+
"already_active",
|
|
775
|
+
"already_purchased",
|
|
776
|
+
"topup_required",
|
|
777
|
+
"payment_required",
|
|
778
|
+
"invalid",
|
|
779
|
+
]
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def narrate_activate_plan(result: Mapping[str, Any]) -> str:
|
|
783
|
+
"""The text ``activate_plan`` answers with, for one activation result."""
|
|
784
|
+
plan_name = get(result, "planName")
|
|
785
|
+
named = str(plan_name) if truthy(plan_name) else "This plan"
|
|
786
|
+
url = get(result, "checkoutUrl")
|
|
787
|
+
checkout = (
|
|
788
|
+
f" [{'Add credits' if '/checkout/topup' in url else 'Open checkout'}]({url})"
|
|
789
|
+
f" ({CHECKOUT_TTL}), or"
|
|
790
|
+
if isinstance(url, str) and url
|
|
791
|
+
else ""
|
|
792
|
+
)
|
|
793
|
+
status = get(result, "status")
|
|
794
|
+
view = f"`{VIEWER_TOOL_NAME}` with view:"
|
|
795
|
+
if status == "activated":
|
|
796
|
+
return (
|
|
797
|
+
f"Activated {plan_name}. Paid tools are available now."
|
|
798
|
+
if truthy(plan_name)
|
|
799
|
+
else "Plan activated. Paid tools are available now."
|
|
800
|
+
)
|
|
801
|
+
if status == "already_purchased":
|
|
802
|
+
return f"{named} is already purchased. Call {view} 'account' to manage it."
|
|
803
|
+
if status == "payment_required":
|
|
804
|
+
return f"{named} requires payment.{checkout} Call {view} 'checkout' to pay."
|
|
805
|
+
if status == "topup_required":
|
|
806
|
+
return (
|
|
807
|
+
f"{named} needs credits before it can activate.{checkout} "
|
|
808
|
+
f"Call {view} 'topup' to add credits."
|
|
809
|
+
)
|
|
810
|
+
if status == "invalid":
|
|
811
|
+
message = get(result, "message")
|
|
812
|
+
if message is not None and message is not UNDEFINED:
|
|
813
|
+
return str(message)
|
|
814
|
+
return f"That planRef is not valid for this product. Call {view} 'checkout' to see plans."
|
|
815
|
+
if status == "already_active":
|
|
816
|
+
balance, cost = get(result, "creditBalance"), get(result, "creditsPerUnit")
|
|
817
|
+
if balance is not UNDEFINED and cost is not UNDEFINED:
|
|
818
|
+
shortfall = max(0, number(cost) - number(balance))
|
|
819
|
+
if shortfall > 0:
|
|
820
|
+
return (
|
|
821
|
+
f"{named} is already active. Balance {format_count(balance)} credits; "
|
|
822
|
+
f"this call costs {format_count(cost)} credits — "
|
|
823
|
+
f"{format_count(shortfall)} short. "
|
|
824
|
+
f"Call the `{VIEWER_TOOL_NAME}` tool with view: 'topup' to add credits."
|
|
825
|
+
)
|
|
826
|
+
return f"{named} is already active."
|
|
827
|
+
raise SolvaPayError(f"Unknown activation status: {status!r}", code="invalid_argument")
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def narrate_manage_account(data: Json, now: datetime | None = None) -> NarratorOutput:
|
|
831
|
+
"""The ``account`` view: where the customer stands and what to do next."""
|
|
832
|
+
now = now or datetime.now(timezone.utc)
|
|
833
|
+
if now.tzinfo is None:
|
|
834
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
835
|
+
customer = _customer(data)
|
|
836
|
+
plans = _plans(data)
|
|
837
|
+
active = _active_purchase(data)
|
|
838
|
+
limits = get(customer, "limits")
|
|
839
|
+
use_limits = _prefer_limits_plan(active, limits)
|
|
840
|
+
if use_limits:
|
|
841
|
+
catalog = _find_catalog_plan(
|
|
842
|
+
plans, {"reference": get(limits, "planRef")}, get(limits, "planRef")
|
|
843
|
+
)
|
|
844
|
+
plan: Any = catalog or {
|
|
845
|
+
key: value
|
|
846
|
+
for key, value in (
|
|
847
|
+
("name", get(limits, "planName")),
|
|
848
|
+
("reference", get(limits, "planRef")),
|
|
849
|
+
)
|
|
850
|
+
if value is not None and value is not UNDEFINED
|
|
851
|
+
}
|
|
852
|
+
else:
|
|
853
|
+
catalog = _find_catalog_plan(plans, get(active, "planSnapshot"), get(active, "planRef"))
|
|
854
|
+
plan = _merge_plan(get(active, "planSnapshot"), catalog)
|
|
855
|
+
plan_shape = _plan_shape(plan)
|
|
856
|
+
state = _account_state(active, limits, plan, now)
|
|
857
|
+
name = product_name(data)
|
|
858
|
+
free = _claimable_free_plan(plans)
|
|
859
|
+
|
|
860
|
+
lines = [
|
|
861
|
+
f"**Welcome to {name}**" if state in ("A", "H") else f"**{name} — your account**",
|
|
862
|
+
"",
|
|
863
|
+
_account_body(state, name, plan, plan_shape, active, customer, plans, now),
|
|
864
|
+
]
|
|
865
|
+
identity = _identity_row(customer)
|
|
866
|
+
if identity:
|
|
867
|
+
lines.append(identity)
|
|
868
|
+
if state in ("A", "H"):
|
|
869
|
+
balance = _balance_row(customer)
|
|
870
|
+
if balance:
|
|
871
|
+
lines.append(balance)
|
|
872
|
+
manage = _manage_row(data)
|
|
873
|
+
if manage:
|
|
874
|
+
lines.append(manage)
|
|
875
|
+
checkout = _checkout_row(data)
|
|
876
|
+
if checkout:
|
|
877
|
+
lines.append(checkout)
|
|
878
|
+
lines.append("")
|
|
879
|
+
lines.append(_recovery_for_state(state, get(free, "reference")))
|
|
880
|
+
|
|
881
|
+
links = [link for link in (_hosted_portal_link(data), _checkout_link(data)) if link]
|
|
882
|
+
return NarratorOutput(text="\n".join(lines), links=links)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _with_checkout(data: Json, lines: list[str]) -> NarratorOutput:
|
|
886
|
+
checkout = _checkout_row(data)
|
|
887
|
+
if checkout:
|
|
888
|
+
lines.append(checkout)
|
|
889
|
+
link = _checkout_link(data)
|
|
890
|
+
return NarratorOutput(text="\n".join(lines), links=[link] if link else [])
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def narrate_upgrade(data: Json, now: datetime | None = None) -> NarratorOutput:
|
|
894
|
+
"""The ``checkout`` view: the plans on offer."""
|
|
895
|
+
lines = [f"**Upgrade — {product_name(data)}**", ""]
|
|
896
|
+
has_active = _active_purchase(data) is not None
|
|
897
|
+
plans = [plan for plan in _plans(data) if not has_active or not _is_free_plan(plan)]
|
|
898
|
+
if plans:
|
|
899
|
+
lines.append("Plans available:")
|
|
900
|
+
lines.extend(_plans_list_lines(plans))
|
|
901
|
+
else:
|
|
902
|
+
lines.append("No paid plans are configured on this product yet.")
|
|
903
|
+
lines.append("")
|
|
904
|
+
lines.append(_recovery_line(["account"]))
|
|
905
|
+
return _with_checkout(data, lines)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def narrate_topup(data: Json, now: datetime | None = None) -> NarratorOutput:
|
|
909
|
+
"""The ``topup`` view: the balance and where to add credits."""
|
|
910
|
+
lines = [f"**Top up — {product_name(data)}**", ""]
|
|
911
|
+
balance = _balance_row(_customer(data))
|
|
912
|
+
if balance:
|
|
913
|
+
lines.append(balance)
|
|
914
|
+
lines.append("")
|
|
915
|
+
lines.append(_recovery_line(["account"]))
|
|
916
|
+
return _with_checkout(data, lines)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def narrate_auto_recharge(data: Json, now: datetime | None = None) -> NarratorOutput:
|
|
920
|
+
"""The ``auto-recharge`` view: whether it is on, and the portal link to change it."""
|
|
921
|
+
lines = [f"**Auto-recharge — {product_name(data)}**", ""]
|
|
922
|
+
customer = _customer(data)
|
|
923
|
+
balance = _balance_row(customer)
|
|
924
|
+
if balance:
|
|
925
|
+
lines.append(balance)
|
|
926
|
+
auto = get(customer, "autoRecharge")
|
|
927
|
+
enabled = get(auto, "enabled") is True
|
|
928
|
+
if get(auto, "status") == "failed":
|
|
929
|
+
lines.append("Auto-recharge failed — update your card to resume")
|
|
930
|
+
else:
|
|
931
|
+
lines.append(
|
|
932
|
+
"Auto-recharge is on. It tops your balance up automatically so calls do not fail."
|
|
933
|
+
if enabled
|
|
934
|
+
else "Auto-recharge is off. Turn it on from the link below — it stores a card "
|
|
935
|
+
"and tops your balance up automatically so calls do not fail."
|
|
936
|
+
)
|
|
937
|
+
manage_url = _https_url(get(data, "autoRechargeUrl")) or _https_url(get(data, "portalUrl"))
|
|
938
|
+
if manage_url:
|
|
939
|
+
lines.append(f"Manage: {_named_manage_markdown(manage_url)} ({CHECKOUT_TTL})")
|
|
940
|
+
lines.append("")
|
|
941
|
+
lines.append(_recovery_line(["account"]))
|
|
942
|
+
links = []
|
|
943
|
+
if manage_url:
|
|
944
|
+
links.append(
|
|
945
|
+
{
|
|
946
|
+
"uri": manage_url,
|
|
947
|
+
"name": "Manage auto-recharge" if enabled else "Turn on auto-recharge",
|
|
948
|
+
}
|
|
949
|
+
)
|
|
950
|
+
return NarratorOutput(text="\n".join(lines), links=links)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
NARRATORS: dict[str, Callable[[Json, datetime | None], NarratorOutput]] = {
|
|
954
|
+
"checkout": narrate_upgrade,
|
|
955
|
+
"account": narrate_manage_account,
|
|
956
|
+
"topup": narrate_topup,
|
|
957
|
+
"auto-recharge": narrate_auto_recharge,
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
_UI_OPENED: dict[str, Callable[[str], str]] = {
|
|
961
|
+
"topup": lambda p: f"Opened {p} top-up.",
|
|
962
|
+
"checkout": lambda p: f"Opened {p} upgrade.",
|
|
963
|
+
"account": lambda p: f"Opened your {p} account.",
|
|
964
|
+
"auto-recharge": lambda p: f"Opened {p} auto-recharge.",
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _placeholder_plan(view: str, data: Json) -> Any:
|
|
969
|
+
if view == "account":
|
|
970
|
+
snapshot = get(_active_purchase(data), "planSnapshot")
|
|
971
|
+
if not truthy(snapshot):
|
|
972
|
+
return None
|
|
973
|
+
return {
|
|
974
|
+
key: get(snapshot, key)
|
|
975
|
+
for key in ("name", "price", "currency", "options", "reference")
|
|
976
|
+
if get(snapshot, key) is not UNDEFINED
|
|
977
|
+
}
|
|
978
|
+
plans = _plans(data)
|
|
979
|
+
return next((plan for plan in plans if not _is_free_plan(plan)), plans[0] if plans else None)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def ui_placeholder(view: str, data: Json) -> str:
|
|
983
|
+
"""The one line ``mode: 'ui'`` leads with: still self-sufficient on a text-only host."""
|
|
984
|
+
name = product_name(data)
|
|
985
|
+
parts = [_UI_OPENED[view](name)]
|
|
986
|
+
plan = _placeholder_plan(view, data)
|
|
987
|
+
if plan is not None:
|
|
988
|
+
price = _format_plan_prices(plan)
|
|
989
|
+
label_parts = [
|
|
990
|
+
str(coalesce(get(plan, "name"), "Plan")),
|
|
991
|
+
price if price and not _is_free_plan(plan) else None,
|
|
992
|
+
]
|
|
993
|
+
label = " · ".join(part for part in label_parts if part)
|
|
994
|
+
if label:
|
|
995
|
+
parts.append(f"{label}.")
|
|
996
|
+
balance = balance_summary(_customer(data))
|
|
997
|
+
if balance:
|
|
998
|
+
parts.append(f"Balance: {balance}.")
|
|
999
|
+
url = _checkout_url(data)
|
|
1000
|
+
if url:
|
|
1001
|
+
parts.append(f"Checkout: {_named_checkout_markdown(url)} ({CHECKOUT_TTL}).")
|
|
1002
|
+
return " ".join(parts)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
__all__ = [
|
|
1006
|
+
"NARRATORS",
|
|
1007
|
+
"VIEWER_TOOL_NAME",
|
|
1008
|
+
"NarratorOutput",
|
|
1009
|
+
"ViewKind",
|
|
1010
|
+
"balance_summary",
|
|
1011
|
+
"format_money",
|
|
1012
|
+
"narrate_activate_plan",
|
|
1013
|
+
"narrate_auto_recharge",
|
|
1014
|
+
"narrate_manage_account",
|
|
1015
|
+
"narrate_topup",
|
|
1016
|
+
"narrate_upgrade",
|
|
1017
|
+
"product_name",
|
|
1018
|
+
"ui_placeholder",
|
|
1019
|
+
]
|