solvapay 0.1.0.dev4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solvapay/__init__.py +26 -0
- solvapay/_currencies.py +170 -0
- solvapay/_generated/__init__.py +1 -0
- solvapay/_generated/models.py +3643 -0
- solvapay/_generated/operations.py +1894 -0
- solvapay/_http.py +243 -0
- solvapay/_js.py +114 -0
- solvapay/_tax_ids.py +442 -0
- solvapay/agents/__init__.py +67 -0
- solvapay/agents/calls.py +302 -0
- solvapay/agents/identity.py +293 -0
- solvapay/agents/mcp.py +614 -0
- solvapay/agents/oauth.py +105 -0
- solvapay/business_details.py +124 -0
- solvapay/client.py +107 -0
- solvapay/errors.py +33 -0
- solvapay/mcp/__init__.py +90 -0
- solvapay/mcp/app.py +243 -0
- solvapay/mcp/auth.py +604 -0
- solvapay/mcp/bootstrap.py +494 -0
- solvapay/mcp/intent.py +474 -0
- solvapay/mcp/mcp-app.html +119 -0
- solvapay/mcp/narrate.py +1019 -0
- solvapay/mcp/response.py +220 -0
- solvapay/mcp/server.py +343 -0
- solvapay/mcp/tools.py +273 -0
- solvapay/mcp/transport.py +550 -0
- solvapay/money.py +61 -0
- solvapay/paywall/__init__.py +25 -0
- solvapay/paywall/decide.py +450 -0
- solvapay/paywall/gate.py +176 -0
- solvapay/paywall/state.py +371 -0
- solvapay/pricing.py +281 -0
- solvapay/py.typed +0 -0
- solvapay/solvapay.py +173 -0
- solvapay/webhooks.py +93 -0
- solvapay-0.1.0.dev4.dist-info/METADATA +183 -0
- solvapay-0.1.0.dev4.dist-info/RECORD +39 -0
- solvapay-0.1.0.dev4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Paywall state and copy, ported from ``@solvapay/server``'s ``paywall-state.ts``.
|
|
2
|
+
|
|
3
|
+
Classifies a limits response into the recovery the customer needs, and writes
|
|
4
|
+
the gate and nudge messages. Inputs and outputs are JSON objects with the
|
|
5
|
+
API's camelCase keys. The conformance suite checks the result against the
|
|
6
|
+
TypeScript SDK, message text included.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from .._js import UNDEFINED, coalesce, get, num_str, number, to_locale_string, truthy
|
|
16
|
+
from ..money import credits_to_display_minor_units, format_minor
|
|
17
|
+
|
|
18
|
+
CHECKOUT_SESSION_TTL_MINUTES = 15
|
|
19
|
+
|
|
20
|
+
PaywallStateKind = Literal[
|
|
21
|
+
"activation_required",
|
|
22
|
+
"topup_required",
|
|
23
|
+
"upgrade_required",
|
|
24
|
+
"limit_reached",
|
|
25
|
+
"reactivation_required",
|
|
26
|
+
]
|
|
27
|
+
PaywallNextAction = Literal["activate", "topup", "checkout", "account"]
|
|
28
|
+
|
|
29
|
+
Json = dict[str, Any]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def credit_signals(limits: Json | None) -> Json:
|
|
33
|
+
"""Coalesces the two credit-balance channels; nested ``balance`` wins."""
|
|
34
|
+
if not limits:
|
|
35
|
+
return {
|
|
36
|
+
"creditBalance": UNDEFINED,
|
|
37
|
+
"creditsPerCall": UNDEFINED,
|
|
38
|
+
"shortfallCredits": UNDEFINED,
|
|
39
|
+
"remainingCalls": UNDEFINED,
|
|
40
|
+
"isCreditBased": False,
|
|
41
|
+
}
|
|
42
|
+
balance = get(limits, "balance")
|
|
43
|
+
credit_balance = coalesce(get(balance, "creditBalance"), get(limits, "creditBalance"))
|
|
44
|
+
credits_per_call = coalesce(get(balance, "creditsPerUnit"), get(limits, "creditsPerUnit"))
|
|
45
|
+
is_credit_based = credit_balance is not UNDEFINED or credits_per_call is not UNDEFINED
|
|
46
|
+
shortfall: Any = UNDEFINED
|
|
47
|
+
if credit_balance is not UNDEFINED and credits_per_call is not UNDEFINED:
|
|
48
|
+
shortfall = max(0, number(credits_per_call) - number(credit_balance))
|
|
49
|
+
shortfall = _same_kind(shortfall, credits_per_call, credit_balance)
|
|
50
|
+
remaining_calls: Any = UNDEFINED
|
|
51
|
+
if get(balance, "remainingUnits") is not UNDEFINED:
|
|
52
|
+
remaining_calls = balance["remainingUnits"]
|
|
53
|
+
elif (
|
|
54
|
+
credit_balance is not UNDEFINED
|
|
55
|
+
and credits_per_call is not UNDEFINED
|
|
56
|
+
and number(credits_per_call) > 0
|
|
57
|
+
):
|
|
58
|
+
remaining_calls = math.floor(number(credit_balance) / number(credits_per_call))
|
|
59
|
+
elif number(get(limits, "remaining")) >= 0:
|
|
60
|
+
remaining_calls = get(limits, "remaining")
|
|
61
|
+
return {
|
|
62
|
+
"creditBalance": credit_balance,
|
|
63
|
+
"creditsPerCall": credits_per_call,
|
|
64
|
+
"shortfallCredits": shortfall,
|
|
65
|
+
"remainingCalls": remaining_calls,
|
|
66
|
+
"isCreditBased": is_credit_based,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _same_kind(value: float, *sources: Any) -> float | int:
|
|
71
|
+
"""Arithmetic on JSON ints stays an int, as it prints in JavaScript."""
|
|
72
|
+
if all(isinstance(s, int) and not isinstance(s, bool) for s in sources if s is not None):
|
|
73
|
+
return int(value)
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def next_action_for(kind: PaywallStateKind) -> PaywallNextAction:
|
|
78
|
+
return {
|
|
79
|
+
"activation_required": "activate",
|
|
80
|
+
"topup_required": "topup",
|
|
81
|
+
"upgrade_required": "checkout",
|
|
82
|
+
"limit_reached": "checkout",
|
|
83
|
+
"reactivation_required": "account",
|
|
84
|
+
}[kind] # type: ignore[return-value]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _active_plan_ref(limits: Json) -> Any:
|
|
88
|
+
ref = coalesce(get(limits, "planRef"), get(limits, "plan"))
|
|
89
|
+
return ref if truthy(ref) and len(ref) > 0 else UNDEFINED
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def classify_paywall_state(limits: Json | None) -> PaywallStateKind:
|
|
93
|
+
"""Which recovery the customer needs. Same precedence as the TypeScript SDK."""
|
|
94
|
+
if not limits:
|
|
95
|
+
return "upgrade_required"
|
|
96
|
+
reason = get(limits, "paywallReason")
|
|
97
|
+
if get(limits, "activationRequired") is True or reason == "activation_required":
|
|
98
|
+
return "activation_required"
|
|
99
|
+
if reason == "limit_reached":
|
|
100
|
+
return "limit_reached"
|
|
101
|
+
if reason == "topup_required":
|
|
102
|
+
return "topup_required"
|
|
103
|
+
if get(limits, "needsTopUp") is True:
|
|
104
|
+
return "topup_required"
|
|
105
|
+
|
|
106
|
+
signals = credit_signals(limits)
|
|
107
|
+
balance, cost = signals["creditBalance"], signals["creditsPerCall"]
|
|
108
|
+
if signals["isCreditBased"]:
|
|
109
|
+
if balance is not UNDEFINED and cost is not UNDEFINED and number(balance) < number(cost):
|
|
110
|
+
return "topup_required"
|
|
111
|
+
if balance == 0 and not isinstance(balance, bool):
|
|
112
|
+
return "topup_required"
|
|
113
|
+
if cost is UNDEFINED and get(limits, "remaining") == 0:
|
|
114
|
+
return "topup_required"
|
|
115
|
+
|
|
116
|
+
if (truthy(get(limits, "purchaseRef")) or truthy(_active_plan_ref(limits))) and number(
|
|
117
|
+
get(limits, "remaining")
|
|
118
|
+
) <= 0:
|
|
119
|
+
return "limit_reached"
|
|
120
|
+
if signals["isCreditBased"]:
|
|
121
|
+
return "topup_required"
|
|
122
|
+
if truthy(get(limits, "purchaseRef")):
|
|
123
|
+
return "limit_reached"
|
|
124
|
+
return "upgrade_required"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _format_credits(value: Any) -> str:
|
|
128
|
+
return to_locale_string(number(value))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _format_credits_with_money(credits: Any, gate: Json) -> str:
|
|
132
|
+
amount = _format_credits(credits)
|
|
133
|
+
peg = get(gate, "creditsPerMinorUnit")
|
|
134
|
+
currency = get(gate, "currency")
|
|
135
|
+
rate = get(gate, "displayExchangeRate")
|
|
136
|
+
# `rate != null` in the TypeScript: neither null nor undefined.
|
|
137
|
+
has_rate = rate is not None and rate is not UNDEFINED and number(rate) > 0
|
|
138
|
+
if truthy(peg) and number(peg) > 0 and truthy(currency) and has_rate:
|
|
139
|
+
minor = credits_to_display_minor_units(
|
|
140
|
+
credits=number(credits),
|
|
141
|
+
credits_per_minor_unit=number(peg),
|
|
142
|
+
display_exchange_rate=number(rate),
|
|
143
|
+
display_currency=currency,
|
|
144
|
+
)
|
|
145
|
+
if minor is not None:
|
|
146
|
+
return f"{amount} credits (~{format_minor(minor, currency)})"
|
|
147
|
+
return f"{amount} credits"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_free_meter(name: Any) -> bool:
|
|
151
|
+
return isinstance(name, str) and name.startswith("free-")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _meter_label(gate: Json) -> str:
|
|
155
|
+
name = get(gate, "meterName")
|
|
156
|
+
if not truthy(name):
|
|
157
|
+
return "units"
|
|
158
|
+
return re.sub(r"[_-]", " ", name)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _named_checkout_markdown(url: str, label: str = "Open checkout") -> str:
|
|
162
|
+
return f"[{label}]({url})"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def link_label(name: str) -> str:
|
|
166
|
+
"""Escapes markdown link-label delimiters: plan names are merchant-authored."""
|
|
167
|
+
return re.sub(r"([\[\]])", r"\\\1", name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def plan_ladder(gate: Json) -> str | None:
|
|
171
|
+
plans = coalesce(get(gate, "plans"), [])
|
|
172
|
+
plan_ref = get(gate, "planRef")
|
|
173
|
+
linkable = [
|
|
174
|
+
p
|
|
175
|
+
for p in plans
|
|
176
|
+
if isinstance(get(p, "checkoutUrl"), str)
|
|
177
|
+
and len(p["checkoutUrl"]) > 0
|
|
178
|
+
and get(p, "reference") != plan_ref
|
|
179
|
+
]
|
|
180
|
+
linkable = sorted(linkable, key=lambda p: number(get(p, "price")))[:4]
|
|
181
|
+
if not linkable:
|
|
182
|
+
return None
|
|
183
|
+
links = " · ".join(
|
|
184
|
+
f"[{link_label(coalesce(get(p, 'name'), get(p, 'reference')))}]({p['checkoutUrl']})"
|
|
185
|
+
for p in linkable
|
|
186
|
+
)
|
|
187
|
+
return (
|
|
188
|
+
f"{links} (first link used closes the rest; "
|
|
189
|
+
f"links expire in {CHECKOUT_SESSION_TTL_MINUTES} minutes)"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_VIEWER_TOOL = "account"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _call_viewer(view: str | None = None) -> str:
|
|
197
|
+
if view:
|
|
198
|
+
return f"call the `{_VIEWER_TOOL}` tool with view: '{view}'"
|
|
199
|
+
return f"call the `{_VIEWER_TOOL}` tool"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _capital(text: str) -> str:
|
|
203
|
+
return "C" + text[1:] if text.startswith("c") else text
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _recover_clause(
|
|
207
|
+
url: str | None, verb: str, view: str | None = None, label: str = "Open checkout"
|
|
208
|
+
) -> str:
|
|
209
|
+
if url:
|
|
210
|
+
return (
|
|
211
|
+
f" {_named_checkout_markdown(url, label)} to {verb} "
|
|
212
|
+
f"(expires in {CHECKOUT_SESSION_TTL_MINUTES} minutes), or {_call_viewer(view)}."
|
|
213
|
+
)
|
|
214
|
+
return f" {_capital(_call_viewer(view))}."
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _has_active_plan(gate: Json) -> bool:
|
|
218
|
+
plan_ref = get(gate, "planRef")
|
|
219
|
+
return truthy(get(gate, "purchaseRef")) or (truthy(plan_ref) and len(plan_ref) > 0)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _auto_recharge_disabled(gate: Json) -> bool:
|
|
223
|
+
return get(get(gate, "autoRecharge"), "enabled") is False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_gate_message(kind: PaywallStateKind, gate: Json) -> str:
|
|
227
|
+
"""The gate message: names one recovery tool and inlines the checkout link."""
|
|
228
|
+
checkout_url = get(gate, "checkoutUrl")
|
|
229
|
+
url = checkout_url if truthy(checkout_url) and len(checkout_url) > 0 else None
|
|
230
|
+
ladder = plan_ladder(gate)
|
|
231
|
+
|
|
232
|
+
if kind == "limit_reached":
|
|
233
|
+
included = get(gate, "included")
|
|
234
|
+
if _is_free_meter(get(gate, "meterName")):
|
|
235
|
+
total = get(included, "total")
|
|
236
|
+
used_line = (
|
|
237
|
+
f"You've used all {num_str(total)} {_meter_label(gate)} for this period."
|
|
238
|
+
if total is not UNDEFINED
|
|
239
|
+
else f"You've used all {_meter_label(gate)} for this period."
|
|
240
|
+
)
|
|
241
|
+
switch_line = (
|
|
242
|
+
f" Pick a plan to keep going: {ladder}. "
|
|
243
|
+
f"{_capital(_call_viewer('account'))} for usage and recovery."
|
|
244
|
+
if ladder
|
|
245
|
+
else _recover_clause(url, "keep going", "checkout")
|
|
246
|
+
)
|
|
247
|
+
return f"{used_line}{switch_line}"
|
|
248
|
+
unit_price = get(gate, "unitPriceMinor")
|
|
249
|
+
currency = get(gate, "currency")
|
|
250
|
+
price = (
|
|
251
|
+
format_minor(number(unit_price), currency)
|
|
252
|
+
if unit_price is not None and unit_price is not UNDEFINED and truthy(currency)
|
|
253
|
+
else None
|
|
254
|
+
)
|
|
255
|
+
used_line = (
|
|
256
|
+
f"You've used {num_str(get(included, 'used'))} of "
|
|
257
|
+
f"{num_str(get(included, 'total'))} included {_meter_label(gate)} this period."
|
|
258
|
+
if truthy(included)
|
|
259
|
+
else "You've reached the included usage for this period."
|
|
260
|
+
)
|
|
261
|
+
next_line = f" The next call is {price}." if price else ""
|
|
262
|
+
plan_name = coalesce(get(gate, "planName"), "This plan")
|
|
263
|
+
credit_balance = get(gate, "creditBalance")
|
|
264
|
+
credits_per_call = get(gate, "creditsPerCall")
|
|
265
|
+
anti_trap = (
|
|
266
|
+
f" Adding credits will not help, because {plan_name} does not spend them."
|
|
267
|
+
if credit_balance is not UNDEFINED
|
|
268
|
+
and number(credit_balance) > 0
|
|
269
|
+
and (credits_per_call is UNDEFINED or credits_per_call == 0)
|
|
270
|
+
else ""
|
|
271
|
+
)
|
|
272
|
+
switch_line = (
|
|
273
|
+
f" Or switch plan: {ladder}. "
|
|
274
|
+
f"{_capital(_call_viewer('account'))} for usage and recovery."
|
|
275
|
+
if ladder
|
|
276
|
+
else _recover_clause(url, "continue", "checkout")
|
|
277
|
+
)
|
|
278
|
+
return f"{used_line}{next_line}{anti_trap}{switch_line}"
|
|
279
|
+
|
|
280
|
+
if kind == "activation_required":
|
|
281
|
+
return (
|
|
282
|
+
f"Your plan needs activation.{_recover_clause(url, 'activate', 'checkout')} "
|
|
283
|
+
"Or call `activate_plan` with a `planRef`."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if kind == "topup_required":
|
|
287
|
+
balance = get(gate, "creditBalance")
|
|
288
|
+
cost = get(gate, "creditsPerCall")
|
|
289
|
+
shortfall = get(gate, "shortfallCredits")
|
|
290
|
+
if UNDEFINED not in (balance, cost, shortfall):
|
|
291
|
+
have = _format_credits_with_money(balance, gate)
|
|
292
|
+
need = _format_credits_with_money(cost, gate)
|
|
293
|
+
lead = (
|
|
294
|
+
f"Out of credits for this call. Balance {have}; this call costs {need} — "
|
|
295
|
+
f"{_format_credits(shortfall)} short."
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
lead = "Included usage is exhausted."
|
|
299
|
+
topup = _recover_clause(url, "add credits", "topup", "Add credits")
|
|
300
|
+
auto = (
|
|
301
|
+
f" Auto-recharge is off — {_call_viewer('account')} to turn it on "
|
|
302
|
+
"and avoid this next time."
|
|
303
|
+
if _auto_recharge_disabled(gate)
|
|
304
|
+
else ""
|
|
305
|
+
)
|
|
306
|
+
if ladder:
|
|
307
|
+
switch_line = f" Or switch plan: {ladder}."
|
|
308
|
+
elif _has_active_plan(gate):
|
|
309
|
+
switch_line = ""
|
|
310
|
+
else:
|
|
311
|
+
switch_line = f" Or {_call_viewer('checkout')} to pick a plan."
|
|
312
|
+
return f"{lead}{topup}{auto}{switch_line}"
|
|
313
|
+
|
|
314
|
+
if kind == "upgrade_required":
|
|
315
|
+
if ladder:
|
|
316
|
+
return (
|
|
317
|
+
f"You don't have an active plan for this tool. Pick a plan to use this tool: "
|
|
318
|
+
f"{ladder}, or {_call_viewer('checkout')}. "
|
|
319
|
+
f"{_capital(_call_viewer('account'))} for usage and recovery."
|
|
320
|
+
)
|
|
321
|
+
return (
|
|
322
|
+
"You don't have an active plan for this tool."
|
|
323
|
+
f"{_recover_clause(url, 'pick a plan', 'checkout')}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
f"Your previous plan is no longer active. {_capital(_call_viewer('account'))} "
|
|
328
|
+
f"to reactivate it, or {_call_viewer('checkout')} to pick a new plan."
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build_nudge_message(kind: PaywallStateKind, limits: Json | None) -> str:
|
|
333
|
+
"""The softer message appended to a successful call that is near a limit."""
|
|
334
|
+
checkout_url = get(limits, "checkoutUrl")
|
|
335
|
+
url = checkout_url if truthy(checkout_url) and len(checkout_url) > 0 else None
|
|
336
|
+
visit = f", or {_named_checkout_markdown(url)}" if url else ""
|
|
337
|
+
signals = credit_signals(limits)
|
|
338
|
+
remaining = signals["remainingCalls"]
|
|
339
|
+
# `limits && …` in the TypeScript: any object, even an empty one, is truthy.
|
|
340
|
+
if kind == "topup_required" and limits is not None and not signals["isCreditBased"]:
|
|
341
|
+
kind = "limit_reached"
|
|
342
|
+
|
|
343
|
+
if kind == "topup_required":
|
|
344
|
+
if remaining == 0:
|
|
345
|
+
return (
|
|
346
|
+
"Heads up — 0 calls left — the next call needs a top-up. "
|
|
347
|
+
f"{_capital(_call_viewer('topup'))} to add more{visit}."
|
|
348
|
+
)
|
|
349
|
+
return (
|
|
350
|
+
"Heads up — running low on credits. "
|
|
351
|
+
f"{_capital(_call_viewer('topup'))} to add more{visit}."
|
|
352
|
+
)
|
|
353
|
+
if kind in ("upgrade_required", "limit_reached"):
|
|
354
|
+
if remaining == 0:
|
|
355
|
+
return (
|
|
356
|
+
"Heads up — 0 calls left — the next call needs a top-up. "
|
|
357
|
+
f"{_capital(_call_viewer('checkout'))} for more headroom{visit}."
|
|
358
|
+
)
|
|
359
|
+
return (
|
|
360
|
+
"Heads up — approaching your plan's limit this period. "
|
|
361
|
+
f"{_capital(_call_viewer('checkout'))} for more headroom{visit}."
|
|
362
|
+
)
|
|
363
|
+
if kind == "activation_required":
|
|
364
|
+
return (
|
|
365
|
+
"Heads up — this plan still needs activation. "
|
|
366
|
+
f"Call the `activate_plan` tool with a `planRef`{visit}."
|
|
367
|
+
)
|
|
368
|
+
return (
|
|
369
|
+
"Heads up — your plan is no longer active. "
|
|
370
|
+
f"{_capital(_call_viewer('account'))} to reactivate it{visit}."
|
|
371
|
+
)
|
solvapay/pricing.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Readers for a plan's ``options[]`` pricing, ported from ``@solvapay/core``'s
|
|
2
|
+
``pricing-options.ts``.
|
|
3
|
+
|
|
4
|
+
Every plan-shaped payload (a catalog plan, the frozen ``planSnapshot`` on a
|
|
5
|
+
purchase) describes its pricing as an ordered list of ``options``, each with a
|
|
6
|
+
``kind``. These helpers read that list structurally, as the TypeScript SDK
|
|
7
|
+
does: an option kind this version does not know is skipped, never an error.
|
|
8
|
+
Inputs are JSON objects with the API's camelCase keys.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from typing import Any, TypeGuard
|
|
15
|
+
|
|
16
|
+
from ._js import UNDEFINED, coalesce, get, js_round
|
|
17
|
+
|
|
18
|
+
Json = dict[str, Any]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_number(value: Any) -> TypeGuard[float]:
|
|
22
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _options(priced: Any) -> list[Mapping[str, Any]]:
|
|
26
|
+
options = get(priced, "options")
|
|
27
|
+
if not isinstance(options, Sequence) or isinstance(options, str):
|
|
28
|
+
return []
|
|
29
|
+
return [option for option in options if isinstance(option, Mapping)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _as_charge(option: Mapping[str, Any]) -> Json | None:
|
|
33
|
+
if option.get("kind") != "charge":
|
|
34
|
+
return None
|
|
35
|
+
per, amount, currency = option.get("per"), option.get("amountMinor"), option.get("currency")
|
|
36
|
+
if per not in ("flat", "unit", "seat"):
|
|
37
|
+
return None
|
|
38
|
+
if not _is_number(amount) or not isinstance(currency, str):
|
|
39
|
+
return None
|
|
40
|
+
charge: Json = {"per": per, "amountMinor": amount, "currency": currency}
|
|
41
|
+
if isinstance(option.get("meter"), str):
|
|
42
|
+
charge["meter"] = option["meter"]
|
|
43
|
+
if option.get("oneTime") is True:
|
|
44
|
+
charge["oneTime"] = True
|
|
45
|
+
return charge
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def charges(priced: Any) -> list[Json]:
|
|
49
|
+
"""Every charge option on the plan, in wire order."""
|
|
50
|
+
return [c for c in (_as_charge(option) for option in _options(priced)) if c is not None]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def headline_charges(priced: Any) -> list[Json]:
|
|
54
|
+
"""The base flat charge in each currency, first seen first; setup fees excluded."""
|
|
55
|
+
flat = [charge for charge in charges(priced) if charge["per"] == "flat"]
|
|
56
|
+
base = [charge for charge in flat if not charge.get("oneTime")]
|
|
57
|
+
seen: set[str] = set()
|
|
58
|
+
out = []
|
|
59
|
+
for charge in base or flat:
|
|
60
|
+
key = charge["currency"].upper()
|
|
61
|
+
if key not in seen:
|
|
62
|
+
seen.add(key)
|
|
63
|
+
out.append(charge)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def per_unit_charge(priced: Any, meter: str | None = None) -> Json | None:
|
|
68
|
+
"""The first ``per: 'unit'`` charge, optionally for one meter."""
|
|
69
|
+
unit = [charge for charge in charges(priced) if charge["per"] == "unit"]
|
|
70
|
+
if meter:
|
|
71
|
+
return next((charge for charge in unit if charge.get("meter") == meter), None)
|
|
72
|
+
return unit[0] if unit else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def billing_cycle(priced: Any) -> Json | None:
|
|
76
|
+
"""``{"interval", "count"?}``, or None for a one-time or pure usage plan."""
|
|
77
|
+
for option in _options(priced):
|
|
78
|
+
if option.get("kind") != "billingCycle":
|
|
79
|
+
continue
|
|
80
|
+
interval, count = option.get("interval"), option.get("count")
|
|
81
|
+
if interval not in ("week", "month", "year"):
|
|
82
|
+
continue
|
|
83
|
+
cycle: Json = {"interval": interval}
|
|
84
|
+
if _is_number(count) and count > 1:
|
|
85
|
+
cycle["count"] = count
|
|
86
|
+
return cycle
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _as_tier(option: Mapping[str, Any]) -> Json | None:
|
|
91
|
+
if option.get("kind") != "tier":
|
|
92
|
+
return None
|
|
93
|
+
start, end, mode = option.get("from"), get(option, "to"), option.get("mode")
|
|
94
|
+
if not _is_number(start):
|
|
95
|
+
return None
|
|
96
|
+
if end is not None and not _is_number(end):
|
|
97
|
+
return None
|
|
98
|
+
if mode not in ("graduated", "volume"):
|
|
99
|
+
return None
|
|
100
|
+
embedded = option.get("charge")
|
|
101
|
+
if not isinstance(embedded, Mapping):
|
|
102
|
+
return None
|
|
103
|
+
charge = _as_charge({**embedded, "kind": "charge"})
|
|
104
|
+
if charge is None:
|
|
105
|
+
return None
|
|
106
|
+
return {"from": start, "to": end, "mode": mode, "charge": charge}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def tier_bands(priced: Any, meter: str | None = None) -> list[Json]:
|
|
110
|
+
"""The bands ``meter`` (else the first tiered meter) is priced in, by floor."""
|
|
111
|
+
tiers = [t for t in (_as_tier(option) for option in _options(priced)) if t is not None]
|
|
112
|
+
if not tiers:
|
|
113
|
+
return []
|
|
114
|
+
target = meter if meter is not None else tiers[0]["charge"].get("meter")
|
|
115
|
+
bands = [t for t in tiers if target is None or t["charge"].get("meter") == target]
|
|
116
|
+
return sorted(bands, key=lambda tier: tier["from"])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def tier_meters(priced: Any) -> list[str]:
|
|
120
|
+
seen: list[str] = []
|
|
121
|
+
for option in _options(priced):
|
|
122
|
+
tier = _as_tier(option)
|
|
123
|
+
meter = tier["charge"].get("meter") if tier else None
|
|
124
|
+
if meter and meter not in seen:
|
|
125
|
+
seen.append(meter)
|
|
126
|
+
return seen
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def usage_rate(priced: Any, meter: str | None = None) -> Json | None:
|
|
130
|
+
"""What one metered unit costs: the per-unit charge, else the first paying band.
|
|
131
|
+
``tiered`` is True when later bands may cost differently (present it as a floor)."""
|
|
132
|
+
charge = per_unit_charge(priced, meter)
|
|
133
|
+
if charge and charge["amountMinor"] > 0:
|
|
134
|
+
return {**charge, "tiered": False}
|
|
135
|
+
paying = [band for band in tier_bands(priced, meter) if band["charge"]["amountMinor"] > 0]
|
|
136
|
+
if paying:
|
|
137
|
+
return {**paying[0]["charge"], "tiered": len(paying) > 1}
|
|
138
|
+
return {**charge, "tiered": False} if charge else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def trial_days(priced: Any) -> Any:
|
|
142
|
+
"""Free trial length in days, or None."""
|
|
143
|
+
for option in _options(priced):
|
|
144
|
+
if option.get("kind") != "trial":
|
|
145
|
+
continue
|
|
146
|
+
days = option.get("days")
|
|
147
|
+
if _is_number(days) and days > 0:
|
|
148
|
+
return days
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _first_limit(priced: Any, meter: str | None = None) -> Json | None:
|
|
153
|
+
for option in _options(priced):
|
|
154
|
+
if option.get("kind") != "limit":
|
|
155
|
+
continue
|
|
156
|
+
if meter and option.get("meter") != meter:
|
|
157
|
+
continue
|
|
158
|
+
if not _is_number(option.get("cap")):
|
|
159
|
+
continue
|
|
160
|
+
name = option.get("meter")
|
|
161
|
+
return {"cap": option["cap"], "meter": name if isinstance(name, str) and name else None}
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def included_units(priced: Any, meter: str | None = None) -> Any:
|
|
166
|
+
"""Included units from the limit option. ``0`` means unlimited. None without a limit."""
|
|
167
|
+
limit = _first_limit(priced, meter)
|
|
168
|
+
return limit["cap"] if limit else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def meter_name(priced: Any) -> str | None:
|
|
172
|
+
"""The meter the plan counts against: per-unit charge, else tier, else limit."""
|
|
173
|
+
charge = per_unit_charge(priced)
|
|
174
|
+
if charge and charge.get("meter"):
|
|
175
|
+
return str(charge["meter"])
|
|
176
|
+
meters = tier_meters(priced)
|
|
177
|
+
if meters:
|
|
178
|
+
return meters[0]
|
|
179
|
+
limit = _first_limit(priced)
|
|
180
|
+
return limit["meter"] if limit else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def counts_usage(priced: Any) -> bool:
|
|
184
|
+
"""True when the plan counts usage: a per-unit charge, a limit or a tier."""
|
|
185
|
+
if per_unit_charge(priced) is not None:
|
|
186
|
+
return True
|
|
187
|
+
if included_units(priced) is not None:
|
|
188
|
+
return True
|
|
189
|
+
return len(tier_bands(priced)) > 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def pegged_credits_per_unit(
|
|
193
|
+
charge_minor: float, credits_per_minor_unit: float, usd_to_charge_rate: float = 1
|
|
194
|
+
) -> int:
|
|
195
|
+
"""Credits per metered unit, as the platform's debit engine computes them."""
|
|
196
|
+
if not charge_minor > 0 or not credits_per_minor_unit > 0:
|
|
197
|
+
return 0
|
|
198
|
+
rate = usd_to_charge_rate if usd_to_charge_rate > 0 else 1
|
|
199
|
+
return js_round((charge_minor / rate) * credits_per_minor_unit)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_metered(priced: Any) -> bool:
|
|
203
|
+
return per_unit_charge(priced) is not None or len(tier_bands(priced)) > 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_billable_metered(priced: Any) -> bool:
|
|
207
|
+
rate = usage_rate(priced)
|
|
208
|
+
if rate is not None and rate["amountMinor"] > 0:
|
|
209
|
+
return True
|
|
210
|
+
return any(band["charge"]["amountMinor"] > 0 for band in tier_bands(priced))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _headline_price_minor(priced: Any) -> Any:
|
|
214
|
+
if billing_cycle(priced):
|
|
215
|
+
flat = next(
|
|
216
|
+
(c for c in charges(priced) if c["per"] == "flat" and not c.get("oneTime")), None
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
flat = next((c for c in charges(priced) if c["per"] == "flat"), None)
|
|
220
|
+
return flat["amountMinor"] if flat else 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def plan_pricing_shape(priced: Any) -> Json:
|
|
224
|
+
"""``{"shape", "headlineMinor", "currency", "cycle", "rate"}``; ``shape`` is one of
|
|
225
|
+
free, usage, recurring, hybrid, oneTime."""
|
|
226
|
+
fallback = str(coalesce(get(priced, "currency"), "USD")).upper()
|
|
227
|
+
if not isinstance(priced, Mapping):
|
|
228
|
+
return {
|
|
229
|
+
"shape": "oneTime",
|
|
230
|
+
"headlineMinor": 0,
|
|
231
|
+
"currency": fallback,
|
|
232
|
+
"cycle": None,
|
|
233
|
+
"rate": None,
|
|
234
|
+
}
|
|
235
|
+
if priced.get("requiresPayment") is False:
|
|
236
|
+
return {
|
|
237
|
+
"shape": "free",
|
|
238
|
+
"headlineMinor": 0,
|
|
239
|
+
"currency": fallback,
|
|
240
|
+
"cycle": billing_cycle(priced),
|
|
241
|
+
"rate": usage_rate(priced),
|
|
242
|
+
}
|
|
243
|
+
recurring = billing_cycle(priced) is not None
|
|
244
|
+
metered = _is_metered(priced)
|
|
245
|
+
if recurring and _is_billable_metered(priced):
|
|
246
|
+
shape = "hybrid"
|
|
247
|
+
elif recurring:
|
|
248
|
+
shape = "recurring"
|
|
249
|
+
elif metered:
|
|
250
|
+
shape = "usage"
|
|
251
|
+
else:
|
|
252
|
+
shape = "oneTime"
|
|
253
|
+
headline = headline_charges(priced)
|
|
254
|
+
currency = coalesce(
|
|
255
|
+
headline[0]["currency"] if headline else UNDEFINED, get(priced, "currency"), "USD"
|
|
256
|
+
)
|
|
257
|
+
return {
|
|
258
|
+
"shape": shape,
|
|
259
|
+
"headlineMinor": _headline_price_minor(priced),
|
|
260
|
+
"currency": str(currency).upper(),
|
|
261
|
+
"cycle": billing_cycle(priced) if recurring else None,
|
|
262
|
+
"rate": usage_rate(priced) if metered else None,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def credits_per_unit_from_balance(priced: Any, balance: Any, meter: str | None = None) -> Any:
|
|
267
|
+
"""Credits per metered call against the customer's balance peg; None when the charge is
|
|
268
|
+
not in the balance's display currency (the SDK does not hold that FX rate)."""
|
|
269
|
+
charge = usage_rate(priced, meter)
|
|
270
|
+
if not charge or not charge["amountMinor"] > 0:
|
|
271
|
+
return None
|
|
272
|
+
display_currency = get(balance, "displayCurrency")
|
|
273
|
+
per_minor = get(balance, "creditsPerMinorUnit")
|
|
274
|
+
if not isinstance(display_currency, str) or not _is_number(per_minor):
|
|
275
|
+
return None
|
|
276
|
+
if charge["currency"].upper() != display_currency.upper():
|
|
277
|
+
return None
|
|
278
|
+
credits = pegged_credits_per_unit(
|
|
279
|
+
charge["amountMinor"], per_minor, coalesce(get(balance, "displayExchangeRate"), 1)
|
|
280
|
+
)
|
|
281
|
+
return credits if credits > 0 else None
|
solvapay/py.typed
ADDED
|
File without changes
|