alphapay 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.
- alphapay/__init__.py +36 -0
- alphapay/client.py +56 -0
- alphapay/exceptions.py +138 -0
- alphapay/http.py +198 -0
- alphapay/pagination.py +36 -0
- alphapay/py.typed +0 -0
- alphapay/resources/__init__.py +0 -0
- alphapay/resources/api_keys.py +63 -0
- alphapay/resources/balances.py +34 -0
- alphapay/resources/checkout_sessions.py +57 -0
- alphapay/resources/customers.py +42 -0
- alphapay/resources/payment_links.py +103 -0
- alphapay/resources/settlements.py +49 -0
- alphapay/resources/transactions.py +161 -0
- alphapay/resources/wallet_transfers.py +37 -0
- alphapay/resources/webhook_endpoints.py +111 -0
- alphapay/webhook.py +74 -0
- alphapay-0.1.0.dist-info/METADATA +229 -0
- alphapay-0.1.0.dist-info/RECORD +22 -0
- alphapay-0.1.0.dist-info/WHEEL +5 -0
- alphapay-0.1.0.dist-info/licenses/LICENSE +21 -0
- alphapay-0.1.0.dist-info/top_level.txt +1 -0
alphapay/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Official Python SDK for the AlphaPay payment aggregator API."""
|
|
2
|
+
|
|
3
|
+
from .client import AlphaPayClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
AlphaPayAuthenticationError,
|
|
6
|
+
AlphaPayConnectionError,
|
|
7
|
+
AlphaPayError,
|
|
8
|
+
AlphaPayIdempotencyError,
|
|
9
|
+
AlphaPayNotFoundError,
|
|
10
|
+
AlphaPayPermissionError,
|
|
11
|
+
AlphaPayRateLimitError,
|
|
12
|
+
AlphaPayServerError,
|
|
13
|
+
AlphaPayValidationError,
|
|
14
|
+
AlphaPayWebhookSignatureError,
|
|
15
|
+
)
|
|
16
|
+
from .pagination import paginate
|
|
17
|
+
from .webhook import verify_signature
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AlphaPayClient",
|
|
23
|
+
"AlphaPayError",
|
|
24
|
+
"AlphaPayAuthenticationError",
|
|
25
|
+
"AlphaPayPermissionError",
|
|
26
|
+
"AlphaPayNotFoundError",
|
|
27
|
+
"AlphaPayValidationError",
|
|
28
|
+
"AlphaPayRateLimitError",
|
|
29
|
+
"AlphaPayServerError",
|
|
30
|
+
"AlphaPayConnectionError",
|
|
31
|
+
"AlphaPayIdempotencyError",
|
|
32
|
+
"AlphaPayWebhookSignatureError",
|
|
33
|
+
"verify_signature",
|
|
34
|
+
"paginate",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
alphapay/client.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .http import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, Http
|
|
6
|
+
from .resources.api_keys import ApiKeysResource
|
|
7
|
+
from .resources.balances import BalancesResource
|
|
8
|
+
from .resources.checkout_sessions import CheckoutSessionsResource
|
|
9
|
+
from .resources.customers import CustomersResource
|
|
10
|
+
from .resources.payment_links import PaymentLinksResource
|
|
11
|
+
from .resources.settlements import SettlementsResource
|
|
12
|
+
from .resources.transactions import TransactionsResource
|
|
13
|
+
from .resources.wallet_transfers import WalletTransfersResource
|
|
14
|
+
from .resources.webhook_endpoints import WebhookEndpointsResource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AlphaPayClient:
|
|
18
|
+
"""Client principal du SDK AlphaPay. Une instance = une clé API = un
|
|
19
|
+
marchand + un environnement (live/sandbox, déduit automatiquement du
|
|
20
|
+
préfixe de la clé -- ``sk_live_...`` ou ``sk_test_...``).
|
|
21
|
+
|
|
22
|
+
:Example:
|
|
23
|
+
|
|
24
|
+
>>> alphapay = AlphaPayClient(os.environ["ALPHAPAY_SECRET_KEY"])
|
|
25
|
+
>>> payment = alphapay.transactions.payin_initialize(
|
|
26
|
+
... amount=5000,
|
|
27
|
+
... currency="XOF",
|
|
28
|
+
... country="BJ",
|
|
29
|
+
... network="mtn_bj",
|
|
30
|
+
... customer={"email": "client@exemple.com", "first_name": "Client", "last_name": "Test", "phone": "+22900000000"},
|
|
31
|
+
... idempotency_key=True,
|
|
32
|
+
... )
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: str,
|
|
38
|
+
base_url: Optional[str] = None,
|
|
39
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
40
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
41
|
+
) -> None:
|
|
42
|
+
#: Client HTTP bas niveau -- nécessaire à :func:`alphapay.pagination.paginate`
|
|
43
|
+
#: pour suivre un lien "next" (URL absolue renvoyée par l'API), pas
|
|
44
|
+
#: destiné à un usage direct en dehors de ce cas.
|
|
45
|
+
self.http = Http(api_key, base_url, timeout, max_retries)
|
|
46
|
+
self.environment = self.http.environment
|
|
47
|
+
|
|
48
|
+
self.transactions = TransactionsResource(self.http)
|
|
49
|
+
self.payment_links = PaymentLinksResource(self.http)
|
|
50
|
+
self.checkout_sessions = CheckoutSessionsResource(self.http)
|
|
51
|
+
self.customers = CustomersResource(self.http)
|
|
52
|
+
self.settlements = SettlementsResource(self.http)
|
|
53
|
+
self.wallet_transfers = WalletTransfersResource(self.http)
|
|
54
|
+
self.balances = BalancesResource(self.http)
|
|
55
|
+
self.api_keys = ApiKeysResource(self.http)
|
|
56
|
+
self.webhook_endpoints = WebhookEndpointsResource(self.http)
|
alphapay/exceptions.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Toute erreur API passe par apps.core.renderers.StandardJSONRenderer côté
|
|
3
|
+
AlphaPayBack :
|
|
4
|
+
{"success": false, "error": <forme variable>, "code": <statut HTTP>}
|
|
5
|
+
`error` n'a PAS une forme unique :
|
|
6
|
+
- erreurs de validation DRF -> {"champ": ["message", ...], ...}
|
|
7
|
+
- erreurs métier personnalisées -> {"message": "...", "code": "<code_machine>"}
|
|
8
|
+
- erreurs d'authentification/permission -> {"detail": "..."}
|
|
9
|
+
str(exception) normalise ces trois formes en une seule chaîne lisible ;
|
|
10
|
+
`.raw` garde la forme originale pour qui a besoin du détail par champ.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AlphaPayError(Exception):
|
|
19
|
+
"""Exception de base -- toutes les erreurs API en sont une sous-classe."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status: int,
|
|
26
|
+
error_code: Optional[str] = None,
|
|
27
|
+
raw: Any = None,
|
|
28
|
+
field_errors: Optional[Dict[str, List[str]]] = None,
|
|
29
|
+
request_id: Optional[str] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.status = status
|
|
33
|
+
#: Code machine, quand l'API en fournit un (ex. "dashboard_only", "invalid_file_type") --
|
|
34
|
+
#: absent sur les erreurs de validation par champ.
|
|
35
|
+
self.error_code = error_code
|
|
36
|
+
#: Corps d'erreur brut, tel que renvoyé par l'API.
|
|
37
|
+
self.raw = raw
|
|
38
|
+
#: Présent uniquement sur une erreur de validation DRF ({"champ": [...]}).
|
|
39
|
+
self.field_errors = field_errors
|
|
40
|
+
self.request_id = request_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AlphaPayAuthenticationError(AlphaPayError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AlphaPayPermissionError(AlphaPayError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AlphaPayNotFoundError(AlphaPayError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AlphaPayValidationError(AlphaPayError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AlphaPayRateLimitError(AlphaPayError):
|
|
60
|
+
"""429 -- `retry_after` (secondes) est renseigné quand l'API précise un header Retry-After."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, *, retry_after: Optional[int] = None, **kwargs: Any) -> None:
|
|
63
|
+
super().__init__(message, **kwargs)
|
|
64
|
+
self.retry_after = retry_after
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AlphaPayServerError(AlphaPayError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AlphaPayConnectionError(AlphaPayError):
|
|
72
|
+
"""Connexion/DNS/timeout -- jamais atteint le serveur AlphaPay, `status` vaut 0."""
|
|
73
|
+
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AlphaPayIdempotencyError(AlphaPayError):
|
|
78
|
+
"""409 -- Idempotency-Key réutilisée avec un payload différent (cf. apps.core.mixins.IdempotencyMixin côté API)."""
|
|
79
|
+
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AlphaPayWebhookSignatureError(Exception):
|
|
84
|
+
"""Levée par alphapay.webhook.verify_signature() -- signature invalide ou timestamp hors fenêtre de tolérance (rejeu)."""
|
|
85
|
+
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def error_from_response(status: int, body: Any, request_id: Optional[str] = None) -> AlphaPayError:
|
|
90
|
+
"""Construit l'exception normalisée à partir de la réponse HTTP. `body` est
|
|
91
|
+
déjà le contenu de la clé "error" de l'enveloppe (pas l'enveloppe entière).
|
|
92
|
+
"""
|
|
93
|
+
message, error_code, field_errors = _interpret_error_body(body)
|
|
94
|
+
|
|
95
|
+
kwargs = dict(status=status, error_code=error_code, raw=body, field_errors=field_errors, request_id=request_id)
|
|
96
|
+
|
|
97
|
+
if status == 401:
|
|
98
|
+
return AlphaPayAuthenticationError(message, **kwargs)
|
|
99
|
+
if status == 403:
|
|
100
|
+
return AlphaPayPermissionError(message, **kwargs)
|
|
101
|
+
if status == 404:
|
|
102
|
+
return AlphaPayNotFoundError(message, **kwargs)
|
|
103
|
+
if status == 409:
|
|
104
|
+
return AlphaPayIdempotencyError(message, **kwargs)
|
|
105
|
+
if status == 429:
|
|
106
|
+
return AlphaPayRateLimitError(message, **kwargs)
|
|
107
|
+
if status in (400, 422):
|
|
108
|
+
return AlphaPayValidationError(message, **kwargs)
|
|
109
|
+
if status >= 500:
|
|
110
|
+
return AlphaPayServerError(message, **kwargs)
|
|
111
|
+
return AlphaPayError(message, **kwargs)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _interpret_error_body(body: Any):
|
|
115
|
+
if body is None:
|
|
116
|
+
return "Erreur AlphaPay inconnue.", None, None
|
|
117
|
+
if isinstance(body, str):
|
|
118
|
+
return body, None, None
|
|
119
|
+
if isinstance(body, dict):
|
|
120
|
+
# {"message": "...", "code": "..."} -- erreurs métier personnalisées.
|
|
121
|
+
message = body.get("message")
|
|
122
|
+
if isinstance(message, str):
|
|
123
|
+
code = body.get("code")
|
|
124
|
+
return message, code if isinstance(code, str) else None, None
|
|
125
|
+
# {"detail": "..."} -- erreurs d'auth/permission DRF standard.
|
|
126
|
+
detail = body.get("detail")
|
|
127
|
+
if isinstance(detail, str):
|
|
128
|
+
return detail, None, None
|
|
129
|
+
# {"champ": ["erreur", ...], ...} -- erreurs de validation DRF.
|
|
130
|
+
field_errors = {
|
|
131
|
+
field: value
|
|
132
|
+
for field, value in body.items()
|
|
133
|
+
if isinstance(field, str) and isinstance(value, list) and value and all(isinstance(v, str) for v in value)
|
|
134
|
+
}
|
|
135
|
+
if field_errors:
|
|
136
|
+
summary = " — ".join(f"{field}: {', '.join(errors)}" for field, errors in field_errors.items())
|
|
137
|
+
return summary, None, field_errors
|
|
138
|
+
return "Erreur AlphaPay inconnue.", None, None
|
alphapay/http.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client HTTP bas niveau, partagé par toutes les ressources. Gère
|
|
3
|
+
l'authentification, l'enveloppe standard {success, data, code}, les retries
|
|
4
|
+
avec backoff sur 429/5xx/erreur réseau, et le mapping vers des exceptions
|
|
5
|
+
typées (cf. exceptions.py) -- chaque ressource (Transactions, PaymentLinks,
|
|
6
|
+
...) n'a plus qu'à décrire le endpoint, jamais la mécanique réseau.
|
|
7
|
+
|
|
8
|
+
Volontairement stdlib uniquement (urllib), pas de dépendance à `requests` --
|
|
9
|
+
cohérent avec les SDK Node/PHP jumeaux (0 dépendance runtime).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import random
|
|
16
|
+
import secrets
|
|
17
|
+
import socket
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from typing import Any, Dict, Optional, Union
|
|
23
|
+
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
AlphaPayConnectionError,
|
|
26
|
+
AlphaPayError,
|
|
27
|
+
AlphaPayRateLimitError,
|
|
28
|
+
AlphaPayServerError,
|
|
29
|
+
error_from_response,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.alphapay.me/api/v1"
|
|
33
|
+
DEFAULT_TIMEOUT = 30.0
|
|
34
|
+
DEFAULT_MAX_RETRIES = 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Http:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
base_url: Optional[str] = None,
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
43
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
44
|
+
) -> None:
|
|
45
|
+
if not api_key:
|
|
46
|
+
raise ValueError("AlphaPayClient: `api_key` est requis.")
|
|
47
|
+
self._api_key = api_key
|
|
48
|
+
self.environment = "live" if api_key.startswith("sk_live_") else "sandbox"
|
|
49
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
50
|
+
self._timeout = timeout
|
|
51
|
+
self._max_retries = max_retries
|
|
52
|
+
|
|
53
|
+
def request(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
query: Optional[Dict[str, Any]] = None,
|
|
59
|
+
body: Optional[Dict[str, Any]] = None,
|
|
60
|
+
idempotency_key: Union[str, bool, None] = None,
|
|
61
|
+
) -> Any:
|
|
62
|
+
# `path` est une URL absolue quand on suit un lien "next"/"previous"
|
|
63
|
+
# renvoyé tel quel par l'API (cf. pagination.paginate()) -- déjà
|
|
64
|
+
# complète, avec sa propre query string ; ne jamais la préfixer par
|
|
65
|
+
# base_url ni y rajouter `query` par-dessus.
|
|
66
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
67
|
+
url = path
|
|
68
|
+
else:
|
|
69
|
+
qs = self._build_query_string(query)
|
|
70
|
+
url = f"{self.base_url}{path}{qs}"
|
|
71
|
+
|
|
72
|
+
headers = {"Authorization": f"Bearer {self._api_key}", "Accept": "application/json"}
|
|
73
|
+
data: Optional[bytes] = None
|
|
74
|
+
if body is not None:
|
|
75
|
+
headers["Content-Type"] = "application/json"
|
|
76
|
+
data = json.dumps(body).encode("utf-8")
|
|
77
|
+
if idempotency_key:
|
|
78
|
+
key = idempotency_key if isinstance(idempotency_key, str) else self._random_idempotency_key()
|
|
79
|
+
headers["Idempotency-Key"] = key
|
|
80
|
+
|
|
81
|
+
attempt = 0
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
return self._attempt(method, url, headers, data)
|
|
85
|
+
except (AlphaPayRateLimitError, AlphaPayServerError) as exc:
|
|
86
|
+
if attempt >= self._max_retries:
|
|
87
|
+
raise
|
|
88
|
+
retry_after = getattr(exc, "retry_after", None)
|
|
89
|
+
delay = retry_after if retry_after is not None else self._backoff_seconds(attempt)
|
|
90
|
+
time.sleep(delay)
|
|
91
|
+
attempt += 1
|
|
92
|
+
except AlphaPayConnectionError:
|
|
93
|
+
if attempt >= self._max_retries:
|
|
94
|
+
raise
|
|
95
|
+
time.sleep(self._backoff_seconds(attempt))
|
|
96
|
+
attempt += 1
|
|
97
|
+
|
|
98
|
+
def request_binary(self, path: str) -> Dict[str, Any]:
|
|
99
|
+
"""Pour les rares endpoints qui répondent en binaire plutôt qu'en JSON
|
|
100
|
+
(ex. GET /transactions/{id}/invoice/, application/pdf, et
|
|
101
|
+
/transactions/export/, text/csv) -- bypass l'enveloppe standard,
|
|
102
|
+
jamais utilisée par un endpoint qui en renvoie une.
|
|
103
|
+
|
|
104
|
+
Retourne {"data": bytes, "content_type": str|None, "filename": str|None}.
|
|
105
|
+
"""
|
|
106
|
+
url = f"{self.base_url}{path}"
|
|
107
|
+
req = urllib.request.Request(
|
|
108
|
+
url,
|
|
109
|
+
headers={"Authorization": f"Bearer {self._api_key}", "Accept": "application/pdf, text/csv, application/octet-stream"},
|
|
110
|
+
method="GET",
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as response:
|
|
114
|
+
raw = response.read()
|
|
115
|
+
content_type = response.headers.get("Content-Type")
|
|
116
|
+
filename = self._extract_filename(response.headers.get("Content-Disposition"))
|
|
117
|
+
return {"data": raw, "content_type": content_type, "filename": filename}
|
|
118
|
+
except urllib.error.HTTPError as exc:
|
|
119
|
+
# Une erreur sur cet endpoint reste en JSON (cf. InvoiceError côté API) --
|
|
120
|
+
# on retombe sur le mapping JSON normal pour un message exploitable.
|
|
121
|
+
raise self._error_from_http_error(exc) from exc
|
|
122
|
+
except socket.timeout as exc:
|
|
123
|
+
raise AlphaPayConnectionError("Requête AlphaPay expirée.", status=0) from exc
|
|
124
|
+
except urllib.error.URLError as exc:
|
|
125
|
+
raise AlphaPayConnectionError(f"Impossible de joindre l'API AlphaPay : {exc.reason}", status=0) from exc
|
|
126
|
+
|
|
127
|
+
def _attempt(self, method: str, url: str, headers: Dict[str, str], data: Optional[bytes]) -> Any:
|
|
128
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
129
|
+
try:
|
|
130
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as response:
|
|
131
|
+
body = response.read()
|
|
132
|
+
request_id = response.headers.get("X-Request-Id")
|
|
133
|
+
return self._unwrap_success(body, request_id)
|
|
134
|
+
except urllib.error.HTTPError as exc:
|
|
135
|
+
raise self._error_from_http_error(exc) from exc
|
|
136
|
+
except socket.timeout as exc:
|
|
137
|
+
raise AlphaPayConnectionError("Requête AlphaPay expirée.", status=0) from exc
|
|
138
|
+
except urllib.error.URLError as exc:
|
|
139
|
+
reason = exc.reason
|
|
140
|
+
is_timeout = "timed out" in str(reason).lower()
|
|
141
|
+
message = "Requête AlphaPay expirée." if is_timeout else f"Impossible de joindre l'API AlphaPay : {reason}"
|
|
142
|
+
raise AlphaPayConnectionError(message, status=0) from exc
|
|
143
|
+
|
|
144
|
+
def _error_from_http_error(self, exc: urllib.error.HTTPError) -> AlphaPayError:
|
|
145
|
+
request_id = exc.headers.get("X-Request-Id") if exc.headers else None
|
|
146
|
+
raw_body = exc.read()
|
|
147
|
+
json_body = self._try_parse_json(raw_body)
|
|
148
|
+
error_body = json_body.get("error") if isinstance(json_body, dict) and "error" in json_body else json_body
|
|
149
|
+
error = error_from_response(exc.code, error_body, request_id)
|
|
150
|
+
if isinstance(error, AlphaPayRateLimitError):
|
|
151
|
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
152
|
+
error.retry_after = int(retry_after) if retry_after else None
|
|
153
|
+
return error
|
|
154
|
+
|
|
155
|
+
def _unwrap_success(self, raw_body: bytes, request_id: Optional[str]) -> Any:
|
|
156
|
+
json_body = self._try_parse_json(raw_body)
|
|
157
|
+
if isinstance(json_body, dict) and "data" in json_body:
|
|
158
|
+
return json_body["data"]
|
|
159
|
+
return json_body
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _try_parse_json(raw_body: bytes) -> Any:
|
|
163
|
+
if not raw_body:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
return json.loads(raw_body)
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
# Réponse non-JSON (page d'erreur d'un proxy en amont, ex. 502 Traefik) --
|
|
169
|
+
# traité comme une absence de corps exploitable.
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _build_query_string(query: Optional[Dict[str, Any]]) -> str:
|
|
174
|
+
if not query:
|
|
175
|
+
return ""
|
|
176
|
+
filtered = {k: v for k, v in query.items() if v is not None}
|
|
177
|
+
if not filtered:
|
|
178
|
+
return ""
|
|
179
|
+
return "?" + urllib.parse.urlencode(filtered)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _backoff_seconds(attempt: int) -> float:
|
|
183
|
+
# Backoff exponentiel + gigue : 0.4-0.6s, 0.8-1.2s, 1.6-2.4s...
|
|
184
|
+
base = 0.4 * (2**attempt)
|
|
185
|
+
return base + random.uniform(0, base * 0.5)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _random_idempotency_key() -> str:
|
|
189
|
+
return f"idem_{secrets.token_hex(16)}"
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _extract_filename(content_disposition: Optional[str]) -> Optional[str]:
|
|
193
|
+
if not content_disposition:
|
|
194
|
+
return None
|
|
195
|
+
import re
|
|
196
|
+
|
|
197
|
+
match = re.search(r'filename="?([^";]+)"?', content_disposition)
|
|
198
|
+
return match.group(1) if match else None
|
alphapay/pagination.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Itération automatique sur les ressources paginées."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Iterator
|
|
6
|
+
|
|
7
|
+
from .http import Http
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def paginate(http: Http, first_page: Dict[str, Any]) -> Iterator[Any]:
|
|
11
|
+
"""Parcourt automatiquement toutes les pages d'une ressource paginée,
|
|
12
|
+
qu'elle soit paginée par page (avec "count") ou par curseur (sans
|
|
13
|
+
"count" -- ex. ``client.transactions.list()``).
|
|
14
|
+
|
|
15
|
+
Suit le lien "next" renvoyé TEL QUEL par l'API plutôt que de recalculer
|
|
16
|
+
un numéro de page soi-même : un curseur n'est pas un numéro de page
|
|
17
|
+
(c'est un jeton opaque encodant une position dans le tri), l'incrémenter
|
|
18
|
+
à la main ne ferait qu'interroger indéfiniment la même première page.
|
|
19
|
+
|
|
20
|
+
:param http: ``client.http`` -- nécessaire pour requêter l'URL absolue "next".
|
|
21
|
+
:param first_page: Le premier appel déjà résolu, ex.
|
|
22
|
+
``client.transactions.list(status="SUCCESS")``.
|
|
23
|
+
|
|
24
|
+
:Example:
|
|
25
|
+
|
|
26
|
+
>>> for tx in paginate(client.http, client.transactions.list(status="SUCCESS")):
|
|
27
|
+
... print(tx["reference"])
|
|
28
|
+
"""
|
|
29
|
+
page = first_page
|
|
30
|
+
while True:
|
|
31
|
+
for item in page["results"]:
|
|
32
|
+
yield item
|
|
33
|
+
next_url = page.get("next")
|
|
34
|
+
if not next_url:
|
|
35
|
+
return
|
|
36
|
+
page = http.request("GET", next_url)
|
alphapay/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..http import Http
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IpWhitelistResource:
|
|
9
|
+
"""Whitelist IP requise pour les payouts -- partagée par toutes les
|
|
10
|
+
clés du marchand (cf. ``apps.api_keys.models.MerchantIpWhitelistEntry``
|
|
11
|
+
côté API). Fonctionne bien via clé API -- vérifié en conditions réelles.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: Http) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
18
|
+
return self._http.request("GET", "/merchant-ip-whitelist/", query=params)
|
|
19
|
+
|
|
20
|
+
def create(self, *, ip_address: str, label: Optional[str] = None) -> Dict[str, Any]:
|
|
21
|
+
body = {k: v for k, v in dict(ip_address=ip_address, label=label).items() if v is not None}
|
|
22
|
+
return self._http.request("POST", "/merchant-ip-whitelist/", body=body)
|
|
23
|
+
|
|
24
|
+
def update(self, id: str, **params: Any) -> Dict[str, Any]:
|
|
25
|
+
return self._http.request("PATCH", f"/merchant-ip-whitelist/{id}/", body=params)
|
|
26
|
+
|
|
27
|
+
def delete(self, id: str) -> None:
|
|
28
|
+
self._http.request("DELETE", f"/merchant-ip-whitelist/{id}/")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApiKeysResource:
|
|
32
|
+
"""Gestion des clés API du marchand, et whitelist IP (via ``.ip_whitelist``).
|
|
33
|
+
|
|
34
|
+
ATTENTION : ``list``/``create``/``get``/``revoke``/``delete``
|
|
35
|
+
ci-dessous sont entièrement inaccessibles via clé API (403,
|
|
36
|
+
``error_code == "dashboard_only"``, vérifié en conditions réelles) --
|
|
37
|
+
cohérent avec la sécurité attendue : une clé compromise ne doit pas
|
|
38
|
+
pouvoir créer d'autres clés pour elle-même ni lister les clés
|
|
39
|
+
existantes. Seul ``.ip_whitelist`` fonctionne via clé API.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, http: Http) -> None:
|
|
43
|
+
self._http = http
|
|
44
|
+
self.ip_whitelist = IpWhitelistResource(http)
|
|
45
|
+
|
|
46
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
47
|
+
return self._http.request("GET", "/merchant-api-keys/", query=params)
|
|
48
|
+
|
|
49
|
+
def create(self, *, environment: str, name: Optional[str] = None, scope: Optional[str] = None, expires_at: Optional[str] = None) -> Dict[str, Any]:
|
|
50
|
+
"""``secret`` n'est présent QUE dans cette réponse -- jamais
|
|
51
|
+
récupérable ensuite, à stocker immédiatement côté appelant.
|
|
52
|
+
"""
|
|
53
|
+
body = {k: v for k, v in dict(name=name, environment=environment, scope=scope, expires_at=expires_at).items() if v is not None}
|
|
54
|
+
return self._http.request("POST", "/merchant-api-keys/", body=body)
|
|
55
|
+
|
|
56
|
+
def get(self, id: str) -> Dict[str, Any]:
|
|
57
|
+
return self._http.request("GET", f"/merchant-api-keys/{id}/")
|
|
58
|
+
|
|
59
|
+
def revoke(self, id: str) -> Dict[str, Any]:
|
|
60
|
+
return self._http.request("POST", f"/merchant-api-keys/{id}/revoke/")
|
|
61
|
+
|
|
62
|
+
def delete(self, id: str) -> None:
|
|
63
|
+
self._http.request("DELETE", f"/merchant-api-keys/{id}/")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from ..http import Http
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BalancesResource:
|
|
9
|
+
"""Soldes par pays/devise et grand livre des mouvements -- lecture seule
|
|
10
|
+
(les mouvements naissent des autres ressources : transactions,
|
|
11
|
+
reversements, transferts).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: Http) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
18
|
+
return self._http.request("GET", "/merchant-balances/", query=params)
|
|
19
|
+
|
|
20
|
+
def get(self, id: str) -> Dict[str, Any]:
|
|
21
|
+
return self._http.request("GET", f"/merchant-balances/{id}/")
|
|
22
|
+
|
|
23
|
+
def ledger_entries(self, **params: Any) -> Dict[str, Any]:
|
|
24
|
+
"""ATTENTION : inaccessible via clé API (contrairement à
|
|
25
|
+
:meth:`list`/:meth:`get`, qui fonctionnent bien avec une clé --
|
|
26
|
+
vérifié en conditions réelles) : lève systématiquement
|
|
27
|
+
:class:`~alphapay.exceptions.AlphaPayPermissionError` (403,
|
|
28
|
+
``error_code == "dashboard_only"``). Le grand livre détaillé n'est
|
|
29
|
+
consultable que depuis le dashboard (compte utilisateur).
|
|
30
|
+
|
|
31
|
+
Filtres reconnus : ``page``, ``page_size``, ``country``,
|
|
32
|
+
``created_at__gte``, ``created_at__lte``.
|
|
33
|
+
"""
|
|
34
|
+
return self._http.request("GET", "/merchant-ledger-entries/", query=params)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional, Union
|
|
4
|
+
|
|
5
|
+
from ..http import Http
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CheckoutSessionsResource:
|
|
9
|
+
"""Sessions de checkout à usage unique -- chacune génère sa propre page
|
|
10
|
+
de paiement hébergée (``checkout_url``), pré-remplie pour un client
|
|
11
|
+
précis. Contrairement à un lien de paiement réutilisable, une session
|
|
12
|
+
correspond à UNE tentative de paiement.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, http: Http) -> None:
|
|
16
|
+
self._http = http
|
|
17
|
+
|
|
18
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
19
|
+
"""Filtres reconnus : ``page``, ``page_size``, ``search``, ``status``."""
|
|
20
|
+
return self._http.request("GET", "/checkout-sessions/", query=params)
|
|
21
|
+
|
|
22
|
+
def create(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
amount: Union[int, float, str],
|
|
26
|
+
currency: str,
|
|
27
|
+
customer_email: str,
|
|
28
|
+
customer_name: str,
|
|
29
|
+
description: Optional[str] = None,
|
|
30
|
+
country: Optional[str] = None,
|
|
31
|
+
customer_phone: Optional[str] = None,
|
|
32
|
+
return_url: Optional[str] = None,
|
|
33
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
34
|
+
idempotency_key: Union[str, bool, None] = None,
|
|
35
|
+
) -> Dict[str, Any]:
|
|
36
|
+
body = {
|
|
37
|
+
k: v
|
|
38
|
+
for k, v in dict(
|
|
39
|
+
amount=amount,
|
|
40
|
+
currency=currency,
|
|
41
|
+
description=description,
|
|
42
|
+
country=country,
|
|
43
|
+
customer_email=customer_email,
|
|
44
|
+
customer_name=customer_name,
|
|
45
|
+
customer_phone=customer_phone,
|
|
46
|
+
return_url=return_url,
|
|
47
|
+
metadata=metadata,
|
|
48
|
+
).items()
|
|
49
|
+
if v is not None
|
|
50
|
+
}
|
|
51
|
+
return self._http.request("POST", "/checkout-sessions/", body=body, idempotency_key=idempotency_key)
|
|
52
|
+
|
|
53
|
+
def get(self, id: str) -> Dict[str, Any]:
|
|
54
|
+
return self._http.request("GET", f"/checkout-sessions/{id}/")
|
|
55
|
+
|
|
56
|
+
def cancel(self, id: str) -> Dict[str, Any]:
|
|
57
|
+
return self._http.request("POST", f"/checkout-sessions/{id}/cancel/")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..http import Http
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomersResource:
|
|
9
|
+
def __init__(self, http: Http) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def list(self, **params: Any) -> Dict[str, Any]:
|
|
13
|
+
"""Filtres reconnus : ``page``, ``page_size``, ``search``, ``ordering``."""
|
|
14
|
+
return self._http.request("GET", "/customers/", query=params)
|
|
15
|
+
|
|
16
|
+
def create(
|
|
17
|
+
self, *, country: str, phone: Optional[str] = None, full_name: Optional[str] = None, email: Optional[str] = None
|
|
18
|
+
) -> Dict[str, Any]:
|
|
19
|
+
"""``country`` -- UUID de ``geo.Country`` (ForeignKey côté API), PAS un
|
|
20
|
+
code ISO2 ("BJ") -- à la différence de ``country`` sur les transactions/
|
|
21
|
+
paiements. Référentiel pas encore couvert par ce SDK.
|
|
22
|
+
"""
|
|
23
|
+
body = {k: v for k, v in dict(country=country, phone=phone, full_name=full_name, email=email).items() if v is not None}
|
|
24
|
+
return self._http.request("POST", "/customers/", body=body)
|
|
25
|
+
|
|
26
|
+
def get(self, id: str) -> Dict[str, Any]:
|
|
27
|
+
return self._http.request("GET", f"/customers/{id}/")
|
|
28
|
+
|
|
29
|
+
def update(self, id: str, **params: Any) -> Dict[str, Any]:
|
|
30
|
+
return self._http.request("PATCH", f"/customers/{id}/", body=params)
|
|
31
|
+
|
|
32
|
+
def delete(self, id: str) -> None:
|
|
33
|
+
self._http.request("DELETE", f"/customers/{id}/")
|
|
34
|
+
|
|
35
|
+
def transactions(self, id: str, **params: Any) -> Dict[str, Any]:
|
|
36
|
+
"""Contrairement à ``TransactionsResource.list()``, cet endpoint EST
|
|
37
|
+
paginé par page (``page`` fonctionne ici) -- ``CustomerTransactionsView``
|
|
38
|
+
n'a pas de ``pagination_class`` propre, il retombe donc sur le
|
|
39
|
+
défaut global (``StandardResultsPagination``), pas
|
|
40
|
+
``CreatedAtCursorPagination``.
|
|
41
|
+
"""
|
|
42
|
+
return self._http.request("GET", f"/customers/{id}/transactions/", query=params)
|