Skip to content

Commit 9c367f2

Browse files
authored
feat: add enums for the values the API uses (#1)
Values come from the sandbox's own enum module and the contract notes, so they match what the API actually sends rather than what the docs imply. Currency records that checkout takes AZN, USD, EUR and RUB while split, pre-auth, refund, reverse, payout and wallet take AZN only. Raw strings still work everywhere, so nothing that already compiles breaks.
1 parent f91158d commit 9c367f2

8 files changed

Lines changed: 163 additions & 10 deletions

File tree

src/epoint/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,43 @@
33
from .aio import AsyncEpointClient
44
from .callbacks import verify_callback
55
from .client import EpointClient
6+
from .enums import (
7+
AZN_ONLY,
8+
SETTLED_STATUSES,
9+
SUPPORTED_CURRENCIES,
10+
USABLE_CARD_STATUSES,
11+
B2BStatus,
12+
CardStatus,
13+
Currency,
14+
InvoiceStatus,
15+
Language,
16+
OperationCode,
17+
Status,
18+
)
619
from .errors import EpointError, GatewayError, SignatureError, TransportError
720
from .models import Callback, Response
821

922
__version__ = "0.1.1"
1023

1124
__all__ = [
25+
"AZN_ONLY",
26+
"SETTLED_STATUSES",
27+
"SUPPORTED_CURRENCIES",
28+
"USABLE_CARD_STATUSES",
1229
"AsyncEpointClient",
30+
"B2BStatus",
1331
"Callback",
32+
"CardStatus",
33+
"Currency",
1434
"EpointClient",
1535
"EpointError",
1636
"GatewayError",
37+
"InvoiceStatus",
38+
"Language",
39+
"OperationCode",
1740
"Response",
1841
"SignatureError",
42+
"Status",
1943
"TransportError",
2044
"__version__",
2145
"verify_callback",

src/epoint/_transport.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any
44

55
from . import _signing
6+
from .enums import Status
67
from .errors import GatewayError, TransportError
78
from .models import Response
89

@@ -21,7 +22,7 @@ def parse(status_code: int, body: Any) -> Response:
2122
if not isinstance(body, dict):
2223
raise TransportError(f"unexpected response body: {body!r}", status_code=status_code)
2324

24-
if body.get("status") == "error":
25+
if body.get("status") == Status.ERROR:
2526
raise GatewayError(
2627
str(body.get("message") or "request refused"),
2728
code=str(body["code"]) if body.get("code") is not None else None,

src/epoint/aio.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ._endpoints import Amount, Defaults
1212
from .callbacks import verify_callback
1313
from .client import PRODUCTION_URL
14+
from .enums import Currency, Language
1415
from .errors import TransportError
1516
from .models import Callback, Response
1617

@@ -22,8 +23,8 @@ def __init__(
2223
private_key: str,
2324
*,
2425
base_url: str = PRODUCTION_URL,
25-
language: str = "az",
26-
currency: str = "AZN",
26+
language: str = Language.AZ,
27+
currency: str = Currency.AZN,
2728
success_redirect_url: str | None = None,
2829
error_redirect_url: str | None = None,
2930
timeout: float = 30.0,
@@ -42,7 +43,7 @@ def __init__(
4243
def from_env(cls, **overrides: Any) -> AsyncEpointClient:
4344
params: dict[str, Any] = {
4445
"base_url": os.environ.get("EPOINT_BASE_URL", PRODUCTION_URL),
45-
"language": os.environ.get("EPOINT_LANGUAGE", "az"),
46+
"language": os.environ.get("EPOINT_LANGUAGE", Language.AZ),
4647
"success_redirect_url": os.environ.get("EPOINT_SUCCESS_REDIRECT_URL"),
4748
"error_redirect_url": os.environ.get("EPOINT_FAILED_REDIRECT_URL"),
4849
}

src/epoint/client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from . import _transport
1111
from ._endpoints import Amount, Defaults
1212
from .callbacks import verify_callback
13+
from .enums import Currency, Language
1314
from .errors import TransportError
1415
from .models import Callback, Response
1516

@@ -23,8 +24,8 @@ def __init__(
2324
private_key: str,
2425
*,
2526
base_url: str = PRODUCTION_URL,
26-
language: str = "az",
27-
currency: str = "AZN",
27+
language: str = Language.AZ,
28+
currency: str = Currency.AZN,
2829
success_redirect_url: str | None = None,
2930
error_redirect_url: str | None = None,
3031
timeout: float = 30.0,
@@ -43,7 +44,7 @@ def __init__(
4344
def from_env(cls, **overrides: Any) -> EpointClient:
4445
params: dict[str, Any] = {
4546
"base_url": os.environ.get("EPOINT_BASE_URL", PRODUCTION_URL),
46-
"language": os.environ.get("EPOINT_LANGUAGE", "az"),
47+
"language": os.environ.get("EPOINT_LANGUAGE", Language.AZ),
4748
"success_redirect_url": os.environ.get("EPOINT_SUCCESS_REDIRECT_URL"),
4849
"error_redirect_url": os.environ.get("EPOINT_FAILED_REDIRECT_URL"),
4950
}

src/epoint/enums.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
5+
6+
class StrEnum(str, Enum):
7+
"""A str subclass, so members go over the wire unchanged.
8+
9+
Python 3.11 has this built in. Defining it here keeps 3.10 working.
10+
"""
11+
12+
__hash__ = str.__hash__
13+
14+
def __str__(self) -> str:
15+
return str(self.value)
16+
17+
18+
class Status(StrEnum):
19+
"""The status on a response or a callback."""
20+
21+
NEW = "new"
22+
SUCCESS = "success"
23+
FAILED = "failed"
24+
ERROR = "error"
25+
RETURNED = "returned"
26+
SERVER_ERROR = "server_error"
27+
28+
29+
SETTLED_STATUSES = frozenset({Status.SUCCESS.value})
30+
31+
32+
class CardStatus(StrEnum):
33+
NEW = "new"
34+
ACTIVE = "active"
35+
PENDING = "pending"
36+
REJECTED = "rejected"
37+
EXPIRED = "expired"
38+
SESSION_EXPIRED = "session_expired"
39+
40+
41+
USABLE_CARD_STATUSES = frozenset({CardStatus.ACTIVE.value})
42+
43+
44+
class InvoiceStatus(StrEnum):
45+
WAITING = "waiting_for_payment"
46+
PAID = "paid"
47+
CANCELED = "canceled"
48+
49+
50+
class B2BStatus(StrEnum):
51+
PENDING = "PENDING"
52+
PROCESSING = "PROCESSING"
53+
SUCCESS = "SUCCESS"
54+
FAILED = "FAILED"
55+
56+
57+
class OperationCode(StrEnum):
58+
"""What the transaction did, as it comes back on a callback."""
59+
60+
CARD_REGISTRATION = "001"
61+
PAYMENT = "100"
62+
REGISTRATION_WITH_PAYMENT = "200"
63+
64+
65+
class Language(StrEnum):
66+
AZ = "az"
67+
EN = "en"
68+
RU = "ru"
69+
70+
71+
class Currency(StrEnum):
72+
AZN = "AZN"
73+
USD = "USD"
74+
EUR = "EUR"
75+
RUB = "RUB"
76+
77+
78+
#: Checkout takes any of these.
79+
SUPPORTED_CURRENCIES = frozenset(c.value for c in Currency)
80+
81+
#: Split, pre-auth, refund, reverse, payout and wallet take AZN and nothing else.
82+
AZN_ONLY = frozenset({Currency.AZN.value})

src/epoint/models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from dataclasses import dataclass
44
from typing import Any
55

6+
from .enums import SETTLED_STATUSES
7+
68

79
class Response:
810
def __init__(self, raw: dict[str, Any]) -> None:
@@ -15,7 +17,7 @@ def status(self) -> str | None:
1517

1618
@property
1719
def ok(self) -> bool:
18-
return self.raw.get("status") == "success"
20+
return self.raw.get("status") in SETTLED_STATUSES
1921

2022
@property
2123
def transaction(self) -> str | None:
@@ -63,7 +65,7 @@ class Callback:
6365

6466
@property
6567
def ok(self) -> bool:
66-
return self.status == "success"
68+
return self.status in SETTLED_STATUSES
6769

6870
@classmethod
6971
def from_dict(cls, data: dict[str, Any]) -> Callback:

tests/test_client_unit.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,20 @@ def handler(request: httpx.Request) -> httpx.Response:
148148
assert "application/json" in seen["content_type"]
149149
assert "data" in seen["body"]
150150
assert "signature" in seen["body"]
151+
152+
153+
def test_enums_go_over_the_wire_as_plain_strings():
154+
from epoint import Currency, Language, OperationCode, Status
155+
156+
assert json.dumps({"language": Language.AZ, "currency": Currency.AZN}) == (
157+
'{"language": "az", "currency": "AZN"}'
158+
)
159+
assert OperationCode.PAYMENT == "100"
160+
assert "success" in {Status.SUCCESS, Status.NEW}
161+
162+
163+
def test_a_raw_status_string_still_reads_as_ok():
164+
from epoint.models import Response
165+
166+
assert Response({"status": "success"}).ok
167+
assert not Response({"status": "failed"}).ok

tests/test_sandbox.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import httpx
44
import pytest
55

6-
from epoint import EpointClient, GatewayError
6+
from epoint import CardStatus, EpointClient, GatewayError, Status
77

88
pytestmark = pytest.mark.sandbox
99

@@ -144,3 +144,28 @@ def test_missing_required_field_is_refused(client):
144144
with pytest.raises(GatewayError) as info:
145145
client._post("/api/1/request", {"amount": "10.00", "currency": "AZN"})
146146
assert "order_id" in str(info.value)
147+
148+
149+
def test_the_statuses_the_sandbox_returns_are_all_in_the_enum(client, pay):
150+
"""Guards against the enum drifting away from what the API actually sends."""
151+
paid = client.create_payment(amount=5, order_id=order())
152+
pay(client, paid.redirect_url)
153+
154+
declined = client.create_payment(amount=5, order_id=order())
155+
pay(client, declined.redirect_url, card="4000000000000116")
156+
157+
seen = {
158+
client.get_status(paid.transaction).status,
159+
client.get_status(declined.transaction).status,
160+
}
161+
assert seen == {Status.SUCCESS, Status.FAILED}
162+
assert all(s in set(Status) for s in seen)
163+
164+
165+
def test_a_card_status_from_the_sandbox_is_in_the_enum(client, pay):
166+
registered = client.register_card(description="enum check")
167+
pay(client, registered.redirect_url)
168+
169+
card = client.get_card_status(registered["card_id"])
170+
assert card.get("status") == CardStatus.ACTIVE
171+
assert card.get("status") in set(CardStatus)

0 commit comments

Comments
 (0)