smscode 1.0.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.
- smscode/__init__.py +153 -0
- smscode/_async_transport.py +90 -0
- smscode/_decode.py +74 -0
- smscode/_transport.py +115 -0
- smscode/async_client.py +83 -0
- smscode/client.py +78 -0
- smscode/errors.py +224 -0
- smscode/idempotency.py +22 -0
- smscode/models.py +280 -0
- smscode/money.py +46 -0
- smscode/py.typed +1 -0
- smscode/resources/__init__.py +66 -0
- smscode/resources/balance.py +52 -0
- smscode/resources/catalog.py +359 -0
- smscode/resources/orders.py +625 -0
- smscode/resources/webhook.py +135 -0
- smscode/retry.py +66 -0
- smscode/types.py +35 -0
- smscode/wait.py +125 -0
- smscode/webhook.py +67 -0
- smscode-1.0.0.dist-info/METADATA +169 -0
- smscode-1.0.0.dist-info/RECORD +24 -0
- smscode-1.0.0.dist-info/WHEEL +4 -0
- smscode-1.0.0.dist-info/licenses/LICENSE +21 -0
smscode/errors.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SmscodeError(Exception):
|
|
9
|
+
default_code = "SMSCODE_ERROR"
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
code: str | None = None,
|
|
16
|
+
http_status: int | None = None,
|
|
17
|
+
details: Any | None = None,
|
|
18
|
+
request_id: str | None = None,
|
|
19
|
+
retry_after_seconds: float | None = None,
|
|
20
|
+
idempotency_key: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.code = code or self.default_code
|
|
25
|
+
self.http_status = http_status
|
|
26
|
+
self.details = details
|
|
27
|
+
self.request_id = request_id
|
|
28
|
+
self.retry_after_seconds = retry_after_seconds
|
|
29
|
+
self.idempotency_key = idempotency_key
|
|
30
|
+
|
|
31
|
+
def with_idempotency_key(self, key: str) -> SmscodeError:
|
|
32
|
+
if self.idempotency_key is None:
|
|
33
|
+
self.idempotency_key = key
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnauthorizedError(SmscodeError):
|
|
38
|
+
default_code = "UNAUTHORIZED"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ForbiddenError(SmscodeError):
|
|
42
|
+
default_code = "FORBIDDEN"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotFoundError(SmscodeError):
|
|
46
|
+
default_code = "NOT_FOUND"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ValidationError(SmscodeError):
|
|
50
|
+
default_code = "VALIDATION_ERROR"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProviderError(SmscodeError):
|
|
54
|
+
default_code = "PROVIDER_ERROR"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NoOfferAvailableError(SmscodeError):
|
|
58
|
+
default_code = "NO_OFFER_AVAILABLE"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IdempotencyKeyReuseError(SmscodeError):
|
|
62
|
+
default_code = "IDEMPOTENCY_KEY_REUSED"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InsufficientBalanceError(SmscodeError):
|
|
66
|
+
default_code = "INSUFFICIENT_BALANCE"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ConflictError(SmscodeError):
|
|
70
|
+
default_code = "CONFLICT"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CancelTooEarlyError(SmscodeError):
|
|
74
|
+
default_code = "CANCEL_TOO_EARLY"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RequestInProgressError(SmscodeError):
|
|
78
|
+
default_code = "REQUEST_IN_PROGRESS"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RateLimitError(SmscodeError):
|
|
82
|
+
default_code = "RATE_LIMIT_EXCEEDED"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TempBannedError(SmscodeError):
|
|
86
|
+
default_code = "TEMP_BANNED_ABUSE_GUARD"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ServiceUnavailableError(SmscodeError):
|
|
90
|
+
default_code = "SERVICE_UNAVAILABLE"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class FxRateUnavailableError(SmscodeError):
|
|
94
|
+
default_code = "FX_RATE_UNAVAILABLE"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class InternalError(SmscodeError):
|
|
98
|
+
default_code = "INTERNAL_ERROR"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PayloadTooLargeError(SmscodeError):
|
|
102
|
+
default_code = "PAYLOAD_TOO_LARGE"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class NetworkError(SmscodeError):
|
|
106
|
+
default_code = "NETWORK_ERROR"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TimeoutError(SmscodeError):
|
|
110
|
+
default_code = "TIMEOUT"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class InvalidResponseError(SmscodeError):
|
|
114
|
+
default_code = "INVALID_RESPONSE"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class InvalidMoneyError(SmscodeError):
|
|
118
|
+
default_code = "INVALID_MONEY"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class OtpTimeoutError(SmscodeError):
|
|
122
|
+
default_code = "OTP_TIMEOUT"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class OrderTerminalError(SmscodeError):
|
|
126
|
+
default_code = "ORDER_TERMINAL"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class RequestCancelledError(asyncio.CancelledError):
|
|
130
|
+
code = "REQUEST_CANCELLED"
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
message: str = "Request cancelled",
|
|
135
|
+
*,
|
|
136
|
+
request_id: str | None = None,
|
|
137
|
+
idempotency_key: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
super().__init__(message)
|
|
140
|
+
self.message = message
|
|
141
|
+
self.http_status: int | None = None
|
|
142
|
+
self.details: Any | None = None
|
|
143
|
+
self.request_id = request_id
|
|
144
|
+
self.retry_after_seconds: float | None = None
|
|
145
|
+
self.idempotency_key = idempotency_key
|
|
146
|
+
|
|
147
|
+
def with_idempotency_key(self, key: str) -> RequestCancelledError:
|
|
148
|
+
if self.idempotency_key is None:
|
|
149
|
+
self.idempotency_key = key
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
ERROR_BY_CODE: dict[str, type[SmscodeError]] = {
|
|
154
|
+
"UNAUTHORIZED": UnauthorizedError,
|
|
155
|
+
"FORBIDDEN": ForbiddenError,
|
|
156
|
+
"NOT_FOUND": NotFoundError,
|
|
157
|
+
"VALIDATION_ERROR": ValidationError,
|
|
158
|
+
"PROVIDER_ERROR": ProviderError,
|
|
159
|
+
"NO_OFFER_AVAILABLE": NoOfferAvailableError,
|
|
160
|
+
"IDEMPOTENCY_KEY_REUSED": IdempotencyKeyReuseError,
|
|
161
|
+
"INSUFFICIENT_BALANCE": InsufficientBalanceError,
|
|
162
|
+
"CONFLICT": ConflictError,
|
|
163
|
+
"CANCEL_TOO_EARLY": CancelTooEarlyError,
|
|
164
|
+
"REQUEST_IN_PROGRESS": RequestInProgressError,
|
|
165
|
+
"RATE_LIMIT_EXCEEDED": RateLimitError,
|
|
166
|
+
"TEMP_BANNED_ABUSE_GUARD": TempBannedError,
|
|
167
|
+
"SERVICE_UNAVAILABLE": ServiceUnavailableError,
|
|
168
|
+
"FX_RATE_UNAVAILABLE": FxRateUnavailableError,
|
|
169
|
+
"INTERNAL_ERROR": InternalError,
|
|
170
|
+
"PAYLOAD_TOO_LARGE": PayloadTooLargeError,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
STATUS_FALLBACK: dict[int, tuple[str, type[SmscodeError], str]] = {
|
|
174
|
+
401: ("UNAUTHORIZED", UnauthorizedError, "Unauthorized"),
|
|
175
|
+
402: ("INSUFFICIENT_BALANCE", InsufficientBalanceError, "Insufficient balance"),
|
|
176
|
+
403: ("FORBIDDEN", ForbiddenError, "Forbidden"),
|
|
177
|
+
404: ("NOT_FOUND", NotFoundError, "Not found"),
|
|
178
|
+
409: ("CONFLICT", ConflictError, "Conflict"),
|
|
179
|
+
413: ("PAYLOAD_TOO_LARGE", PayloadTooLargeError, "Payload too large"),
|
|
180
|
+
422: ("VALIDATION_ERROR", ValidationError, "Validation error"),
|
|
181
|
+
429: ("RATE_LIMIT_EXCEEDED", RateLimitError, "Rate limit exceeded"),
|
|
182
|
+
500: ("INTERNAL_ERROR", InternalError, "Internal server error"),
|
|
183
|
+
503: ("SERVICE_UNAVAILABLE", ServiceUnavailableError, "Service unavailable"),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def map_error(
|
|
188
|
+
*,
|
|
189
|
+
status: int,
|
|
190
|
+
payload: Mapping[str, Any] | None,
|
|
191
|
+
request_id: str | None,
|
|
192
|
+
retry_after_seconds: float | None,
|
|
193
|
+
) -> SmscodeError:
|
|
194
|
+
error = payload.get("error") if isinstance(payload, Mapping) else None
|
|
195
|
+
if isinstance(error, Mapping):
|
|
196
|
+
code = error.get("code")
|
|
197
|
+
message = error.get("message")
|
|
198
|
+
details = error.get("details")
|
|
199
|
+
else:
|
|
200
|
+
code = None
|
|
201
|
+
message = None
|
|
202
|
+
details = None
|
|
203
|
+
|
|
204
|
+
if isinstance(code, str) and code:
|
|
205
|
+
normalized_code = code
|
|
206
|
+
error_cls = ERROR_BY_CODE.get(normalized_code, SmscodeError)
|
|
207
|
+
normalized_message = (
|
|
208
|
+
message if isinstance(message, str) and message else "SMSCode API error"
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
normalized_code, error_cls, fallback_message = STATUS_FALLBACK.get(
|
|
212
|
+
status,
|
|
213
|
+
("UNKNOWN_ERROR", SmscodeError, "SMSCode API error"),
|
|
214
|
+
)
|
|
215
|
+
normalized_message = message if isinstance(message, str) and message else fallback_message
|
|
216
|
+
|
|
217
|
+
return error_cls(
|
|
218
|
+
normalized_message,
|
|
219
|
+
code=normalized_code,
|
|
220
|
+
http_status=status,
|
|
221
|
+
details=details,
|
|
222
|
+
request_id=request_id,
|
|
223
|
+
retry_after_seconds=retry_after_seconds,
|
|
224
|
+
)
|
smscode/idempotency.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from smscode.errors import ValidationError
|
|
7
|
+
|
|
8
|
+
IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_valid_idempotency_key(key: str) -> bool:
|
|
12
|
+
return bool(IDEMPOTENCY_KEY_PATTERN.fullmatch(key))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_idempotency_key(provided: str | None = None) -> str:
|
|
16
|
+
if provided is not None:
|
|
17
|
+
if not is_valid_idempotency_key(provided):
|
|
18
|
+
raise ValidationError(
|
|
19
|
+
f"Invalid idempotency key {provided!r} (allowed: A-Z a-z 0-9 _ - ; 1-128 chars)."
|
|
20
|
+
)
|
|
21
|
+
return provided
|
|
22
|
+
return str(uuid.uuid4())
|
smscode/models.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from smscode.errors import InvalidResponseError
|
|
8
|
+
from smscode.money import Money
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class V2Fx:
|
|
13
|
+
pair: str
|
|
14
|
+
rate: int | str
|
|
15
|
+
rate_as_of: str | None = None
|
|
16
|
+
source: str | None = None
|
|
17
|
+
as_of: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ExchangeRate:
|
|
22
|
+
pair: str
|
|
23
|
+
rate: int | str
|
|
24
|
+
base_currency: str | None = None
|
|
25
|
+
quote_currency: str | None = None
|
|
26
|
+
rate_as_of: str | None = None
|
|
27
|
+
raw: dict[str, Any] = field(default_factory=dict, compare=False)
|
|
28
|
+
|
|
29
|
+
def __getitem__(self, key: str) -> Any:
|
|
30
|
+
return self.raw[key]
|
|
31
|
+
|
|
32
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
33
|
+
return self.raw.get(key, default)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Country:
|
|
38
|
+
id: int
|
|
39
|
+
name: str
|
|
40
|
+
code: str | None = None
|
|
41
|
+
dial_code: str | None = None
|
|
42
|
+
emoji: str | None = None
|
|
43
|
+
active: bool = True
|
|
44
|
+
raw: dict[str, Any] = field(default_factory=dict, compare=False)
|
|
45
|
+
|
|
46
|
+
def __getitem__(self, key: str) -> Any:
|
|
47
|
+
return self.raw[key]
|
|
48
|
+
|
|
49
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
50
|
+
return self.raw.get(key, default)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Service:
|
|
55
|
+
id: int
|
|
56
|
+
name: str
|
|
57
|
+
code: str
|
|
58
|
+
active: bool = True
|
|
59
|
+
raw: dict[str, Any] = field(default_factory=dict, compare=False)
|
|
60
|
+
|
|
61
|
+
def __getitem__(self, key: str) -> Any:
|
|
62
|
+
return self.raw[key]
|
|
63
|
+
|
|
64
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
65
|
+
return self.raw.get(key, default)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class OrderCapabilities:
|
|
70
|
+
can_finish: bool
|
|
71
|
+
can_resend: bool
|
|
72
|
+
can_cancel: bool
|
|
73
|
+
can_replace: bool
|
|
74
|
+
resend_available_at: str | None = None
|
|
75
|
+
cancel_available_at: str | None = None
|
|
76
|
+
replace_available_at: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Order:
|
|
81
|
+
raw: dict[str, Any]
|
|
82
|
+
capabilities: OrderCapabilities
|
|
83
|
+
amount: Money | int | None = None
|
|
84
|
+
fx: V2Fx | None = None
|
|
85
|
+
|
|
86
|
+
def __getitem__(self, key: str) -> Any:
|
|
87
|
+
return self.raw[key]
|
|
88
|
+
|
|
89
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
90
|
+
return self.raw.get(key, default)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class CreatedOrder(Order):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class FinishResult:
|
|
100
|
+
order_id: int
|
|
101
|
+
status: str
|
|
102
|
+
raw: dict[str, Any]
|
|
103
|
+
|
|
104
|
+
def __getitem__(self, key: str) -> Any:
|
|
105
|
+
return self.raw[key]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ResendResult:
|
|
110
|
+
order_id: int
|
|
111
|
+
status: str
|
|
112
|
+
raw: dict[str, Any]
|
|
113
|
+
resent: bool | None = None
|
|
114
|
+
|
|
115
|
+
def __getitem__(self, key: str) -> Any:
|
|
116
|
+
return self.raw[key]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class WebhookConfig:
|
|
121
|
+
webhook_url: str | None
|
|
122
|
+
webhook_secret: str | None
|
|
123
|
+
webhook_disabled_at: str | None
|
|
124
|
+
webhook_disabled_reason: str | None
|
|
125
|
+
webhook_consecutive_failures: int
|
|
126
|
+
raw: dict[str, Any]
|
|
127
|
+
|
|
128
|
+
def __getitem__(self, key: str) -> Any:
|
|
129
|
+
return self.raw[key]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class WebhookTestResult:
|
|
134
|
+
status_code: int
|
|
135
|
+
raw: dict[str, Any]
|
|
136
|
+
|
|
137
|
+
def __getitem__(self, key: str) -> Any:
|
|
138
|
+
return self.raw[key]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def status(self) -> int:
|
|
142
|
+
return self.status_code
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class BalanceV2:
|
|
147
|
+
balance: Money
|
|
148
|
+
fx: V2Fx
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class BalanceV1:
|
|
153
|
+
balance: int
|
|
154
|
+
currency: str = "IDR"
|
|
155
|
+
raw: dict[str, Any] = field(default_factory=dict, compare=False)
|
|
156
|
+
|
|
157
|
+
def __getitem__(self, key: str) -> Any:
|
|
158
|
+
return self.raw[key]
|
|
159
|
+
|
|
160
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
161
|
+
return self.raw.get(key, default)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class Product:
|
|
166
|
+
id: int
|
|
167
|
+
name: str | None
|
|
168
|
+
country_id: int | None
|
|
169
|
+
platform_id: int | None
|
|
170
|
+
available: int
|
|
171
|
+
price: Money | int | None
|
|
172
|
+
active: bool
|
|
173
|
+
catalog_product_id: int | None = None
|
|
174
|
+
raw: dict[str, Any] = field(default_factory=dict, compare=False)
|
|
175
|
+
|
|
176
|
+
def __getitem__(self, key: str) -> Any:
|
|
177
|
+
return self.raw[key]
|
|
178
|
+
|
|
179
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
180
|
+
return self.raw.get(key, default)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class ProductV2(Product):
|
|
185
|
+
price: Money
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class ProductsPage:
|
|
190
|
+
products: list[Product]
|
|
191
|
+
page: int | None
|
|
192
|
+
limit: int | None
|
|
193
|
+
count: int | None
|
|
194
|
+
fx: V2Fx | None = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True)
|
|
198
|
+
class ProductsPageV2(ProductsPage):
|
|
199
|
+
products: list[Product]
|
|
200
|
+
page: int | None
|
|
201
|
+
limit: int | None
|
|
202
|
+
count: int | None
|
|
203
|
+
fx: V2Fx
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class ProductsPageV1(ProductsPage):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class OrdersList:
|
|
213
|
+
orders: list[Order]
|
|
214
|
+
fx: V2Fx | None = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class OrdersListV2(OrdersList):
|
|
219
|
+
orders: list[Order]
|
|
220
|
+
fx: V2Fx
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class CreateOrderResult:
|
|
225
|
+
orders: list[CreatedOrder]
|
|
226
|
+
failed_count: int
|
|
227
|
+
idempotency_key: str
|
|
228
|
+
fx: V2Fx | None = None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class CreateOrderResultV2(CreateOrderResult):
|
|
233
|
+
orders: list[CreatedOrder]
|
|
234
|
+
failed_count: int
|
|
235
|
+
idempotency_key: str
|
|
236
|
+
fx: V2Fx
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class CreateOrderResultV1(CreateOrderResult):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class CancelResult:
|
|
246
|
+
order_id: int
|
|
247
|
+
status: str
|
|
248
|
+
refund_amount: Money | int
|
|
249
|
+
new_balance: Money | int
|
|
250
|
+
fx: V2Fx | None = None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True)
|
|
254
|
+
class CancelResultV2(CancelResult):
|
|
255
|
+
order_id: int
|
|
256
|
+
status: str
|
|
257
|
+
refund_amount: Money
|
|
258
|
+
new_balance: Money
|
|
259
|
+
fx: V2Fx
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_v2_fx(value: object) -> V2Fx:
|
|
263
|
+
if not isinstance(value, Mapping):
|
|
264
|
+
raise InvalidResponseError("The /v2 response is missing its FX receipt.")
|
|
265
|
+
pair = value.get("pair")
|
|
266
|
+
rate = value.get("rate")
|
|
267
|
+
rate_as_of = value.get("rate_as_of")
|
|
268
|
+
if not isinstance(pair, str) or not isinstance(rate, (int, str)):
|
|
269
|
+
raise InvalidResponseError("The /v2 response has a malformed FX receipt.")
|
|
270
|
+
if rate_as_of is not None and not isinstance(rate_as_of, str):
|
|
271
|
+
raise InvalidResponseError("The /v2 response has a malformed FX receipt.")
|
|
272
|
+
source = value.get("source")
|
|
273
|
+
as_of = value.get("as_of")
|
|
274
|
+
return V2Fx(
|
|
275
|
+
pair=pair,
|
|
276
|
+
rate=rate,
|
|
277
|
+
rate_as_of=rate_as_of,
|
|
278
|
+
source=source if isinstance(source, str) else None,
|
|
279
|
+
as_of=as_of if isinstance(as_of, str) else None,
|
|
280
|
+
)
|
smscode/money.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from smscode.errors import InvalidMoneyError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Money:
|
|
12
|
+
amount: str
|
|
13
|
+
currency: Literal["USD"]
|
|
14
|
+
canonical_amount: int
|
|
15
|
+
canonical_currency: Literal["IDR"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_money(value: object) -> Money:
|
|
19
|
+
if not isinstance(value, Mapping):
|
|
20
|
+
raise InvalidMoneyError("A money value is missing or malformed in the response.")
|
|
21
|
+
|
|
22
|
+
amount = value.get("amount")
|
|
23
|
+
currency = value.get("currency")
|
|
24
|
+
canonical_amount = value.get("canonical_amount")
|
|
25
|
+
canonical_currency = value.get("canonical_currency")
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
not isinstance(amount, str)
|
|
29
|
+
or currency != "USD"
|
|
30
|
+
or type(canonical_amount) is not int
|
|
31
|
+
or canonical_currency != "IDR"
|
|
32
|
+
):
|
|
33
|
+
raise InvalidMoneyError("A money value is missing or malformed in the response.")
|
|
34
|
+
|
|
35
|
+
return Money(
|
|
36
|
+
amount=amount,
|
|
37
|
+
currency="USD",
|
|
38
|
+
canonical_amount=canonical_amount,
|
|
39
|
+
canonical_currency="IDR",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_optional_money(value: object | None) -> Money | None:
|
|
44
|
+
if value is None:
|
|
45
|
+
return None
|
|
46
|
+
return parse_money(value)
|
smscode/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from smscode.resources.balance import (
|
|
6
|
+
AsyncV1BalanceResource,
|
|
7
|
+
AsyncV2BalanceResource,
|
|
8
|
+
V1BalanceResource,
|
|
9
|
+
V2BalanceResource,
|
|
10
|
+
)
|
|
11
|
+
from smscode.resources.catalog import (
|
|
12
|
+
AsyncV1CatalogResource,
|
|
13
|
+
AsyncV2CatalogResource,
|
|
14
|
+
V1CatalogResource,
|
|
15
|
+
V2CatalogResource,
|
|
16
|
+
)
|
|
17
|
+
from smscode.resources.orders import (
|
|
18
|
+
AsyncV1OrdersResource,
|
|
19
|
+
AsyncV2OrdersResource,
|
|
20
|
+
V1OrdersResource,
|
|
21
|
+
V2OrdersResource,
|
|
22
|
+
)
|
|
23
|
+
from smscode.resources.webhook import (
|
|
24
|
+
AsyncV1WebhookResource,
|
|
25
|
+
AsyncV2WebhookResource,
|
|
26
|
+
V1WebhookResource,
|
|
27
|
+
V2WebhookResource,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class V1CatalogBalanceNamespace:
|
|
33
|
+
catalog: V1CatalogResource
|
|
34
|
+
balance: V1BalanceResource
|
|
35
|
+
orders: V1OrdersResource
|
|
36
|
+
webhook: V1WebhookResource
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AsyncV1CatalogBalanceNamespace:
|
|
41
|
+
catalog: AsyncV1CatalogResource
|
|
42
|
+
balance: AsyncV1BalanceResource
|
|
43
|
+
orders: AsyncV1OrdersResource
|
|
44
|
+
webhook: AsyncV1WebhookResource
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AsyncV1BalanceResource",
|
|
49
|
+
"AsyncV1CatalogBalanceNamespace",
|
|
50
|
+
"AsyncV1CatalogResource",
|
|
51
|
+
"AsyncV1OrdersResource",
|
|
52
|
+
"AsyncV1WebhookResource",
|
|
53
|
+
"AsyncV2BalanceResource",
|
|
54
|
+
"AsyncV2CatalogResource",
|
|
55
|
+
"AsyncV2OrdersResource",
|
|
56
|
+
"AsyncV2WebhookResource",
|
|
57
|
+
"V1BalanceResource",
|
|
58
|
+
"V1CatalogBalanceNamespace",
|
|
59
|
+
"V1CatalogResource",
|
|
60
|
+
"V1OrdersResource",
|
|
61
|
+
"V1WebhookResource",
|
|
62
|
+
"V2BalanceResource",
|
|
63
|
+
"V2CatalogResource",
|
|
64
|
+
"V2OrdersResource",
|
|
65
|
+
"V2WebhookResource",
|
|
66
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from smscode.models import BalanceV1, BalanceV2, parse_v2_fx
|
|
6
|
+
from smscode.money import parse_money
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _decode_v2_balance(result: Any) -> BalanceV2:
|
|
10
|
+
return BalanceV2(balance=parse_money(result.data["balance"]), fx=parse_v2_fx(result.meta["fx"]))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _decode_v1_balance(result: Any) -> BalanceV1:
|
|
14
|
+
data = result.data
|
|
15
|
+
currency = data.get("currency") if isinstance(data, dict) else None
|
|
16
|
+
return BalanceV1(
|
|
17
|
+
balance=int(data["balance"]),
|
|
18
|
+
currency=currency if isinstance(currency, str) else "IDR",
|
|
19
|
+
raw=dict(data),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class V2BalanceResource:
|
|
24
|
+
def __init__(self, request: Any) -> None:
|
|
25
|
+
self._request = request
|
|
26
|
+
|
|
27
|
+
def get(self) -> BalanceV2:
|
|
28
|
+
return _decode_v2_balance(self._request("GET", "/v2/balance"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class V1BalanceResource:
|
|
32
|
+
def __init__(self, request: Any) -> None:
|
|
33
|
+
self._request = request
|
|
34
|
+
|
|
35
|
+
def get(self) -> BalanceV1:
|
|
36
|
+
return _decode_v1_balance(self._request("GET", "/v1/balance"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyncV2BalanceResource:
|
|
40
|
+
def __init__(self, request: Any) -> None:
|
|
41
|
+
self._request = request
|
|
42
|
+
|
|
43
|
+
async def get(self) -> BalanceV2:
|
|
44
|
+
return _decode_v2_balance(await self._request("GET", "/v2/balance"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncV1BalanceResource:
|
|
48
|
+
def __init__(self, request: Any) -> None:
|
|
49
|
+
self._request = request
|
|
50
|
+
|
|
51
|
+
async def get(self) -> BalanceV1:
|
|
52
|
+
return _decode_v1_balance(await self._request("GET", "/v1/balance"))
|