cobrala 1.0.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.
- cobrala/__init__.py +18 -0
- cobrala/client.py +321 -0
- cobrala/py.typed +0 -0
- cobrala/webhooks.py +37 -0
- cobrala-1.0.0.dist-info/METADATA +114 -0
- cobrala-1.0.0.dist-info/RECORD +8 -0
- cobrala-1.0.0.dist-info/WHEEL +4 -0
- cobrala-1.0.0.dist-info/licenses/LICENSE +21 -0
cobrala/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cobrala: the Python SDK of the Access API of Cobrala.
|
|
3
|
+
|
|
4
|
+
from cobrala import Cobrala
|
|
5
|
+
|
|
6
|
+
cobrala = Cobrala(api_key="sk_...", base_url="https://access-api.cobrala.com.py")
|
|
7
|
+
verdict = cobrala.verify("USR-42", "invoices")
|
|
8
|
+
if verdict["allowed"]:
|
|
9
|
+
cobrala.consume("USR-42", "invoices", idempotency_key="factura-1")
|
|
10
|
+
|
|
11
|
+
It keeps no answers: what the API says has to be right every time. Only the
|
|
12
|
+
standard library is needed. ``AsyncCobrala`` has the same methods, to
|
|
13
|
+
``await``.
|
|
14
|
+
"""
|
|
15
|
+
from .client import AsyncCobrala, Cobrala, CobralaError, entitlement_rows
|
|
16
|
+
from .webhooks import verify_webhook
|
|
17
|
+
|
|
18
|
+
__all__ = ["AsyncCobrala", "Cobrala", "CobralaError", "entitlement_rows", "verify_webhook"]
|
cobrala/client.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cobrala/client.py
|
|
3
|
+
|
|
4
|
+
One method per route of the Access API. A rejection raises ``CobralaError``
|
|
5
|
+
with its stable ``code``; a 429 waits what the API says and tries twice more.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import functools
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, AsyncIterator, Callable, Dict, Iterator, Optional
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT = 10.0
|
|
21
|
+
RETRIES = 2
|
|
22
|
+
RATE_LIMITED = 429
|
|
23
|
+
UNREACHABLE = "unreachable" # the status is 0: nobody answered
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CobralaError(Exception):
|
|
27
|
+
def __init__(self, status: int, code: str, message: str, data: Optional[Dict[str, Any]] = None) -> None:
|
|
28
|
+
super().__init__(f"{code}: {message}")
|
|
29
|
+
self.status = status
|
|
30
|
+
self.code = code
|
|
31
|
+
self.message = message
|
|
32
|
+
self.data = data or {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class EntitlementRow:
|
|
37
|
+
"""One row of the synchronization: the same in the API and in the event ``entitlements.changed``."""
|
|
38
|
+
included: bool
|
|
39
|
+
limit: Optional[int]
|
|
40
|
+
resets_at: Optional[str]
|
|
41
|
+
ends_at: Optional[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def entitlement_rows(state: Dict[str, Any]) -> Dict[str, EntitlementRow]:
|
|
45
|
+
"""The rows of a client, with their names, from ``sync_entitlements`` or a webhook."""
|
|
46
|
+
return {code: EntitlementRow(*row) for code, row in state["rows"].items()}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _without_empty(values: Dict[str, Any]) -> Dict[str, Any]:
|
|
50
|
+
return {name: value for name, value in values.items() if value is not None}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Cobrala:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: str,
|
|
57
|
+
base_url: str,
|
|
58
|
+
*,
|
|
59
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
60
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._key = api_key
|
|
63
|
+
self._base = f"{base_url.rstrip('/')}/v1"
|
|
64
|
+
self._timeout = timeout
|
|
65
|
+
self._sleep = sleep
|
|
66
|
+
|
|
67
|
+
# ---- The catalog ----
|
|
68
|
+
|
|
69
|
+
def plans(self, cursor: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
70
|
+
return self._get("/plans", cursor=cursor, limit=limit)
|
|
71
|
+
|
|
72
|
+
def plan(self, plan_id: int) -> Dict[str, Any]:
|
|
73
|
+
return self._get(f"/plans/{plan_id}")
|
|
74
|
+
|
|
75
|
+
def products(self, cursor: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
76
|
+
return self._get("/products", cursor=cursor, limit=limit)
|
|
77
|
+
|
|
78
|
+
def product(self, product_id: int) -> Dict[str, Any]:
|
|
79
|
+
return self._get(f"/products/{product_id}")
|
|
80
|
+
|
|
81
|
+
def features(self, cursor: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
82
|
+
return self._get("/features", cursor=cursor, limit=limit)
|
|
83
|
+
|
|
84
|
+
def feature(self, code: str) -> Dict[str, Any]:
|
|
85
|
+
return self._get(f"/features/{_part(code)}")
|
|
86
|
+
|
|
87
|
+
# ---- Clients ----
|
|
88
|
+
|
|
89
|
+
def clients(
|
|
90
|
+
self,
|
|
91
|
+
email: Optional[str] = None,
|
|
92
|
+
updated_since: Optional[str] = None,
|
|
93
|
+
cursor: Optional[str] = None,
|
|
94
|
+
limit: Optional[int] = None,
|
|
95
|
+
) -> Dict[str, Any]:
|
|
96
|
+
return self._get("/clients", email=email, updated_since=updated_since, cursor=cursor, limit=limit)
|
|
97
|
+
|
|
98
|
+
def client(self, code: str) -> Dict[str, Any]:
|
|
99
|
+
return self._get(f"/clients/{_part(code)}")
|
|
100
|
+
|
|
101
|
+
def create_client(self, legal_name: str, code: Optional[str] = None, **fields: Any) -> Dict[str, Any]:
|
|
102
|
+
return self._post("/clients", _without_empty({"legal_name": legal_name, "code": code, **fields}))
|
|
103
|
+
|
|
104
|
+
def update_client(self, client_code: str, **changes: Any) -> Dict[str, Any]:
|
|
105
|
+
"""``code=`` among the changes gives the client another code."""
|
|
106
|
+
return self._request("PATCH", f"/clients/{_part(client_code)}", body=changes)
|
|
107
|
+
|
|
108
|
+
def portal_link(self, code: str) -> str:
|
|
109
|
+
return self._post(f"/clients/{_part(code)}/portal-link")["url"]
|
|
110
|
+
|
|
111
|
+
# ---- Entitlements ----
|
|
112
|
+
|
|
113
|
+
def verify(self, client_code: str, feature_code: str) -> Dict[str, Any]:
|
|
114
|
+
return self._post("/entitlements/verify", {"client_code": client_code, "feature_code": feature_code})
|
|
115
|
+
|
|
116
|
+
def consume(
|
|
117
|
+
self, client_code: str, feature_code: str, amount: int = 1, idempotency_key: Optional[str] = None,
|
|
118
|
+
) -> Dict[str, Any]:
|
|
119
|
+
return self._post("/entitlements/consume", _without_empty({
|
|
120
|
+
"client_code": client_code, "feature_code": feature_code, "amount": amount,
|
|
121
|
+
"idempotency_key": idempotency_key,
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
def set_usage(self, client_code: str, feature_code: str, value: int) -> Dict[str, Any]:
|
|
125
|
+
return self._post("/entitlements/set", {"client_code": client_code, "feature_code": feature_code, "value": value})
|
|
126
|
+
|
|
127
|
+
def sync_entitlements(
|
|
128
|
+
self,
|
|
129
|
+
client_code: Optional[str] = None,
|
|
130
|
+
since: Optional[str] = None,
|
|
131
|
+
cursor: Optional[str] = None,
|
|
132
|
+
limit: Optional[int] = None,
|
|
133
|
+
) -> Dict[str, Any]:
|
|
134
|
+
return self._get("/entitlements/sync", client_code=client_code, since=since, cursor=cursor, limit=limit)
|
|
135
|
+
|
|
136
|
+
# ---- Subscriptions ----
|
|
137
|
+
|
|
138
|
+
def subscriptions(
|
|
139
|
+
self,
|
|
140
|
+
client_code: Optional[str] = None,
|
|
141
|
+
status: Optional[str] = None,
|
|
142
|
+
cursor: Optional[str] = None,
|
|
143
|
+
limit: Optional[int] = None,
|
|
144
|
+
) -> Dict[str, Any]:
|
|
145
|
+
return self._get("/subscriptions", client_code=client_code, status=status, cursor=cursor, limit=limit)
|
|
146
|
+
|
|
147
|
+
def subscription(self, subscription_id: int) -> Dict[str, Any]:
|
|
148
|
+
return self._get(f"/subscriptions/{subscription_id}")
|
|
149
|
+
|
|
150
|
+
def cancel_subscription(self, subscription_id: int, when: str) -> Dict[str, Any]:
|
|
151
|
+
"""``when``: ``now`` or ``period_end``."""
|
|
152
|
+
return self._post(f"/subscriptions/{subscription_id}/cancel", {"when": when})
|
|
153
|
+
|
|
154
|
+
def revert_cancellation(self, subscription_id: int) -> Dict[str, Any]:
|
|
155
|
+
return self._post(f"/subscriptions/{subscription_id}/cancel/revert")
|
|
156
|
+
|
|
157
|
+
def change_plan(
|
|
158
|
+
self,
|
|
159
|
+
subscription_id: int,
|
|
160
|
+
price_id: int,
|
|
161
|
+
actor: str,
|
|
162
|
+
*,
|
|
163
|
+
when: str = "now",
|
|
164
|
+
paid: bool = False,
|
|
165
|
+
return_url: Optional[str] = None,
|
|
166
|
+
) -> Dict[str, Any]:
|
|
167
|
+
"""``actor``: ``operator`` (it applies at once) or ``customer`` (it pays first)."""
|
|
168
|
+
return self._post(
|
|
169
|
+
f"/subscriptions/{subscription_id}/change-plan", _change(price_id, actor, when, paid, return_url),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def preview_change_plan(self, subscription_id: int, price_id: int, actor: str, *, when: str = "now") -> Dict[str, Any]:
|
|
173
|
+
return self._post(
|
|
174
|
+
f"/subscriptions/{subscription_id}/change-plan/preview", _change(price_id, actor, when, False, None),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def cancel_plan_change(self, subscription_id: int) -> Dict[str, Any]:
|
|
178
|
+
return self._post(f"/subscriptions/{subscription_id}/change-plan/cancel")
|
|
179
|
+
|
|
180
|
+
# ---- Charges and documents ----
|
|
181
|
+
|
|
182
|
+
def payments(
|
|
183
|
+
self,
|
|
184
|
+
client_code: Optional[str] = None,
|
|
185
|
+
subscription_id: Optional[int] = None,
|
|
186
|
+
status: Optional[str] = None,
|
|
187
|
+
cursor: Optional[str] = None,
|
|
188
|
+
limit: Optional[int] = None,
|
|
189
|
+
) -> Dict[str, Any]:
|
|
190
|
+
return self._get(
|
|
191
|
+
"/payments", client_code=client_code, subscription_id=subscription_id, status=status,
|
|
192
|
+
cursor=cursor, limit=limit,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def payment(self, payment_id: int) -> Dict[str, Any]:
|
|
196
|
+
return self._get(f"/payments/{payment_id}")
|
|
197
|
+
|
|
198
|
+
def pay_link(self, payment_id: int, return_url: Optional[str] = None) -> str:
|
|
199
|
+
return self._post(f"/payments/{payment_id}/pay-link", _without_empty({"return_url": return_url}))["url"]
|
|
200
|
+
|
|
201
|
+
def upload_receipt(
|
|
202
|
+
self, payment_id: int, content: bytes, filename: str, content_type: str, note: Optional[str] = None,
|
|
203
|
+
) -> Dict[str, Any]:
|
|
204
|
+
boundary = f"cobrala{secrets.token_hex(12)}"
|
|
205
|
+
parts = []
|
|
206
|
+
if note is not None:
|
|
207
|
+
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="note"\r\n\r\n{note}\r\n'.encode())
|
|
208
|
+
parts.append(
|
|
209
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{_header_safe(filename)}"\r\n'
|
|
210
|
+
f"Content-Type: {content_type}\r\n\r\n".encode()
|
|
211
|
+
)
|
|
212
|
+
data = b"".join(parts) + content + f"\r\n--{boundary}--\r\n".encode()
|
|
213
|
+
return self._send("POST", f"/payments/{payment_id}/receipt", data, f"multipart/form-data; boundary={boundary}")
|
|
214
|
+
|
|
215
|
+
def documents(
|
|
216
|
+
self,
|
|
217
|
+
client_code: Optional[str] = None,
|
|
218
|
+
payment_id: Optional[int] = None,
|
|
219
|
+
cursor: Optional[str] = None,
|
|
220
|
+
limit: Optional[int] = None,
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
return self._get("/documents", client_code=client_code, payment_id=payment_id, cursor=cursor, limit=limit)
|
|
223
|
+
|
|
224
|
+
# ---- Checkout ----
|
|
225
|
+
|
|
226
|
+
def checkout_subscription(self, price_id: int, **client: Any) -> Dict[str, Any]:
|
|
227
|
+
"""``client``: client_code, or else legal_name and email; ruc and return_url are optional."""
|
|
228
|
+
return self._post("/checkout/subscription", _without_empty({"price_id": price_id, **client}))
|
|
229
|
+
|
|
230
|
+
def checkout_product(self, price_id: int, **client: Any) -> Dict[str, Any]:
|
|
231
|
+
return self._post("/checkout/product", _without_empty({"price_id": price_id, **client}))
|
|
232
|
+
|
|
233
|
+
# ---- Pages ----
|
|
234
|
+
|
|
235
|
+
def every(self, method: Callable[..., Dict[str, Any]], **filters: Any) -> Iterator[Dict[str, Any]]:
|
|
236
|
+
"""Every item of a list, page by page: ``for plan in cobrala.every(cobrala.plans): ...``."""
|
|
237
|
+
cursor = None
|
|
238
|
+
while True:
|
|
239
|
+
page = method(cursor=cursor, **filters)
|
|
240
|
+
yield from page["data"]
|
|
241
|
+
if not page["pagination"]["has_more"]:
|
|
242
|
+
return
|
|
243
|
+
cursor = page["pagination"]["next_cursor"]
|
|
244
|
+
|
|
245
|
+
# ---- The request ----
|
|
246
|
+
|
|
247
|
+
def _get(self, path: str, **query: Any) -> Dict[str, Any]:
|
|
248
|
+
wanted = _without_empty(query)
|
|
249
|
+
return self._request("GET", f"{path}?{urllib.parse.urlencode(wanted)}" if wanted else path)
|
|
250
|
+
|
|
251
|
+
def _post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
252
|
+
return self._request("POST", path, body=body if body is not None else {})
|
|
253
|
+
|
|
254
|
+
def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
255
|
+
return self._send(method, path, None if body is None else json.dumps(body).encode(), "application/json")
|
|
256
|
+
|
|
257
|
+
def _send(self, method: str, path: str, data: Optional[bytes], content_type: str) -> Dict[str, Any]:
|
|
258
|
+
request = urllib.request.Request(
|
|
259
|
+
f"{self._base}{path}", data=data, method=method,
|
|
260
|
+
headers={"X-API-Key": self._key, "Content-Type": content_type, "Accept": "application/json"},
|
|
261
|
+
)
|
|
262
|
+
for attempt in range(RETRIES + 1):
|
|
263
|
+
try:
|
|
264
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as answer:
|
|
265
|
+
return json.loads(answer.read() or b"null")
|
|
266
|
+
except urllib.error.HTTPError as failure:
|
|
267
|
+
if failure.code == RATE_LIMITED and attempt < RETRIES:
|
|
268
|
+
self._sleep(float(failure.headers.get("Retry-After") or 1))
|
|
269
|
+
continue
|
|
270
|
+
raise _error_of(failure) from failure
|
|
271
|
+
except OSError as failure: # no answer: refused, cut or timed out
|
|
272
|
+
raise CobralaError(0, UNREACHABLE, str(failure)) from failure
|
|
273
|
+
raise AssertionError("every attempt returns or raises")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _change(price_id: int, actor: str, when: str, paid: bool, return_url: Optional[str]) -> Dict[str, Any]:
|
|
277
|
+
return _without_empty({"price_id": price_id, "as": actor, "when": when, "paid": paid, "return_url": return_url})
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _part(value: str) -> str:
|
|
281
|
+
return urllib.parse.quote(value, safe="")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _header_safe(value: str) -> str:
|
|
285
|
+
return value.replace('"', "'").replace("\r", " ").replace("\n", " ")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _error_of(failure: urllib.error.HTTPError) -> CobralaError:
|
|
289
|
+
raw = failure.read().decode(errors="replace")
|
|
290
|
+
try:
|
|
291
|
+
body = json.loads(raw)
|
|
292
|
+
except ValueError:
|
|
293
|
+
body = {}
|
|
294
|
+
return CobralaError(failure.code, body.get("code", "http_error"), body.get("message", raw), body.get("data"))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class AsyncCobrala:
|
|
298
|
+
"""The same methods as ``Cobrala``, to ``await``: each one runs in a thread."""
|
|
299
|
+
|
|
300
|
+
def __init__(self, api_key: str, base_url: str, **options: Any) -> None:
|
|
301
|
+
self._client = Cobrala(api_key, base_url, **options)
|
|
302
|
+
|
|
303
|
+
async def every(self, method: Callable[..., Any], **filters: Any) -> AsyncIterator[Dict[str, Any]]:
|
|
304
|
+
"""Every item of a list, page by page: ``async for plan in cobrala.every(cobrala.plans): ...``."""
|
|
305
|
+
cursor = None
|
|
306
|
+
while True:
|
|
307
|
+
page = await method(cursor=cursor, **filters)
|
|
308
|
+
for item in page["data"]:
|
|
309
|
+
yield item
|
|
310
|
+
if not page["pagination"]["has_more"]:
|
|
311
|
+
return
|
|
312
|
+
cursor = page["pagination"]["next_cursor"]
|
|
313
|
+
|
|
314
|
+
def __getattr__(self, name: str) -> Callable[..., Any]:
|
|
315
|
+
method = getattr(self._client, name)
|
|
316
|
+
|
|
317
|
+
@functools.wraps(method)
|
|
318
|
+
async def call(*args: Any, **kwargs: Any) -> Any:
|
|
319
|
+
return await asyncio.to_thread(method, *args, **kwargs)
|
|
320
|
+
|
|
321
|
+
return call
|
cobrala/py.typed
ADDED
|
File without changes
|
cobrala/webhooks.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cobrala/webhooks.py
|
|
3
|
+
|
|
4
|
+
The signature of a webhook: the HMAC SHA-256 of ``"{timestamp}.{body}"``
|
|
5
|
+
with the secret of the endpoint, over the exact bytes that arrived, in
|
|
6
|
+
``X-Webhook-Signature: sha256=<hex>``, with its moment in ``X-Webhook-Timestamp``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import time
|
|
13
|
+
from typing import Optional, Union
|
|
14
|
+
|
|
15
|
+
PREFIX = "sha256="
|
|
16
|
+
TOLERANCE_SECONDS = 300
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_webhook(
|
|
20
|
+
secret: str,
|
|
21
|
+
body: Union[bytes, str],
|
|
22
|
+
timestamp: str,
|
|
23
|
+
signature: str,
|
|
24
|
+
*,
|
|
25
|
+
tolerance: int = TOLERANCE_SECONDS,
|
|
26
|
+
now: Optional[float] = None,
|
|
27
|
+
) -> bool:
|
|
28
|
+
"""Whether the webhook is of Cobrala and recent. ``body`` is the raw body, before parsing it."""
|
|
29
|
+
raw = body.encode() if isinstance(body, str) else body
|
|
30
|
+
try:
|
|
31
|
+
moment = int(timestamp)
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
return False
|
|
34
|
+
if abs((time.time() if now is None else now) - moment) > tolerance:
|
|
35
|
+
return False
|
|
36
|
+
expected = hmac.new(secret.encode(), f"{moment}.".encode() + raw, hashlib.sha256).hexdigest()
|
|
37
|
+
return hmac.compare_digest(f"{PREFIX}{expected}", signature or "")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cobrala
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for the Access API of Cobrala: plans, entitlements, usage and checkout
|
|
5
|
+
Project-URL: Homepage, https://cobrala.com.py/
|
|
6
|
+
Project-URL: Documentación de la API, https://access-api.cobrala.com.py/docs
|
|
7
|
+
Author: Araitek
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: checkout,cobrala,cobros,entitlements,paraguay,suscripciones
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: Spanish
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# cobrala (Python)
|
|
29
|
+
|
|
30
|
+
SDK de la Access API de Cobrala, para Python 3.9 o más y sin dependencias. Envuelve cada ruta de la API, que además publica su OpenAPI en `/docs`, y se autentica con `X-API-Key: sk_...`. La versión para TypeScript y JavaScript tiene las mismas funciones: `npm install cobrala`.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install cobrala
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from cobrala import Cobrala, CobralaError, verify_webhook
|
|
38
|
+
|
|
39
|
+
cobrala = Cobrala(api_key="sk_...", base_url="https://access-api.cobrala.com.py")
|
|
40
|
+
|
|
41
|
+
# Mostrar los planes en tu sitio
|
|
42
|
+
for plan in cobrala.every(cobrala.plans):
|
|
43
|
+
print(plan["name"], plan["intervals"][0]["prices"][0]["price"])
|
|
44
|
+
|
|
45
|
+
# Vender: abrí la url que vuelve
|
|
46
|
+
sale = cobrala.checkout_subscription(
|
|
47
|
+
price_id=17, client_code="USR-42", legal_name="Ana", email="ana@example.com",
|
|
48
|
+
return_url="https://example.com/gracias",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Verificar y consumir
|
|
52
|
+
if cobrala.verify("USR-42", "facturas")["allowed"]:
|
|
53
|
+
cobrala.consume("USR-42", "facturas", idempotency_key="factura-9921")
|
|
54
|
+
|
|
55
|
+
# Un rechazo tiene su código
|
|
56
|
+
try:
|
|
57
|
+
cobrala.change_plan(sale["subscription_id"], price_id=18, actor="customer")
|
|
58
|
+
except CobralaError as error:
|
|
59
|
+
if error.code == "grace_pay_first":
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
# Un webhook, sobre el cuerpo crudo
|
|
63
|
+
valid = verify_webhook(secret, raw_body, headers["X-Webhook-Timestamp"], headers["X-Webhook-Signature"])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`AsyncCobrala` tiene los mismos métodos, para `await`. El SDK no guarda respuestas: lo que dice la API tiene que estar bien siempre.
|
|
67
|
+
|
|
68
|
+
## Lo que resolvés en tu sistema
|
|
69
|
+
|
|
70
|
+
**La clave va solo en tu servidor.** Nunca en el navegador ni en una app móvil: quien la tenga hace todo lo que sus permisos dejan. Cada clave es de un servicio.
|
|
71
|
+
|
|
72
|
+
**Qué campo es el `client_code`.** Elegí un dato que tu sistema ya tenga, único y que no cambie: el id de tu usuario, por ejemplo `USR-42`. Es lo que une a tu usuario con su cliente en Cobrala.
|
|
73
|
+
- Si tu usuario ya existe, mandalo en el checkout. Si el cliente no existe en Cobrala, se crea en la misma llamada con el nombre y el correo que mandes.
|
|
74
|
+
- Si todavía no tiene cuenta (la página de precios abierta), no mandes código: Cobrala crea un cliente con un código generado (`CLI-8F3A21BC`) y lo devuelve junto con la dirección. Guardalo al abrirle la cuenta, o cambialo por el tuyo con `PATCH /v1/clients/{code}`.
|
|
75
|
+
- Cobrala nunca une clientes por correo: el mismo correo con otro código es otro cliente, porque dos personas pueden compartirlo. Para unir a uno que ya estaba cargado, buscalo con `GET /v1/clients?email=` y cambiale el código.
|
|
76
|
+
|
|
77
|
+
**Verificar en vivo o con una copia.** Las dos cosas son válidas:
|
|
78
|
+
- En vivo, `POST /v1/entitlements/verify` en cada acción sensible: siempre dice lo correcto, y cuenta contra el límite de 50 pedidos por segundo de tu clave.
|
|
79
|
+
- Con copia, `GET /v1/entitlements/sync` para armar menús y pantallas sin un pedido por feature. Cada fila trae `resets_at` y el vencimiento más cercano: un reinicio o un vencimiento no es un cambio y no te llega como tal, así que tu copia tiene que mirar esas fechas.
|
|
80
|
+
|
|
81
|
+
**Cada cuánto sincronizar.** Lo normal: el webhook `entitlements.changed` en el momento y, por las dudas, una sincronización cada 15 minutos con `since` igual al `as_of` de la anterior. Sin webhooks, sincronizá más seguido y al entrar cada usuario.
|
|
82
|
+
|
|
83
|
+
**Qué hacer si Cobrala no contesta.** Lo decidís vos según tu negocio. Con copia local, seguí con lo último que sabés. Sin copia, elegí entre dejar pasar (mejor para el usuario) o cortar (mejor para una feature cara), y dejalo escrito en tu código. El SDK reintenta solo los `429`, dos veces, esperando lo que dice `Retry-After`; si Cobrala no contesta, da un error con el código `unreachable`.
|
|
84
|
+
|
|
85
|
+
**Registrar consumo sin contar dos veces.** Mandá `idempotency_key` en cada consumo, con un valor propio del evento (`factura-9921`). Si reintentás con la misma clave, la API contesta lo que contestó y no suma.
|
|
86
|
+
|
|
87
|
+
**Vender.** Tu servidor pide el checkout con el `price_id` y abre la `url` que vuelve: es la página de Cobrala, donde el cliente paga. El resultado nunca lo dice la vuelta del navegador: confirmalo por webhook o verificando. El `return_url` es solo el botón "Volver a" de esa página.
|
|
88
|
+
|
|
89
|
+
**Cambiar de plan.** Con `operator` rige en el acto o se programa, y la diferencia sigue el flujo normal. Con `customer`, subir primero se paga por la `url` que devuelve y bajar se programa. `POST /v1/subscriptions/{id}/change-plan/preview` dice qué va a pasar antes de confirmar.
|
|
90
|
+
|
|
91
|
+
**Webhooks.** Verificá la firma sobre el cuerpo crudo, antes de parsearlo; el SDK trae la función. Las filas de `entitlements.changed` y de la sincronización tienen la misma forma, `[incluida, límite, resets_at, vencimiento]`, y el SDK trae otra función que les pone nombre.
|
|
92
|
+
|
|
93
|
+
## Lo que necesita cada función
|
|
94
|
+
|
|
95
|
+
| Qué hace | Ruta | Permiso |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| Planes, productos, features | `GET /v1/plans`, `/v1/products`, `/v1/features` | `plans`, `products`, `features` |
|
|
98
|
+
| Clientes | `GET /v1/clients`, `/v1/clients/{code}` | `clients` |
|
|
99
|
+
| Crear y editar clientes | `POST /v1/clients`, `PATCH /v1/clients/{code}` | `clients_write` |
|
|
100
|
+
| Verificar y sincronizar | `POST /v1/entitlements/verify`, `GET /v1/entitlements/sync` | `entitlements` |
|
|
101
|
+
| Consumir o fijar el consumo | `POST /v1/entitlements/consume`, `/set` | `entitlements_write` |
|
|
102
|
+
| Ver suscripciones y preview del cambio | `GET /v1/subscriptions`, `POST .../change-plan/preview` | `subscriptions` |
|
|
103
|
+
| Bajas y cambios de plan | `POST .../cancel`, `.../cancel/revert`, `.../change-plan`, `.../change-plan/cancel` | `subscriptions_write` |
|
|
104
|
+
| Ver cobros y pedir su link | `GET /v1/payments`, `POST /v1/payments/{id}/pay-link` | `payments` |
|
|
105
|
+
| Subir un comprobante | `POST /v1/payments/{id}/receipt` | `payments_receipt` |
|
|
106
|
+
| Documentos y su PDF | `GET /v1/documents` | `documents` |
|
|
107
|
+
| Vender | `POST /v1/checkout/subscription`, `/product` | `checkout` |
|
|
108
|
+
| Abrir el portal ya autenticado | `POST /v1/clients/{code}/portal-link` | `portal` |
|
|
109
|
+
|
|
110
|
+
Los rechazos son siempre `{"code": ..., "message": ..., "data": {...}}`, y el `code` es estable. Con la cuenta congelada, la clave lee pero no escribe (`account_frozen`); sin la API en el plan, contesta `plan_without_api`.
|
|
111
|
+
|
|
112
|
+
## Licencia
|
|
113
|
+
|
|
114
|
+
MIT. Ver [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cobrala/__init__.py,sha256=02JKztYuRCiPlD1rICJqV-kNJTIJ7xySR5bavbi8HQ8,697
|
|
2
|
+
cobrala/client.py,sha256=_EXftB-ogpx1GTtloJM9-tZ7GK4wHMnnPVbFBqFr69w,12981
|
|
3
|
+
cobrala/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
cobrala/webhooks.py,sha256=9r1_WXy89W6dDLUNW2Mybl4qsiGVZzuPJP1wzGkgq0A,1129
|
|
5
|
+
cobrala-1.0.0.dist-info/METADATA,sha256=-uErAwUt9I5bSEAG2FjIEszvyJUXUBi40B9OntjvOmM,7302
|
|
6
|
+
cobrala-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
cobrala-1.0.0.dist-info/licenses/LICENSE,sha256=QJFlN3ydmoLYPPLdVj5Jgbqa5TkywLD3oxT3OCp3CZg,1064
|
|
8
|
+
cobrala-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Araitek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|