tinker-payments 0.3.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.
tinker/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from . import lifecycle
2
+ from .api import PayoutManager, TransactionManager
3
+ from .client import TinkerClient, TinkerPayments
4
+ from .managed_checkout import ManagedCheckout
5
+
6
+ __all__ = [
7
+ "ManagedCheckout",
8
+ "PayoutManager",
9
+ "TinkerClient",
10
+ "TinkerPayments",
11
+ "TransactionManager",
12
+ "lifecycle",
13
+ ]
tinker/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
tinker/api.py ADDED
@@ -0,0 +1,339 @@
1
+ """API managers for transactions and subscriptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any, cast
7
+ from urllib.parse import quote, urlencode
8
+
9
+ from . import endpoints
10
+ from . import lifecycle as lc
11
+ from .auth import AuthenticationManager
12
+ from .configuration import Configuration
13
+ from .exceptions import ApiError, NetworkError
14
+ from .lifecycle_helpers import idempotency_headers, payment_path, positive_amount
15
+ from .managed_checkout import ManagedCheckout
16
+ from .models import ApiMeta, Transaction
17
+
18
+ requests: Any
19
+ try:
20
+ import requests
21
+ except ModuleNotFoundError: # pragma: no cover
22
+ requests = None
23
+
24
+
25
+ class BaseManager:
26
+ def __init__(
27
+ self,
28
+ config: Configuration,
29
+ auth_manager: AuthenticationManager,
30
+ session: Any | None = None,
31
+ ) -> None:
32
+ self._config = config
33
+ self._auth_manager = auth_manager
34
+ if session is not None:
35
+ self._session = session
36
+ elif requests is not None:
37
+ self._session = requests.Session()
38
+ else:
39
+ raise NetworkError(
40
+ "requests is required unless a custom session is provided"
41
+ )
42
+ self._last_meta: ApiMeta | None = None
43
+
44
+ def get_last_meta(self) -> ApiMeta | None:
45
+ return self._last_meta
46
+
47
+ def _request(
48
+ self,
49
+ method: str,
50
+ endpoint: str,
51
+ data: dict[str, Any] | None = None,
52
+ headers: dict[str, str] | None = None,
53
+ ) -> Any:
54
+ base_url = self._config.base_url.rstrip("/")
55
+ url = f"{base_url}/{endpoint.lstrip('/')}"
56
+ token = self._auth_manager.get_token()
57
+
58
+ try:
59
+ response = self._session.request(
60
+ method=method,
61
+ url=url,
62
+ headers={
63
+ "Authorization": f"Bearer {token}",
64
+ "Accept": "application/json",
65
+ "Content-Type": "application/json",
66
+ **(headers or {}),
67
+ },
68
+ json=data if data else None,
69
+ timeout=30,
70
+ )
71
+ try:
72
+ result = response.json() if response.text else {}
73
+ except ValueError:
74
+ if response.status_code < 400:
75
+ raise
76
+ result = {}
77
+
78
+ if response.status_code >= 400:
79
+ raise self._api_error(response, result)
80
+
81
+ if isinstance(result, dict) and "success" in result:
82
+ self._last_meta = ApiMeta.from_dict(
83
+ result.get("meta") if isinstance(result.get("meta"), dict) else {}
84
+ )
85
+
86
+ if result.get("success") is False:
87
+ raise self._api_error(response, result)
88
+
89
+ payload = result.get("data")
90
+ if isinstance(payload, (dict, list)):
91
+ return payload
92
+ return {"value": payload}
93
+
94
+ return result if isinstance(result, (dict, list)) else {}
95
+ except ApiError:
96
+ raise
97
+ except Exception as exc:
98
+ raise NetworkError(f"Failed to communicate with Tinker API: {exc}") from exc
99
+
100
+ def _api_error(self, response: Any, result: Any) -> ApiError:
101
+ return ApiError(
102
+ self._extract_error_message(result),
103
+ http_status=response.status_code,
104
+ details=result,
105
+ headers=getattr(response, "headers", {}),
106
+ )
107
+
108
+ @staticmethod
109
+ def _extract_error_message(result: Any) -> str:
110
+ if isinstance(result, dict):
111
+ if isinstance(result.get("error"), dict):
112
+ error = result["error"]
113
+ return str(error.get("message") or error.get("code") or "Unknown error")
114
+ if "message" in result:
115
+ return str(result["message"])
116
+
117
+ return "Unknown error"
118
+
119
+
120
+ class TransactionManager(BaseManager):
121
+ def initiate(self, payload: dict[str, Any]) -> Transaction:
122
+ response = self._request("POST", endpoints.PAYMENT_INITIATE_PATH, payload)
123
+ if not isinstance(response, dict):
124
+ response = {"value": response}
125
+ return Transaction.from_dict(response)
126
+
127
+ def query(self, payload: dict[str, Any]) -> Transaction:
128
+ response = self._request("POST", endpoints.PAYMENT_QUERY_PATH, payload)
129
+ if not isinstance(response, dict):
130
+ response = {"value": response}
131
+ return Transaction.from_dict(response)
132
+
133
+ def refund(
134
+ self, payment_id: str, payload: lc.RefundRequest, key: str
135
+ ) -> lc.RefundResult:
136
+ if "amount" in payload:
137
+ positive_amount(payload["amount"])
138
+ return cast(
139
+ lc.RefundResult,
140
+ self._request(
141
+ "POST",
142
+ payment_path(payment_id) + "/refund",
143
+ dict(payload),
144
+ idempotency_headers(key),
145
+ ),
146
+ )
147
+
148
+ def refunds(self, payment_id: str) -> lc.RefundHistory:
149
+ return cast(
150
+ lc.RefundHistory,
151
+ self._request("GET", payment_path(payment_id) + "/refunds"),
152
+ )
153
+
154
+ def get_refund(self, payment_id: str, refund_id: str) -> lc.Refund:
155
+ return cast(
156
+ lc.Refund,
157
+ self._request(
158
+ "GET",
159
+ payment_path(payment_id) + "/refunds/" + quote(refund_id, safe=""),
160
+ ),
161
+ )
162
+
163
+ def cancel_checkout(
164
+ self, payment_id: str, payload: lc.CancellationRequest, key: str
165
+ ) -> lc.CancellationResult:
166
+ return cast(
167
+ lc.CancellationResult,
168
+ self._request(
169
+ "POST",
170
+ payment_path(payment_id) + "/cancellations",
171
+ dict(payload),
172
+ idempotency_headers(key),
173
+ ),
174
+ )
175
+
176
+ def cancellations(self, payment_id: str) -> lc.CancellationHistory:
177
+ return cast(
178
+ lc.CancellationHistory,
179
+ self._request("GET", payment_path(payment_id) + "/cancellations"),
180
+ )
181
+
182
+ def get_cancellation(self, payment_id: str, request_id: str) -> lc.Cancellation:
183
+ return cast(
184
+ lc.Cancellation,
185
+ self._request(
186
+ "GET",
187
+ payment_path(payment_id)
188
+ + "/cancellations/"
189
+ + quote(request_id, safe=""),
190
+ ),
191
+ )
192
+
193
+
194
+ class PayoutManager(BaseManager):
195
+ """Merchant wallet withdrawals to the merchant's verified destinations."""
196
+
197
+ def methods(self) -> list[lc.PayoutMethod]:
198
+ return cast(
199
+ list[lc.PayoutMethod], self._request("GET", "merchant/payout-methods")
200
+ )
201
+
202
+ def quote(self, payload: lc.PayoutQuoteRequest) -> lc.PayoutQuote:
203
+ positive_amount(payload["amount"])
204
+ return cast(
205
+ lc.PayoutQuote,
206
+ self._request("GET", "merchant/payouts/quote?" + urlencode(payload)),
207
+ )
208
+
209
+ def request_payout(self, payload: lc.PayoutRequest, key: str) -> lc.PayoutResult:
210
+ positive_amount(payload["amount"])
211
+ if (
212
+ not math.isfinite(payload["expected_fee_amount"])
213
+ or payload["expected_fee_amount"] < 0
214
+ ):
215
+ raise ValueError("expected_fee_amount must be finite and non-negative")
216
+ return cast(
217
+ lc.PayoutResult,
218
+ self._request(
219
+ "POST", "merchant/payouts", dict(payload), idempotency_headers(key)
220
+ ),
221
+ )
222
+
223
+ def list(self, filters: lc.PayoutFilters | None = None) -> lc.PayoutPage:
224
+ query = "?" + urlencode(filters) if filters else ""
225
+ return cast(lc.PayoutPage, self._request("GET", "merchant/payouts" + query))
226
+
227
+ def get(self, payout_id: str) -> lc.Payout:
228
+ return cast(
229
+ lc.Payout,
230
+ self._request("GET", "merchant/payouts/" + quote(payout_id, safe="")),
231
+ )
232
+
233
+
234
+ class SubscriptionManager(BaseManager):
235
+ def create_plan(self, payload: dict[str, Any]) -> dict[str, Any]:
236
+ response = self._request("POST", endpoints.SUBSCRIPTION_PLANS_PATH, payload)
237
+ return response if isinstance(response, dict) else {"value": response}
238
+
239
+ def list_plans(self) -> list[dict[str, Any]]:
240
+ response = self._request("GET", endpoints.SUBSCRIPTION_PLANS_PATH)
241
+ return response if isinstance(response, list) else []
242
+
243
+ def create(self, payload: dict[str, Any]) -> dict[str, Any]:
244
+ response = self._request("POST", endpoints.SUBSCRIPTION_BASE_PATH, payload)
245
+ return response if isinstance(response, dict) else {"value": response}
246
+
247
+ def list(
248
+ self,
249
+ plan_id: str | None = None,
250
+ external_customer_id: str | None = None,
251
+ ) -> list[dict[str, Any]]:
252
+ params = []
253
+ if plan_id and plan_id.strip():
254
+ params.append(f"plan_id={plan_id}")
255
+ if external_customer_id and external_customer_id.strip():
256
+ params.append(f"external_customer_id={external_customer_id}")
257
+
258
+ endpoint = endpoints.SUBSCRIPTION_BASE_PATH
259
+ if params:
260
+ endpoint = f"{endpoint}?{'&'.join(params)}"
261
+
262
+ response = self._request("GET", endpoint)
263
+ return response if isinstance(response, list) else []
264
+
265
+ def cancel(self, subscription_id: str) -> dict[str, Any]:
266
+ endpoint = f"{endpoints.SUBSCRIPTION_BASE_PATH}/{subscription_id}/cancel"
267
+ response = self._request("POST", endpoint)
268
+ return response if isinstance(response, dict) else {"value": response}
269
+
270
+ def checkout(self, request: ManagedCheckout) -> dict[str, Any]:
271
+ return self._request("POST", "merchant/billing/checkout", request.to_dict())
272
+
273
+ def billing_resource(self, resource_id: str) -> dict[str, Any]:
274
+ return self._request("GET", self._billing_resource_path(resource_id))
275
+
276
+ def change_quantity(
277
+ self, resource_id: str, quantity: int, key: str
278
+ ) -> dict[str, Any]:
279
+ return self._request(
280
+ "POST",
281
+ self._billing_resource_path(resource_id) + "/quantity",
282
+ {"quantity": quantity, "idempotency_key": key},
283
+ )
284
+
285
+ def schedule_quantity(
286
+ self, resource_id: str, quantity: int, key: str
287
+ ) -> dict[str, Any]:
288
+ return self._request(
289
+ "POST",
290
+ self._billing_resource_path(resource_id) + "/schedule",
291
+ {"quantity": quantity, "idempotency_key": key},
292
+ )
293
+
294
+ def clear_scheduled_quantity(self, resource_id: str, key: str) -> dict[str, Any]:
295
+ return self._request(
296
+ "POST",
297
+ self._billing_resource_path(resource_id) + "/unschedule",
298
+ {"idempotency_key": key},
299
+ )
300
+
301
+ def set_cancel_at_renewal(
302
+ self, resource_id: str, cancel: bool, key: str
303
+ ) -> dict[str, Any]:
304
+ return self._request(
305
+ "POST",
306
+ self._billing_resource_path(resource_id) + "/cancel",
307
+ {"cancel": cancel, "idempotency_key": key},
308
+ )
309
+
310
+ def billing_operation(self, key: str) -> dict[str, Any]:
311
+ return self._request(
312
+ "GET", "merchant/billing/operations/" + quote(key, safe="")
313
+ )
314
+
315
+ def payment_method_portal(
316
+ self, resource_id: str, return_url: str, key: str
317
+ ) -> dict[str, Any]:
318
+ return self._request(
319
+ "POST",
320
+ self._billing_resource_path(resource_id) + "/payment-method",
321
+ {"return_url": return_url, "idempotency_key": key},
322
+ )
323
+
324
+ def billing_payment_history(
325
+ self, resource_id: str, cursor: str = "", snapshot: int = 0
326
+ ) -> dict[str, Any]:
327
+ params = {}
328
+ if cursor:
329
+ params["starting_after"] = cursor
330
+ if snapshot > 0:
331
+ params["snapshot"] = str(snapshot)
332
+ endpoint = self._billing_resource_path(resource_id) + "/payment-history"
333
+ if params:
334
+ endpoint += "?" + urlencode(params)
335
+ return self._request("GET", endpoint)
336
+
337
+ @staticmethod
338
+ def _billing_resource_path(resource_id: str) -> str:
339
+ return "merchant/billing/resources/" + quote(resource_id, safe="")
tinker/auth.py ADDED
@@ -0,0 +1,107 @@
1
+ """Authentication manager for token fetching/caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import time
7
+ from typing import Any
8
+
9
+ from .configuration import Configuration
10
+ from .exceptions import ApiError, NetworkError
11
+ from .models import ApiMeta
12
+
13
+ requests: Any
14
+ try:
15
+ import requests
16
+ except ModuleNotFoundError: # pragma: no cover
17
+ requests = None
18
+
19
+
20
+ class AuthenticationManager:
21
+ def __init__(self, config: Configuration, session: Any | None = None) -> None:
22
+ self._config = config
23
+ if session is not None:
24
+ self._session = session
25
+ elif requests is not None:
26
+ self._session = requests.Session()
27
+ else:
28
+ raise NetworkError(
29
+ "requests is required unless a custom session is provided"
30
+ )
31
+ self._token: str | None = None
32
+ self._expires_at: int | None = None
33
+ self._last_meta: ApiMeta | None = None
34
+
35
+ def get_token(self) -> str:
36
+ if self._is_token_valid():
37
+ return self._token or ""
38
+ return self._fetch_token()
39
+
40
+ def get_last_meta(self) -> ApiMeta | None:
41
+ return self._last_meta
42
+
43
+ def _is_token_valid(self) -> bool:
44
+ if self._token is None or self._expires_at is None:
45
+ return False
46
+ return int(time.time()) < self._expires_at - 60
47
+
48
+ def _fetch_token(self) -> str:
49
+ credentials = base64.b64encode(
50
+ f"{self._config.api_public_key}:{self._config.api_secret_key}".encode()
51
+ ).decode("utf-8")
52
+
53
+ try:
54
+ response = self._session.post(
55
+ self._config.auth_url,
56
+ data={"credentials": credentials},
57
+ headers={
58
+ "Content-Type": "application/x-www-form-urlencoded",
59
+ "Accept": "application/json",
60
+ },
61
+ timeout=30,
62
+ )
63
+ result = response.json() if response.text else {}
64
+ auth_data = self._extract_auth_data(result)
65
+
66
+ if response.status_code >= 400:
67
+ raise ApiError(self._extract_error_message(result))
68
+
69
+ token = auth_data.get("token")
70
+ if not token:
71
+ raise ApiError("Invalid authentication response: token missing")
72
+
73
+ self._token = str(token)
74
+ expires_in = int(auth_data.get("expires_in", 3600))
75
+ self._expires_at = int(time.time()) + expires_in
76
+ return self._token
77
+ except ApiError:
78
+ raise
79
+ except Exception as exc:
80
+ raise NetworkError(f"Failed to authenticate: {exc}") from exc
81
+
82
+ def _extract_auth_data(self, result: Any) -> dict[str, Any]:
83
+ if isinstance(result, dict) and "success" in result:
84
+ self._last_meta = ApiMeta.from_dict(
85
+ result.get("meta") if isinstance(result.get("meta"), dict) else {}
86
+ )
87
+
88
+ if result.get("success") is False:
89
+ raise ApiError(self._extract_error_message(result))
90
+
91
+ data = result.get("data", {})
92
+ return data if isinstance(data, dict) else {}
93
+
94
+ return result if isinstance(result, dict) else {}
95
+
96
+ def _extract_error_message(self, result: Any) -> str:
97
+ if isinstance(result, dict):
98
+ if isinstance(result.get("error"), dict):
99
+ error = result["error"]
100
+ return str(
101
+ error.get("message") or error.get("code") or "Authentication failed"
102
+ )
103
+
104
+ if "message" in result:
105
+ return str(result["message"])
106
+
107
+ return "Authentication failed"
tinker/client.py ADDED
@@ -0,0 +1,76 @@
1
+ """Public SDK client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .api import PayoutManager, SubscriptionManager, TransactionManager
8
+ from .auth import AuthenticationManager
9
+ from .configuration import Configuration
10
+ from .models import ApiMeta
11
+ from .webhook import WebhookHandler
12
+
13
+ try:
14
+ import requests
15
+ except ModuleNotFoundError: # pragma: no cover
16
+ requests = None
17
+
18
+
19
+ class TinkerClient:
20
+ def __init__(
21
+ self,
22
+ api_public_key: str,
23
+ api_secret_key: str,
24
+ base_url: str | None = None,
25
+ session: Any | None = None,
26
+ ) -> None:
27
+ self._config = Configuration.create(api_public_key, api_secret_key, base_url)
28
+ if session is not None:
29
+ self._session = session
30
+ elif requests is not None:
31
+ self._session = requests.Session()
32
+ else:
33
+ raise RuntimeError(
34
+ "requests is required unless a custom session is provided"
35
+ )
36
+ self._auth_manager = AuthenticationManager(self._config, self._session)
37
+ self._payouts: PayoutManager | None = None
38
+ self._transactions: TransactionManager | None = None
39
+ self._subscriptions: SubscriptionManager | None = None
40
+ self._webhooks: WebhookHandler | None = None
41
+
42
+ @property
43
+ def config(self) -> Configuration:
44
+ return self._config
45
+
46
+ def transactions(self) -> TransactionManager:
47
+ if self._transactions is None:
48
+ self._transactions = TransactionManager(
49
+ self._config, self._auth_manager, self._session
50
+ )
51
+ return self._transactions
52
+
53
+ def subscriptions(self) -> SubscriptionManager:
54
+ if self._subscriptions is None:
55
+ self._subscriptions = SubscriptionManager(
56
+ self._config, self._auth_manager, self._session
57
+ )
58
+ return self._subscriptions
59
+
60
+ def payouts(self) -> PayoutManager:
61
+ if self._payouts is None:
62
+ self._payouts = PayoutManager(
63
+ self._config, self._auth_manager, self._session
64
+ )
65
+ return self._payouts
66
+
67
+ def webhooks(self) -> WebhookHandler:
68
+ if self._webhooks is None:
69
+ self._webhooks = WebhookHandler()
70
+ return self._webhooks
71
+
72
+ def get_last_auth_meta(self) -> ApiMeta | None:
73
+ return self._auth_manager.get_last_meta()
74
+
75
+
76
+ TinkerPayments = TinkerClient
tinker/client.pyi ADDED
@@ -0,0 +1,24 @@
1
+ from typing import Any
2
+
3
+ from .api import PayoutManager, SubscriptionManager, TransactionManager
4
+ from .configuration import Configuration
5
+ from .models import ApiMeta
6
+ from .webhook import WebhookHandler
7
+
8
+ class TinkerClient:
9
+ @property
10
+ def config(self) -> Configuration: ...
11
+ def __init__(
12
+ self,
13
+ api_public_key: str,
14
+ api_secret_key: str,
15
+ base_url: str | None = None,
16
+ session: Any | None = None,
17
+ ) -> None: ...
18
+ def transactions(self) -> TransactionManager: ...
19
+ def subscriptions(self) -> SubscriptionManager: ...
20
+ def payouts(self) -> PayoutManager: ...
21
+ def webhooks(self) -> WebhookHandler: ...
22
+ def get_last_auth_meta(self) -> ApiMeta | None: ...
23
+
24
+ TinkerPayments = TinkerClient
@@ -0,0 +1,45 @@
1
+ """SDK configuration and environment resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from . import endpoints
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Configuration:
12
+ api_public_key: str
13
+ api_secret_key: str
14
+ base_url: str
15
+ auth_url: str
16
+
17
+ @classmethod
18
+ def create(
19
+ cls,
20
+ api_public_key: str,
21
+ api_secret_key: str,
22
+ base_url: str | None = None,
23
+ ) -> Configuration:
24
+ resolved_base_url = base_url
25
+ if resolved_base_url is None or not resolved_base_url.strip():
26
+ resolved_base_url = (
27
+ endpoints.SANDBOX_BASE_URL
28
+ if api_public_key.startswith("pk_test_")
29
+ or api_secret_key.startswith("sk_test_")
30
+ else endpoints.PRODUCTION_BASE_URL
31
+ )
32
+
33
+ resolved_base_url = resolved_base_url.rstrip("/")
34
+ if not resolved_base_url.endswith(endpoints.API_VERSION_PATH):
35
+ resolved_base_url = f"{resolved_base_url}{endpoints.API_VERSION_PATH}"
36
+
37
+ normalized_base_url = f"{resolved_base_url}/"
38
+ auth_url = f"{resolved_base_url}{endpoints.AUTH_TOKEN_PATH}"
39
+
40
+ return cls(
41
+ api_public_key=api_public_key,
42
+ api_secret_key=api_secret_key,
43
+ base_url=normalized_base_url,
44
+ auth_url=auth_url,
45
+ )
tinker/endpoints.py ADDED
@@ -0,0 +1,10 @@
1
+ """API endpoint constants."""
2
+
3
+ PRODUCTION_BASE_URL = "https://api.tinkerpayments.com"
4
+ SANDBOX_BASE_URL = "https://sandbox-api.tinkerpayments.com"
5
+ API_VERSION_PATH = "/v1"
6
+ AUTH_TOKEN_PATH = "/auth/token"
7
+ PAYMENT_INITIATE_PATH = "/merchant/payment/initiate"
8
+ PAYMENT_QUERY_PATH = "/merchant/payment/query"
9
+ SUBSCRIPTION_BASE_PATH = "/merchant/subscriptions"
10
+ SUBSCRIPTION_PLANS_PATH = "/merchant/subscriptions/plans"
tinker/exceptions.py ADDED
@@ -0,0 +1,42 @@
1
+ """SDK exception types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ class TinkerError(Exception):
10
+ """Base SDK exception."""
11
+
12
+
13
+ class ApiError(TinkerError):
14
+ """API failure. Only outcome == 'not_applied' confirms no mutation."""
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ http_status: int | None = None,
21
+ details: Any = None,
22
+ headers: Mapping[str, str] | None = None,
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.http_status = http_status
26
+ self.details = details
27
+ error = details.get("error") if isinstance(details, dict) else None
28
+ error = error if isinstance(error, dict) else {}
29
+ headers = {str(k).lower(): v for k, v in (headers or {}).items()}
30
+ self.error_code = error.get("code")
31
+ self.provider_code = error.get("provider_code")
32
+ self.request_id = error.get("request_id") or headers.get("x-request-id")
33
+ self.retry_after = headers.get("retry-after")
34
+ self.outcome = error.get("outcome")
35
+
36
+
37
+ class NetworkError(TinkerError):
38
+ """Raised when request transport fails."""
39
+
40
+
41
+ class InvalidPayloadError(TinkerError):
42
+ """Raised when payload parsing or shape validation fails."""