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,550 @@
1
+ """The widget's transport tools, ported from ``@solvapay/mcp-core``'s ``descriptors.ts``
2
+ and the ``@solvapay/server`` ``*Core`` helpers they call.
3
+
4
+ The SolvaPay widget calls these from its iframe (``app.callServerTool``) to pay, top
5
+ up, cancel or reactivate a renewal, open a hosted page and load history. They are
6
+ not for the model: each carries ``_meta.ui.visibility: ['app']`` and
7
+ ``_meta.audience: 'ui'`` (see ``app.transport_tool_meta``).
8
+
9
+ Each answers like TypeScript's ``toolResult`` (the data as JSON text and as
10
+ structured content) or ``toolErrorResult`` (``{error, status, details}``), so the
11
+ widget sees the same responses from either SDK; ``conformance/cases/transport-tools``
12
+ pins them down. Unlike TypeScript, which goes through ``ensureCustomer``, the
13
+ customer is the signed-in ``cus_`` ref, used as is.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import inspect
20
+ import json
21
+ import time
22
+ import uuid
23
+ from collections.abc import Awaitable, Callable, Mapping
24
+ from typing import Annotated, Any, Literal
25
+
26
+ from mcp.server.mcpserver import Context, MCPServer
27
+ from mcp.types import CallToolResult, TextContent, ToolAnnotations
28
+ from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema
29
+
30
+ from ..business_details import TAX_ID_TYPES, validate_business_details
31
+ from ..errors import SolvaPayError
32
+ from ..solvapay import SolvaPay
33
+ from .app import transport_tool_meta
34
+ from .intent import (
35
+ _CONTEXT_PARAM,
36
+ GetCustomerRef,
37
+ _annotate,
38
+ _error_from,
39
+ _resolve_customer,
40
+ tool_error_result,
41
+ )
42
+ from .narrate import ACTIVATE_PLAN_TOOL_NAME, VIEWER_TOOL_NAME
43
+ from .tools import default_customer_ref
44
+
45
+ Json = dict[str, Any]
46
+
47
+ CREATE_HOSTED_SESSION = "create_hosted_session"
48
+ CREATE_PAYMENT_INTENT = "create_payment_intent"
49
+ PROCESS_PAYMENT = "process_payment"
50
+ ATTACH_BUSINESS_DETAILS = "attach_business_details"
51
+ SET_RENEWAL = "set_renewal"
52
+ GET_HISTORY = "get_history"
53
+
54
+ TRANSPORT_TOOL_NAMES = (
55
+ CREATE_HOSTED_SESSION,
56
+ CREATE_PAYMENT_INTENT,
57
+ PROCESS_PAYMENT,
58
+ ATTACH_BUSINESS_DETAILS,
59
+ SET_RENEWAL,
60
+ GET_HISTORY,
61
+ )
62
+
63
+ UI_ONLY_PREFIX = (
64
+ f"UI-only; agents should prefer `{VIEWER_TOOL_NAME}` / `{ACTIVATE_PLAN_TOOL_NAME}`. "
65
+ )
66
+
67
+ SETTLE_SECONDS = 0.5
68
+ """How long ``set_renewal`` waits after the API answers, as TypeScript's
69
+ ``cancelPurchaseCore`` and ``reactivatePurchaseCore`` do, so the widget's next read
70
+ sees the change."""
71
+
72
+ TaxIdType = Literal[TAX_ID_TYPES] # type: ignore[valid-type]
73
+
74
+
75
+ MAX_SAFE_INTEGER = 9007199254740991
76
+ """zod's ``int()`` bound, which TypeScript's input schemas advertise as ``maximum``."""
77
+
78
+ # A positive amount in major units. ``int | float`` keeps a whole number whole on the
79
+ # wire; the schema says ``number``, as zod's ``z.number().positive()`` does.
80
+ PositiveMajor = Annotated[
81
+ int | float, Field(gt=0), WithJsonSchema({"type": "number", "exclusiveMinimum": 0})
82
+ ]
83
+
84
+
85
+ # ``autoRecharge`` on a top-up: save the card as the auto-recharge funding source.
86
+ # (A comment, not a docstring: pydantic would publish a docstring as the schema's
87
+ # description, and TypeScript's has none.)
88
+ class AutoRechargeArg(BaseModel):
89
+ # zod drops keys it does not know, so this does too.
90
+ model_config = ConfigDict(extra="ignore")
91
+
92
+ enabled: bool
93
+ triggerType: Literal["balance"]
94
+ thresholdAmountMajor: PositiveMajor | None = None
95
+ topupAmountMajor: PositiveMajor | None = None
96
+ maxMonthlySpendMajor: PositiveMajor | None = None
97
+ currency: Annotated[str, Field(min_length=3, max_length=3)]
98
+
99
+
100
+ def tool_result(data: Any) -> CallToolResult:
101
+ """``toolResult``: the data as JSON text and as structured content."""
102
+ return CallToolResult(
103
+ content=[
104
+ TextContent(
105
+ type="text", text=json.dumps(data, separators=(",", ":"), ensure_ascii=False)
106
+ )
107
+ ],
108
+ structured_content=data,
109
+ )
110
+
111
+
112
+ def _dump(model: BaseModel) -> Any:
113
+ return model.model_dump(mode="json", by_alias=True, exclude_unset=True)
114
+
115
+
116
+ def _idempotency_key(prefix: str) -> str:
117
+ return f"{prefix}-{int(time.time() * 1000)}-{uuid.uuid4().hex[:9]}"
118
+
119
+
120
+ def _text(value: Any) -> str | None:
121
+ """A string argument, or None when it is missing or empty (TypeScript's
122
+ ``typeof x === 'string' && x ? x : undefined``)."""
123
+ return value if isinstance(value, str) and value else None
124
+
125
+
126
+ def _solvapay_tool(**hints: bool) -> ToolAnnotations:
127
+ """Every SolvaPay tool talks to the SolvaPay API, so ``openWorldHint`` is always set."""
128
+ return ToolAnnotations(open_world_hint=True, **hints)
129
+
130
+
131
+ def _param(name: str, annotation: Any, default: Any = inspect.Parameter.empty) -> inspect.Parameter:
132
+ return inspect.Parameter(
133
+ name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=annotation
134
+ )
135
+
136
+
137
+ def _optional(name: str, annotation: Any) -> inspect.Parameter:
138
+ return _param(name, annotation | None, None)
139
+
140
+
141
+ ToolBody = Callable[[str, Mapping[str, Any]], Awaitable[CallToolResult]]
142
+
143
+
144
+ def register_transport_tools(
145
+ server: MCPServer,
146
+ *,
147
+ solvapay: SolvaPay,
148
+ product_ref: str,
149
+ resource_uri: str,
150
+ get_customer_ref: GetCustomerRef | None = None,
151
+ ) -> None:
152
+ """Registers the six tools the widget calls. ``account`` and ``activate_plan`` come
153
+ from ``register_intent_tools``."""
154
+ api = solvapay.api
155
+ resolve = get_customer_ref or default_customer_ref
156
+ meta = transport_tool_meta(resource_uri)
157
+
158
+ def add(
159
+ name: str,
160
+ body: ToolBody,
161
+ params: list[inspect.Parameter],
162
+ description: str,
163
+ annotations: ToolAnnotations,
164
+ ) -> None:
165
+ async def tool(**kwargs: Any) -> CallToolResult:
166
+ context: Context = kwargs.pop(_CONTEXT_PARAM)
167
+ customer_ref = await _resolve_customer(resolve, kwargs, context)
168
+ if customer_ref is None:
169
+ return tool_error_result(
170
+ "Unauthorized", 401, "customer_ref missing from MCP auth context"
171
+ )
172
+ return await body(customer_ref, kwargs)
173
+
174
+ tool.__name__ = name
175
+ _annotate(tool, params)
176
+ server.add_tool(
177
+ tool,
178
+ name=name,
179
+ description=UI_ONLY_PREFIX + description,
180
+ annotations=annotations,
181
+ meta=meta,
182
+ structured_output=False,
183
+ )
184
+
185
+ # ---- create_hosted_session -----------------------------------------------
186
+
187
+ async def create_hosted_session(customer_ref: str, args: Mapping[str, Any]) -> CallToolResult:
188
+ kind = args.get("kind")
189
+ if kind not in ("checkout", "portal"):
190
+ return tool_error_result(
191
+ "create_hosted_session requires kind",
192
+ 400,
193
+ 'Pass kind: "checkout" or kind: "portal".',
194
+ )
195
+ try:
196
+ if kind == "portal":
197
+ portal = await api.create_customer_session(body={"customerRef": customer_ref})
198
+ return tool_result(_dump(portal))
199
+ body: Json = {
200
+ "productRef": _text(args.get("productRef")) or product_ref,
201
+ "customerRef": customer_ref,
202
+ }
203
+ plan_ref = _text(args.get("planRef"))
204
+ if plan_ref:
205
+ body["planRef"] = plan_ref
206
+ # MCP has no page to come back to, so no returnUrl (createMcpCheckoutSession).
207
+ session = await api.create_checkout_session(body=body)
208
+ except SolvaPayError as error:
209
+ return _error_from(error)
210
+ return tool_result({"sessionId": session.session_id, "checkoutUrl": session.checkout_url})
211
+
212
+ add(
213
+ CREATE_HOSTED_SESSION,
214
+ create_hosted_session,
215
+ [
216
+ _param(
217
+ "kind",
218
+ Annotated[
219
+ Literal["checkout", "portal"],
220
+ Field(
221
+ description='"checkout" for hosted checkout; "portal" for customer portal.'
222
+ ),
223
+ ],
224
+ ),
225
+ _optional("planRef", str),
226
+ _optional("productRef", str),
227
+ ],
228
+ 'Create a SolvaPay hosted session and return its URL. Pass kind: "checkout" for a '
229
+ 'hosted checkout URL (fallback when Stripe Elements is blocked) or kind: "portal" for '
230
+ "the customer portal URL.",
231
+ _solvapay_tool(read_only_hint=False, destructive_hint=False, idempotent_hint=True),
232
+ )
233
+
234
+ # ---- create_payment_intent -----------------------------------------------
235
+
236
+ def intent_result(intent: Any, customer_ref: str) -> CallToolResult:
237
+ data: Json = {
238
+ "processorPaymentId": intent.processor_payment_id,
239
+ "clientSecret": intent.client_secret,
240
+ "publishableKey": intent.publishable_key,
241
+ }
242
+ if intent.account_id is not None:
243
+ data["accountId"] = intent.account_id
244
+ data["customerRef"] = customer_ref
245
+ return tool_result(data)
246
+
247
+ async def create_payment_intent(customer_ref: str, args: Mapping[str, Any]) -> CallToolResult:
248
+ purpose = args.get("purpose")
249
+ if purpose not in ("plan", "topup"):
250
+ return tool_error_result(
251
+ "create_payment_intent requires purpose",
252
+ 400,
253
+ 'Pass purpose: "plan" or purpose: "topup".',
254
+ )
255
+ auto_recharge = args.get("autoRecharge")
256
+ if purpose == "plan" and auto_recharge is not None:
257
+ return tool_error_result(
258
+ "create_payment_intent plan does not accept autoRecharge",
259
+ 400,
260
+ 'autoRecharge is only honoured on purpose: "topup".',
261
+ )
262
+
263
+ if purpose == "topup":
264
+ amount = args.get("amount")
265
+ currency = _text(args.get("currency"))
266
+ if not amount or not currency:
267
+ return tool_error_result(
268
+ "create_payment_intent topup requires amount and currency",
269
+ 400,
270
+ 'Pass purpose: "topup" with amount (integer cents) and currency.',
271
+ )
272
+ if currency != currency.upper():
273
+ return tool_error_result(
274
+ f'Invalid currency "{currency}": must be an uppercase ISO 4217 code '
275
+ '(e.g. "USD", "EUR")',
276
+ 400,
277
+ )
278
+ body: Json = {
279
+ "customerRef": customer_ref,
280
+ "purpose": "credit_topup",
281
+ "amount": amount,
282
+ "currency": currency,
283
+ }
284
+ description = args.get("description")
285
+ if isinstance(description, str):
286
+ body["description"] = description
287
+ if auto_recharge is not None:
288
+ body["autoRecharge"] = AutoRechargeArg.model_validate(auto_recharge).model_dump(
289
+ exclude_none=True
290
+ )
291
+ try:
292
+ intent = await api.create_payment_intent(
293
+ body=body, idempotency_key=_idempotency_key("topup")
294
+ )
295
+ except SolvaPayError as error:
296
+ return _error_from(error)
297
+ return intent_result(intent, customer_ref)
298
+
299
+ plan_ref = _text(args.get("planRef"))
300
+ if not plan_ref:
301
+ return tool_error_result(
302
+ "create_payment_intent plan requires planRef",
303
+ 400,
304
+ 'Pass purpose: "plan" with planRef and productRef.',
305
+ )
306
+ plan_body: Json = {
307
+ "productRef": _text(args.get("productRef")) or product_ref,
308
+ "planRef": plan_ref,
309
+ "customerRef": customer_ref,
310
+ }
311
+ plan_currency = _text(args.get("currency"))
312
+ if plan_currency:
313
+ plan_body["currency"] = plan_currency
314
+ try:
315
+ intent = await api.create_payment_intent(
316
+ body=plan_body, idempotency_key=_idempotency_key(f"payment-{plan_ref}")
317
+ )
318
+ except SolvaPayError as error:
319
+ return _error_from(error)
320
+ return intent_result(intent, customer_ref)
321
+
322
+ add(
323
+ CREATE_PAYMENT_INTENT,
324
+ create_payment_intent,
325
+ [
326
+ _param(
327
+ "purpose",
328
+ Annotated[
329
+ Literal["plan", "topup"],
330
+ Field(description='"plan" for plan checkout; "topup" for credit top-up.'),
331
+ ],
332
+ ),
333
+ _optional("planRef", str),
334
+ _optional("productRef", str),
335
+ _optional("currency", str),
336
+ _optional("amount", Annotated[int, Field(gt=0, le=MAX_SAFE_INTEGER)]),
337
+ _optional("description", str),
338
+ _optional("autoRecharge", AutoRechargeArg),
339
+ ],
340
+ "Create a Stripe payment intent for the authenticated customer. Pass purpose: "
341
+ '"plan" to purchase a plan (returns { clientSecret, publishableKey, accountId?, '
342
+ 'customerRef }) or purpose: "topup" for a credit top-up (credits are recorded by '
343
+ "webhook after confirmation). A topup may carry autoRecharge so the card entered for "
344
+ "the top-up is saved as the auto-recharge funding source.",
345
+ _solvapay_tool(read_only_hint=False, destructive_hint=False),
346
+ )
347
+
348
+ # ---- process_payment -----------------------------------------------------
349
+
350
+ async def process_payment(customer_ref: str, args: Mapping[str, Any]) -> CallToolResult:
351
+ payment_intent_id = _text(args.get("paymentIntentId"))
352
+ product = _text(args.get("productRef")) or product_ref
353
+ if not payment_intent_id:
354
+ return tool_error_result("paymentIntentId and productRef are required", 400)
355
+ body: Json = {"productRef": product, "customerRef": customer_ref}
356
+ plan_ref = _text(args.get("planRef"))
357
+ if plan_ref:
358
+ body["planRef"] = plan_ref
359
+ try:
360
+ result = await api.process_payment_intent(
361
+ processor_payment_id=payment_intent_id, body=body
362
+ )
363
+ except SolvaPayError as error:
364
+ return _error_from(error)
365
+ return tool_result(_dump(result))
366
+
367
+ add(
368
+ PROCESS_PAYMENT,
369
+ process_payment,
370
+ [
371
+ _param("paymentIntentId", str),
372
+ _param("productRef", str),
373
+ _optional("planRef", str),
374
+ ],
375
+ "Process a Stripe payment intent after client-side confirmation and create the "
376
+ "SolvaPay purchase. Call after confirmPayment resolves to short-circuit webhook latency.",
377
+ _solvapay_tool(destructive_hint=True),
378
+ )
379
+
380
+ # ---- attach_business_details ---------------------------------------------
381
+
382
+ async def attach_business_details(customer_ref: str, args: Mapping[str, Any]) -> CallToolResult:
383
+ payment_intent_id = _text(args.get("paymentIntentId"))
384
+ if not payment_intent_id:
385
+ return tool_error_result("paymentIntentId is required", 400)
386
+ try:
387
+ details = validate_business_details(
388
+ is_business=args.get("isBusiness") is True,
389
+ business_name=args.get("businessName"),
390
+ country=args.get("country"),
391
+ customer_country=args.get("customerCountry"),
392
+ customer_name=args.get("customerName"),
393
+ customer_state=args.get("customerState"),
394
+ customer_postal_code=args.get("customerPostalCode"),
395
+ tax_id=args.get("taxId"),
396
+ )
397
+ except SolvaPayError as error:
398
+ return tool_error_result(error.message, 400)
399
+ try:
400
+ result = await api.attach_business_details(
401
+ processor_payment_id=payment_intent_id,
402
+ body={**details, "customerRef": customer_ref},
403
+ )
404
+ except SolvaPayError as error:
405
+ return _error_from(error)
406
+ return tool_result(_dump(result))
407
+
408
+ add(
409
+ ATTACH_BUSINESS_DETAILS,
410
+ attach_business_details,
411
+ [
412
+ _param("paymentIntentId", str),
413
+ _param("isBusiness", bool),
414
+ _optional("businessName", str),
415
+ _optional("country", str),
416
+ _optional("customerCountry", str),
417
+ _optional("customerName", str),
418
+ _optional("customerState", str),
419
+ _optional("customerPostalCode", str),
420
+ _optional("taxId", str),
421
+ _optional("taxIdType", TaxIdType),
422
+ ],
423
+ "Attach business purchase details to a payment intent and retrieve the computed "
424
+ "tax breakdown.",
425
+ _solvapay_tool(read_only_hint=False, destructive_hint=False, idempotent_hint=True),
426
+ )
427
+
428
+ # ---- set_renewal ---------------------------------------------------------
429
+
430
+ def renewal_error(error: SolvaPayError, refused: str) -> CallToolResult:
431
+ if error.status == 404:
432
+ return tool_error_result("Purchase not found", 404, error.message)
433
+ if error.status == 400:
434
+ return tool_error_result(refused, 400, error.message)
435
+ return tool_error_result(error.message, 500, error.message)
436
+
437
+ async def set_renewal(_customer_ref: str, args: Mapping[str, Any]) -> CallToolResult:
438
+ enabled = args.get("enabled")
439
+ if enabled is not True and enabled is not False:
440
+ return tool_error_result(
441
+ "set_renewal requires enabled",
442
+ 400,
443
+ "Pass enabled: true to reactivate auto-renewal or enabled: false to cancel.",
444
+ )
445
+ purchase_ref = _text(args.get("purchaseRef"))
446
+ if not purchase_ref:
447
+ return tool_error_result("Missing required parameter: purchaseRef is required", 400)
448
+
449
+ if enabled:
450
+ try:
451
+ response: Json = _dump(await api.reactivate_purchase(purchase_ref=purchase_ref))
452
+ except SolvaPayError as error:
453
+ return renewal_error(error, "Purchase cannot be reactivated")
454
+ purchase = response.get("purchase")
455
+ purchase = purchase if isinstance(purchase, dict) else response
456
+ if not purchase.get("reference"):
457
+ return tool_error_result(
458
+ "Reactivate purchase response missing required fields", 500
459
+ )
460
+ if purchase.get("cancelledAt"):
461
+ return tool_error_result(
462
+ "Purchase reactivation failed: cancelledAt is still set", 500
463
+ )
464
+ await asyncio.sleep(SETTLE_SECONDS)
465
+ return tool_result(purchase)
466
+
467
+ reason = args.get("reason")
468
+ try:
469
+ response = _dump(
470
+ await api.cancel_purchase(
471
+ purchase_ref=purchase_ref,
472
+ body={"reason": reason} if isinstance(reason, str) and reason else {},
473
+ )
474
+ )
475
+ except SolvaPayError as error:
476
+ return renewal_error(
477
+ error, "Purchase cannot be cancelled or does not belong to provider"
478
+ )
479
+ purchase = response.get("purchase")
480
+ purchase = purchase if isinstance(purchase, dict) else response
481
+ if not purchase.get("reference"):
482
+ return tool_error_result("Cancel purchase response missing required fields", 500)
483
+ if purchase.get("status") != "cancelled" and not purchase.get("cancelledAt"):
484
+ return tool_error_result(
485
+ "Purchase cancellation failed: backend returned status "
486
+ f"'{purchase.get('status')}' without cancelledAt timestamp",
487
+ 500,
488
+ )
489
+ await asyncio.sleep(SETTLE_SECONDS)
490
+ return tool_result(purchase)
491
+
492
+ add(
493
+ SET_RENEWAL,
494
+ set_renewal,
495
+ [
496
+ _param("purchaseRef", str),
497
+ _param(
498
+ "enabled",
499
+ Annotated[
500
+ bool,
501
+ Field(description="true to reactivate auto-renewal; false to cancel renewal."),
502
+ ],
503
+ ),
504
+ _optional("reason", str),
505
+ ],
506
+ "Toggle auto-renewal on an active purchase. Pass enabled: false to cancel (access "
507
+ "continues until period end) or enabled: true to undo a pending cancellation.",
508
+ _solvapay_tool(destructive_hint=True, idempotent_hint=True),
509
+ )
510
+
511
+ # ---- get_history ---------------------------------------------------------
512
+
513
+ async def get_history(customer_ref: str, args: Mapping[str, Any]) -> CallToolResult:
514
+ product = _text(args.get("productRef")) or product_ref
515
+ limit = args.get("limit")
516
+ try:
517
+ purchases, activity = await asyncio.gather(
518
+ api.list_purchases(customer_ref=customer_ref, product_ref=product),
519
+ api.get_credit_activity(customer_ref=customer_ref, limit=limit),
520
+ )
521
+ except SolvaPayError as error:
522
+ return _error_from(error)
523
+ return tool_result(
524
+ {
525
+ "charges": [_dump(purchase) for purchase in purchases.purchases or []],
526
+ "creditActivity": _dump(activity),
527
+ }
528
+ )
529
+
530
+ add(
531
+ GET_HISTORY,
532
+ get_history,
533
+ [
534
+ _optional("productRef", str),
535
+ _optional("limit", Annotated[int, Field(gt=0, le=100)]),
536
+ ],
537
+ "Load product charge history and account-wide credit activity for the authenticated "
538
+ "customer. Charges come from purchases for this product; credit activity is every "
539
+ "credit event on the account.",
540
+ _solvapay_tool(read_only_hint=True, idempotent_hint=True),
541
+ )
542
+
543
+
544
+ __all__ = [
545
+ "TRANSPORT_TOOL_NAMES",
546
+ "UI_ONLY_PREFIX",
547
+ "AutoRechargeArg",
548
+ "register_transport_tools",
549
+ "tool_result",
550
+ ]
solvapay/money.py ADDED
@@ -0,0 +1,61 @@
1
+ """Money and credit display, matching ``@solvapay/core``'s credit-display helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from decimal import ROUND_HALF_UP, Decimal
7
+
8
+ from ._currencies import PREFIXES
9
+ from ._js import js_round
10
+
11
+ ZERO_DECIMAL = frozenset(
12
+ {
13
+ "bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga",
14
+ "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf",
15
+ }
16
+ ) # fmt: skip
17
+
18
+ _NBSP = "\u00a0"
19
+
20
+
21
+ def minor_units_per_major(currency: str) -> int:
22
+ return 1 if currency.lower() in ZERO_DECIMAL else 100
23
+
24
+
25
+ def is_zero_decimal_currency(currency: str) -> bool:
26
+ return currency.lower() in ZERO_DECIMAL
27
+
28
+
29
+ def credits_to_display_minor_units(
30
+ *,
31
+ credits: float,
32
+ credits_per_minor_unit: float,
33
+ display_exchange_rate: float,
34
+ display_currency: str,
35
+ ) -> int | None:
36
+ """Fiat value of a credit balance, in minor units of ``display_currency``."""
37
+ if not credits_per_minor_unit or credits_per_minor_unit <= 0:
38
+ return None
39
+ usd_major = credits / credits_per_minor_unit / 100
40
+ return js_round(
41
+ usd_major * (display_exchange_rate or 1) * minor_units_per_major(display_currency)
42
+ )
43
+
44
+
45
+ def format_minor(amount_minor: float, currency: str) -> str:
46
+ """An amount in minor units, as ``Intl.NumberFormat('en-US', { style: 'currency' })``
47
+ writes it with 0 decimals for zero-decimal currencies and 2 otherwise."""
48
+ divisor = minor_units_per_major(currency)
49
+ digits = 0 if divisor == 1 else 2
50
+ code = currency.upper()
51
+ major = Decimal(repr(float(amount_minor))) / divisor
52
+ if not re.fullmatch(r"[A-Z]{3}", code):
53
+ # Intl throws on a malformed code; the TypeScript SDK then falls back to this.
54
+ fallback = str(js_round(float(major))) if divisor == 1 else f"{float(major):.2f}"
55
+ return f"{code} {fallback}"
56
+ quantized = major.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP)
57
+ sign = "-" if quantized < 0 else ""
58
+ whole, _, fraction = f"{abs(quantized):f}".partition(".")
59
+ amount = f"{int(whole):,}" + (f".{fraction}" if digits else "")
60
+ prefix = PREFIXES.get(code, code + _NBSP)
61
+ return f"{sign}{prefix}{amount}"
@@ -0,0 +1,25 @@
1
+ """The paywall: classify a limits response, build the gate and its message."""
2
+
3
+ from .gate import build_paywall_gate
4
+ from .state import (
5
+ CHECKOUT_SESSION_TTL_MINUTES,
6
+ PaywallNextAction,
7
+ PaywallStateKind,
8
+ build_gate_message,
9
+ build_nudge_message,
10
+ classify_paywall_state,
11
+ credit_signals,
12
+ next_action_for,
13
+ )
14
+
15
+ __all__ = [
16
+ "CHECKOUT_SESSION_TTL_MINUTES",
17
+ "PaywallNextAction",
18
+ "PaywallStateKind",
19
+ "build_gate_message",
20
+ "build_nudge_message",
21
+ "build_paywall_gate",
22
+ "classify_paywall_state",
23
+ "credit_signals",
24
+ "next_action_for",
25
+ ]