altpay-py 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.
- altpay/__init__.py +119 -0
- altpay/__meta__.py +5 -0
- altpay/client/__init__.py +6 -0
- altpay/client/async_client.py +114 -0
- altpay/client/base.py +149 -0
- altpay/client/sync_client.py +149 -0
- altpay/credentials.py +40 -0
- altpay/enums.py +94 -0
- altpay/errors.py +246 -0
- altpay/methods/__init__.py +8 -0
- altpay/methods/account.py +54 -0
- altpay/methods/base.py +61 -0
- altpay/methods/invoices.py +257 -0
- altpay/methods/withdrawals.py +119 -0
- altpay/models.py +369 -0
- altpay/py.typed +0 -0
- altpay/signing.py +226 -0
- altpay/webhook.py +88 -0
- altpay_py-0.1.0.dist-info/METADATA +170 -0
- altpay_py-0.1.0.dist-info/RECORD +22 -0
- altpay_py-0.1.0.dist-info/WHEEL +4 -0
- altpay_py-0.1.0.dist-info/licenses/LICENSE +21 -0
altpay/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""AltPay - the official Python SDK for the AltPay crypto-payments API.
|
|
2
|
+
|
|
3
|
+
Quick start (sync)::
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from altpay import AltPay, Credentials
|
|
7
|
+
|
|
8
|
+
client = AltPay(Credentials(
|
|
9
|
+
merchant_id="YOUR_MERCHANT_ID",
|
|
10
|
+
api_key="vc_live_...",
|
|
11
|
+
api_secret="YOUR_API_SECRET",
|
|
12
|
+
))
|
|
13
|
+
invoice = client.invoices.create(
|
|
14
|
+
uuid="order-42",
|
|
15
|
+
amount=Decimal("100.00"),
|
|
16
|
+
fiat_currency="USD",
|
|
17
|
+
url_callback="https://example.com/altpay/webhook",
|
|
18
|
+
)
|
|
19
|
+
print(invoice.url)
|
|
20
|
+
|
|
21
|
+
Quick start (async)::
|
|
22
|
+
|
|
23
|
+
from altpay import AsyncAltPay, Credentials
|
|
24
|
+
|
|
25
|
+
async with AsyncAltPay(creds) as client:
|
|
26
|
+
invoice = await client.invoices.create(uuid="order-42", amount="100.00", fiat_currency="USD")
|
|
27
|
+
|
|
28
|
+
Verifying a webhook::
|
|
29
|
+
|
|
30
|
+
from altpay import WebhookVerifier
|
|
31
|
+
verifier = WebhookVerifier(webhook_secret, target="/altpay/webhook")
|
|
32
|
+
event = verifier.parse(raw_body, request_headers) # raises on a bad signature
|
|
33
|
+
|
|
34
|
+
Documentation: https://docs.altpay.money/docs
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from .__meta__ import __version__
|
|
38
|
+
from .client import AltPay, AsyncAltPay
|
|
39
|
+
from .credentials import Credentials
|
|
40
|
+
from .enums import FiatCurrency, MerchantStatus, PaymentMethod, PaymentStatus, WithdrawalStatus
|
|
41
|
+
from .errors import (
|
|
42
|
+
AltPayError,
|
|
43
|
+
AltPayTransportError,
|
|
44
|
+
APIError,
|
|
45
|
+
AuthenticationError,
|
|
46
|
+
ConflictError,
|
|
47
|
+
Forbidden,
|
|
48
|
+
NotFoundError,
|
|
49
|
+
PermissionError_,
|
|
50
|
+
RateLimitError,
|
|
51
|
+
ServerError,
|
|
52
|
+
ValidationError,
|
|
53
|
+
)
|
|
54
|
+
from .models import (
|
|
55
|
+
Account,
|
|
56
|
+
AssetBalance,
|
|
57
|
+
AssetBalanceItem,
|
|
58
|
+
Balance,
|
|
59
|
+
BalanceAsset,
|
|
60
|
+
Invoice,
|
|
61
|
+
InvoicePage,
|
|
62
|
+
MethodBalance,
|
|
63
|
+
MethodBalanceItem,
|
|
64
|
+
Service,
|
|
65
|
+
ServiceCommission,
|
|
66
|
+
Statistics,
|
|
67
|
+
Wallet,
|
|
68
|
+
WalletDeposit,
|
|
69
|
+
WebhookEvent,
|
|
70
|
+
Withdrawal,
|
|
71
|
+
WithdrawalFee,
|
|
72
|
+
)
|
|
73
|
+
from .webhook import WebhookVerifier
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"__version__",
|
|
77
|
+
# Clients
|
|
78
|
+
"AltPay",
|
|
79
|
+
"AsyncAltPay",
|
|
80
|
+
"Credentials",
|
|
81
|
+
# Webhooks
|
|
82
|
+
"WebhookVerifier",
|
|
83
|
+
# Enums
|
|
84
|
+
"PaymentStatus",
|
|
85
|
+
"PaymentMethod",
|
|
86
|
+
"FiatCurrency",
|
|
87
|
+
"MerchantStatus",
|
|
88
|
+
"WithdrawalStatus",
|
|
89
|
+
# Models
|
|
90
|
+
"Invoice",
|
|
91
|
+
"InvoicePage",
|
|
92
|
+
"Service",
|
|
93
|
+
"ServiceCommission",
|
|
94
|
+
"Balance",
|
|
95
|
+
"BalanceAsset",
|
|
96
|
+
"AssetBalance",
|
|
97
|
+
"AssetBalanceItem",
|
|
98
|
+
"Wallet",
|
|
99
|
+
"WalletDeposit",
|
|
100
|
+
"Account",
|
|
101
|
+
"MethodBalance",
|
|
102
|
+
"MethodBalanceItem",
|
|
103
|
+
"Statistics",
|
|
104
|
+
"Withdrawal",
|
|
105
|
+
"WithdrawalFee",
|
|
106
|
+
"WebhookEvent",
|
|
107
|
+
# Errors
|
|
108
|
+
"AltPayError",
|
|
109
|
+
"AltPayTransportError",
|
|
110
|
+
"APIError",
|
|
111
|
+
"AuthenticationError",
|
|
112
|
+
"Forbidden",
|
|
113
|
+
"PermissionError_",
|
|
114
|
+
"NotFoundError",
|
|
115
|
+
"ValidationError",
|
|
116
|
+
"ConflictError",
|
|
117
|
+
"RateLimitError",
|
|
118
|
+
"ServerError",
|
|
119
|
+
]
|
altpay/__meta__.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The asynchronous client, :class:`AsyncAltPay`.
|
|
2
|
+
|
|
3
|
+
The async twin of :class:`~altpay.client.sync_client.AltPay`. Same API, same resources, same
|
|
4
|
+
request/response logic from :mod:`altpay.client.base`; only the I/O is awaited.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ..credentials import Credentials
|
|
15
|
+
from ..errors import AltPayTransportError, RateLimitError, ServerError
|
|
16
|
+
from ..methods.account import AccountResource
|
|
17
|
+
from ..methods.base import APICall
|
|
18
|
+
from ..methods.invoices import Invoices
|
|
19
|
+
from ..methods.withdrawals import Withdrawals
|
|
20
|
+
from .base import parse_response, prepare
|
|
21
|
+
from .sync_client import DEFAULT_BASE_URL, _backoff, _retry_delay
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncAltPay:
|
|
27
|
+
"""Asynchronous AltPay API client.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
from decimal import Decimal
|
|
32
|
+
from altpay import AsyncAltPay, Credentials
|
|
33
|
+
|
|
34
|
+
async with AsyncAltPay(Credentials(
|
|
35
|
+
merchant_id="...", api_key="vc_live_...", api_secret="...",
|
|
36
|
+
)) as client:
|
|
37
|
+
invoice = await client.invoices.create(
|
|
38
|
+
uuid="order-1", amount=Decimal("100.00"), fiat_currency="USD",
|
|
39
|
+
)
|
|
40
|
+
print(invoice.url)
|
|
41
|
+
|
|
42
|
+
Args mirror :class:`~altpay.client.sync_client.AltPay`; ``http_client`` here is an
|
|
43
|
+
:class:`httpx.AsyncClient`.
|
|
44
|
+
|
|
45
|
+
API reference: https://docs.altpay.money/docs
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
credentials: Credentials,
|
|
51
|
+
*,
|
|
52
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
53
|
+
timeout: float = 30.0,
|
|
54
|
+
max_retries: int = 2,
|
|
55
|
+
http_client: httpx.AsyncClient | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._credentials = credentials
|
|
58
|
+
self._base_url = base_url.rstrip("/")
|
|
59
|
+
self._max_retries = max_retries
|
|
60
|
+
self._owns_client = http_client is None
|
|
61
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
62
|
+
|
|
63
|
+
#: Invoice and static-wallet operations.
|
|
64
|
+
self.invoices = Invoices(self.call)
|
|
65
|
+
#: Payout (withdrawal) operations.
|
|
66
|
+
self.withdrawals = Withdrawals(self.call)
|
|
67
|
+
#: Account identity, balance and statistics.
|
|
68
|
+
self.account = AccountResource(self.call)
|
|
69
|
+
|
|
70
|
+
async def __aenter__(self) -> "AsyncAltPay":
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
74
|
+
await self.aclose()
|
|
75
|
+
|
|
76
|
+
async def aclose(self) -> None:
|
|
77
|
+
"""Close the underlying HTTP connection pool (unless you supplied your own client)."""
|
|
78
|
+
if self._owns_client:
|
|
79
|
+
await self._http.aclose()
|
|
80
|
+
|
|
81
|
+
async def call(self, call: APICall[T]) -> T:
|
|
82
|
+
"""Execute a described :class:`~altpay.methods.base.APICall` and return its typed result."""
|
|
83
|
+
request = prepare(
|
|
84
|
+
credentials=self._credentials,
|
|
85
|
+
base_url=self._base_url,
|
|
86
|
+
path=call.path,
|
|
87
|
+
payload=call.payload,
|
|
88
|
+
)
|
|
89
|
+
attempt = 0
|
|
90
|
+
while True:
|
|
91
|
+
try:
|
|
92
|
+
response = await self._http.request(
|
|
93
|
+
request.method, request.url, headers=request.headers, content=request.content
|
|
94
|
+
)
|
|
95
|
+
except httpx.HTTPError as exc:
|
|
96
|
+
if attempt >= self._max_retries:
|
|
97
|
+
raise AltPayTransportError(str(exc)) from exc
|
|
98
|
+
await asyncio.sleep(_backoff(attempt))
|
|
99
|
+
attempt += 1
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = parse_response(
|
|
104
|
+
status_code=response.status_code,
|
|
105
|
+
body_text=response.text,
|
|
106
|
+
headers=response.headers,
|
|
107
|
+
)
|
|
108
|
+
except (RateLimitError, ServerError) as exc:
|
|
109
|
+
if attempt >= self._max_retries:
|
|
110
|
+
raise
|
|
111
|
+
await asyncio.sleep(_retry_delay(exc, attempt))
|
|
112
|
+
attempt += 1
|
|
113
|
+
continue
|
|
114
|
+
return call.parse(result)
|
altpay/client/base.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Transport-agnostic request building and response parsing.
|
|
2
|
+
|
|
3
|
+
The sync and async clients differ only in how they *send* bytes over the wire; everything
|
|
4
|
+
else - serializing the body, signing it, choosing headers, unwrapping ``result``, turning a
|
|
5
|
+
non-2xx status into a typed exception - is identical and lives here so there is exactly one
|
|
6
|
+
implementation to reason about and audit.
|
|
7
|
+
|
|
8
|
+
A :class:`PreparedRequest` is a pure value: given credentials and a call, it produces the
|
|
9
|
+
exact method/url/headers/body to send. :func:`parse_response` is a pure function from a raw
|
|
10
|
+
response to either the unwrapped ``result`` payload or a raised :class:`~altpay.errors.APIError`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..__meta__ import __user_agent__
|
|
20
|
+
from ..credentials import Credentials
|
|
21
|
+
from ..errors import error_from_status
|
|
22
|
+
from ..signing import sign_request
|
|
23
|
+
|
|
24
|
+
# The public API is JSON-only and every endpoint is POST (including reads). Bodies are
|
|
25
|
+
# serialized compactly and deterministically; the exact bytes are what we hash and sign, so
|
|
26
|
+
# the body that goes on the wire must be the body we signed - never re-serialize downstream.
|
|
27
|
+
_JSON_SEPARATORS = (",", ":")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def serialize_body(payload: dict[str, Any] | None) -> bytes:
|
|
31
|
+
"""Serialize a request payload to the canonical bytes that get signed and sent.
|
|
32
|
+
|
|
33
|
+
``None`` and ``{}`` both serialize to ``b""`` - the body-less endpoints (``me.get``,
|
|
34
|
+
``invoice/services``) take no body, and the server hashes an empty body for them.
|
|
35
|
+
"""
|
|
36
|
+
if not payload:
|
|
37
|
+
return b""
|
|
38
|
+
return json.dumps(payload, separators=_JSON_SEPARATORS, ensure_ascii=False).encode("utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class PreparedRequest:
|
|
43
|
+
"""A fully-signed request ready to hand to httpx."""
|
|
44
|
+
|
|
45
|
+
method: str
|
|
46
|
+
url: str
|
|
47
|
+
headers: dict[str, str]
|
|
48
|
+
content: bytes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prepare(
|
|
52
|
+
*,
|
|
53
|
+
credentials: Credentials,
|
|
54
|
+
base_url: str,
|
|
55
|
+
path: str,
|
|
56
|
+
payload: dict[str, Any] | None,
|
|
57
|
+
) -> PreparedRequest:
|
|
58
|
+
"""Build and sign a request for ``path`` with ``payload``.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
credentials: The merchant credentials to sign with.
|
|
62
|
+
base_url: API root, e.g. ``"https://api.altpay.money"`` (no trailing ``/api/v2``).
|
|
63
|
+
path: The endpoint path, e.g. ``"/api/v2/invoice/create"``.
|
|
64
|
+
payload: The request body as a dict, or ``None`` for body-less endpoints.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A :class:`PreparedRequest` whose ``headers`` already include the signature set.
|
|
68
|
+
"""
|
|
69
|
+
body = serialize_body(payload)
|
|
70
|
+
headers = {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
"Accept": "application/json",
|
|
73
|
+
"User-Agent": __user_agent__,
|
|
74
|
+
}
|
|
75
|
+
headers.update(
|
|
76
|
+
sign_request(
|
|
77
|
+
api_secret=credentials.api_secret,
|
|
78
|
+
merchant_id=credentials.merchant_id,
|
|
79
|
+
api_key=credentials.api_key,
|
|
80
|
+
method="POST",
|
|
81
|
+
path=path,
|
|
82
|
+
body=body,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return PreparedRequest(method="POST", url=f"{base_url}{path}", headers=headers, content=body)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_response(
|
|
89
|
+
*,
|
|
90
|
+
status_code: int,
|
|
91
|
+
body_text: str,
|
|
92
|
+
headers: Any,
|
|
93
|
+
) -> Any:
|
|
94
|
+
"""Turn a raw response into the unwrapped ``result`` payload, or raise a typed error.
|
|
95
|
+
|
|
96
|
+
On a 2xx response, returns the value of the JSON body's ``result`` field (or the whole
|
|
97
|
+
body if it is not wrapped). On any other status, raises the most specific
|
|
98
|
+
:class:`~altpay.errors.APIError` subclass, carrying the server's ``detail`` and the
|
|
99
|
+
``X-Request-Id`` header (when present) for support correlation.
|
|
100
|
+
"""
|
|
101
|
+
parsed = _safe_json(body_text)
|
|
102
|
+
request_id = _get_header(headers, "X-Request-Id")
|
|
103
|
+
|
|
104
|
+
if 200 <= status_code < 300:
|
|
105
|
+
if isinstance(parsed, dict) and "result" in parsed:
|
|
106
|
+
return parsed["result"]
|
|
107
|
+
return parsed
|
|
108
|
+
|
|
109
|
+
detail = parsed.get("detail") if isinstance(parsed, dict) else None
|
|
110
|
+
retry_after = _parse_retry_after(_get_header(headers, "Retry-After"))
|
|
111
|
+
raise error_from_status(
|
|
112
|
+
status_code,
|
|
113
|
+
detail=detail if isinstance(detail, str) else None,
|
|
114
|
+
response_body=parsed,
|
|
115
|
+
request_id=request_id,
|
|
116
|
+
retry_after=retry_after,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _safe_json(text: str) -> Any:
|
|
121
|
+
"""Parse JSON, falling back to the raw text so a non-JSON error page is still surfaced."""
|
|
122
|
+
if not text:
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
return json.loads(text)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _get_header(headers: Any, name: str) -> str | None:
|
|
131
|
+
"""Read a header from an httpx.Headers (case-insensitive) or a plain mapping."""
|
|
132
|
+
try:
|
|
133
|
+
return headers.get(name)
|
|
134
|
+
except AttributeError:
|
|
135
|
+
lowered = name.lower()
|
|
136
|
+
for key, value in dict(headers).items():
|
|
137
|
+
if str(key).lower() == lowered:
|
|
138
|
+
return value
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
143
|
+
"""Parse a ``Retry-After`` header expressed in seconds; ignore HTTP-date form."""
|
|
144
|
+
if not value:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return float(value)
|
|
148
|
+
except ValueError:
|
|
149
|
+
return None
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The synchronous client, :class:`AltPay`.
|
|
2
|
+
|
|
3
|
+
Wraps an :class:`httpx.Client`. Resources (``invoices``, ``account``) describe calls; this
|
|
4
|
+
class signs, sends and parses them. It is the synchronous twin of
|
|
5
|
+
:class:`~altpay.client.async_client.AsyncAltPay` and shares all of its logic via
|
|
6
|
+
:mod:`altpay.client.base`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ..credentials import Credentials
|
|
17
|
+
from ..errors import AltPayTransportError, RateLimitError, ServerError
|
|
18
|
+
from ..methods.account import AccountResource
|
|
19
|
+
from ..methods.base import APICall
|
|
20
|
+
from ..methods.invoices import Invoices
|
|
21
|
+
from ..methods.withdrawals import Withdrawals
|
|
22
|
+
from .base import parse_response, prepare
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.altpay.money"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AltPay:
|
|
30
|
+
"""Synchronous AltPay API client.
|
|
31
|
+
|
|
32
|
+
Example::
|
|
33
|
+
|
|
34
|
+
from decimal import Decimal
|
|
35
|
+
from altpay import AltPay, Credentials
|
|
36
|
+
|
|
37
|
+
client = AltPay(Credentials(
|
|
38
|
+
merchant_id="...", api_key="vc_live_...", api_secret="...",
|
|
39
|
+
))
|
|
40
|
+
invoice = client.invoices.create(
|
|
41
|
+
uuid="order-1", amount=Decimal("100.00"), fiat_currency="USD",
|
|
42
|
+
url_callback="https://example.com/altpay/webhook",
|
|
43
|
+
)
|
|
44
|
+
print(invoice.url)
|
|
45
|
+
client.close()
|
|
46
|
+
|
|
47
|
+
Use it as a context manager (``with AltPay(...) as client:``) to close the underlying
|
|
48
|
+
HTTP connection pool automatically.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
credentials: Your merchant credentials.
|
|
52
|
+
base_url: API root (default ``https://api.altpay.money``). Override for staging.
|
|
53
|
+
timeout: Per-request timeout in seconds (default 30).
|
|
54
|
+
max_retries: How many times to retry a request that failed transiently - a network
|
|
55
|
+
error, a 5xx, or a 429. Retries use exponential backoff and honor ``Retry-After``.
|
|
56
|
+
Set to 0 to disable. Only idempotent reads and create-with-your-own-uuid are
|
|
57
|
+
safe to retry, which is the whole public surface.
|
|
58
|
+
http_client: Bring your own configured :class:`httpx.Client` (proxies, custom TLS,
|
|
59
|
+
etc.). If given, ``base_url``/``timeout`` on the httpx client are used and this
|
|
60
|
+
class will not close it for you.
|
|
61
|
+
|
|
62
|
+
API reference: https://docs.altpay.money/docs
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
credentials: Credentials,
|
|
68
|
+
*,
|
|
69
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
70
|
+
timeout: float = 30.0,
|
|
71
|
+
max_retries: int = 2,
|
|
72
|
+
http_client: httpx.Client | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._credentials = credentials
|
|
75
|
+
self._base_url = base_url.rstrip("/")
|
|
76
|
+
self._max_retries = max_retries
|
|
77
|
+
self._owns_client = http_client is None
|
|
78
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
79
|
+
|
|
80
|
+
#: Invoice and static-wallet operations.
|
|
81
|
+
self.invoices = Invoices(self.call)
|
|
82
|
+
#: Payout (withdrawal) operations.
|
|
83
|
+
self.withdrawals = Withdrawals(self.call)
|
|
84
|
+
#: Account identity, balance and statistics.
|
|
85
|
+
self.account = AccountResource(self.call)
|
|
86
|
+
|
|
87
|
+
def __enter__(self) -> "AltPay":
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, *exc: object) -> None:
|
|
91
|
+
self.close()
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
"""Close the underlying HTTP connection pool (unless you supplied your own client)."""
|
|
95
|
+
if self._owns_client:
|
|
96
|
+
self._http.close()
|
|
97
|
+
|
|
98
|
+
def call(self, call: APICall[T]) -> T:
|
|
99
|
+
"""Execute a described :class:`~altpay.methods.base.APICall` and return its typed result.
|
|
100
|
+
|
|
101
|
+
You normally call resource methods (``client.invoices.create(...)``) which return an
|
|
102
|
+
``APICall`` and pass it here implicitly; use :meth:`call` directly only for advanced
|
|
103
|
+
or custom calls.
|
|
104
|
+
"""
|
|
105
|
+
request = prepare(
|
|
106
|
+
credentials=self._credentials,
|
|
107
|
+
base_url=self._base_url,
|
|
108
|
+
path=call.path,
|
|
109
|
+
payload=call.payload,
|
|
110
|
+
)
|
|
111
|
+
attempt = 0
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
response = self._http.request(
|
|
115
|
+
request.method, request.url, headers=request.headers, content=request.content
|
|
116
|
+
)
|
|
117
|
+
except httpx.HTTPError as exc:
|
|
118
|
+
if attempt >= self._max_retries:
|
|
119
|
+
raise AltPayTransportError(str(exc)) from exc
|
|
120
|
+
time.sleep(_backoff(attempt))
|
|
121
|
+
attempt += 1
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
result = parse_response(
|
|
126
|
+
status_code=response.status_code,
|
|
127
|
+
body_text=response.text,
|
|
128
|
+
headers=response.headers,
|
|
129
|
+
)
|
|
130
|
+
except (RateLimitError, ServerError) as exc:
|
|
131
|
+
if attempt >= self._max_retries:
|
|
132
|
+
raise
|
|
133
|
+
time.sleep(_retry_delay(exc, attempt))
|
|
134
|
+
attempt += 1
|
|
135
|
+
continue
|
|
136
|
+
return call.parse(result)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _backoff(attempt: int) -> float:
|
|
140
|
+
"""Exponential backoff: 0.5s, 1s, 2s, ... capped at 8s."""
|
|
141
|
+
return min(0.5 * (2 ** attempt), 8.0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _retry_delay(exc: Any, attempt: int) -> float:
|
|
145
|
+
"""Honor a 429's Retry-After when present, else fall back to exponential backoff."""
|
|
146
|
+
retry_after = getattr(exc, "retry_after", None)
|
|
147
|
+
if retry_after is not None:
|
|
148
|
+
return float(retry_after)
|
|
149
|
+
return _backoff(attempt)
|
altpay/credentials.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Merchant credentials.
|
|
2
|
+
|
|
3
|
+
A small immutable holder for the four secrets the SDK needs. Grouping them keeps the client
|
|
4
|
+
constructors clean and makes it obvious which value plays which role - a frequent source of
|
|
5
|
+
``invalid_signature`` errors is swapping the public ``api_key`` with the secret ``api_secret``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Credentials:
|
|
15
|
+
"""The credentials issued for one API key in your dashboard.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
merchant_id: Your merchant UUID. Sent in the clear as ``X-Merchant-Id``.
|
|
19
|
+
api_key: The public key identifier (``vc_live_...``). Sent in the clear as
|
|
20
|
+
``X-Api-Key``; the server looks it up by hash.
|
|
21
|
+
api_secret: The signing secret. Never transmitted - it is the HMAC key for every
|
|
22
|
+
request signature. Keep it server-side only.
|
|
23
|
+
webhook_secret: The secret used to sign webhooks AltPay sends you. Optional here;
|
|
24
|
+
required only if you verify webhooks with this SDK. Distinct from ``api_secret``.
|
|
25
|
+
|
|
26
|
+
All four are shown once at key creation and cannot be retrieved later - store them in
|
|
27
|
+
your secret manager. See https://docs.altpay.money/docs/authentication
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
merchant_id: str
|
|
31
|
+
api_key: str
|
|
32
|
+
api_secret: str
|
|
33
|
+
webhook_secret: str | None = None
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if not self.merchant_id or not self.api_key or not self.api_secret:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"merchant_id, api_key and api_secret are all required. "
|
|
39
|
+
"See https://docs.altpay.money/docs/authentication"
|
|
40
|
+
)
|
altpay/enums.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Enumerations used across the public API.
|
|
2
|
+
|
|
3
|
+
These mirror the server's vocabulary exactly. Each is a ``str`` enum, so a member compares
|
|
4
|
+
equal to its wire value (``PaymentStatus.PAYED == "PAYED"``) and serializes transparently -
|
|
5
|
+
you can pass either a member or the raw string anywhere the SDK accepts one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PaymentStatus(str, Enum):
|
|
14
|
+
"""Lifecycle state of an invoice.
|
|
15
|
+
|
|
16
|
+
* ``CREATED`` - issued, awaiting payment.
|
|
17
|
+
* ``INCOMPLETE`` - underpaid (received less than due); still open.
|
|
18
|
+
* ``PAYED`` - fully paid and confirmed (final). The webhook fires on this transition.
|
|
19
|
+
* ``EXPIRED`` - the lifetime window elapsed before payment (final).
|
|
20
|
+
* ``FAILED`` - the payment failed or was rejected (final).
|
|
21
|
+
|
|
22
|
+
``CREATED``/``INCOMPLETE`` are non-final; the other three are final
|
|
23
|
+
(see :attr:`is_final`).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
CREATED = "CREATED"
|
|
27
|
+
PAYED = "PAYED"
|
|
28
|
+
INCOMPLETE = "INCOMPLETE"
|
|
29
|
+
EXPIRED = "EXPIRED"
|
|
30
|
+
FAILED = "FAILED"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_final(self) -> bool:
|
|
34
|
+
"""Whether no further status change is possible (PAYED, EXPIRED or FAILED)."""
|
|
35
|
+
return self in (PaymentStatus.PAYED, PaymentStatus.EXPIRED, PaymentStatus.FAILED)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PaymentMethod(str, Enum):
|
|
39
|
+
"""A payment method the payer can settle with.
|
|
40
|
+
|
|
41
|
+
On-chain methods deliver to a generated deposit address; ``LOLZTEAM`` and ``CRYPTOBOT``
|
|
42
|
+
are off-chain providers that redirect the payer to a hosted page. ``USDT_*`` names encode
|
|
43
|
+
the network the stablecoin settles on (e.g. ``USDT_TRC20`` is USDT on TRON).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
TRX = "TRX"
|
|
47
|
+
BTC = "BTC"
|
|
48
|
+
ETH = "ETH"
|
|
49
|
+
LTC = "LTC"
|
|
50
|
+
BNB = "BNB"
|
|
51
|
+
TON = "TON"
|
|
52
|
+
SOLANA = "SOLANA"
|
|
53
|
+
USDT_TRC20 = "USDT_TRC20"
|
|
54
|
+
USDT_ETH = "USDT_ETH"
|
|
55
|
+
USDT_BSC = "USDT_BSC"
|
|
56
|
+
USDT_TON = "USDT_TON"
|
|
57
|
+
USDT_SOLANA = "USDT_SOLANA"
|
|
58
|
+
LOLZTEAM = "LOLZTEAM"
|
|
59
|
+
CRYPTOBOT = "CRYPTOBOT"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FiatCurrency(str, Enum):
|
|
63
|
+
"""A fiat currency an invoice can be denominated in."""
|
|
64
|
+
|
|
65
|
+
RUB = "RUB"
|
|
66
|
+
USD = "USD"
|
|
67
|
+
EUR = "EUR"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MerchantStatus(str, Enum):
|
|
71
|
+
"""Account status of a merchant.
|
|
72
|
+
|
|
73
|
+
Only ``APPROVED`` merchants can create invoices; the others block payment creation
|
|
74
|
+
(``PENDING_REVIEW`` until onboarding completes, ``SUSPENDED``/``ARCHIVED``/``REJECTED``
|
|
75
|
+
permanently or until support intervenes).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
PENDING_REVIEW = "PENDING_REVIEW"
|
|
79
|
+
APPROVED = "APPROVED"
|
|
80
|
+
REJECTED = "REJECTED"
|
|
81
|
+
ARCHIVED = "ARCHIVED"
|
|
82
|
+
SUSPENDED = "SUSPENDED"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WithdrawalStatus(str, Enum):
|
|
86
|
+
"""Lifecycle state of a payout request.
|
|
87
|
+
|
|
88
|
+
A new request is ``PENDING`` review; an operator then ``APPROVED`` it (funds are paid
|
|
89
|
+
out) or ``REJECTED`` it (the reserved amount is credited back to your balance).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
PENDING = "PENDING"
|
|
93
|
+
APPROVED = "APPROVED"
|
|
94
|
+
REJECTED = "REJECTED"
|