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 +8 -0
- solverr/_client.py +48 -0
- solverr/_config.py +38 -0
- solverr/_errors.py +38 -0
- solverr/_http.py +192 -0
- solverr/py.typed +0 -0
- solverr/resources/__init__.py +10 -0
- solverr/resources/credits.py +45 -0
- solverr/resources/invoices.py +80 -0
- solverr/resources/payment_links.py +33 -0
- solverr/resources/payouts.py +51 -0
- solverr/resources/refunds.py +40 -0
- solverr/resources/subscriptions.py +40 -0
- solverr/resources/tokens.py +15 -0
- solverr/resources/webhooks.py +15 -0
- solverr/types.py +298 -0
- solverr/webhooks.py +109 -0
- solverr-0.1.0.dist-info/METADATA +235 -0
- solverr-0.1.0.dist-info/RECORD +21 -0
- solverr-0.1.0.dist-info/WHEEL +4 -0
- solverr-0.1.0.dist-info/licenses/LICENSE +21 -0
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"))
|