epoint-python-sdk 0.2.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.
- epoint_python_sdk-0.2.0.dist-info/METADATA +154 -0
- epoint_python_sdk-0.2.0.dist-info/RECORD +19 -0
- epoint_python_sdk-0.2.0.dist-info/WHEEL +4 -0
- epoint_python_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- epoint_sdk/__init__.py +73 -0
- epoint_sdk/_core.py +209 -0
- epoint_sdk/_version.py +1 -0
- epoint_sdk/callbacks.py +31 -0
- epoint_sdk/client.py +55 -0
- epoint_sdk/config.py +108 -0
- epoint_sdk/enums.py +61 -0
- epoint_sdk/errors.py +73 -0
- epoint_sdk/messages.py +60 -0
- epoint_sdk/messages.yaml +43 -0
- epoint_sdk/models.py +278 -0
- epoint_sdk/money.py +40 -0
- epoint_sdk/py.typed +0 -0
- epoint_sdk/resources.py +374 -0
- epoint_sdk/signing.py +49 -0
epoint_sdk/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from urllib.parse import quote, urlsplit
|
|
5
|
+
|
|
6
|
+
from goodconf import Field, GoodConf, GoodConfConfigDict
|
|
7
|
+
from pydantic import BaseModel, SecretStr, field_validator
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .enums import Currency, Language
|
|
11
|
+
from .errors import ConfigurationError
|
|
12
|
+
from .messages import text
|
|
13
|
+
|
|
14
|
+
LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Endpoints(BaseModel):
|
|
18
|
+
payment: str = "/api/1/request"
|
|
19
|
+
checkout: str = "/api/1/checkout"
|
|
20
|
+
status: str = "/api/1/get-status"
|
|
21
|
+
reverse: str = "/api/1/reverse"
|
|
22
|
+
hold: str = "/api/1/pre-auth-request"
|
|
23
|
+
capture: str = "/api/1/pre-auth-complete"
|
|
24
|
+
card_register: str = "/api/1/card-registration"
|
|
25
|
+
card_register_pay: str = "/api/1/card-registration-with-pay"
|
|
26
|
+
card_charge: str = "/api/1/execute-pay"
|
|
27
|
+
card_status: str = "/api/1/get-status-card"
|
|
28
|
+
payout: str = "/api/1/refund-request"
|
|
29
|
+
split_payment: str = "/api/1/split-request"
|
|
30
|
+
split_charge: str = "/api/1/split-execute-pay"
|
|
31
|
+
split_register_pay: str = "/api/1/split-card-registration-with-pay"
|
|
32
|
+
installment_plans: str = "/api/1/get-installment-request"
|
|
33
|
+
installment_payment: str = "/api/1/installment-request"
|
|
34
|
+
wallets: str = "/api/1/wallet/status"
|
|
35
|
+
wallet_payment: str = "/api/1/wallet/payment"
|
|
36
|
+
widget: str = "/api/1/token/widget"
|
|
37
|
+
widget_split: str = "/api/1/token/split-payment"
|
|
38
|
+
invoice_create: str = "/api/1/invoices/create"
|
|
39
|
+
invoice_update: str = "/api/1/invoices/update"
|
|
40
|
+
invoice_view: str = "/api/1/invoices/view"
|
|
41
|
+
invoice_list: str = "/api/1/invoices/list"
|
|
42
|
+
invoice_sms: str = "/api/1/invoices/send-sms"
|
|
43
|
+
invoice_email: str = "/api/1/invoices/send-email"
|
|
44
|
+
b2b_transfer: str = "/api/1/b2b/payment"
|
|
45
|
+
b2b_status: str = "/api/1/b2b/payment/{order_id}"
|
|
46
|
+
heartbeat: str = "/api/heartbeat"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Settings(GoodConf):
|
|
50
|
+
model_config = GoodConfConfigDict(
|
|
51
|
+
env_prefix="EPOINT_",
|
|
52
|
+
file_env_var="EPOINT_CONFIG",
|
|
53
|
+
default_files=["epoint.toml", "epoint.yaml", "epoint.yml", "epoint.json"],
|
|
54
|
+
extra="ignore",
|
|
55
|
+
env_nested_delimiter="__",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
public_key: str = Field(default="", description=text("settings.public_key"))
|
|
59
|
+
private_key: SecretStr = Field(default=SecretStr(""), description=text("settings.private_key"))
|
|
60
|
+
base_url: str = Field(default="https://epoint.az", description=text("settings.base_url"))
|
|
61
|
+
endpoints: Endpoints = Field(default_factory=Endpoints, description=text("settings.endpoints"))
|
|
62
|
+
language: Language = Field(default=Language.AZ, description=text("settings.language"))
|
|
63
|
+
currency: Currency = Field(default=Currency.AZN, description=text("settings.currency"))
|
|
64
|
+
success_url: Optional[str] = Field(default=None, description=text("settings.success_url"))
|
|
65
|
+
error_url: Optional[str] = Field(default=None, description=text("settings.error_url"))
|
|
66
|
+
timeout: float = Field(default=30.0, gt=0, description=text("settings.timeout"))
|
|
67
|
+
connect_timeout: float = Field(default=10.0, gt=0, description=text("settings.connect_timeout"))
|
|
68
|
+
max_retries: int = Field(default=2, ge=0, le=10, description=text("settings.max_retries"))
|
|
69
|
+
backoff_base: float = Field(default=0.5, ge=0, description=text("settings.backoff_base"))
|
|
70
|
+
backoff_cap: float = Field(default=8.0, ge=0, description=text("settings.backoff_cap"))
|
|
71
|
+
user_agent: str = Field(default="epoint-python-sdk/{version}", description=text("settings.user_agent"))
|
|
72
|
+
allow_insecure_http: bool = Field(default=False, description=text("settings.allow_insecure_http"))
|
|
73
|
+
|
|
74
|
+
@field_validator("base_url")
|
|
75
|
+
@classmethod
|
|
76
|
+
def _base_url(cls, value: str) -> str:
|
|
77
|
+
parts = urlsplit(value.strip())
|
|
78
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
79
|
+
raise ValueError(text("error.base_url", url=value))
|
|
80
|
+
return value.strip()
|
|
81
|
+
|
|
82
|
+
def check_transport(self) -> None:
|
|
83
|
+
parts = urlsplit(self.base_url)
|
|
84
|
+
if parts.scheme == "https" or self.allow_insecure_http or parts.hostname in LOCAL_HOSTS:
|
|
85
|
+
return
|
|
86
|
+
raise ConfigurationError(text("error.insecure", url=self.base_url))
|
|
87
|
+
|
|
88
|
+
def url(self, endpoint: str, **params: Any) -> str:
|
|
89
|
+
template = endpoint if endpoint.startswith("/") else getattr(self.endpoints, endpoint)
|
|
90
|
+
path = template.format(**{k: quote(str(v), safe="") for k, v in params.items()})
|
|
91
|
+
return self.base_url.rstrip("/") + path
|
|
92
|
+
|
|
93
|
+
def resolved_user_agent(self) -> str:
|
|
94
|
+
return self.user_agent.replace("{version}", __version__)
|
|
95
|
+
|
|
96
|
+
def keys(self) -> tuple[str, str]:
|
|
97
|
+
public, private = self.public_key.strip(), self.private_key.get_secret_value().strip()
|
|
98
|
+
if not public or not private:
|
|
99
|
+
raise ConfigurationError(text("error.missing_keys"))
|
|
100
|
+
return public, private
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
Settings.__doc__ = text("settings.description")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_settings(config_file: Optional[str] = None, **overrides: Any) -> Settings:
|
|
107
|
+
given = {key: value for key, value in overrides.items() if value is not None}
|
|
108
|
+
return Settings(load=True, config_file=config_file, **given)
|
epoint_sdk/enums.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _Str(str, Enum):
|
|
7
|
+
def __str__(self) -> str:
|
|
8
|
+
return str(self.value)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Status(_Str):
|
|
12
|
+
NEW = "new"
|
|
13
|
+
SUCCESS = "success"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
ERROR = "error"
|
|
16
|
+
RETURNED = "returned"
|
|
17
|
+
SERVER_ERROR = "server_error"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def of(cls, value: object) -> Status:
|
|
22
|
+
try:
|
|
23
|
+
return cls(str(value or "").strip().lower())
|
|
24
|
+
except ValueError:
|
|
25
|
+
return cls.UNKNOWN
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CardStatus(_Str):
|
|
29
|
+
NEW = "new"
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
PENDING = "pending"
|
|
32
|
+
REJECTED = "rejected"
|
|
33
|
+
EXPIRED = "expired"
|
|
34
|
+
SESSION_EXPIRED = "session_expired"
|
|
35
|
+
UNKNOWN = "unknown"
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def of(cls, value: object) -> CardStatus:
|
|
39
|
+
try:
|
|
40
|
+
return cls(str(value or "").strip().lower())
|
|
41
|
+
except ValueError:
|
|
42
|
+
return cls.UNKNOWN
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OperationCode(_Str):
|
|
46
|
+
CARD_REGISTRATION = "001"
|
|
47
|
+
PAYMENT = "100"
|
|
48
|
+
REGISTRATION_WITH_PAYMENT = "200"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Language(_Str):
|
|
52
|
+
AZ = "az"
|
|
53
|
+
EN = "en"
|
|
54
|
+
RU = "ru"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Currency(_Str):
|
|
58
|
+
AZN = "AZN"
|
|
59
|
+
USD = "USD"
|
|
60
|
+
EUR = "EUR"
|
|
61
|
+
RUB = "RUB"
|
epoint_sdk/errors.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EpointError(Exception):
|
|
7
|
+
def __init__(self, message: str) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(EpointError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidRequestError(EpointError, ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SignatureError(EpointError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TransportError(EpointError):
|
|
25
|
+
def __init__(self, message: str, *, path: str = "", order_id: Optional[str] = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.path = path
|
|
28
|
+
self.order_id = order_id
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotSentError(TransportError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OutcomeUnknownError(TransportError):
|
|
36
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None, **kwargs: Any) -> None:
|
|
37
|
+
super().__init__(message, **kwargs)
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HTTPStatusError(EpointError):
|
|
42
|
+
def __init__(self, message: str, *, status_code: int, body: Any = None, path: str = "") -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
self.body = body
|
|
46
|
+
self.path = path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GatewayError(EpointError):
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
code: Optional[str] = None,
|
|
55
|
+
trace_id: Optional[str] = None,
|
|
56
|
+
status_code: int = 200,
|
|
57
|
+
payload: Optional[dict[str, Any]] = None,
|
|
58
|
+
path: str = "",
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.code = code
|
|
62
|
+
self.trace_id = trace_id
|
|
63
|
+
self.status_code = status_code
|
|
64
|
+
self.payload = payload or {}
|
|
65
|
+
self.path = path
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
parts = [self.message]
|
|
69
|
+
if self.code:
|
|
70
|
+
parts.insert(0, f"[{self.code}]")
|
|
71
|
+
if self.trace_id:
|
|
72
|
+
parts.append(f"(trace {self.trace_id})")
|
|
73
|
+
return " ".join(parts)
|
epoint_sdk/messages.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
PathLike = Union[str, Path]
|
|
11
|
+
|
|
12
|
+
_PACKAGE_FILE = "messages.yaml"
|
|
13
|
+
_catalog: dict[str, str] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _flatten(node: Mapping[str, Any], prefix: str = "") -> dict[str, str]:
|
|
17
|
+
flat: dict[str, str] = {}
|
|
18
|
+
for key, value in node.items():
|
|
19
|
+
path = f"{prefix}{key}"
|
|
20
|
+
if isinstance(value, Mapping):
|
|
21
|
+
flat.update(_flatten(value, f"{path}."))
|
|
22
|
+
else:
|
|
23
|
+
flat[path] = str(value)
|
|
24
|
+
return flat
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse(raw: str) -> dict[str, str]:
|
|
28
|
+
data = yaml.safe_load(raw) or {}
|
|
29
|
+
if not isinstance(data, Mapping):
|
|
30
|
+
raise ValueError("messages file must contain a mapping")
|
|
31
|
+
return _flatten(data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_packaged() -> dict[str, str]:
|
|
35
|
+
raw = resources.files(__package__).joinpath(_PACKAGE_FILE).read_text(encoding="utf-8")
|
|
36
|
+
return _parse(raw)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def catalog() -> Mapping[str, str]:
|
|
40
|
+
if not _catalog:
|
|
41
|
+
_catalog.update(_read_packaged())
|
|
42
|
+
return _catalog
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def use(source: PathLike | Mapping[str, Any]) -> None:
|
|
46
|
+
overrides = _flatten(source) if isinstance(source, Mapping) else _parse(Path(source).read_text(encoding="utf-8"))
|
|
47
|
+
unknown = set(overrides) - set(_read_packaged())
|
|
48
|
+
if unknown:
|
|
49
|
+
raise KeyError(", ".join(sorted(unknown)))
|
|
50
|
+
catalog()
|
|
51
|
+
_catalog.update(overrides)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reset() -> None:
|
|
55
|
+
_catalog.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def text(key: str, **params: Any) -> str:
|
|
59
|
+
template = catalog()[key]
|
|
60
|
+
return template.format(**params) if params else template
|
epoint_sdk/messages.yaml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
settings:
|
|
2
|
+
description: Settings for the Epoint client. Values come from code, EPOINT_* environment variables or a config file.
|
|
3
|
+
public_key: Merchant public key, for example i000000001
|
|
4
|
+
private_key: Merchant private key used to sign requests and verify callbacks
|
|
5
|
+
base_url: Gateway address
|
|
6
|
+
endpoints: Path of every API call, relative to base_url
|
|
7
|
+
language: Default language of the hosted payment page (az, en, ru)
|
|
8
|
+
currency: Default currency
|
|
9
|
+
success_url: Default page the customer returns to after a successful payment
|
|
10
|
+
error_url: Default page the customer returns to after a failed payment
|
|
11
|
+
timeout: Seconds to wait for an answer
|
|
12
|
+
connect_timeout: Seconds to wait for a connection
|
|
13
|
+
max_retries: Extra attempts for calls that are safe to repeat
|
|
14
|
+
backoff_base: First wait between retries, in seconds
|
|
15
|
+
backoff_cap: Longest wait between retries, in seconds
|
|
16
|
+
user_agent: User-Agent header
|
|
17
|
+
allow_insecure_http: Allow a plain http base_url outside localhost, for a sandbox on a private network only
|
|
18
|
+
|
|
19
|
+
value:
|
|
20
|
+
amount_type: "{field} must be a number, got {value!r}"
|
|
21
|
+
amount_positive: "{field} must be greater than zero, got {value}"
|
|
22
|
+
amount_scale: "{field} {value} has more than two decimal places"
|
|
23
|
+
amount_finite: "{field} must be a finite number, got {value}"
|
|
24
|
+
empty: "{field} cannot be empty"
|
|
25
|
+
too_long: "{field} is longer than {limit} characters"
|
|
26
|
+
months: "months must be a positive whole number, got {value!r}"
|
|
27
|
+
split_amount: "split_amount {split} cannot exceed amount {amount}"
|
|
28
|
+
|
|
29
|
+
error:
|
|
30
|
+
missing_keys: Epoint public_key and private_key are required
|
|
31
|
+
insecure: "Refusing to send signed requests over plain http to {url}. Use https, or set allow_insecure_http for a sandbox."
|
|
32
|
+
base_url: "base_url must be an http or https address, got {url}"
|
|
33
|
+
too_large: "The data field is larger than {limit} bytes"
|
|
34
|
+
signature: The signature does not match the data
|
|
35
|
+
data: The data field is not base64 encoded JSON
|
|
36
|
+
data_object: The data field does not decode to a JSON object
|
|
37
|
+
callback_fields: A callback needs both data and signature
|
|
38
|
+
connect: "Could not reach Epoint, nothing was sent: {error}"
|
|
39
|
+
timeout: "Epoint did not answer in time: {error}"
|
|
40
|
+
unknown_outcome: "The request reached Epoint but no answer came back ({reason}). Check the order before trying again."
|
|
41
|
+
status: "Epoint answered HTTP {status}"
|
|
42
|
+
body: "Epoint sent a body that is not JSON: {body}"
|
|
43
|
+
refused: Epoint refused the request
|
epoint_sdk/models.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from .enums import CardStatus, Status
|
|
9
|
+
from .money import parse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _str(body: Mapping[str, Any], key: str) -> Optional[str]:
|
|
13
|
+
value = body.get(key)
|
|
14
|
+
if value is None or value == "":
|
|
15
|
+
return None
|
|
16
|
+
return str(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Redirect:
|
|
21
|
+
redirect_url: Optional[str]
|
|
22
|
+
transaction: Optional[str]
|
|
23
|
+
trace_id: Optional[str] = None
|
|
24
|
+
message: Optional[str] = None
|
|
25
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_api(cls, body: Mapping[str, Any]) -> Redirect:
|
|
29
|
+
return cls(_str(body, "redirect_url"), _str(body, "transaction"), _str(body, "trace_id"), _str(body, "message"),
|
|
30
|
+
dict(body))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Transaction:
|
|
35
|
+
status: Status
|
|
36
|
+
transaction: Optional[str] = None
|
|
37
|
+
order_id: Optional[str] = None
|
|
38
|
+
amount: Optional[Decimal] = None
|
|
39
|
+
code: Optional[str] = None
|
|
40
|
+
message: Optional[str] = None
|
|
41
|
+
bank_transaction: Optional[str] = None
|
|
42
|
+
bank_response: Optional[str] = None
|
|
43
|
+
operation_code: Optional[str] = None
|
|
44
|
+
rrn: Optional[str] = None
|
|
45
|
+
card_id: Optional[str] = None
|
|
46
|
+
card_name: Optional[str] = None
|
|
47
|
+
card_mask: Optional[str] = None
|
|
48
|
+
split_amount: Optional[Decimal] = None
|
|
49
|
+
other_attr: Any = None
|
|
50
|
+
trace_id: Optional[str] = None
|
|
51
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_api(cls, body: Mapping[str, Any]) -> Transaction:
|
|
55
|
+
return cls(
|
|
56
|
+
status=Status.of(body.get("status")),
|
|
57
|
+
transaction=_str(body, "transaction"),
|
|
58
|
+
order_id=_str(body, "order_id"),
|
|
59
|
+
amount=parse(body.get("amount")),
|
|
60
|
+
code=_str(body, "code"),
|
|
61
|
+
message=_str(body, "message"),
|
|
62
|
+
bank_transaction=_str(body, "bank_transaction"),
|
|
63
|
+
bank_response=_str(body, "bank_response"),
|
|
64
|
+
operation_code=_str(body, "operation_code"),
|
|
65
|
+
rrn=_str(body, "rrn"),
|
|
66
|
+
card_id=_str(body, "card_id"),
|
|
67
|
+
card_name=_str(body, "card_name"),
|
|
68
|
+
card_mask=_str(body, "card_mask"),
|
|
69
|
+
split_amount=parse(body.get("split_amount")),
|
|
70
|
+
other_attr=body.get("other_attr"),
|
|
71
|
+
trace_id=_str(body, "trace_id"),
|
|
72
|
+
raw=dict(body),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def paid(self) -> bool:
|
|
77
|
+
return self.status is Status.SUCCESS
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def returned(self) -> bool:
|
|
81
|
+
return self.status is Status.RETURNED
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def failed(self) -> bool:
|
|
85
|
+
return self.status in (Status.FAILED, Status.ERROR)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def pending(self) -> bool:
|
|
89
|
+
return self.status in (Status.NEW, Status.SERVER_ERROR, Status.UNKNOWN)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def final(self) -> bool:
|
|
93
|
+
return not self.pending
|
|
94
|
+
|
|
95
|
+
def matches(self, *, order_id: Optional[str] = None, amount: Any = None, transaction: Optional[str] = None) -> bool:
|
|
96
|
+
if order_id is not None and self.order_id != str(order_id):
|
|
97
|
+
return False
|
|
98
|
+
if transaction is not None and self.transaction is not None and self.transaction != str(transaction):
|
|
99
|
+
return False
|
|
100
|
+
if amount is not None and self.amount != parse(amount):
|
|
101
|
+
return False
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
Callback = Transaction
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Result:
|
|
110
|
+
status: Status
|
|
111
|
+
transaction: Optional[str] = None
|
|
112
|
+
message: Optional[str] = None
|
|
113
|
+
trace_id: Optional[str] = None
|
|
114
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_api(cls, body: Mapping[str, Any]) -> Result:
|
|
118
|
+
return cls(Status.of(body.get("status")), _str(body, "transaction"), _str(body, "message"),
|
|
119
|
+
_str(body, "trace_id"), dict(body))
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def ok(self) -> bool:
|
|
123
|
+
return self.status is Status.SUCCESS
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class CardRegistration:
|
|
128
|
+
card_id: Optional[str]
|
|
129
|
+
redirect_url: Optional[str]
|
|
130
|
+
transaction: Optional[str] = None
|
|
131
|
+
trace_id: Optional[str] = None
|
|
132
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_api(cls, body: Mapping[str, Any]) -> CardRegistration:
|
|
136
|
+
return cls(_str(body, "card_id"), _str(body, "redirect_url"), _str(body, "transaction"), _str(body, "trace_id"),
|
|
137
|
+
dict(body))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class Card:
|
|
142
|
+
id: Optional[str]
|
|
143
|
+
status: CardStatus
|
|
144
|
+
name: Optional[str] = None
|
|
145
|
+
mask: Optional[str] = None
|
|
146
|
+
expiry: Optional[str] = None
|
|
147
|
+
description: Optional[str] = None
|
|
148
|
+
trace_id: Optional[str] = None
|
|
149
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_api(cls, body: Mapping[str, Any]) -> Card:
|
|
153
|
+
return cls(_str(body, "id") or _str(body, "card_id"), CardStatus.of(body.get("status")), _str(body, "name"),
|
|
154
|
+
_str(body, "mask"), _str(body, "expired_date"), _str(body, "description"), _str(body, "trace_id"),
|
|
155
|
+
dict(body))
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def usable(self) -> bool:
|
|
159
|
+
return self.status is CardStatus.ACTIVE
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class Widget:
|
|
164
|
+
widget_url: Optional[str]
|
|
165
|
+
transaction: Optional[str] = None
|
|
166
|
+
trace_id: Optional[str] = None
|
|
167
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_api(cls, body: Mapping[str, Any]) -> Widget:
|
|
171
|
+
return cls(_str(body, "widget_url"), _str(body, "transaction"), _str(body, "trace_id"), dict(body))
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def origin(self) -> Optional[str]:
|
|
175
|
+
return origin_of(self.widget_url)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def origin_of(url: Optional[str]) -> Optional[str]:
|
|
179
|
+
if not url:
|
|
180
|
+
return None
|
|
181
|
+
parts = urlsplit(url)
|
|
182
|
+
if not parts.scheme or not parts.netloc:
|
|
183
|
+
return None
|
|
184
|
+
return f"{parts.scheme}://{parts.netloc}".lower()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class WidgetMessage:
|
|
189
|
+
status: Status
|
|
190
|
+
order_id: Optional[str] = None
|
|
191
|
+
transaction: Optional[str] = None
|
|
192
|
+
amount: Optional[Decimal] = None
|
|
193
|
+
card_mask: Optional[str] = None
|
|
194
|
+
message: Optional[str] = None
|
|
195
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def from_event(cls, data: Mapping[str, Any]) -> WidgetMessage:
|
|
199
|
+
found = data.get("payment")
|
|
200
|
+
payment: Mapping[str, Any] = found if isinstance(found, Mapping) else {}
|
|
201
|
+
status = payment.get("status") or data.get("status")
|
|
202
|
+
return cls(Status.of(status), _str(payment, "order_id"), _str(payment, "transaction"),
|
|
203
|
+
parse(payment.get("amount")), _str(payment, "card_mask"), _str(payment, "message"), dict(data))
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def looks_paid(self) -> bool:
|
|
207
|
+
return self.status is Status.SUCCESS
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True)
|
|
211
|
+
class InstallmentPlan:
|
|
212
|
+
card_id: str
|
|
213
|
+
name: str
|
|
214
|
+
months: tuple[int, ...]
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_api(cls, body: Mapping[str, Any]) -> InstallmentPlan:
|
|
218
|
+
months = body.get("installment_months") or []
|
|
219
|
+
return cls(str(body.get("installment_card_id") or ""), str(body.get("name") or ""),
|
|
220
|
+
tuple(int(m) for m in months if str(m).isdigit()))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class Wallet:
|
|
225
|
+
id: str
|
|
226
|
+
name: str
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_api(cls, body: Mapping[str, Any]) -> Wallet:
|
|
230
|
+
return cls(str(body.get("id") or body.get("wallet_id") or ""), str(body.get("name") or ""))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True)
|
|
234
|
+
class Invoice:
|
|
235
|
+
id: Optional[int]
|
|
236
|
+
amount: Optional[Decimal]
|
|
237
|
+
status: Optional[str]
|
|
238
|
+
name: Optional[str] = None
|
|
239
|
+
description: Optional[str] = None
|
|
240
|
+
phone: Optional[str] = None
|
|
241
|
+
email: Optional[str] = None
|
|
242
|
+
period_from: Optional[str] = None
|
|
243
|
+
period_to: Optional[str] = None
|
|
244
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_api(cls, body: Mapping[str, Any]) -> Invoice:
|
|
248
|
+
ident = str(body.get("id") or "")
|
|
249
|
+
return cls(int(ident) if ident.isdigit() else None, parse(body.get("sum")), _str(body, "status"),
|
|
250
|
+
_str(body, "name"), _str(body, "description"), _str(body, "phone"), _str(body, "email"),
|
|
251
|
+
_str(body, "period_from"), _str(body, "period_to"), dict(body))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class Transfer:
|
|
256
|
+
order_id: Optional[str]
|
|
257
|
+
status: Optional[str]
|
|
258
|
+
bulk_id: Optional[str] = None
|
|
259
|
+
amount: Optional[Decimal] = None
|
|
260
|
+
payee_iban: Optional[str] = None
|
|
261
|
+
payee_name: Optional[str] = None
|
|
262
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_api(cls, body: Mapping[str, Any]) -> Transfer:
|
|
266
|
+
return cls(_str(body, "order_id"), _str(body, "status"), _str(body, "bulkId") or _str(body, "bulk_id"),
|
|
267
|
+
parse(body.get("amount")), _str(body, "payee_iban"), _str(body, "payee_name"), dict(body))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True)
|
|
271
|
+
class CheckoutForm:
|
|
272
|
+
action: str
|
|
273
|
+
data: str
|
|
274
|
+
signature: str
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def fields(self) -> dict[str, str]:
|
|
278
|
+
return {"data": self.data, "signature": self.signature}
|
epoint_sdk/money.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal, InvalidOperation
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
from .errors import InvalidRequestError
|
|
7
|
+
from .messages import text
|
|
8
|
+
|
|
9
|
+
Amount = Union[Decimal, int, float, str]
|
|
10
|
+
CENT = Decimal("0.01")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def amount(value: Amount, field: str = "amount") -> Decimal:
|
|
14
|
+
if isinstance(value, bool) or not isinstance(value, (Decimal, int, float, str)):
|
|
15
|
+
raise InvalidRequestError(text("value.amount_type", field=field, value=value))
|
|
16
|
+
try:
|
|
17
|
+
number = Decimal(str(value).strip()) if not isinstance(value, Decimal) else value
|
|
18
|
+
except InvalidOperation:
|
|
19
|
+
raise InvalidRequestError(text("value.amount_type", field=field, value=value)) from None
|
|
20
|
+
if not number.is_finite():
|
|
21
|
+
raise InvalidRequestError(text("value.amount_finite", field=field, value=value))
|
|
22
|
+
if number <= 0:
|
|
23
|
+
raise InvalidRequestError(text("value.amount_positive", field=field, value=value))
|
|
24
|
+
if number != number.quantize(CENT):
|
|
25
|
+
raise InvalidRequestError(text("value.amount_scale", field=field, value=value))
|
|
26
|
+
return number.quantize(CENT)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def wire(value: Decimal) -> float:
|
|
30
|
+
return float(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse(value: object) -> Decimal | None:
|
|
34
|
+
if value is None or value == "" or isinstance(value, bool):
|
|
35
|
+
return None
|
|
36
|
+
try:
|
|
37
|
+
number = Decimal(str(value))
|
|
38
|
+
except InvalidOperation:
|
|
39
|
+
return None
|
|
40
|
+
return number.quantize(CENT) if number.is_finite() else None
|
epoint_sdk/py.typed
ADDED
|
File without changes
|