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