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_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
@@ -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