eupago 0.5.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.
- eupago/__init__.py +41 -0
- eupago/_auth.py +113 -0
- eupago/_client.py +120 -0
- eupago/_config.py +14 -0
- eupago/_http.py +239 -0
- eupago/_logging.py +30 -0
- eupago/exceptions.py +76 -0
- eupago/models/__init__.py +10 -0
- eupago/models/_base.py +11 -0
- eupago/models/customer.py +10 -0
- eupago/models/payment.py +91 -0
- eupago/models/webhook.py +24 -0
- eupago/py.typed +0 -0
- eupago/services/__init__.py +17 -0
- eupago/services/_base.py +99 -0
- eupago/services/apple_pay.py +157 -0
- eupago/services/credit_card.py +588 -0
- eupago/services/google_pay.py +154 -0
- eupago/services/mbway.py +213 -0
- eupago/services/multibanco.py +216 -0
- eupago/services/pay_by_link.py +157 -0
- eupago/services/refund.py +124 -0
- eupago/utils.py +96 -0
- eupago/webhooks/__init__.py +84 -0
- eupago/webhooks/_parser.py +70 -0
- eupago/webhooks/_signature.py +53 -0
- eupago-0.5.0.dist-info/METADATA +299 -0
- eupago-0.5.0.dist-info/RECORD +30 -0
- eupago-0.5.0.dist-info/WHEEL +4 -0
- eupago-0.5.0.dist-info/licenses/LICENSE +21 -0
eupago/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Unofficial Python SDK for the eupago payment gateway."""
|
|
2
|
+
|
|
3
|
+
from eupago._client import EupagoClient
|
|
4
|
+
from eupago.exceptions import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
DecryptionError,
|
|
8
|
+
EupagoError,
|
|
9
|
+
NetworkError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
PaymentError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ServiceUnavailableError,
|
|
14
|
+
SignatureError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
WebhookError,
|
|
17
|
+
)
|
|
18
|
+
from eupago.models import Customer, PaymentResult, PaymentStatus, WebhookEvent
|
|
19
|
+
|
|
20
|
+
__version__ = "0.5.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ApiError",
|
|
24
|
+
"AuthenticationError",
|
|
25
|
+
"Customer",
|
|
26
|
+
"DecryptionError",
|
|
27
|
+
"EupagoClient",
|
|
28
|
+
"EupagoError",
|
|
29
|
+
"NetworkError",
|
|
30
|
+
"NotFoundError",
|
|
31
|
+
"PaymentError",
|
|
32
|
+
"PaymentResult",
|
|
33
|
+
"PaymentStatus",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ServiceUnavailableError",
|
|
36
|
+
"SignatureError",
|
|
37
|
+
"ValidationError",
|
|
38
|
+
"WebhookError",
|
|
39
|
+
"WebhookEvent",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
eupago/_auth.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from eupago._http import HttpTransport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiKeyAuth:
|
|
11
|
+
def __init__(self, api_key: str) -> None:
|
|
12
|
+
self.api_key = api_key
|
|
13
|
+
|
|
14
|
+
def apply_header(self, headers: dict[str, str]) -> dict[str, str]:
|
|
15
|
+
return {**headers, "Authorization": f"ApiKey {self.api_key}"}
|
|
16
|
+
|
|
17
|
+
def apply_body(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
18
|
+
return {**body, "chave": self.api_key}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StaticBearerAuth:
|
|
22
|
+
"""Bearer token supplied directly by the caller (no token fetch).
|
|
23
|
+
|
|
24
|
+
Use this when the caller has already obtained a Bearer through some other
|
|
25
|
+
flow (e.g. the eupago backoffice login). It exposes the same
|
|
26
|
+
``apply_header`` / ``apply_header_async`` surface as :class:`OAuthAuth`
|
|
27
|
+
so the rest of the SDK can treat them interchangeably.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, token: str) -> None:
|
|
31
|
+
self._token = token
|
|
32
|
+
|
|
33
|
+
def get_token(self) -> str:
|
|
34
|
+
return self._token
|
|
35
|
+
|
|
36
|
+
async def get_token_async(self) -> str:
|
|
37
|
+
return self._token
|
|
38
|
+
|
|
39
|
+
def apply_header(self, headers: dict[str, str]) -> dict[str, str]:
|
|
40
|
+
return {**headers, "Authorization": f"Bearer {self._token}"}
|
|
41
|
+
|
|
42
|
+
async def apply_header_async(self, headers: dict[str, str]) -> dict[str, str]:
|
|
43
|
+
return {**headers, "Authorization": f"Bearer {self._token}"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OAuthAuth:
|
|
47
|
+
_TOKEN_BUFFER_SECONDS = 60
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
client_id: str,
|
|
52
|
+
client_secret: str,
|
|
53
|
+
transport: HttpTransport,
|
|
54
|
+
auth_path: str = "/api/auth/token",
|
|
55
|
+
) -> None:
|
|
56
|
+
self._client_id = client_id
|
|
57
|
+
self._client_secret = client_secret
|
|
58
|
+
self._transport = transport
|
|
59
|
+
self._auth_path = auth_path
|
|
60
|
+
self._token: str | None = None
|
|
61
|
+
self._expires_at: float = 0.0
|
|
62
|
+
|
|
63
|
+
def _is_expired(self) -> bool:
|
|
64
|
+
return time.monotonic() >= self._expires_at
|
|
65
|
+
|
|
66
|
+
def _fetch_token(self) -> str:
|
|
67
|
+
response = self._transport.request(
|
|
68
|
+
"POST",
|
|
69
|
+
self._auth_path,
|
|
70
|
+
json={
|
|
71
|
+
"client_id": self._client_id,
|
|
72
|
+
"client_secret": self._client_secret,
|
|
73
|
+
"grant_type": "client_credentials",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
data = response.json()
|
|
77
|
+
self._token = data["access_token"]
|
|
78
|
+
expires_in: int = data.get("expires_in", 3600)
|
|
79
|
+
self._expires_at = time.monotonic() + expires_in - self._TOKEN_BUFFER_SECONDS
|
|
80
|
+
return self._token
|
|
81
|
+
|
|
82
|
+
async def _fetch_token_async(self) -> str:
|
|
83
|
+
response = await self._transport.request_async(
|
|
84
|
+
"POST",
|
|
85
|
+
self._auth_path,
|
|
86
|
+
json={
|
|
87
|
+
"client_id": self._client_id,
|
|
88
|
+
"client_secret": self._client_secret,
|
|
89
|
+
"grant_type": "client_credentials",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
data = response.json()
|
|
93
|
+
self._token = data["access_token"]
|
|
94
|
+
expires_in: int = data.get("expires_in", 3600)
|
|
95
|
+
self._expires_at = time.monotonic() + expires_in - self._TOKEN_BUFFER_SECONDS
|
|
96
|
+
return self._token
|
|
97
|
+
|
|
98
|
+
def get_token(self) -> str:
|
|
99
|
+
if self._token is None or self._is_expired():
|
|
100
|
+
return self._fetch_token()
|
|
101
|
+
return self._token
|
|
102
|
+
|
|
103
|
+
async def get_token_async(self) -> str:
|
|
104
|
+
if self._token is None or self._is_expired():
|
|
105
|
+
return await self._fetch_token_async()
|
|
106
|
+
return self._token
|
|
107
|
+
|
|
108
|
+
def apply_header(self, headers: dict[str, str]) -> dict[str, str]:
|
|
109
|
+
return {**headers, "Authorization": f"Bearer {self.get_token()}"}
|
|
110
|
+
|
|
111
|
+
async def apply_header_async(self, headers: dict[str, str]) -> dict[str, str]:
|
|
112
|
+
token = await self.get_token_async()
|
|
113
|
+
return {**headers, "Authorization": f"Bearer {token}"}
|
eupago/_client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from eupago._auth import ApiKeyAuth, OAuthAuth, StaticBearerAuth
|
|
6
|
+
from eupago._config import (
|
|
7
|
+
DEFAULT_MAX_RETRIES,
|
|
8
|
+
DEFAULT_TIMEOUT,
|
|
9
|
+
PRODUCTION_BASE_URL,
|
|
10
|
+
SANDBOX_BASE_URL,
|
|
11
|
+
)
|
|
12
|
+
from eupago._http import AuditHook, HttpTransport
|
|
13
|
+
from eupago.services.apple_pay import ApplePayService
|
|
14
|
+
from eupago.services.credit_card import CreditCardService
|
|
15
|
+
from eupago.services.google_pay import GooglePayService
|
|
16
|
+
from eupago.services.mbway import MBWayService
|
|
17
|
+
from eupago.services.multibanco import MultibancoService
|
|
18
|
+
from eupago.services.pay_by_link import PayByLinkService
|
|
19
|
+
from eupago.services.refund import RefundService
|
|
20
|
+
from eupago.webhooks import Webhooks
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EupagoClient:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
api_key: str,
|
|
27
|
+
*,
|
|
28
|
+
client_id: str | None = None,
|
|
29
|
+
client_secret: str | None = None,
|
|
30
|
+
management_bearer: str | None = None,
|
|
31
|
+
webhook_secret: str | None = None,
|
|
32
|
+
sandbox: bool = False,
|
|
33
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
34
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
:param management_bearer: pre-obtained Bearer token for ``/api/management/*``.
|
|
38
|
+
Overrides ``client_id``/``client_secret`` when set. Intended for tests
|
|
39
|
+
or one-off scripts where the caller already has a token (e.g. from the
|
|
40
|
+
backoffice login flow); production callers should prefer OAuth via
|
|
41
|
+
``client_id``/``client_secret``.
|
|
42
|
+
"""
|
|
43
|
+
from eupago import __version__
|
|
44
|
+
|
|
45
|
+
base_url = SANDBOX_BASE_URL if sandbox else PRODUCTION_BASE_URL
|
|
46
|
+
|
|
47
|
+
self._transport = HttpTransport(
|
|
48
|
+
base_url=base_url,
|
|
49
|
+
version=__version__,
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
max_retries=max_retries,
|
|
52
|
+
)
|
|
53
|
+
self._auth = ApiKeyAuth(api_key)
|
|
54
|
+
self._oauth: OAuthAuth | StaticBearerAuth | None = None
|
|
55
|
+
|
|
56
|
+
if management_bearer:
|
|
57
|
+
self._oauth = StaticBearerAuth(management_bearer)
|
|
58
|
+
elif client_id and client_secret:
|
|
59
|
+
self._oauth = OAuthAuth(client_id, client_secret, self._transport)
|
|
60
|
+
|
|
61
|
+
self._services: dict[str, Any] = {}
|
|
62
|
+
self._webhooks = Webhooks(webhook_secret)
|
|
63
|
+
|
|
64
|
+
def _get_service(self, name: str, cls: type[Any]) -> Any:
|
|
65
|
+
if name not in self._services:
|
|
66
|
+
self._services[name] = cls(self._transport, self._auth, self._oauth)
|
|
67
|
+
return self._services[name]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def mbway(self) -> MBWayService:
|
|
71
|
+
return self._get_service("mbway", MBWayService) # type: ignore[no-any-return]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def multibanco(self) -> MultibancoService:
|
|
75
|
+
return self._get_service("multibanco", MultibancoService) # type: ignore[no-any-return]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def credit_card(self) -> CreditCardService:
|
|
79
|
+
return self._get_service("credit_card", CreditCardService) # type: ignore[no-any-return]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def apple_pay(self) -> ApplePayService:
|
|
83
|
+
return self._get_service("apple_pay", ApplePayService) # type: ignore[no-any-return]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def google_pay(self) -> GooglePayService:
|
|
87
|
+
return self._get_service("google_pay", GooglePayService) # type: ignore[no-any-return]
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def pay_by_link(self) -> PayByLinkService:
|
|
91
|
+
return self._get_service("pay_by_link", PayByLinkService) # type: ignore[no-any-return]
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def refunds(self) -> RefundService:
|
|
95
|
+
return self._get_service("refunds", RefundService) # type: ignore[no-any-return]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def webhooks(self) -> Webhooks:
|
|
99
|
+
return self._webhooks
|
|
100
|
+
|
|
101
|
+
def set_audit_hook(self, hook: AuditHook | None) -> None:
|
|
102
|
+
self._transport.set_audit_hook(hook)
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
self._transport.close()
|
|
106
|
+
|
|
107
|
+
async def aclose(self) -> None:
|
|
108
|
+
await self._transport.aclose()
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> EupagoClient:
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *args: Any) -> None:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
async def __aenter__(self) -> EupagoClient:
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
120
|
+
await self.aclose()
|
eupago/_config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
SANDBOX_BASE_URL = "https://sandbox.eupago.pt"
|
|
4
|
+
PRODUCTION_BASE_URL = "https://clientes.eupago.pt"
|
|
5
|
+
|
|
6
|
+
API_PREFIX = "/api/v1.02"
|
|
7
|
+
LEGACY_PREFIX = "/clientes/rest_api"
|
|
8
|
+
AUTH_PREFIX = "/api/auth"
|
|
9
|
+
MANAGEMENT_PREFIX = "/api/management/v1.02"
|
|
10
|
+
|
|
11
|
+
DEFAULT_TIMEOUT = 10.0
|
|
12
|
+
DEFAULT_MAX_RETRIES = 3
|
|
13
|
+
MAX_RETRY_DELAY = 5.0
|
|
14
|
+
INITIAL_RETRY_DELAY = 0.5
|
eupago/_http.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import platform
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from eupago._config import (
|
|
12
|
+
DEFAULT_MAX_RETRIES,
|
|
13
|
+
DEFAULT_TIMEOUT,
|
|
14
|
+
INITIAL_RETRY_DELAY,
|
|
15
|
+
MAX_RETRY_DELAY,
|
|
16
|
+
)
|
|
17
|
+
from eupago._logging import logger
|
|
18
|
+
from eupago.exceptions import (
|
|
19
|
+
ApiError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
NetworkError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ServiceUnavailableError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
AuditHook = Callable[[httpx.Request, httpx.Response, float], Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _user_agent(version: str) -> str:
|
|
31
|
+
return f"eupago-python/{version} (Python/{platform.python_version()})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpTransport:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str,
|
|
39
|
+
version: str = "0.0.0",
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
41
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._base_url = base_url
|
|
44
|
+
self._timeout = timeout
|
|
45
|
+
self._max_retries = max_retries
|
|
46
|
+
self._audit_hook: AuditHook | None = None
|
|
47
|
+
self._ua = _user_agent(version)
|
|
48
|
+
|
|
49
|
+
self._sync_client = httpx.Client(
|
|
50
|
+
base_url=base_url,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
headers={"User-Agent": self._ua, "Content-Type": "application/json"},
|
|
53
|
+
)
|
|
54
|
+
self._async_client: httpx.AsyncClient | None = None
|
|
55
|
+
|
|
56
|
+
def _get_async_client(self) -> httpx.AsyncClient:
|
|
57
|
+
if self._async_client is None or self._async_client.is_closed:
|
|
58
|
+
self._async_client = httpx.AsyncClient(
|
|
59
|
+
base_url=self._base_url,
|
|
60
|
+
timeout=self._timeout,
|
|
61
|
+
headers={"User-Agent": self._ua, "Content-Type": "application/json"},
|
|
62
|
+
)
|
|
63
|
+
return self._async_client
|
|
64
|
+
|
|
65
|
+
def set_audit_hook(self, hook: AuditHook | None) -> None:
|
|
66
|
+
self._audit_hook = hook
|
|
67
|
+
|
|
68
|
+
def _should_retry(self, method: str, response: httpx.Response | None, attempt: int) -> bool:
|
|
69
|
+
if attempt >= self._max_retries:
|
|
70
|
+
return False
|
|
71
|
+
if method.upper() != "GET":
|
|
72
|
+
return False
|
|
73
|
+
if response is None:
|
|
74
|
+
return True
|
|
75
|
+
return response.status_code >= 500
|
|
76
|
+
|
|
77
|
+
def _retry_delay(self, attempt: int) -> float:
|
|
78
|
+
delay = min(INITIAL_RETRY_DELAY * (2**attempt), MAX_RETRY_DELAY)
|
|
79
|
+
jitter: float = delay * (0.5 + random.random() * 0.5) # noqa: S311
|
|
80
|
+
return jitter
|
|
81
|
+
|
|
82
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
83
|
+
if response.status_code < 400:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
data = response.json()
|
|
88
|
+
except Exception:
|
|
89
|
+
data = {}
|
|
90
|
+
|
|
91
|
+
message = str(
|
|
92
|
+
data.get("message")
|
|
93
|
+
or data.get("Message")
|
|
94
|
+
or data.get("text")
|
|
95
|
+
or data.get("resposta")
|
|
96
|
+
or response.text
|
|
97
|
+
)
|
|
98
|
+
error_code_raw = data.get("code") or data.get("estado")
|
|
99
|
+
error_code: int | str | None
|
|
100
|
+
if error_code_raw is None:
|
|
101
|
+
error_code = None
|
|
102
|
+
else:
|
|
103
|
+
try:
|
|
104
|
+
error_code = int(error_code_raw)
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
error_code = error_code_raw
|
|
107
|
+
|
|
108
|
+
kwargs: dict[str, Any] = {
|
|
109
|
+
"status_code": response.status_code,
|
|
110
|
+
"error_code": error_code,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if response.status_code == 401:
|
|
114
|
+
raise AuthenticationError(message)
|
|
115
|
+
if response.status_code == 404:
|
|
116
|
+
raise NotFoundError(message, **kwargs)
|
|
117
|
+
if response.status_code == 429:
|
|
118
|
+
raise RateLimitError(message, **kwargs)
|
|
119
|
+
if response.status_code >= 500:
|
|
120
|
+
raise ServiceUnavailableError(message, **kwargs)
|
|
121
|
+
raise ApiError(message, **kwargs)
|
|
122
|
+
|
|
123
|
+
def request(
|
|
124
|
+
self,
|
|
125
|
+
method: str,
|
|
126
|
+
path: str,
|
|
127
|
+
*,
|
|
128
|
+
json: dict[str, Any] | None = None,
|
|
129
|
+
data: dict[str, Any] | None = None,
|
|
130
|
+
params: dict[str, Any] | None = None,
|
|
131
|
+
headers: dict[str, str] | None = None,
|
|
132
|
+
) -> httpx.Response:
|
|
133
|
+
last_exc: Exception | None = None
|
|
134
|
+
effective_headers = dict(headers or {})
|
|
135
|
+
# Form-encoded body needs the matching Content-Type (httpx Client default is JSON).
|
|
136
|
+
if data is not None:
|
|
137
|
+
effective_headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
138
|
+
|
|
139
|
+
for attempt in range(self._max_retries + 1):
|
|
140
|
+
try:
|
|
141
|
+
start = time.monotonic()
|
|
142
|
+
response = self._sync_client.request(
|
|
143
|
+
method,
|
|
144
|
+
path,
|
|
145
|
+
json=json,
|
|
146
|
+
data=data,
|
|
147
|
+
params=params,
|
|
148
|
+
headers=effective_headers,
|
|
149
|
+
)
|
|
150
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
151
|
+
logger.debug(
|
|
152
|
+
"HTTP %s %s → %d (%.0fms)", method, path, response.status_code, duration_ms
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if self._audit_hook is not None:
|
|
156
|
+
self._audit_hook(response.request, response, duration_ms)
|
|
157
|
+
|
|
158
|
+
if response.status_code >= 400:
|
|
159
|
+
if self._should_retry(method, response, attempt):
|
|
160
|
+
time.sleep(self._retry_delay(attempt))
|
|
161
|
+
continue
|
|
162
|
+
self._raise_for_status(response)
|
|
163
|
+
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
167
|
+
last_exc = exc
|
|
168
|
+
logger.debug(
|
|
169
|
+
"HTTP %s %s → %s (attempt %d)", method, path, type(exc).__name__, attempt + 1
|
|
170
|
+
)
|
|
171
|
+
if self._should_retry(method, None, attempt):
|
|
172
|
+
time.sleep(self._retry_delay(attempt))
|
|
173
|
+
continue
|
|
174
|
+
raise NetworkError(str(exc)) from exc
|
|
175
|
+
|
|
176
|
+
raise NetworkError(str(last_exc)) from last_exc
|
|
177
|
+
|
|
178
|
+
async def request_async(
|
|
179
|
+
self,
|
|
180
|
+
method: str,
|
|
181
|
+
path: str,
|
|
182
|
+
*,
|
|
183
|
+
json: dict[str, Any] | None = None,
|
|
184
|
+
data: dict[str, Any] | None = None,
|
|
185
|
+
params: dict[str, Any] | None = None,
|
|
186
|
+
headers: dict[str, str] | None = None,
|
|
187
|
+
) -> httpx.Response:
|
|
188
|
+
client = self._get_async_client()
|
|
189
|
+
last_exc: Exception | None = None
|
|
190
|
+
effective_headers = dict(headers or {})
|
|
191
|
+
if data is not None:
|
|
192
|
+
effective_headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
193
|
+
|
|
194
|
+
for attempt in range(self._max_retries + 1):
|
|
195
|
+
try:
|
|
196
|
+
start = time.monotonic()
|
|
197
|
+
response = await client.request(
|
|
198
|
+
method,
|
|
199
|
+
path,
|
|
200
|
+
json=json,
|
|
201
|
+
data=data,
|
|
202
|
+
params=params,
|
|
203
|
+
headers=effective_headers,
|
|
204
|
+
)
|
|
205
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
206
|
+
logger.debug(
|
|
207
|
+
"HTTP %s %s → %d (%.0fms)", method, path, response.status_code, duration_ms
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if self._audit_hook is not None:
|
|
211
|
+
self._audit_hook(response.request, response, duration_ms)
|
|
212
|
+
|
|
213
|
+
if response.status_code >= 400:
|
|
214
|
+
if self._should_retry(method, response, attempt):
|
|
215
|
+
await asyncio.sleep(self._retry_delay(attempt))
|
|
216
|
+
continue
|
|
217
|
+
self._raise_for_status(response)
|
|
218
|
+
|
|
219
|
+
return response
|
|
220
|
+
|
|
221
|
+
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
|
222
|
+
last_exc = exc
|
|
223
|
+
logger.debug(
|
|
224
|
+
"HTTP %s %s → %s (attempt %d)", method, path, type(exc).__name__, attempt + 1
|
|
225
|
+
)
|
|
226
|
+
if self._should_retry(method, None, attempt):
|
|
227
|
+
await asyncio.sleep(self._retry_delay(attempt))
|
|
228
|
+
continue
|
|
229
|
+
raise NetworkError(str(exc)) from exc
|
|
230
|
+
|
|
231
|
+
raise NetworkError(str(last_exc)) from last_exc
|
|
232
|
+
|
|
233
|
+
def close(self) -> None:
|
|
234
|
+
self._sync_client.close()
|
|
235
|
+
|
|
236
|
+
async def aclose(self) -> None:
|
|
237
|
+
self._sync_client.close()
|
|
238
|
+
if self._async_client is not None:
|
|
239
|
+
await self._async_client.aclose()
|
eupago/_logging.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("eupago")
|
|
7
|
+
|
|
8
|
+
_PII_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
9
|
+
(re.compile(r"\b\d{9}\b"), "***PHONE***"),
|
|
10
|
+
(re.compile(r"\b351#\d{9}\b"), "***PHONE***"),
|
|
11
|
+
(re.compile(r"\b[+]351\s?\d{9}\b"), "***PHONE***"),
|
|
12
|
+
(re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b"), "***EMAIL***"),
|
|
13
|
+
(re.compile(r"\b\d{3}[.\s]?\d{3}[.\s]?\d{3}\b"), "***NIF***"),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def redact_pii(text: str) -> str:
|
|
18
|
+
for pattern, replacement in _PII_PATTERNS:
|
|
19
|
+
text = pattern.sub(replacement, text)
|
|
20
|
+
return text
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PiiFilter(logging.Filter):
|
|
24
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
25
|
+
if isinstance(record.msg, str):
|
|
26
|
+
record.msg = redact_pii(record.msg)
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
logger.addFilter(PiiFilter())
|
eupago/exceptions.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EupagoError(Exception):
|
|
5
|
+
"""Base exception for all eupago SDK errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str) -> None:
|
|
8
|
+
self.message = message
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(EupagoError):
|
|
13
|
+
"""Invalid API key or expired OAuth token."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationError(EupagoError):
|
|
17
|
+
"""Invalid parameters detected before calling the API."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiError(EupagoError):
|
|
21
|
+
"""Error response from the eupago API."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
status_code: int | None = None,
|
|
28
|
+
error_code: int | str | None = None,
|
|
29
|
+
request_id: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.error_code = error_code
|
|
33
|
+
self.request_id = request_id
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
parts = [self.message]
|
|
38
|
+
if self.status_code is not None:
|
|
39
|
+
parts.append(f"HTTP {self.status_code}")
|
|
40
|
+
if self.error_code is not None:
|
|
41
|
+
parts.append(f"code={self.error_code}")
|
|
42
|
+
if self.request_id:
|
|
43
|
+
parts.append(f"request_id={self.request_id}")
|
|
44
|
+
return " | ".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PaymentError(ApiError):
|
|
48
|
+
"""Payment failed or was declined."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RateLimitError(ApiError):
|
|
52
|
+
"""Request was rate limited."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NotFoundError(ApiError):
|
|
56
|
+
"""Reference or transaction not found."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ServiceUnavailableError(ApiError):
|
|
60
|
+
"""eupago API is unavailable."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class WebhookError(EupagoError):
|
|
64
|
+
"""Webhook processing error."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SignatureError(WebhookError):
|
|
68
|
+
"""Invalid HMAC signature on webhook."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DecryptionError(WebhookError):
|
|
72
|
+
"""Failed to decrypt encrypted webhook payload."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NetworkError(EupagoError):
|
|
76
|
+
"""Network-level error: timeout, connection refused, DNS failure."""
|
eupago/models/_base.py
ADDED