From a3ad18d67b5d9b81a3edede418b5a18f2dea1414 Mon Sep 17 00:00:00 2001 From: martian56 Date: Wed, 22 Jul 2026 12:32:45 +0400 Subject: [PATCH] feat: add enums for the values the API uses 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. --- src/epoint/__init__.py | 24 ++++++++++++ src/epoint/_transport.py | 3 +- src/epoint/aio.py | 7 ++-- src/epoint/client.py | 7 ++-- src/epoint/enums.py | 82 +++++++++++++++++++++++++++++++++++++++ src/epoint/models.py | 6 ++- tests/test_client_unit.py | 17 ++++++++ tests/test_sandbox.py | 27 ++++++++++++- 8 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 src/epoint/enums.py diff --git a/src/epoint/__init__.py b/src/epoint/__init__.py index e553140..c27c70f 100644 --- a/src/epoint/__init__.py +++ b/src/epoint/__init__.py @@ -3,19 +3,43 @@ from .aio import AsyncEpointClient from .callbacks import verify_callback from .client import EpointClient +from .enums import ( + AZN_ONLY, + SETTLED_STATUSES, + SUPPORTED_CURRENCIES, + USABLE_CARD_STATUSES, + B2BStatus, + CardStatus, + Currency, + InvoiceStatus, + Language, + OperationCode, + Status, +) from .errors import EpointError, GatewayError, SignatureError, TransportError from .models import Callback, Response __version__ = "0.1.1" __all__ = [ + "AZN_ONLY", + "SETTLED_STATUSES", + "SUPPORTED_CURRENCIES", + "USABLE_CARD_STATUSES", "AsyncEpointClient", + "B2BStatus", "Callback", + "CardStatus", + "Currency", "EpointClient", "EpointError", "GatewayError", + "InvoiceStatus", + "Language", + "OperationCode", "Response", "SignatureError", + "Status", "TransportError", "__version__", "verify_callback", diff --git a/src/epoint/_transport.py b/src/epoint/_transport.py index b05a15a..344be03 100644 --- a/src/epoint/_transport.py +++ b/src/epoint/_transport.py @@ -3,6 +3,7 @@ from typing import Any from . import _signing +from .enums import Status from .errors import GatewayError, TransportError from .models import Response @@ -21,7 +22,7 @@ def parse(status_code: int, body: Any) -> Response: if not isinstance(body, dict): raise TransportError(f"unexpected response body: {body!r}", status_code=status_code) - if body.get("status") == "error": + if body.get("status") == Status.ERROR: raise GatewayError( str(body.get("message") or "request refused"), code=str(body["code"]) if body.get("code") is not None else None, diff --git a/src/epoint/aio.py b/src/epoint/aio.py index 41b7341..7dab58f 100644 --- a/src/epoint/aio.py +++ b/src/epoint/aio.py @@ -11,6 +11,7 @@ from ._endpoints import Amount, Defaults from .callbacks import verify_callback from .client import PRODUCTION_URL +from .enums import Currency, Language from .errors import TransportError from .models import Callback, Response @@ -22,8 +23,8 @@ def __init__( private_key: str, *, base_url: str = PRODUCTION_URL, - language: str = "az", - currency: str = "AZN", + language: str = Language.AZ, + currency: str = Currency.AZN, success_redirect_url: str | None = None, error_redirect_url: str | None = None, timeout: float = 30.0, @@ -42,7 +43,7 @@ def __init__( def from_env(cls, **overrides: Any) -> AsyncEpointClient: params: dict[str, Any] = { "base_url": os.environ.get("EPOINT_BASE_URL", PRODUCTION_URL), - "language": os.environ.get("EPOINT_LANGUAGE", "az"), + "language": os.environ.get("EPOINT_LANGUAGE", Language.AZ), "success_redirect_url": os.environ.get("EPOINT_SUCCESS_REDIRECT_URL"), "error_redirect_url": os.environ.get("EPOINT_FAILED_REDIRECT_URL"), } diff --git a/src/epoint/client.py b/src/epoint/client.py index 53fcc42..cc6246e 100644 --- a/src/epoint/client.py +++ b/src/epoint/client.py @@ -10,6 +10,7 @@ from . import _transport from ._endpoints import Amount, Defaults from .callbacks import verify_callback +from .enums import Currency, Language from .errors import TransportError from .models import Callback, Response @@ -23,8 +24,8 @@ def __init__( private_key: str, *, base_url: str = PRODUCTION_URL, - language: str = "az", - currency: str = "AZN", + language: str = Language.AZ, + currency: str = Currency.AZN, success_redirect_url: str | None = None, error_redirect_url: str | None = None, timeout: float = 30.0, @@ -43,7 +44,7 @@ def __init__( def from_env(cls, **overrides: Any) -> EpointClient: params: dict[str, Any] = { "base_url": os.environ.get("EPOINT_BASE_URL", PRODUCTION_URL), - "language": os.environ.get("EPOINT_LANGUAGE", "az"), + "language": os.environ.get("EPOINT_LANGUAGE", Language.AZ), "success_redirect_url": os.environ.get("EPOINT_SUCCESS_REDIRECT_URL"), "error_redirect_url": os.environ.get("EPOINT_FAILED_REDIRECT_URL"), } diff --git a/src/epoint/enums.py b/src/epoint/enums.py new file mode 100644 index 0000000..86ceb10 --- /dev/null +++ b/src/epoint/enums.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from enum import Enum + + +class StrEnum(str, Enum): + """A str subclass, so members go over the wire unchanged. + + Python 3.11 has this built in. Defining it here keeps 3.10 working. + """ + + __hash__ = str.__hash__ + + def __str__(self) -> str: + return str(self.value) + + +class Status(StrEnum): + """The status on a response or a callback.""" + + NEW = "new" + SUCCESS = "success" + FAILED = "failed" + ERROR = "error" + RETURNED = "returned" + SERVER_ERROR = "server_error" + + +SETTLED_STATUSES = frozenset({Status.SUCCESS.value}) + + +class CardStatus(StrEnum): + NEW = "new" + ACTIVE = "active" + PENDING = "pending" + REJECTED = "rejected" + EXPIRED = "expired" + SESSION_EXPIRED = "session_expired" + + +USABLE_CARD_STATUSES = frozenset({CardStatus.ACTIVE.value}) + + +class InvoiceStatus(StrEnum): + WAITING = "waiting_for_payment" + PAID = "paid" + CANCELED = "canceled" + + +class B2BStatus(StrEnum): + PENDING = "PENDING" + PROCESSING = "PROCESSING" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + + +class OperationCode(StrEnum): + """What the transaction did, as it comes back on a callback.""" + + CARD_REGISTRATION = "001" + PAYMENT = "100" + REGISTRATION_WITH_PAYMENT = "200" + + +class Language(StrEnum): + AZ = "az" + EN = "en" + RU = "ru" + + +class Currency(StrEnum): + AZN = "AZN" + USD = "USD" + EUR = "EUR" + RUB = "RUB" + + +#: Checkout takes any of these. +SUPPORTED_CURRENCIES = frozenset(c.value for c in Currency) + +#: Split, pre-auth, refund, reverse, payout and wallet take AZN and nothing else. +AZN_ONLY = frozenset({Currency.AZN.value}) diff --git a/src/epoint/models.py b/src/epoint/models.py index 86e1538..14ca547 100644 --- a/src/epoint/models.py +++ b/src/epoint/models.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Any +from .enums import SETTLED_STATUSES + class Response: def __init__(self, raw: dict[str, Any]) -> None: @@ -15,7 +17,7 @@ def status(self) -> str | None: @property def ok(self) -> bool: - return self.raw.get("status") == "success" + return self.raw.get("status") in SETTLED_STATUSES @property def transaction(self) -> str | None: @@ -63,7 +65,7 @@ class Callback: @property def ok(self) -> bool: - return self.status == "success" + return self.status in SETTLED_STATUSES @classmethod def from_dict(cls, data: dict[str, Any]) -> Callback: diff --git a/tests/test_client_unit.py b/tests/test_client_unit.py index 5cd91b7..478f416 100644 --- a/tests/test_client_unit.py +++ b/tests/test_client_unit.py @@ -148,3 +148,20 @@ def handler(request: httpx.Request) -> httpx.Response: assert "application/json" in seen["content_type"] assert "data" in seen["body"] assert "signature" in seen["body"] + + +def test_enums_go_over_the_wire_as_plain_strings(): + from epoint import Currency, Language, OperationCode, Status + + assert json.dumps({"language": Language.AZ, "currency": Currency.AZN}) == ( + '{"language": "az", "currency": "AZN"}' + ) + assert OperationCode.PAYMENT == "100" + assert "success" in {Status.SUCCESS, Status.NEW} + + +def test_a_raw_status_string_still_reads_as_ok(): + from epoint.models import Response + + assert Response({"status": "success"}).ok + assert not Response({"status": "failed"}).ok diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index ca64867..8d06a30 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -3,7 +3,7 @@ import httpx import pytest -from epoint import EpointClient, GatewayError +from epoint import CardStatus, EpointClient, GatewayError, Status pytestmark = pytest.mark.sandbox @@ -144,3 +144,28 @@ def test_missing_required_field_is_refused(client): with pytest.raises(GatewayError) as info: client._post("/api/1/request", {"amount": "10.00", "currency": "AZN"}) assert "order_id" in str(info.value) + + +def test_the_statuses_the_sandbox_returns_are_all_in_the_enum(client, pay): + """Guards against the enum drifting away from what the API actually sends.""" + paid = client.create_payment(amount=5, order_id=order()) + pay(client, paid.redirect_url) + + declined = client.create_payment(amount=5, order_id=order()) + pay(client, declined.redirect_url, card="4000000000000116") + + seen = { + client.get_status(paid.transaction).status, + client.get_status(declined.transaction).status, + } + assert seen == {Status.SUCCESS, Status.FAILED} + assert all(s in set(Status) for s in seen) + + +def test_a_card_status_from_the_sandbox_is_in_the_enum(client, pay): + registered = client.register_card(description="enum check") + pay(client, registered.redirect_url) + + card = client.get_card_status(registered["card_id"]) + assert card.get("status") == CardStatus.ACTIVE + assert card.get("status") in set(CardStatus)