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)