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 +13 -0
- tinker/__version__.py +1 -0
- tinker/api.py +339 -0
- tinker/auth.py +107 -0
- tinker/client.py +76 -0
- tinker/client.pyi +24 -0
- tinker/configuration.py +45 -0
- tinker/endpoints.py +10 -0
- tinker/exceptions.py +42 -0
- tinker/lifecycle.py +235 -0
- tinker/lifecycle_helpers.py +20 -0
- tinker/managed_checkout.py +20 -0
- tinker/models.py +62 -0
- tinker/py.typed +0 -0
- tinker/webhook.py +377 -0
- tinker_payments-0.3.0.dist-info/METADATA +194 -0
- tinker_payments-0.3.0.dist-info/RECORD +19 -0
- tinker_payments-0.3.0.dist-info/WHEEL +4 -0
- tinker_payments-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|
tinker/configuration.py
ADDED
|
@@ -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."""
|