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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.