Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/epoint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/epoint/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any

from . import _signing
from .enums import Status
from .errors import GatewayError, TransportError
from .models import Response

Expand All @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions src/epoint/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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"),
}
Expand Down
7 changes: 4 additions & 3 deletions src/epoint/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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"),
}
Expand Down
82 changes: 82 additions & 0 deletions src/epoint/enums.py
Original file line number Diff line number Diff line change
@@ -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})
6 changes: 4 additions & 2 deletions src/epoint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions tests/test_client_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 26 additions & 1 deletion tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import httpx
import pytest

from epoint import EpointClient, GatewayError
from epoint import CardStatus, EpointClient, GatewayError, Status

pytestmark = pytest.mark.sandbox

Expand Down Expand Up @@ -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)