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 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."""
@@ -0,0 +1,10 @@
1
+ from eupago.models.customer import Customer
2
+ from eupago.models.payment import PaymentResult, PaymentStatus
3
+ from eupago.models.webhook import WebhookEvent
4
+
5
+ __all__ = [
6
+ "Customer",
7
+ "PaymentResult",
8
+ "PaymentStatus",
9
+ "WebhookEvent",
10
+ ]
eupago/models/_base.py ADDED
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel as PydanticBaseModel
4
+ from pydantic import ConfigDict
5
+
6
+
7
+ class BaseModel(PydanticBaseModel):
8
+ model_config = ConfigDict(
9
+ populate_by_name=True,
10
+ str_strip_whitespace=True,
11
+ )
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from eupago.models._base import BaseModel
4
+
5
+
6
+ class Customer(BaseModel):
7
+ name: str | None = None
8
+ email: str | None = None
9
+ phone_number: str | None = None
10
+ notify: bool = True