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