finopal-py-sdk 0.1.0__py3-none-any.whl

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.
finopal/__init__.py ADDED
@@ -0,0 +1,106 @@
1
+ """FinoPal Python SDK — a friendly client for the FinoPal payment gateway.
2
+
3
+ Quick start::
4
+
5
+ from finopal import FinoPal, Config
6
+
7
+ finopal = FinoPal(Config(merchant_id="fino-xxxx-xxxx-xxxx-xxxx", sandbox=True))
8
+
9
+ payment = finopal.payments.create({
10
+ "amount": 1_100, # Rials
11
+ "callback_url": "https://shop.example.com/callback",
12
+ "order_id": "ORD-1001",
13
+ })
14
+ print(payment.payment_url)
15
+
16
+ # ... buyer returns to callback_url ...
17
+
18
+ from finopal import parse_callback
19
+
20
+ cb = parse_callback(request.args)
21
+ if cb.is_success:
22
+ result = finopal.verifications.verify({
23
+ "authority": cb.authority,
24
+ "amount": 1_100,
25
+ })
26
+ print(result.ref_id)
27
+
28
+ Docs: https://finopal.ir/docs
29
+ """
30
+
31
+ from finopal.errors import (
32
+ ApiError,
33
+ FinoPalError,
34
+ FinoPalException,
35
+ TransportError,
36
+ ValidationError,
37
+ )
38
+ from finopal.finopal import FinoPal
39
+ from finopal.http import CallableTransport, RequestsTransport, Transport
40
+ from finopal.models import (
41
+ ApiResponse,
42
+ FeeResponse,
43
+ InquiryResponse,
44
+ PaymentRequestResponse,
45
+ ReverseResponse,
46
+ VerificationResponse,
47
+ )
48
+ from finopal.utils import (
49
+ MAX_AMOUNT_RIAL,
50
+ MIN_AMOUNT_RIAL,
51
+ CallbackResult,
52
+ CallbackStatus,
53
+ Config,
54
+ Currency,
55
+ FeeType,
56
+ ResultCode,
57
+ TransactionStatus,
58
+ Validator,
59
+ describe,
60
+ is_success_status,
61
+ parse_callback,
62
+ to_rial,
63
+ to_toman,
64
+ )
65
+
66
+ __version__ = "0.1.0"
67
+
68
+ __all__ = [
69
+ # client
70
+ "FinoPal",
71
+ "Config",
72
+ # transports
73
+ "Transport",
74
+ "RequestsTransport",
75
+ "CallableTransport",
76
+ # errors
77
+ "FinoPalError",
78
+ "ValidationError",
79
+ "TransportError",
80
+ "FinoPalException",
81
+ "ApiError",
82
+ # response models
83
+ "ApiResponse",
84
+ "PaymentRequestResponse",
85
+ "VerificationResponse",
86
+ "InquiryResponse",
87
+ "ReverseResponse",
88
+ "FeeResponse",
89
+ # helpers
90
+ "Validator",
91
+ "to_rial",
92
+ "to_toman",
93
+ "MIN_AMOUNT_RIAL",
94
+ "MAX_AMOUNT_RIAL",
95
+ "parse_callback",
96
+ "is_success_status",
97
+ "CallbackResult",
98
+ "describe",
99
+ # enums
100
+ "Currency",
101
+ "TransactionStatus",
102
+ "CallbackStatus",
103
+ "FeeType",
104
+ "ResultCode",
105
+ "__version__",
106
+ ]
@@ -0,0 +1,17 @@
1
+ """Error types raised by the FinoPal SDK."""
2
+
3
+ from finopal.errors.exceptions import (
4
+ ApiError,
5
+ FinoPalError,
6
+ FinoPalException,
7
+ TransportError,
8
+ ValidationError,
9
+ )
10
+
11
+ __all__ = [
12
+ "FinoPalError",
13
+ "ValidationError",
14
+ "TransportError",
15
+ "FinoPalException",
16
+ "ApiError",
17
+ ]
@@ -0,0 +1,124 @@
1
+ """Exception hierarchy for the FinoPal SDK.
2
+
3
+ Every exception raised by this package derives from :class:`FinoPalError`, so
4
+ ``except FinoPalError`` is always a safe catch-all::
5
+
6
+ from finopal import FinoPalError, FinoPalException, ValidationError
7
+
8
+ try:
9
+ payment = finopal.payments.create({...})
10
+ except ValidationError as exc:
11
+ # Bad input caught before the HTTP call.
12
+ ...
13
+ except FinoPalException as exc:
14
+ # The gateway answered with success=false or a non-100 code.
15
+ print(exc.code, exc.describe())
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Dict, Mapping, Optional
21
+
22
+ __all__ = [
23
+ "FinoPalError",
24
+ "ValidationError",
25
+ "TransportError",
26
+ "FinoPalException",
27
+ "ApiError",
28
+ ]
29
+
30
+
31
+ class FinoPalError(Exception):
32
+ """Base class for every error raised by the SDK."""
33
+
34
+
35
+ class ValidationError(FinoPalError):
36
+ """Raised when input fails client-side validation.
37
+
38
+ No HTTP request is made when this is raised.
39
+ """
40
+
41
+
42
+ class TransportError(FinoPalError):
43
+ """Raised when the gateway could not be reached or answered with garbage.
44
+
45
+ Covers DNS/TLS/connection failures, timeouts, non-JSON bodies and 5xx
46
+ responses. It means "we do not know what happened", which is exactly why
47
+ the caller should retry the *inquiry* endpoint rather than blindly
48
+ re-issuing a payment request.
49
+
50
+ :ivar status_code: HTTP status if a response was received, else ``None``.
51
+ :ivar response: Decoded body if available, else ``{}``.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ message: str,
57
+ status_code: Optional[int] = None,
58
+ response: Optional[Mapping[str, Any]] = None,
59
+ ) -> None:
60
+ super().__init__(message)
61
+ self.message = message
62
+ self.status_code = status_code
63
+ self.response: Dict[str, Any] = dict(response or {})
64
+
65
+ def __str__(self) -> str: # pragma: no cover - trivial
66
+ if self.status_code is not None:
67
+ return "{} (HTTP {})".format(self.message, self.status_code)
68
+ return self.message
69
+
70
+
71
+ class FinoPalException(FinoPalError):
72
+ """Raised when the gateway rejects a request.
73
+
74
+ Carries the numeric gateway ``code`` so callers can branch on it::
75
+
76
+ from finopal.utils.error_codes import describe
77
+
78
+ try:
79
+ finopal.verifications.verify({"authority": authority, "amount": amount})
80
+ except FinoPalException as exc:
81
+ if exc.code == -50:
82
+ ... # amount mismatch
83
+ elif exc.code == 101:
84
+ ... # already verified — treat as success
85
+
86
+ :ivar code: Numeric gateway code, or ``None`` if none was returned.
87
+ :ivar message: Human-readable message from the gateway.
88
+ :ivar response: The full decoded payload, useful for logging.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ message: str,
94
+ code: Optional[int] = None,
95
+ response: Optional[Mapping[str, Any]] = None,
96
+ ) -> None:
97
+ super().__init__(message)
98
+ self.message = message
99
+ self.code = code
100
+ self.response: Dict[str, Any] = dict(response or {})
101
+
102
+ def describe(self) -> str:
103
+ """English description of :attr:`code` from the published error list."""
104
+ # Imported lazily: ``finopal.utils`` pulls in the validator, which in
105
+ # turn imports this module — a module-level import would be circular.
106
+ from finopal.utils.error_codes import describe
107
+
108
+ return describe(self.code)
109
+
110
+ @property
111
+ def scope(self) -> str:
112
+ """API surface the code belongs to (``PaymentRequest``, ...)."""
113
+ from finopal.utils.error_codes import scope_of
114
+
115
+ return scope_of(self.code)
116
+
117
+ def __str__(self) -> str:
118
+ if self.code is None:
119
+ return self.message
120
+ return "{} (code={})".format(self.message, self.code)
121
+
122
+
123
+ #: Backwards/forwards-friendly alias — ``ApiError`` reads better in some codebases.
124
+ ApiError = FinoPalException
finopal/finopal.py ADDED
@@ -0,0 +1,140 @@
1
+ """The :class:`FinoPal` entry point.
2
+
3
+ Mirrors the ergonomics of the ZarinPal Python SDK — one client object, lazily
4
+ created resource namespaces — while speaking FinoPal's REST API exactly as
5
+ documented at https://finopal.ir/docs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Mapping, Optional
11
+
12
+ from finopal.http.client import HttpClient
13
+ from finopal.http.transport import RequestsTransport, Transport
14
+ from finopal.resources.fees import Fees
15
+ from finopal.resources.inquiries import Inquiries
16
+ from finopal.resources.payments import Payments
17
+ from finopal.resources.reversals import Reversals
18
+ from finopal.resources.verifications import Verifications
19
+ from finopal.utils.config import Config
20
+
21
+ __all__ = ["FinoPal"]
22
+
23
+
24
+ class FinoPal:
25
+ """Client for the FinoPal online payment gateway.
26
+
27
+ :param config: A :class:`~finopal.utils.config.Config`.
28
+ :param transport: Optional transport override (proxies, stubs, an async
29
+ adapter...). Defaults to :class:`~finopal.http.transport.RequestsTransport`.
30
+
31
+ Example::
32
+
33
+ from finopal import FinoPal, Config
34
+
35
+ finopal = FinoPal(Config(merchant_id="fino-1234-5678-9012-3456"))
36
+
37
+ payment = finopal.payments.create({
38
+ "amount": 1_100,
39
+ "callback_url": "https://shop.example.com/payment/callback",
40
+ "order_id": "ORD-1001",
41
+ })
42
+ print(payment.payment_url)
43
+ """
44
+
45
+ def __init__(self, config: Config, transport: Optional[Transport] = None) -> None:
46
+ if not isinstance(config, Config):
47
+ raise TypeError(
48
+ "config must be a finopal.Config instance, got {}.".format(
49
+ type(config).__name__
50
+ )
51
+ )
52
+
53
+ self.config = config
54
+ self.transport: Transport = transport or RequestsTransport(
55
+ timeout=config.timeout,
56
+ verify=config.verify_ssl,
57
+ max_retries=config.max_retries,
58
+ )
59
+ self.http = HttpClient(config, self.transport)
60
+
61
+ self._payments: Optional[Payments] = None
62
+ self._verifications: Optional[Verifications] = None
63
+ self._inquiries: Optional[Inquiries] = None
64
+ self._reversals: Optional[Reversals] = None
65
+ self._fees: Optional[Fees] = None
66
+
67
+ # --------------------------------------------------------------- resources
68
+ @property
69
+ def payments(self) -> Payments:
70
+ """Create payment requests. ``POST /api/v1/payment/request``."""
71
+ if self._payments is None:
72
+ self._payments = Payments(self.http)
73
+ return self._payments
74
+
75
+ @property
76
+ def verifications(self) -> Verifications:
77
+ """Verify payments. ``POST /api/v1/payment/verify``."""
78
+ if self._verifications is None:
79
+ self._verifications = Verifications(self.http)
80
+ return self._verifications
81
+
82
+ @property
83
+ def inquiries(self) -> Inquiries:
84
+ """Read-only status lookups. ``POST /api/v1/payment/inquiry``."""
85
+ if self._inquiries is None:
86
+ self._inquiries = Inquiries(self.http)
87
+ return self._inquiries
88
+
89
+ @property
90
+ def reversals(self) -> Reversals:
91
+ """Reverse verified transactions. ``POST /api/v1/payment/reverse``."""
92
+ if self._reversals is None:
93
+ self._reversals = Reversals(self.http)
94
+ return self._reversals
95
+
96
+ @property
97
+ def fees(self) -> Fees:
98
+ """Preview gateway fees. ``POST /api/v1/payment/feeCalculation``."""
99
+ if self._fees is None:
100
+ self._fees = Fees(self.http)
101
+ return self._fees
102
+
103
+ # -------------------------------------------------------------- primitives
104
+ @property
105
+ def base_url(self) -> str:
106
+ """Effective gateway host for this client."""
107
+ return self.config.base_url
108
+
109
+ @property
110
+ def is_sandbox(self) -> bool:
111
+ """Whether this client points at the sandbox host."""
112
+ return bool(self.config.sandbox)
113
+
114
+ def request(
115
+ self,
116
+ method: str,
117
+ path: str,
118
+ payload: Optional[Mapping[str, Any]] = None,
119
+ ) -> Dict[str, Any]:
120
+ """Escape hatch for endpoints the SDK does not wrap yet.
121
+
122
+ ``merchant_id`` is injected automatically. Only ``POST`` is supported
123
+ today because that is all the FinoPal gateway exposes::
124
+
125
+ finopal.request("POST", "/api/v1/payment/inquiry",
126
+ {"authority": "FP_..."})
127
+ """
128
+ if str(method).upper() != "POST":
129
+ raise ValueError(
130
+ "The FinoPal gateway only exposes POST endpoints; got {!r}.".format(
131
+ method
132
+ )
133
+ )
134
+ return self.http.post(path, payload)
135
+
136
+ def __repr__(self) -> str: # pragma: no cover - trivial
137
+ mode = "sandbox" if self.is_sandbox else "live"
138
+ return "FinoPal(merchant_id={!r}, mode={!r})".format(
139
+ self.config.merchant_id, mode
140
+ )
@@ -0,0 +1,6 @@
1
+ """HTTP transport layer."""
2
+
3
+ from finopal.http.client import HttpClient
4
+ from finopal.http.transport import CallableTransport, RequestsTransport, Transport
5
+
6
+ __all__ = ["HttpClient", "Transport", "RequestsTransport", "CallableTransport"]
finopal/http/client.py ADDED
@@ -0,0 +1,69 @@
1
+ """Thin HTTP client shared by every resource.
2
+
3
+ Responsibilities:
4
+
5
+ * build the absolute URL from :class:`~finopal.utils.config.Config`;
6
+ * inject ``merchant_id`` (callers may override it per call);
7
+ * send the standard FinoPal headers;
8
+ * guarantee a ``dict`` comes back, or raise :class:`TransportError`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Dict, Mapping, Optional
14
+
15
+ from finopal.errors.exceptions import TransportError
16
+ from finopal.http.transport import Transport
17
+ from finopal.utils.config import Config
18
+
19
+ __all__ = ["HttpClient"]
20
+
21
+
22
+ class HttpClient:
23
+ """Wraps a :class:`Transport` with FinoPal-specific request shaping."""
24
+
25
+ def __init__(self, config: Config, transport: Transport) -> None:
26
+ self.config = config
27
+ self.transport = transport
28
+
29
+ @property
30
+ def base_url(self) -> str:
31
+ return self.config.base_url
32
+
33
+ def headers(self, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
34
+ headers = {
35
+ "Content-Type": "application/json",
36
+ "Accept": "application/json",
37
+ "User-Agent": self.config.user_agent,
38
+ }
39
+ headers.update(dict(extra or {}))
40
+ return headers
41
+
42
+ def post(
43
+ self,
44
+ path: str,
45
+ payload: Optional[Mapping[str, Any]] = None,
46
+ *,
47
+ headers: Optional[Mapping[str, str]] = None,
48
+ ) -> Dict[str, Any]:
49
+ """POST ``payload`` to ``path`` with ``merchant_id`` injected.
50
+
51
+ A ``merchant_id`` present in ``payload`` wins, so a single client can
52
+ serve several terminals when needed.
53
+ """
54
+ url = self.config.endpoint(path)
55
+ body: Dict[str, Any] = {"merchant_id": self.config.merchant_id}
56
+ body.update(dict(payload or {}))
57
+
58
+ response = self.transport.post_json(url, body, self.headers(headers))
59
+
60
+ if not isinstance(response, Mapping):
61
+ raise TransportError(
62
+ "Malformed response from {}: expected a JSON object.".format(url)
63
+ )
64
+ return dict(response)
65
+
66
+ def __repr__(self) -> str: # pragma: no cover - trivial
67
+ return "HttpClient(base_url={!r}, transport={})".format(
68
+ self.base_url, type(self.transport).__name__
69
+ )
@@ -0,0 +1,176 @@
1
+ """Transport layer.
2
+
3
+ The SDK talks to the gateway through a tiny abstraction — :class:`Transport` —
4
+ so the HTTP implementation can be swapped without touching the resource
5
+ classes. This mirrors ``FinoPal\\Sdk\\Http\\TransportInterface`` in the PHP SDK
6
+ and makes the whole package testable without mocking ``requests``.
7
+
8
+ Built-in implementations:
9
+
10
+ * :class:`RequestsTransport` — default, backed by ``requests.Session``.
11
+ * :class:`CallableTransport` — wraps any ``fn(url, body, headers) -> dict``,
12
+ which is what the test-suite and most integrations use for stubs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from typing import Any, Callable, Dict, Mapping, Optional
19
+
20
+ import requests
21
+
22
+ from finopal.errors.exceptions import TransportError
23
+
24
+ __all__ = ["Transport", "RequestsTransport", "CallableTransport"]
25
+
26
+
27
+ class Transport:
28
+ """Interface every transport must implement."""
29
+
30
+ def post_json(
31
+ self,
32
+ url: str,
33
+ body: Mapping[str, Any],
34
+ headers: Optional[Mapping[str, str]] = None,
35
+ ) -> Dict[str, Any]:
36
+ """POST ``body`` as JSON to ``url`` and return the decoded response.
37
+
38
+ :raises TransportError: on connection failure, timeout, non-JSON body
39
+ or a 5xx response.
40
+ """
41
+ raise NotImplementedError
42
+
43
+
44
+ class RequestsTransport(Transport):
45
+ """Default transport built on ``requests.Session``.
46
+
47
+ A single session is reused so TCP connections and TLS handshakes are
48
+ pooled across calls.
49
+
50
+ :param timeout: Per-request timeout in seconds.
51
+ :param verify: TLS verification. Keep ``True`` in production.
52
+ :param max_retries: Extra attempts for connection errors only.
53
+ :param session: Inject a pre-configured session (proxies, adapters...).
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ timeout: float = 30.0,
59
+ verify: bool = True,
60
+ max_retries: int = 0,
61
+ session: Optional[requests.Session] = None,
62
+ ) -> None:
63
+ self.timeout = float(timeout)
64
+ self.verify = bool(verify)
65
+ self.max_retries = max(0, int(max_retries))
66
+ self.session = session or requests.Session()
67
+
68
+ def post_json(
69
+ self,
70
+ url: str,
71
+ body: Mapping[str, Any],
72
+ headers: Optional[Mapping[str, str]] = None,
73
+ ) -> Dict[str, Any]:
74
+ payload = dict(body)
75
+ request_headers = {
76
+ "Content-Type": "application/json",
77
+ "Accept": "application/json",
78
+ }
79
+ request_headers.update(dict(headers or {}))
80
+
81
+ last_error: Optional[Exception] = None
82
+
83
+ for attempt in range(self.max_retries + 1):
84
+ try:
85
+ response = self.session.post(
86
+ url,
87
+ json=payload,
88
+ headers=request_headers,
89
+ timeout=self.timeout,
90
+ verify=self.verify,
91
+ )
92
+ except requests.RequestException as exc:
93
+ last_error = exc
94
+ # Only connection-level problems are worth retrying; the SDK
95
+ # never retries a request the gateway may have already handled.
96
+ if attempt < self.max_retries:
97
+ time.sleep(0.5 * (attempt + 1))
98
+ continue
99
+ raise TransportError(
100
+ "Could not reach the FinoPal gateway at {}: {}".format(url, exc)
101
+ ) from exc
102
+
103
+ return self._decode(response, url)
104
+
105
+ raise TransportError( # pragma: no cover - loop always raises or returns
106
+ "Could not reach the FinoPal gateway at {}: {}".format(url, last_error)
107
+ )
108
+
109
+ @staticmethod
110
+ def _decode(response: requests.Response, url: str) -> Dict[str, Any]:
111
+ try:
112
+ data = response.json()
113
+ except ValueError as exc:
114
+ snippet = (response.text or "")[:300]
115
+ raise TransportError(
116
+ "The FinoPal gateway returned a non-JSON response "
117
+ "(HTTP {}) from {}: {!r}".format(response.status_code, url, snippet),
118
+ status_code=response.status_code,
119
+ ) from exc
120
+
121
+ if not isinstance(data, dict):
122
+ raise TransportError(
123
+ "Unexpected response shape from {} (expected a JSON object, "
124
+ "got {}).".format(url, type(data).__name__),
125
+ status_code=response.status_code,
126
+ )
127
+
128
+ # 5xx means the gateway itself failed — surface it as a transport error.
129
+ # 4xx bodies are usually structured FinoPal errors, so let the resource
130
+ # layer interpret them.
131
+ if response.status_code >= 500:
132
+ raise TransportError(
133
+ "The FinoPal gateway returned HTTP {} from {}.".format(
134
+ response.status_code, url
135
+ ),
136
+ status_code=response.status_code,
137
+ response=data,
138
+ )
139
+
140
+ return data
141
+
142
+
143
+ class CallableTransport(Transport):
144
+ """Adapt a plain function into a transport.
145
+
146
+ Handy in tests and in frameworks that already own an HTTP client::
147
+
148
+ def fake(url, body, headers):
149
+ return {"success": True, "code": 100,
150
+ "data": {"authority": "FP_1", "payment_url": "https://x"}}
151
+
152
+ finopal = FinoPal(config, transport=CallableTransport(fake))
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ handler: Callable[[str, Mapping[str, Any], Mapping[str, str]], Mapping[str, Any]],
158
+ ) -> None:
159
+ if not callable(handler):
160
+ raise TypeError("handler must be callable")
161
+ self._handler = handler
162
+
163
+ def post_json(
164
+ self,
165
+ url: str,
166
+ body: Mapping[str, Any],
167
+ headers: Optional[Mapping[str, str]] = None,
168
+ ) -> Dict[str, Any]:
169
+ result = self._handler(url, dict(body), dict(headers or {}))
170
+ if not isinstance(result, Mapping):
171
+ raise TransportError(
172
+ "CallableTransport handler must return a mapping, got {}.".format(
173
+ type(result).__name__
174
+ )
175
+ )
176
+ return dict(result)