solverr 0.1.0__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.
solverr/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Server SDK for the Solverr crypto payment API."""
2
+
3
+ from . import types, webhooks
4
+ from ._client import Solverr
5
+ from ._config import SDK_VERSION as __version__
6
+ from ._errors import SolverrError, WebhookVerificationError
7
+
8
+ __all__ = ["Solverr", "SolverrError", "WebhookVerificationError", "__version__", "types", "webhooks"]
solverr/_client.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import urllib.parse
6
+
7
+ from ._config import BASE_URL_ENV, LOCAL_HOSTS
8
+ from ._http import HttpClient
9
+ from .resources import Credits, Invoices, PaymentLinks, Payouts, Refunds, Subscriptions, Tokens, Webhooks
10
+
11
+ _API_KEY_PATTERN = re.compile(r"sk_live_[\x21-\x7e]+")
12
+ # Whitespace and control characters, which http.client refuses in a URL.
13
+ _URL_UNSAFE = re.compile(r"[\s\x00-\x1f\x7f]")
14
+
15
+
16
+ def _resolve_base_url(base_url: str | None) -> str:
17
+ """Validates the base URL and drops a trailing slash so API paths can be appended."""
18
+ value = base_url if base_url is not None else os.environ.get(BASE_URL_ENV)
19
+ if not value:
20
+ raise ValueError(f"Pass base_url to Solverr() or set the {BASE_URL_ENV} environment variable.")
21
+ parts = urllib.parse.urlsplit(value)
22
+ if not parts.netloc or _URL_UNSAFE.search(value):
23
+ raise ValueError(f"base_url is not a valid URL: {value}")
24
+ if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in LOCAL_HOSTS):
25
+ raise ValueError("base_url must be an https URL (http only for localhost).")
26
+ # "?" or "#" with nothing after it gives an empty query or fragment, yet still breaks the appended API paths.
27
+ if "?" in value or "#" in value:
28
+ raise ValueError("base_url must not have a query string or fragment.")
29
+ return value.rstrip("/")
30
+
31
+
32
+ class Solverr:
33
+ """Client for the Solverr API; ``api_key`` is an API key starting with ``sk_live_``.
34
+
35
+ ``base_url`` is the origin of the Solverr app, such as ``"https://pay.example.com"``; defaults to ``SOLVERR_URL``."""
36
+
37
+ def __init__(self, api_key: str | None, base_url: str | None = None) -> None:
38
+ if not api_key or not _API_KEY_PATTERN.fullmatch(api_key):
39
+ raise ValueError("api_key must be a Solverr API key starting with sk_live_.")
40
+ http = HttpClient(api_key, _resolve_base_url(base_url))
41
+ self.invoices = Invoices(http)
42
+ self.payment_links = PaymentLinks(http)
43
+ self.refunds = Refunds(http)
44
+ self.payouts = Payouts(http)
45
+ self.subscriptions = Subscriptions(http)
46
+ self.credits = Credits(http)
47
+ self.tokens = Tokens(http)
48
+ self.webhooks = Webhooks(http)
solverr/_config.py ADDED
@@ -0,0 +1,38 @@
1
+ SDK_VERSION = "0.1.0"
2
+
3
+ BASE_URL_ENV = "SOLVERR_URL"
4
+
5
+ # Hosts that may serve the API over plain http, for local development; every other host needs https.
6
+ LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
7
+
8
+ # Socket timeout for connecting and for each read; reading the body also stops once this many seconds have passed since the attempt began.
9
+ REQUEST_TIMEOUT_SECONDS = 15.0
10
+
11
+ # Largest piece of a response body read at once, between checks of the request deadline.
12
+ READ_CHUNK_BYTES = 65536
13
+
14
+ # Extra attempts after a network error or a 5xx response.
15
+ MAX_RETRIES = 2
16
+
17
+ # Delay before the first retry; each later retry doubles it.
18
+ RETRY_BASE_DELAY_SECONDS = 0.5
19
+
20
+ # Extra attempts after a 429, or a 409 for a key that is still being processed, each after its Retry-After wait.
21
+ MAX_RETRY_AFTER_RETRIES = 3
22
+
23
+ # Longest Retry-After the client waits for; a longer one is raised as an error.
24
+ MAX_RETRY_AFTER_SECONDS = 10
25
+
26
+ WEBHOOK_TOLERANCE_SECONDS = 300
27
+
28
+ # Standard Webhooks headers (standardwebhooks.com), lowercase for lookups.
29
+ WEBHOOK_ID_HEADER = "webhook-id"
30
+
31
+ WEBHOOK_TIMESTAMP_HEADER = "webhook-timestamp"
32
+
33
+ WEBHOOK_SIGNATURE_HEADER = "webhook-signature"
34
+
35
+ WEBHOOK_SECRET_PREFIX = "whsec_"
36
+
37
+ # Shortest signing key accepted after decoding; Standard Webhooks secrets are 24 to 64 bytes.
38
+ WEBHOOK_SECRET_MIN_BYTES = 24
solverr/_errors.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SolverrError(Exception):
5
+ """An error returned by the Solverr API, or a request that never got a response (``status`` 0)."""
6
+
7
+ def __init__(
8
+ self,
9
+ status: int,
10
+ type: str,
11
+ message: str,
12
+ param: str | None = None,
13
+ code: str | None = None,
14
+ retry_after: int | None = None,
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.type = type
19
+ self.message = message
20
+ self.param = param
21
+ # Finer reason inside ``type``, such as ``invoice_paid``, when the API sends one.
22
+ self.code = code
23
+ # Seconds the API asked to wait before trying again (its ``Retry-After`` header), such as on a 429.
24
+ self.retry_after = retry_after
25
+
26
+ def __reduce__(self) -> tuple[type[SolverrError], tuple[int, str, str, str | None, str | None, int | None]]:
27
+ """Rebuilds the error from all its fields, so it survives pickling across processes and ``copy``."""
28
+ return (SolverrError, (self.status, self.type, self.message, self.param, self.code, self.retry_after))
29
+
30
+ def __repr__(self) -> str:
31
+ return (
32
+ f"SolverrError(status={self.status!r}, type={self.type!r}, code={self.code!r}, "
33
+ f"message={self.message!r}, param={self.param!r}, retry_after={self.retry_after!r})"
34
+ )
35
+
36
+
37
+ class WebhookVerificationError(Exception):
38
+ """A webhook whose signature, timestamp or body could not be verified."""
solverr/_http.py ADDED
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import json
5
+ import random
6
+ import re
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+ from collections.abc import Mapping
13
+ from email.message import Message
14
+ from typing import Any, Literal
15
+
16
+ from ._config import (
17
+ MAX_RETRIES,
18
+ MAX_RETRY_AFTER_RETRIES,
19
+ MAX_RETRY_AFTER_SECONDS,
20
+ READ_CHUNK_BYTES,
21
+ REQUEST_TIMEOUT_SECONDS,
22
+ RETRY_BASE_DELAY_SECONDS,
23
+ SDK_VERSION,
24
+ )
25
+ from ._errors import SolverrError
26
+
27
+ Method = Literal["GET", "POST", "DELETE"]
28
+
29
+ # Characters http.client cannot send in a header value; such a key fails before any request is sent.
30
+ _INVALID_HEADER_VALUE = re.compile(r"[\x00\r\n]|[^\x00-\xff]")
31
+
32
+
33
+ class _RejectRedirects(urllib.request.HTTPRedirectHandler):
34
+ """Returns a 3xx response as an error instead of following it, so credentials never move to another URL."""
35
+
36
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
37
+ return None
38
+
39
+
40
+ def _retry_delay(attempt: int) -> float:
41
+ """Exponential backoff with up to 25% random jitter."""
42
+ return RETRY_BASE_DELAY_SECONDS * 2.0**attempt * (1 + random.random() / 4)
43
+
44
+
45
+ def path_segment(value: str) -> str:
46
+ """An id as one URL path segment; empty, ``.`` and ``..`` are refused because they would point at another path."""
47
+ if value in ("", ".", ".."):
48
+ raise ValueError(f"{value!r} is not a valid id.")
49
+ return urllib.parse.quote(value, safe="")
50
+
51
+
52
+ def _read_body(response: Any, deadline: float) -> bytes:
53
+ """Reads the response body piece by piece and gives up with ``TimeoutError`` once ``deadline`` has passed."""
54
+ chunks: list[bytes] = []
55
+ while True:
56
+ if time.monotonic() > deadline:
57
+ raise TimeoutError(f"no complete response within {REQUEST_TIMEOUT_SECONDS:g} seconds")
58
+ chunk = response.read1(READ_CHUNK_BYTES)
59
+ if not chunk:
60
+ return b"".join(chunks)
61
+ chunks.append(chunk)
62
+
63
+
64
+ def _parse_json(text: bytes) -> Any:
65
+ if not text:
66
+ return None
67
+ try:
68
+ return json.loads(text)
69
+ except ValueError:
70
+ return None
71
+
72
+
73
+ def _retry_after_seconds(value: str | None) -> int | None:
74
+ """``Retry-After`` in seconds; only the delay-seconds form is read, as the API never sends an HTTP date."""
75
+ return int(value) if value is not None and value.isascii() and value.isdigit() else None
76
+
77
+
78
+ def _to_api_error(status: int, payload: Any, retry_after: str | None) -> SolverrError:
79
+ seconds = _retry_after_seconds(retry_after)
80
+ error = payload.get("error") if isinstance(payload, dict) else None
81
+ if isinstance(error, dict) and isinstance(error.get("type"), str) and isinstance(error.get("message"), str):
82
+ param, code = error.get("param"), error.get("code")
83
+ return SolverrError(
84
+ status,
85
+ error["type"],
86
+ error["message"],
87
+ param if isinstance(param, str) else None,
88
+ code if isinstance(code, str) else None,
89
+ seconds,
90
+ )
91
+ return SolverrError(status, "api_error", f"Solverr returned HTTP {status}.", retry_after=seconds)
92
+
93
+
94
+ def _retry_after_delay(status: int, retry_after: str | None, payload: Any) -> float | None:
95
+ """Seconds to wait before retrying a 429 or an in-progress Idempotency-Key 409; None when it must not be retried."""
96
+ error = payload.get("error") if isinstance(payload, dict) else None
97
+ error_type = error.get("type") if isinstance(error, dict) else None
98
+ retryable = status == 429 or (status == 409 and error_type == "idempotency_conflict")
99
+ seconds = _retry_after_seconds(retry_after)
100
+ if not retryable or seconds is None:
101
+ return None
102
+ return float(seconds) if seconds <= MAX_RETRY_AFTER_SECONDS else None
103
+
104
+
105
+ class HttpClient:
106
+ """Sends authenticated JSON requests, retrying network errors and 5xx responses, and 429s or in-progress 409s after a short ``Retry-After``."""
107
+
108
+ def __init__(self, api_key: str, base_url: str) -> None:
109
+ self._api_key = api_key
110
+ self._base_url = base_url
111
+ self._opener = urllib.request.build_opener(_RejectRedirects)
112
+
113
+ def request(
114
+ self,
115
+ method: Method,
116
+ path: str,
117
+ *,
118
+ query: Mapping[str, str | int | None] | None = None,
119
+ body: Mapping[str, Any] | None = None,
120
+ idempotency_key: str | None = None,
121
+ ) -> Any:
122
+ url = self._url(path, query or {})
123
+ headers = {
124
+ "Accept": "application/json",
125
+ "Authorization": f"Bearer {self._api_key}",
126
+ "User-Agent": f"solverr-python/{SDK_VERSION}",
127
+ }
128
+ payload: bytes | None = None
129
+ if method == "POST":
130
+ # The same key is sent on every retry, so the server never applies a POST twice.
131
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key is not None else str(uuid.uuid4())
132
+ if _INVALID_HEADER_VALUE.search(headers["Idempotency-Key"]):
133
+ raise ValueError("idempotency_key cannot contain line breaks, NUL or characters above U+00FF.")
134
+ if body is not None:
135
+ # Fields set to None are left out, because the API refuses null in place of an omitted optional field.
136
+ fields = {name: value for name, value in body.items() if value is not None}
137
+ headers["Content-Type"] = "application/json"
138
+ payload = json.dumps(fields, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
139
+
140
+ attempt = 0
141
+ retry_after_retries = 0
142
+ while True:
143
+ can_retry = attempt < MAX_RETRIES
144
+ try:
145
+ status, response_headers, text = self._send(urllib.request.Request(url, data=payload, headers=headers, method=method))
146
+ except (urllib.error.URLError, http.client.HTTPException, OSError) as error:
147
+ if can_retry:
148
+ time.sleep(_retry_delay(attempt))
149
+ attempt += 1
150
+ continue
151
+ reason = getattr(error, "reason", error)
152
+ raise SolverrError(0, "connection_error", f"Could not reach Solverr at {self._base_url}: {reason}") from error
153
+
154
+ if 300 <= status < 400:
155
+ location = response_headers.get("Location")
156
+ target = f" to {location}" if location else ""
157
+ raise SolverrError(
158
+ status, "api_error", f"Solverr answered with a redirect{target}. Set base_url to the address Solverr is served from."
159
+ )
160
+ if status >= 500 and can_retry:
161
+ time.sleep(_retry_delay(attempt))
162
+ attempt += 1
163
+ continue
164
+ parsed = _parse_json(text)
165
+ retry_after = response_headers.get("Retry-After")
166
+ delay = _retry_after_delay(status, retry_after, parsed) if retry_after_retries < MAX_RETRY_AFTER_RETRIES else None
167
+ if delay is not None:
168
+ time.sleep(delay)
169
+ retry_after_retries += 1
170
+ continue
171
+ if status < 200 or status >= 300:
172
+ raise _to_api_error(status, parsed, retry_after)
173
+ if parsed is None:
174
+ raise SolverrError(status, "api_error", "Solverr returned a response that is not JSON.")
175
+ return parsed
176
+
177
+ def _send(self, request: urllib.request.Request) -> tuple[int, Message, bytes]:
178
+ """One HTTP attempt; returns the status, headers and body for every response, including 3xx, 4xx and 5xx.
179
+
180
+ ``REQUEST_TIMEOUT_SECONDS`` is the socket timeout, and the body read stops at that deadline, so a body sent a byte at a time cannot hold the call open."""
181
+ deadline = time.monotonic() + REQUEST_TIMEOUT_SECONDS
182
+ try:
183
+ with self._opener.open(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
184
+ return response.status, response.headers, _read_body(response, deadline)
185
+ except urllib.error.HTTPError as error:
186
+ with error:
187
+ return error.code, error.headers, _read_body(error, deadline)
188
+
189
+ def _url(self, path: str, query: Mapping[str, str | int | None]) -> str:
190
+ url = self._base_url + path
191
+ params = {name: str(value) for name, value in query.items() if value is not None}
192
+ return f"{url}?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}" if params else url
solverr/py.typed ADDED
File without changes
@@ -0,0 +1,10 @@
1
+ from .credits import Credits
2
+ from .invoices import Invoices
3
+ from .payment_links import PaymentLinks
4
+ from .payouts import Payouts
5
+ from .refunds import Refunds
6
+ from .subscriptions import Subscriptions
7
+ from .tokens import Tokens
8
+ from .webhooks import Webhooks
9
+
10
+ __all__ = ["Credits", "Invoices", "PaymentLinks", "Payouts", "Refunds", "Subscriptions", "Tokens", "Webhooks"]
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, cast
4
+
5
+ from .._http import HttpClient, path_segment
6
+ from ..types import CreditCharge, CreditTopup
7
+
8
+
9
+ class Credits:
10
+ def __init__(self, http: HttpClient) -> None:
11
+ self._http = http
12
+
13
+ def topup(
14
+ self,
15
+ *,
16
+ amount: str,
17
+ currency: Literal["USD"],
18
+ credit_account_id: str | None = None,
19
+ idempotency_key: str | None = None,
20
+ ) -> CreditTopup:
21
+ """Creates a USD credit account and the invoice that funds it; the balance is added once the invoice is paid.
22
+
23
+ Pass ``credit_account_id`` to add to an existing account instead of creating a new one (and a new token)."""
24
+ body = {"amount": amount, "currency": currency, "credit_account_id": credit_account_id}
25
+ return cast(CreditTopup, self._http.request("POST", "/api/v1/credits/topup", body=body, idempotency_key=idempotency_key))
26
+
27
+ def charge(
28
+ self,
29
+ *,
30
+ token: str,
31
+ amount: str,
32
+ currency: Literal["USD"] | None = None,
33
+ description: str | None = None,
34
+ idempotency_key: str | None = None,
35
+ ) -> CreditCharge:
36
+ """Deducts an amount from the credit account that owns ``token`` and returns the charge with the remaining balance.
37
+
38
+ Credit balances are kept in USD; other currencies are rejected."""
39
+ body = {"token": token, "amount": amount, "currency": currency, "description": description}
40
+ return cast(CreditCharge, self._http.request("POST", "/api/v1/credits/charge", body=body, idempotency_key=idempotency_key))
41
+
42
+ def refund_charge(self, charge_id: str, *, idempotency_key: str | None = None) -> CreditCharge:
43
+ """Gives a charge back to its credit account, once; for a request the charge paid for that then failed."""
44
+ path = f"/api/v1/credits/charges/{path_segment(charge_id)}/refund"
45
+ return cast(CreditCharge, self._http.request("POST", path, body={}, idempotency_key=idempotency_key))
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Mapping
4
+ from typing import cast
5
+
6
+ from .._http import HttpClient, path_segment
7
+ from ..types import Currency, Invoice, InvoiceList, InvoiceRedemption, InvoiceStatus
8
+
9
+
10
+ def _invoice_path(invoice_id: str) -> str:
11
+ return f"/api/v1/invoices/{path_segment(invoice_id)}"
12
+
13
+
14
+ class Invoices:
15
+ def __init__(self, http: HttpClient) -> None:
16
+ self._http = http
17
+
18
+ def create(
19
+ self,
20
+ *,
21
+ amount: str,
22
+ currency: Currency,
23
+ order_id: str | None = None,
24
+ description: str | None = None,
25
+ redirect_url: str | None = None,
26
+ customer_email: str | None = None,
27
+ metadata: Mapping[str, str] | None = None,
28
+ expires_in: int | None = None,
29
+ idempotency_key: str | None = None,
30
+ ) -> Invoice:
31
+ """Creates an invoice; send the customer to its ``checkout_url``.
32
+
33
+ ``amount`` has up to two decimals, such as ``"25.00"``; ``expires_in`` is the invoice lifetime in seconds, 900 to 86400."""
34
+ body = {
35
+ "amount": amount,
36
+ "currency": currency,
37
+ "order_id": order_id,
38
+ "description": description,
39
+ "redirect_url": redirect_url,
40
+ "customer_email": customer_email,
41
+ "metadata": dict(metadata) if metadata is not None else None,
42
+ "expires_in": expires_in,
43
+ }
44
+ return cast(Invoice, self._http.request("POST", "/api/v1/invoices", body=body, idempotency_key=idempotency_key))
45
+
46
+ def retrieve(self, invoice_id: str) -> Invoice:
47
+ return cast(Invoice, self._http.request("GET", _invoice_path(invoice_id)))
48
+
49
+ def list(
50
+ self,
51
+ *,
52
+ limit: int | None = None,
53
+ starting_after: str | None = None,
54
+ status: InvoiceStatus | None = None,
55
+ ) -> InvoiceList:
56
+ """One page of invoices, newest first. ``limit`` is 1 to 100."""
57
+ query = {"limit": limit, "starting_after": starting_after, "status": status}
58
+ return cast(InvoiceList, self._http.request("GET", "/api/v1/invoices", query=query))
59
+
60
+ def list_all(
61
+ self,
62
+ *,
63
+ limit: int | None = None,
64
+ starting_after: str | None = None,
65
+ status: InvoiceStatus | None = None,
66
+ ) -> Iterator[Invoice]:
67
+ """Every matching invoice, newest first, fetching pages as the loop advances."""
68
+ while True:
69
+ page = self.list(limit=limit, starting_after=starting_after, status=status)
70
+ yield from page["data"]
71
+ if not page["has_more"] or not page["data"]:
72
+ return
73
+ starting_after = page["data"][-1]["id"]
74
+
75
+ def redeem(self, invoice_id: str, *, idempotency_key: str | None = None) -> InvoiceRedemption:
76
+ """Marks a paid invoice as used, so it grants access only once.
77
+
78
+ Raises ``SolverrError`` 409 with type ``invalid_state`` when unpaid, or ``already_redeemed`` when used before."""
79
+ path = f"{_invoice_path(invoice_id)}/redeem"
80
+ return cast(InvoiceRedemption, self._http.request("POST", path, idempotency_key=idempotency_key))
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from .._http import HttpClient, path_segment
6
+ from ..types import Currency, PaymentLink, PaymentLinkList
7
+
8
+
9
+ class PaymentLinks:
10
+ def __init__(self, http: HttpClient) -> None:
11
+ self._http = http
12
+
13
+ def create(
14
+ self,
15
+ *,
16
+ title: str,
17
+ currency: Currency,
18
+ amount: str | None = None,
19
+ redirect_url: str | None = None,
20
+ idempotency_key: str | None = None,
21
+ ) -> PaymentLink:
22
+ """Creates a payment link; leave out ``amount`` to let the customer choose it, and ``redirect_url`` to send customers to the business website after paying."""
23
+ body = {"title": title, "amount": amount, "currency": currency, "redirect_url": redirect_url}
24
+ return cast(PaymentLink, self._http.request("POST", "/api/v1/payment-links", body=body, idempotency_key=idempotency_key))
25
+
26
+ def list(self, *, limit: int | None = None, starting_after: str | None = None) -> PaymentLinkList:
27
+ query = {"limit": limit, "starting_after": starting_after}
28
+ return cast(PaymentLinkList, self._http.request("GET", "/api/v1/payment-links", query=query))
29
+
30
+ def deactivate(self, link_id: str) -> PaymentLink:
31
+ """Turns a link off and returns it with ``active`` false; invoices already created from it stay payable."""
32
+ path = f"/api/v1/payment-links/{path_segment(link_id)}"
33
+ return cast(PaymentLink, self._http.request("DELETE", path))
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import cast
5
+
6
+ from .._http import HttpClient, path_segment
7
+ from ..types import Payout, PayoutBatch, PayoutList
8
+
9
+
10
+ def _payout_path(payout_id: str) -> str:
11
+ return f"/api/v1/payouts/{path_segment(payout_id)}"
12
+
13
+
14
+ class Payouts:
15
+ def __init__(self, http: HttpClient) -> None:
16
+ self._http = http
17
+
18
+ def create(
19
+ self,
20
+ *,
21
+ recipient: str,
22
+ asset_id: str,
23
+ amount: str,
24
+ idempotency_key: str | None = None,
25
+ ) -> Payout:
26
+ """Creates a payout of the coin ``asset_id``, taken from ``tokens.list()``; the business completes it by signing at ``sign_url``."""
27
+ body = {"recipient": recipient, "asset_id": asset_id, "amount": amount}
28
+ return cast(Payout, self._http.request("POST", "/api/v1/payouts", body=body, idempotency_key=idempotency_key))
29
+
30
+ def create_batch(
31
+ self,
32
+ *,
33
+ payouts: Sequence[Mapping[str, str]],
34
+ idempotency_key: str | None = None,
35
+ ) -> PayoutBatch:
36
+ """Creates 1 to 200 payouts that share one ``batch_id``; every row is checked before any payout is created.
37
+
38
+ Each item has the keys ``recipient``, ``asset_id`` and ``amount``."""
39
+ body = {"payouts": [dict(payout) for payout in payouts]}
40
+ return cast(PayoutBatch, self._http.request("POST", "/api/v1/payouts/batch", body=body, idempotency_key=idempotency_key))
41
+
42
+ def retrieve(self, payout_id: str) -> Payout:
43
+ return cast(Payout, self._http.request("GET", _payout_path(payout_id)))
44
+
45
+ def list(self, *, limit: int | None = None, starting_after: str | None = None) -> PayoutList:
46
+ query = {"limit": limit, "starting_after": starting_after}
47
+ return cast(PayoutList, self._http.request("GET", "/api/v1/payouts", query=query))
48
+
49
+ def cancel(self, payout_id: str, *, idempotency_key: str | None = None) -> Payout:
50
+ """Cancels a payout that was not sent. Raises a 409 error once it was sent."""
51
+ return cast(Payout, self._http.request("POST", f"{_payout_path(payout_id)}/cancel", body={}, idempotency_key=idempotency_key))
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from .._http import HttpClient, path_segment
6
+ from ..types import Refund, RefundList
7
+
8
+
9
+ def _refund_path(refund_id: str) -> str:
10
+ return f"/api/v1/refunds/{path_segment(refund_id)}"
11
+
12
+
13
+ class Refunds:
14
+ def __init__(self, http: HttpClient) -> None:
15
+ self._http = http
16
+
17
+ def create(
18
+ self,
19
+ *,
20
+ invoice_id: str,
21
+ payment_id: str | None = None,
22
+ amount: str | None = None,
23
+ idempotency_key: str | None = None,
24
+ ) -> Refund:
25
+ """Refunds the payment that paid the invoice, or ``payment_id`` for an extra payment; the business signs it at ``sign_url``.
26
+
27
+ ``amount`` is in the settlement coin; leave it out to refund what is left of that payment."""
28
+ body = {"invoice_id": invoice_id, "payment_id": payment_id, "amount": amount}
29
+ return cast(Refund, self._http.request("POST", "/api/v1/refunds", body=body, idempotency_key=idempotency_key))
30
+
31
+ def retrieve(self, refund_id: str) -> Refund:
32
+ return cast(Refund, self._http.request("GET", _refund_path(refund_id)))
33
+
34
+ def list(self, *, limit: int | None = None, starting_after: str | None = None) -> RefundList:
35
+ query = {"limit": limit, "starting_after": starting_after}
36
+ return cast(RefundList, self._http.request("GET", "/api/v1/refunds", query=query))
37
+
38
+ def cancel(self, refund_id: str, *, idempotency_key: str | None = None) -> Refund:
39
+ """Cancels a refund that was not sent; its amount can then be refunded again. Raises a 409 error once it was sent."""
40
+ return cast(Refund, self._http.request("POST", f"{_refund_path(refund_id)}/cancel", body={}, idempotency_key=idempotency_key))
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from .._http import HttpClient, path_segment
6
+ from ..types import Currency, Subscription, SubscriptionInterval, SubscriptionList
7
+
8
+
9
+ class Subscriptions:
10
+ def __init__(self, http: HttpClient) -> None:
11
+ self._http = http
12
+
13
+ def create(
14
+ self,
15
+ *,
16
+ customer_email: str,
17
+ amount: str,
18
+ currency: Currency,
19
+ interval: SubscriptionInterval,
20
+ description: str | None = None,
21
+ idempotency_key: str | None = None,
22
+ ) -> Subscription:
23
+ """Creates a subscription that issues an invoice to the customer every ``interval``."""
24
+ body = {
25
+ "customer_email": customer_email,
26
+ "amount": amount,
27
+ "currency": currency,
28
+ "interval": interval,
29
+ "description": description,
30
+ }
31
+ return cast(Subscription, self._http.request("POST", "/api/v1/subscriptions", body=body, idempotency_key=idempotency_key))
32
+
33
+ def list(self, *, limit: int | None = None, starting_after: str | None = None) -> SubscriptionList:
34
+ query = {"limit": limit, "starting_after": starting_after}
35
+ return cast(SubscriptionList, self._http.request("GET", "/api/v1/subscriptions", query=query))
36
+
37
+ def cancel(self, subscription_id: str) -> Subscription:
38
+ """Cancels a subscription and returns it with status ``canceled``; invoices already sent stay payable."""
39
+ path = f"/api/v1/subscriptions/{path_segment(subscription_id)}"
40
+ return cast(Subscription, self._http.request("DELETE", path))
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from .._http import HttpClient
6
+ from ..types import TokenList
7
+
8
+
9
+ class Tokens:
10
+ def __init__(self, http: HttpClient) -> None:
11
+ self._http = http
12
+
13
+ def list(self) -> TokenList:
14
+ """Lists the coins the checkout accepts, with USD prices; the API caches the list for 60 seconds."""
15
+ return cast(TokenList, self._http.request("GET", "/api/v1/tokens"))
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from .._http import HttpClient
6
+ from ..types import WebhookTest
7
+
8
+
9
+ class Webhooks:
10
+ def __init__(self, http: HttpClient) -> None:
11
+ self._http = http
12
+
13
+ def send_test(self) -> WebhookTest:
14
+ """Queues a signed ``test.ping`` event to the business's webhook URL; raises ``SolverrError`` 409 when none is set."""
15
+ return cast(WebhookTest, self._http.request("POST", "/api/v1/webhooks/test"))