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