cyrus-payments 0.1.0__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .venv/
8
+ .idea/
9
+ dist/
10
+ build/
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyrus-payments
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Cyrus Payments API
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx<1,>=0.27
7
+ Requires-Dist: pydantic<3,>=2.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
10
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
11
+ Requires-Dist: respx<1,>=0.22; extra == 'dev'
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "cyrus-payments"
3
+ version = "0.1.0"
4
+ description = "Python SDK for the Cyrus Payments API"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "httpx>=0.27,<1",
8
+ "pydantic>=2.0,<3",
9
+ ]
10
+
11
+ [project.optional-dependencies]
12
+ dev = [
13
+ "pytest>=8,<9",
14
+ "pytest-asyncio>=0.24,<1",
15
+ "respx>=0.22,<1",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/cyrus"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
@@ -0,0 +1,43 @@
1
+ """Python SDK for the Cyrus Payments API."""
2
+
3
+ from cyrus.client import CyrusClient
4
+ from cyrus._enums import (
5
+ CustomerStatus,
6
+ KycTier,
7
+ MatchStatus,
8
+ PaymentEventStatus,
9
+ PaymentEventType,
10
+ ReconciliationFailureReason,
11
+ TransactionStatus,
12
+ TransactionType,
13
+ )
14
+ from cyrus._exceptions import (
15
+ ConflictError,
16
+ CyrusAPIError,
17
+ NotFoundError,
18
+ ProviderError,
19
+ ServerError,
20
+ UnauthorizedError,
21
+ ValidationError,
22
+ )
23
+
24
+ __all__ = [
25
+ "CyrusClient",
26
+ "ConflictError",
27
+ "CustomerStatus",
28
+ "CyrusAPIError",
29
+ "KycTier",
30
+ "MatchStatus",
31
+ "NotFoundError",
32
+ "PaymentEventStatus",
33
+ "PaymentEventType",
34
+ "ProviderError",
35
+ "ReconciliationFailureReason",
36
+ "ServerError",
37
+ "TransactionStatus",
38
+ "TransactionType",
39
+ "UnauthorizedError",
40
+ "ValidationError",
41
+ ]
42
+
43
+ __version__ = "0.1.0"
@@ -0,0 +1,64 @@
1
+ from enum import Enum
2
+
3
+
4
+ class TransactionType(str, Enum):
5
+ CUSTOMER_PAYMENT = "CUSTOMER_PAYMENT"
6
+ PAYOUT = "PAYOUT"
7
+ REVERSAL = "REVERSAL"
8
+ ADJUSTMENT = "ADJUSTMENT"
9
+
10
+
11
+ class TransactionStatus(str, Enum):
12
+ PENDING = "PENDING"
13
+ SUCCESSFUL = "SUCCESSFUL"
14
+ FAILED = "FAILED"
15
+ REVERSED = "REVERSED"
16
+
17
+
18
+ class MatchStatus(str, Enum):
19
+ MATCHED = "MATCHED"
20
+ UNMATCHED = "UNMATCHED"
21
+ DISCREPANCY = "DISCREPANCY"
22
+ ORPHANED = "ORPHANED"
23
+ MANUAL_REVIEW = "MANUAL_REVIEW"
24
+
25
+
26
+ class CustomerStatus(str, Enum):
27
+ ACTIVE = "ACTIVE"
28
+ SUSPENDED = "SUSPENDED"
29
+ CLOSED = "CLOSED"
30
+
31
+
32
+ class KycTier(str, Enum):
33
+ TIER_1 = "TIER_1"
34
+ TIER_2 = "TIER_2"
35
+ TIER_3 = "TIER_3"
36
+
37
+
38
+ class PaymentEventType(str, Enum):
39
+ PAYMENT_SUCCESS = "PAYMENT_SUCCESS"
40
+ PAYMENT_FAILED = "PAYMENT_FAILED"
41
+ PAYMENT_REVERSED = "PAYMENT_REVERSED"
42
+ PAYOUT_SUCCESS = "PAYOUT_SUCCESS"
43
+ PAYOUT_FAILED = "PAYOUT_FAILED"
44
+ PAYOUT_REFUND = "PAYOUT_REFUND"
45
+ UNKNOWN = "UNKNOWN"
46
+
47
+
48
+ class PaymentEventStatus(str, Enum):
49
+ RECEIVED = "RECEIVED"
50
+ PROCESSED = "PROCESSED"
51
+ IGNORED = "IGNORED"
52
+ PROCESSED_DUPLICATE = "PROCESSED_DUPLICATE"
53
+ FAILED = "FAILED"
54
+ REATTRIBUTED = "REATTRIBUTED"
55
+
56
+
57
+ class ReconciliationFailureReason(str, Enum):
58
+ UNKNOWN_VIRTUAL_ACCOUNT = "UNKNOWN_VIRTUAL_ACCOUNT"
59
+ INACTIVE_CUSTOMER = "INACTIVE_CUSTOMER"
60
+ NON_CREDIT_EVENT = "NON_CREDIT_EVENT"
61
+ DUPLICATE = "DUPLICATE"
62
+ SIGNATURE_MISMATCH = "SIGNATURE_MISMATCH"
63
+ AMOUNT_MISMATCH = "AMOUNT_MISMATCH"
64
+ PROVIDER_UNCONFIRMED = "PROVIDER_UNCONFIRMED"
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class CyrusAPIError(Exception):
5
+ """Base exception for all Cyrus API errors."""
6
+
7
+ def __init__(self, code: str, message: str, status_code: int | None = None) -> None:
8
+ self.code = code
9
+ self.message = message
10
+ self.status_code = status_code
11
+ super().__init__(message)
12
+
13
+
14
+ class NotFoundError(CyrusAPIError):
15
+ """Resource not found (code 82)."""
16
+
17
+
18
+ class UnauthorizedError(CyrusAPIError):
19
+ """Invalid or missing API key (code 40)."""
20
+
21
+
22
+ class ValidationError(CyrusAPIError):
23
+ """Request validation failed (code 70/71). Includes field-level errors."""
24
+
25
+ def __init__(
26
+ self,
27
+ code: str,
28
+ message: str,
29
+ field_errors: list[dict[str, str]] | None = None,
30
+ status_code: int | None = None,
31
+ ) -> None:
32
+ self.field_errors = field_errors or []
33
+ super().__init__(code=code, message=message, status_code=status_code)
34
+
35
+
36
+ class ConflictError(CyrusAPIError):
37
+ """Resource state conflict (code 81, 409, etc.)."""
38
+
39
+
40
+ class ProviderError(CyrusAPIError):
41
+ """Upstream provider (Nomba) error (code 90)."""
42
+
43
+
44
+ class ServerError(CyrusAPIError):
45
+ """Internal server error (code 99, 5xx)."""
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from cyrus._exceptions import (
9
+ ConflictError,
10
+ CyrusAPIError,
11
+ NotFoundError,
12
+ ProviderError,
13
+ ServerError,
14
+ UnauthorizedError,
15
+ ValidationError,
16
+ )
17
+
18
+ _DEFAULT_BASE_URL = "https://api.trycyrus.app"
19
+ _MAX_RETRIES = 3
20
+ _RETRY_BACKOFF = 0.5 # seconds, doubled each attempt
21
+
22
+
23
+ class HttpClient:
24
+ """Thin wrapper around httpx that handles auth, envelope parsing, and retries."""
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: str,
29
+ *,
30
+ base_url: str = _DEFAULT_BASE_URL,
31
+ timeout: float = 30.0,
32
+ max_retries: int = _MAX_RETRIES,
33
+ ) -> None:
34
+ self._client = httpx.Client(
35
+ base_url=base_url,
36
+ headers={"Authorization": f"Bearer {api_key}"},
37
+ timeout=timeout,
38
+ )
39
+ self._max_retries = max_retries
40
+
41
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
42
+ return self._request("GET", path, params=params)
43
+
44
+ def post(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
45
+ return self._request("POST", path, json=json)
46
+
47
+ def patch(self, path: str, *, json: dict[str, Any] | None = None) -> Any:
48
+ return self._request("PATCH", path, json=json)
49
+
50
+ def delete(self, path: str) -> Any:
51
+ return self._request("DELETE", path)
52
+
53
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
54
+ last_exc: Exception | None = None
55
+ for attempt in range(self._max_retries):
56
+ try:
57
+ resp = self._client.request(method, path, **kwargs)
58
+ return self._handle_response(resp)
59
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.RemoteProtocolError) as exc:
60
+ last_exc = exc
61
+ if attempt < self._max_retries - 1:
62
+ time.sleep(_RETRY_BACKOFF * (2 ** attempt))
63
+ raise CyrusAPIError(
64
+ code="99",
65
+ message=f"Connection failed after {self._max_retries} attempts: {last_exc}",
66
+ status_code=None,
67
+ )
68
+
69
+ @staticmethod
70
+ def _handle_response(resp: httpx.Response) -> Any:
71
+ try:
72
+ body: dict[str, Any] = resp.json()
73
+ except ValueError:
74
+ # The api-key chain rejects missing/invalid keys before the request ever reaches a
75
+ # controller, so there's no CyrusApiResponse envelope to parse — just a bare status.
76
+ if resp.status_code in (401, 403):
77
+ raise UnauthorizedError(
78
+ code="40",
79
+ message="Invalid or missing API key",
80
+ status_code=resp.status_code,
81
+ ) from None
82
+ raise CyrusAPIError(
83
+ code="99",
84
+ message=f"Unexpected non-JSON response (HTTP {resp.status_code})",
85
+ status_code=resp.status_code,
86
+ ) from None
87
+
88
+ if resp.status_code >= 500:
89
+ msg = body.get("message", "Server error")
90
+ code = body.get("code", "99")
91
+ raise ServerError(code=code, message=msg, status_code=resp.status_code)
92
+
93
+ if body.get("status") is False or body.get("status") == "false":
94
+ code = body.get("code", "99")
95
+ msg = body.get("message", "Unknown error")
96
+ data = body.get("data") or {}
97
+
98
+ # Checked first: ResponseCode.INVALID_REQUEST ("71") is a generic business-rule-violation
99
+ # bucket the backend reuses across many exception types, several of which are genuine
100
+ # state conflicts returned with HTTP 409 (e.g. closing an already-closed customer,
101
+ # insufficient wallet funds). A 409 always means ConflictError regardless of which
102
+ # internal code produced it — checked before the code-based branches below so those
103
+ # conflicts aren't misclassified as ValidationError. Code "70" (INVALID_INPUT) is never
104
+ # emitted with 409 — it's exclusively bean-validation failures at 400 — so this reordering
105
+ # doesn't affect real field-validation errors.
106
+ if resp.status_code == 409 or code == "81":
107
+ raise ConflictError(code=code, message=msg, status_code=resp.status_code)
108
+ if code in ("70", "71"):
109
+ raise ValidationError(
110
+ code=code,
111
+ message=msg,
112
+ field_errors=data.get("fieldErrors") or [],
113
+ status_code=resp.status_code,
114
+ )
115
+ if code == "82":
116
+ raise NotFoundError(code=code, message=msg, status_code=resp.status_code)
117
+ if code == "40":
118
+ raise UnauthorizedError(code=code, message=msg, status_code=resp.status_code)
119
+ if code == "90":
120
+ raise ProviderError(code=code, message=msg, status_code=resp.status_code)
121
+ raise CyrusAPIError(code=code, message=msg, status_code=resp.status_code)
122
+
123
+ return body.get("data")
124
+
125
+ def close(self) -> None:
126
+ self._client.close()
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterator, TypeVar
4
+
5
+ from pydantic import TypeAdapter
6
+
7
+ from cyrus._http import HttpClient
8
+ from cyrus.models.responses import PageResponse
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class Page:
14
+ """A single page of results from a paginated endpoint."""
15
+
16
+ def __init__(self, data: dict[str, Any], item_type: type[T]) -> None:
17
+ self._page = PageResponse.model_validate(data)
18
+ self._adapter = TypeAdapter(list[item_type]) # type: ignore[type-arg]
19
+ self.items: list[T] = self._adapter.validate_python(self._page.content)
20
+
21
+ @property
22
+ def meta(self) -> PageResponse:
23
+ return self._page
24
+
25
+ @property
26
+ def total_elements(self) -> int:
27
+ return self._page.total_elements
28
+
29
+ @property
30
+ def total_pages(self) -> int:
31
+ return self._page.total_pages
32
+
33
+ @property
34
+ def page_number(self) -> int:
35
+ return self._page.number
36
+
37
+ @property
38
+ def is_first(self) -> bool:
39
+ return self._page.first
40
+
41
+ @property
42
+ def is_last(self) -> bool:
43
+ return self._page.last
44
+
45
+ @property
46
+ def is_empty(self) -> bool:
47
+ return self._page.empty
48
+
49
+ def __iter__(self) -> Iterator[T]:
50
+ return iter(self.items)
51
+
52
+ def __getitem__(self, index: int) -> T:
53
+ return self.items[index]
54
+
55
+ def __len__(self) -> int:
56
+ return len(self.items)
57
+
58
+
59
+ class PageIterator:
60
+ """Auto-paginating iterator that yields items across all pages."""
61
+
62
+ def __init__(
63
+ self,
64
+ client: HttpClient,
65
+ path: str,
66
+ item_type: type[T],
67
+ *,
68
+ params: dict[str, Any] | None = None,
69
+ page_size: int = 20,
70
+ ) -> None:
71
+ self._client = client
72
+ self._path = path
73
+ self._item_type = item_type
74
+ self._params = params or {}
75
+ self._page_size = page_size
76
+ self._current_page = 0
77
+
78
+ def __iter__(self) -> Iterator[T]:
79
+ while True:
80
+ params = {**self._params, "page": self._current_page, "size": self._page_size}
81
+ data = self._client.get(self._path, params=params)
82
+ page = Page(data, self._item_type)
83
+ yield from page.items
84
+ if page.is_last or page.is_empty:
85
+ break
86
+ self._current_page += 1
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def query_value(value: Any) -> Any:
7
+ """Unwrap an Enum member to its wire value for use as a query param.
8
+
9
+ httpx's query-param encoder calls ``str()`` on non-primitive values, which for a
10
+ ``(str, Enum)`` member yields ``"ClassName.MEMBER"`` rather than the member's value
11
+ (this only differs from JSON body encoding, which uses the member's str content directly).
12
+ Passing a plain string through unchanged keeps this backward-compatible with callers who
13
+ don't use the enum types.
14
+ """
15
+ return getattr(value, "value", value)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from cyrus._http import HttpClient
4
+ from cyrus.resources.customers import Customers
5
+ from cyrus.resources.payment_events import PaymentEvents
6
+ from cyrus.resources.transactions import Transactions
7
+
8
+
9
+ class CyrusClient:
10
+ """Client for the Cyrus Payments API.
11
+
12
+ Usage::
13
+
14
+ from cyrus import CyrusClient
15
+
16
+ client = CyrusClient(api_key="cyrus_live_xxx")
17
+ customer = client.customers.create(reference="cust_123", first_name="Ada")
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ api_key: str,
23
+ *,
24
+ base_url: str = "https://api.trycyrus.app",
25
+ timeout: float = 30.0,
26
+ max_retries: int = 3,
27
+ ) -> None:
28
+ self._http = HttpClient(
29
+ api_key,
30
+ base_url=base_url,
31
+ timeout=timeout,
32
+ max_retries=max_retries,
33
+ )
34
+ self.customers = Customers(self._http)
35
+ self.transactions = Transactions(self._http)
36
+ self.payment_events = PaymentEvents(self._http)
37
+
38
+ def close(self) -> None:
39
+ self._http.close()
40
+
41
+ def __enter__(self) -> CyrusClient:
42
+ return self
43
+
44
+ def __exit__(self, *args: object) -> None:
45
+ self.close()
@@ -0,0 +1,39 @@
1
+ from cyrus.models.requests import (
2
+ CreateCustomer,
3
+ ReattributePaymentEvent,
4
+ UpdateCustomer,
5
+ UpdateCustomerStatus,
6
+ UpdateKycTier,
7
+ )
8
+ from cyrus.models.responses import (
9
+ Customer,
10
+ CustomerListItem,
11
+ CustomerStatement,
12
+ PageResponse,
13
+ PaymentEventDetail,
14
+ PaymentEventListItem,
15
+ ReattributionResult,
16
+ StatementRow,
17
+ StatementSummary,
18
+ Transaction,
19
+ VirtualAccountSummary,
20
+ )
21
+
22
+ __all__ = [
23
+ "CreateCustomer",
24
+ "Customer",
25
+ "CustomerListItem",
26
+ "CustomerStatement",
27
+ "PageResponse",
28
+ "PaymentEventDetail",
29
+ "PaymentEventListItem",
30
+ "ReattributionResult",
31
+ "ReattributePaymentEvent",
32
+ "StatementRow",
33
+ "StatementSummary",
34
+ "Transaction",
35
+ "UpdateCustomer",
36
+ "UpdateCustomerStatus",
37
+ "UpdateKycTier",
38
+ "VirtualAccountSummary",
39
+ ]
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from cyrus._enums import CustomerStatus, KycTier
6
+
7
+
8
+ class CreateCustomer(BaseModel):
9
+ model_config = ConfigDict(populate_by_name=True)
10
+
11
+ reference: str
12
+ first_name: str = Field(alias="firstName")
13
+ last_name: str | None = Field(default=None, alias="lastName")
14
+ email: str | None = None
15
+ phone_number: str | None = Field(default=None, alias="phoneNumber")
16
+ bvn: str | None = None
17
+
18
+
19
+ class UpdateCustomer(BaseModel):
20
+ model_config = ConfigDict(populate_by_name=True)
21
+
22
+ first_name: str | None = Field(default=None, alias="firstName")
23
+ last_name: str | None = Field(default=None, alias="lastName")
24
+ email: str | None = None
25
+ phone_number: str | None = Field(default=None, alias="phoneNumber")
26
+
27
+
28
+ class UpdateCustomerStatus(BaseModel):
29
+ model_config = ConfigDict(populate_by_name=True)
30
+
31
+ status: CustomerStatus
32
+
33
+
34
+ class UpdateKycTier(BaseModel):
35
+ model_config = ConfigDict(populate_by_name=True)
36
+
37
+ tier: KycTier
38
+
39
+
40
+ class ReattributePaymentEvent(BaseModel):
41
+ model_config = ConfigDict(populate_by_name=True)
42
+
43
+ customer_reference: str = Field(alias="customerReference")