tilde-afip 0.1.0a1__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .pytest_cache/
4
+ dist/
5
+ build/
6
+ .venv/
@@ -0,0 +1,13 @@
1
+ # Generación — tilde-afip (Python)
2
+
3
+ Igual que el SDK TypeScript, el cliente usa un **transport propio sobre la stdlib** (`urllib`,
4
+ cero dependencias de runtime) en vez del cliente generado por Kiota. El facade/auth/errors/webhooks
5
+ son runtime-agnósticos; la cobertura tipada completa de operaciones se generará del contrato
6
+ (`openapi/tilde-api-v1.json`) cuando se adopte un codegen estable (Kiota Python u OpenAPI Generator).
7
+
8
+ Estructura:
9
+ - `client.py` — TildeClient (transport + recurso `invoices` + `request()` genérico).
10
+ - `auth.py` — OAuth2 client credentials con cache/refresh.
11
+ - `errors.py` — familias tipadas (modelo de error común).
12
+ - `webhooks.py` — verifier HMAC (verify-before-parse, anti-replay, constant-time).
13
+ - `_http.py` — transport stdlib inyectable (para tests).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tilde
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.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: tilde-afip
3
+ Version: 0.1.0a1
4
+ Summary: SDK oficial de Tilde para Python — facturación electrónica ARCA (ex AFIP) en Argentina. Server-side.
5
+ Project-URL: Homepage, https://tildeafip.com
6
+ Project-URL: Documentation, https://tildeafip.com/developers
7
+ Project-URL: Repository, https://sistemasat-gustavo.visualstudio.com/Tilde/_git/Tilde
8
+ Author: Tilde
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Tilde
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: afip,arca,argentina,facturacion,invoicing,sdk,tilde
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Typing :: Typed
40
+ Requires-Python: >=3.10
41
+ Provides-Extra: dev
42
+ Requires-Dist: pytest>=8.0; extra == 'dev'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # tilde-afip
46
+
47
+ SDK oficial de **Tilde** para Python — facturación electrónica **ARCA (ex AFIP)** en Argentina.
48
+
49
+ > ⚠️ **Server-side / backend-to-backend.** No uses Client Credentials en código que corra en un cliente no confiable. El `client_secret` nunca debe filtrarse.
50
+
51
+ > Estado: **alpha** (`0.1.0a1`). API sujeta a cambios hasta `1.0.0`.
52
+
53
+ ## Instalación
54
+ ```bash
55
+ pip install tilde-afip
56
+ ```
57
+ Requiere **Python 3.10+**. Sin dependencias de runtime (transport sobre la stdlib).
58
+
59
+ ## Quickstart
60
+ ```python
61
+ from tilde_afip import TildeClient
62
+
63
+ tilde = TildeClient(
64
+ client_id="...",
65
+ client_secret="...",
66
+ environment="sandbox", # "production" por default
67
+ scopes=["invoicing:write"],
68
+ )
69
+
70
+ invoice = tilde.invoices.create({ ... }, idempotency_key=my_key)
71
+ print(invoice["cae"])
72
+ ```
73
+ El token OAuth2 (client credentials) se obtiene y cachea automáticamente.
74
+
75
+ ## Errores tipados
76
+ ```python
77
+ from tilde_afip import TildeValidationError
78
+
79
+ try:
80
+ tilde.invoices.create(req, idempotency_key=key)
81
+ except TildeValidationError as e:
82
+ print(e.code, e.status, e.retryable, e.correlation_id, e.body.get("errors"))
83
+ ```
84
+ Familias: `TildeValidationError`, `TildeAuthenticationError`, `TildeAuthorizationError`,
85
+ `TildeRateLimitError`, `TildeConflictError`, `TildeNotFoundError`, `TildePaymentRequiredError`, `TildeApiError`.
86
+
87
+ ## Webhooks
88
+ Verificá con el **raw body**, antes de parsear:
89
+ ```python
90
+ from tilde_afip import construct_event
91
+
92
+ event = construct_event(
93
+ payload=raw_body, # str | bytes, tal cual llegó
94
+ signature_header=request.headers["X-Tilde-Signature"],
95
+ secret=webhook_secret,
96
+ )
97
+ if event["type"] == "invoice.authorized":
98
+ ...
99
+ ```
100
+ HMAC-SHA256 en tiempo constante, con anti-replay por timestamp.
101
+
102
+ ## Entornos
103
+ | Entorno | Base URL |
104
+ |---|---|
105
+ | `production` | `https://api.tildeafip.com/v1` |
106
+ | `sandbox` | `https://sandbox.api.tildeafip.com/v1` |
107
+
108
+ ## Acceso genérico
109
+ Cualquier endpoint de la API: `tilde.request("GET", "/cuit-profiles")`. El recurso `invoices`
110
+ está tipado como ejemplo; la cobertura tipada completa se generará del contrato.
111
+
112
+ ## Licencia
113
+ Ver `LICENSE`.
@@ -0,0 +1,69 @@
1
+ # tilde-afip
2
+
3
+ SDK oficial de **Tilde** para Python — facturación electrónica **ARCA (ex AFIP)** en Argentina.
4
+
5
+ > ⚠️ **Server-side / backend-to-backend.** No uses Client Credentials en código que corra en un cliente no confiable. El `client_secret` nunca debe filtrarse.
6
+
7
+ > Estado: **alpha** (`0.1.0a1`). API sujeta a cambios hasta `1.0.0`.
8
+
9
+ ## Instalación
10
+ ```bash
11
+ pip install tilde-afip
12
+ ```
13
+ Requiere **Python 3.10+**. Sin dependencias de runtime (transport sobre la stdlib).
14
+
15
+ ## Quickstart
16
+ ```python
17
+ from tilde_afip import TildeClient
18
+
19
+ tilde = TildeClient(
20
+ client_id="...",
21
+ client_secret="...",
22
+ environment="sandbox", # "production" por default
23
+ scopes=["invoicing:write"],
24
+ )
25
+
26
+ invoice = tilde.invoices.create({ ... }, idempotency_key=my_key)
27
+ print(invoice["cae"])
28
+ ```
29
+ El token OAuth2 (client credentials) se obtiene y cachea automáticamente.
30
+
31
+ ## Errores tipados
32
+ ```python
33
+ from tilde_afip import TildeValidationError
34
+
35
+ try:
36
+ tilde.invoices.create(req, idempotency_key=key)
37
+ except TildeValidationError as e:
38
+ print(e.code, e.status, e.retryable, e.correlation_id, e.body.get("errors"))
39
+ ```
40
+ Familias: `TildeValidationError`, `TildeAuthenticationError`, `TildeAuthorizationError`,
41
+ `TildeRateLimitError`, `TildeConflictError`, `TildeNotFoundError`, `TildePaymentRequiredError`, `TildeApiError`.
42
+
43
+ ## Webhooks
44
+ Verificá con el **raw body**, antes de parsear:
45
+ ```python
46
+ from tilde_afip import construct_event
47
+
48
+ event = construct_event(
49
+ payload=raw_body, # str | bytes, tal cual llegó
50
+ signature_header=request.headers["X-Tilde-Signature"],
51
+ secret=webhook_secret,
52
+ )
53
+ if event["type"] == "invoice.authorized":
54
+ ...
55
+ ```
56
+ HMAC-SHA256 en tiempo constante, con anti-replay por timestamp.
57
+
58
+ ## Entornos
59
+ | Entorno | Base URL |
60
+ |---|---|
61
+ | `production` | `https://api.tildeafip.com/v1` |
62
+ | `sandbox` | `https://sandbox.api.tildeafip.com/v1` |
63
+
64
+ ## Acceso genérico
65
+ Cualquier endpoint de la API: `tilde.request("GET", "/cuit-profiles")`. El recurso `invoices`
66
+ está tipado como ejemplo; la cobertura tipada completa se generará del contrato.
67
+
68
+ ## Licencia
69
+ Ver `LICENSE`.
@@ -0,0 +1,23 @@
1
+ import os
2
+ import uuid
3
+
4
+ from tilde_afip import TildeClient, TildeValidationError, construct_event
5
+
6
+ tilde = TildeClient(
7
+ client_id=os.environ["TILDE_CLIENT_ID"],
8
+ client_secret=os.environ["TILDE_CLIENT_SECRET"],
9
+ environment="sandbox",
10
+ scopes=["invoicing:write"],
11
+ )
12
+
13
+ try:
14
+ invoice = tilde.invoices.create({"...": "campos"}, idempotency_key=str(uuid.uuid4()))
15
+ print("CAE:", invoice.get("cae"))
16
+ except TildeValidationError as e:
17
+ print("validación:", e.code, e.body.get("errors"))
18
+
19
+
20
+ def on_webhook(raw_body: bytes, signature: str) -> None:
21
+ event = construct_event(payload=raw_body, signature_header=signature, secret=os.environ["TILDE_WEBHOOK_SECRET"])
22
+ if event["type"] == "invoice.authorized":
23
+ print("autorizado:", event["id"])
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tilde-afip"
7
+ version = "0.1.0a1"
8
+ description = "SDK oficial de Tilde para Python — facturación electrónica ARCA (ex AFIP) en Argentina. Server-side."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Tilde" }]
13
+ keywords = ["tilde", "arca", "afip", "facturacion", "argentina", "invoicing", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://tildeafip.com"
28
+ Documentation = "https://tildeafip.com/developers"
29
+ Repository = "https://sistemasat-gustavo.visualstudio.com/Tilde/_git/Tilde"
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8.0"]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/tilde_afip"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,48 @@
1
+ """SDK oficial de Tilde para Python — facturación electrónica ARCA (ex AFIP). Server-side."""
2
+ from __future__ import annotations
3
+
4
+ from .auth import ClientCredentialsTokenProvider
5
+ from .client import TildeClient, InvoicesResource, __version__
6
+ from .environment import BASE_URLS, TildeEnvironment, base_url_for
7
+ from .errors import (
8
+ TildeApiError,
9
+ TildeAuthenticationError,
10
+ TildeAuthorizationError,
11
+ TildeConflictError,
12
+ TildeError,
13
+ TildeNotFoundError,
14
+ TildePaymentRequiredError,
15
+ TildeRateLimitError,
16
+ TildeValidationError,
17
+ error_from_response,
18
+ )
19
+ from .webhooks import (
20
+ WebhookVerificationError,
21
+ construct_event,
22
+ sign,
23
+ verify_signature,
24
+ )
25
+
26
+ __all__ = [
27
+ "TildeClient",
28
+ "InvoicesResource",
29
+ "ClientCredentialsTokenProvider",
30
+ "TildeEnvironment",
31
+ "BASE_URLS",
32
+ "base_url_for",
33
+ "TildeError",
34
+ "TildeAuthenticationError",
35
+ "TildeAuthorizationError",
36
+ "TildeValidationError",
37
+ "TildeRateLimitError",
38
+ "TildeConflictError",
39
+ "TildeNotFoundError",
40
+ "TildePaymentRequiredError",
41
+ "TildeApiError",
42
+ "error_from_response",
43
+ "verify_signature",
44
+ "construct_event",
45
+ "sign",
46
+ "WebhookVerificationError",
47
+ "__version__",
48
+ ]
@@ -0,0 +1,29 @@
1
+ """Transport HTTP mínimo sobre la stdlib (urllib). Inyectable para tests."""
2
+ from __future__ import annotations
3
+
4
+ import urllib.error
5
+ import urllib.request
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Mapping, Optional
8
+
9
+
10
+ @dataclass
11
+ class HttpResponse:
12
+ status: int
13
+ headers: Mapping[str, str]
14
+ text: str
15
+
16
+
17
+ # (method, url, headers, body_bytes) -> HttpResponse
18
+ Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], HttpResponse]
19
+
20
+
21
+ def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: Optional[bytes]) -> HttpResponse:
22
+ req = urllib.request.Request(url, data=body, method=method)
23
+ for key, value in headers.items():
24
+ req.add_header(key, value)
25
+ try:
26
+ with urllib.request.urlopen(req) as resp: # noqa: S310 (urls controladas por el SDK)
27
+ return HttpResponse(resp.status, dict(resp.headers), resp.read().decode("utf-8"))
28
+ except urllib.error.HTTPError as exc:
29
+ return HttpResponse(exc.code, dict(exc.headers), exc.read().decode("utf-8"))
@@ -0,0 +1,72 @@
1
+ """Autenticación OAuth2 Client Credentials con cache + refresh. Server-side only."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ from typing import Callable, Optional
7
+ from urllib.parse import urlencode
8
+
9
+ from ._http import HttpResponse, Transport, urllib_transport
10
+ from .errors import TildeAuthenticationError
11
+
12
+
13
+ class ClientCredentialsTokenProvider:
14
+ """Obtiene y cachea el access token (OAuth2 client credentials). Refresca antes de expirar."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ client_id: str,
20
+ client_secret: str,
21
+ token_url: str,
22
+ scopes: Optional[list[str]] = None,
23
+ transport: Optional[Transport] = None,
24
+ now: Optional[Callable[[], float]] = None,
25
+ refresh_skew_seconds: float = 30.0,
26
+ ) -> None:
27
+ self._client_id = client_id
28
+ self._client_secret = client_secret
29
+ self._token_url = token_url
30
+ self._scopes = scopes
31
+ self._transport: Transport = transport or urllib_transport
32
+ self._now = now or time.time
33
+ self._refresh_skew = refresh_skew_seconds
34
+ self._token: Optional[str] = None
35
+ self._expires_at: float = 0.0
36
+
37
+ def get_token(self) -> str:
38
+ """Devuelve un access token válido (cacheado si no expiró)."""
39
+ if self._token is not None and self._now() < self._expires_at - self._refresh_skew:
40
+ return self._token
41
+ return self._request_token()
42
+
43
+ def invalidate(self) -> None:
44
+ self._token = None
45
+
46
+ def _request_token(self) -> str:
47
+ form = {
48
+ "grant_type": "client_credentials",
49
+ "client_id": self._client_id,
50
+ "client_secret": self._client_secret,
51
+ }
52
+ if self._scopes:
53
+ form["scope"] = " ".join(self._scopes)
54
+ body = urlencode(form).encode("utf-8")
55
+ headers = {
56
+ "content-type": "application/x-www-form-urlencoded",
57
+ "accept": "application/json",
58
+ }
59
+ resp: HttpResponse = self._transport("POST", self._token_url, headers, body)
60
+ if resp.status < 200 or resp.status >= 300:
61
+ desc = f"token endpoint respondió {resp.status}"
62
+ try:
63
+ payload = json.loads(resp.text)
64
+ desc = payload.get("error_description") or payload.get("error") or desc
65
+ except (ValueError, AttributeError):
66
+ pass
67
+ raise TildeAuthenticationError(desc, status=resp.status, code="token_request_failed")
68
+
69
+ payload = json.loads(resp.text)
70
+ self._token = payload["access_token"]
71
+ self._expires_at = self._now() + float(payload["expires_in"])
72
+ return self._token
@@ -0,0 +1,120 @@
1
+ """Cliente oficial de Tilde para Python (server-side).
2
+
3
+ **No usar en frontend**: el client_secret nunca debe ejecutarse client-side.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from typing import Any, Optional
9
+ from urllib.parse import urlencode
10
+
11
+ from ._http import Transport, urllib_transport
12
+ from .auth import ClientCredentialsTokenProvider
13
+ from .environment import base_url_for
14
+ from .errors import error_from_response
15
+
16
+ __version__ = "0.1.0a1"
17
+
18
+
19
+ class TildeClient:
20
+ """Cliente de la API de Tilde. Maneja OAuth2 (token cache/refresh), idempotencia,
21
+ correlación y mapeo de errores tipados.
22
+
23
+ Ejemplo::
24
+
25
+ tilde = TildeClient(client_id=..., client_secret=..., environment="sandbox")
26
+ invoice = tilde.invoices.create(request, idempotency_key=key)
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ client_id: str,
33
+ client_secret: str,
34
+ environment: str = "production",
35
+ scopes: Optional[list[str]] = None,
36
+ base_url: Optional[str] = None,
37
+ transport: Optional[Transport] = None,
38
+ ) -> None:
39
+ self._base_url = (base_url or base_url_for(environment)).rstrip("/")
40
+ self._transport: Transport = transport or urllib_transport
41
+ self.tokens = ClientCredentialsTokenProvider(
42
+ client_id=client_id,
43
+ client_secret=client_secret,
44
+ token_url=f"{self._base_url}/oauth2/token",
45
+ scopes=scopes,
46
+ transport=self._transport,
47
+ )
48
+ self.invoices = InvoicesResource(self)
49
+
50
+ def request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ *,
55
+ body: Any = None,
56
+ query: Optional[dict[str, Any]] = None,
57
+ idempotency_key: Optional[str] = None,
58
+ correlation_id: Optional[str] = None,
59
+ ) -> Any:
60
+ """Request genérico autenticado. Devuelve el JSON parseado (o None en 204)."""
61
+ url = self._base_url + path
62
+ if query:
63
+ clean = {k: v for k, v in query.items() if v is not None}
64
+ if clean:
65
+ url = f"{url}?{urlencode(clean)}"
66
+
67
+ headers = {
68
+ "authorization": f"Bearer {self.tokens.get_token()}",
69
+ "accept": "application/json",
70
+ "user-agent": f"tilde-sdk-python/{__version__}",
71
+ }
72
+ if idempotency_key:
73
+ headers["idempotency-key"] = idempotency_key
74
+ if correlation_id:
75
+ headers["x-correlation-id"] = correlation_id
76
+
77
+ payload: Optional[bytes] = None
78
+ if body is not None:
79
+ headers["content-type"] = "application/json"
80
+ payload = json.dumps(body).encode("utf-8")
81
+
82
+ resp = self._transport(method, url, headers, payload)
83
+ corr = resp.headers.get("x-correlation-id") or correlation_id
84
+
85
+ if resp.status < 200 or resp.status >= 300:
86
+ err_body: Optional[dict[str, Any]] = None
87
+ try:
88
+ err_body = json.loads(resp.text)
89
+ except ValueError:
90
+ pass
91
+ raise error_from_response(resp.status, err_body, corr)
92
+
93
+ if resp.status == 204 or not resp.text:
94
+ return None
95
+ return json.loads(resp.text)
96
+
97
+
98
+ class InvoicesResource:
99
+ """Recurso de comprobantes."""
100
+
101
+ def __init__(self, client: TildeClient) -> None:
102
+ self._client = client
103
+
104
+ def create(self, request: dict[str, Any], *, idempotency_key: str, correlation_id: Optional[str] = None) -> dict[str, Any]:
105
+ """Emite un comprobante. Requiere ``idempotency_key``."""
106
+ return self._client.request(
107
+ "POST", "/invoices", body=request, idempotency_key=idempotency_key, correlation_id=correlation_id
108
+ )
109
+
110
+ def get(self, invoice_id: str) -> dict[str, Any]:
111
+ return self._client.request("GET", f"/invoices/{invoice_id}")
112
+
113
+ def list(self, *, skip: int = 0, take: int = 50) -> dict[str, Any]:
114
+ return self._client.request("GET", "/invoices", query={"skip": skip, "take": take})
115
+
116
+ def void(self, invoice_id: str, *, idempotency_key: str, correlation_id: Optional[str] = None) -> dict[str, Any]:
117
+ """Anula un comprobante (emite la nota de crédito). Requiere ``idempotency_key``."""
118
+ return self._client.request(
119
+ "POST", f"/invoices/{invoice_id}/void", idempotency_key=idempotency_key, correlation_id=correlation_id
120
+ )
@@ -0,0 +1,18 @@
1
+ """Entornos de la API de Tilde."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Literal
5
+
6
+ TildeEnvironment = Literal["production", "sandbox"]
7
+
8
+ BASE_URLS: dict[str, str] = {
9
+ "production": "https://api.tildeafip.com/v1",
10
+ "sandbox": "https://sandbox.api.tildeafip.com/v1",
11
+ }
12
+
13
+
14
+ def base_url_for(environment: str) -> str:
15
+ try:
16
+ return BASE_URLS[environment]
17
+ except KeyError as exc:
18
+ raise ValueError(f"Entorno desconocido: {environment!r}") from exc
@@ -0,0 +1,88 @@
1
+ """Errores tipados del SDK, sobre el modelo de error común de Tilde (super-set RFC7807)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ class TildeError(Exception):
8
+ """Error base del SDK. Todas las familias derivan de acá."""
9
+
10
+ def __init__(
11
+ self,
12
+ message: str,
13
+ *,
14
+ status: int,
15
+ code: str,
16
+ retryable: bool = False,
17
+ correlation_id: str | None = None,
18
+ body: dict[str, Any] | None = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.status = status
22
+ self.code = code
23
+ self.retryable = retryable
24
+ self.correlation_id = correlation_id
25
+ self.body = body
26
+
27
+
28
+ class TildeAuthenticationError(TildeError):
29
+ """401 — credenciales/token inválidos."""
30
+
31
+
32
+ class TildeAuthorizationError(TildeError):
33
+ """403 — sin permiso/scope."""
34
+
35
+
36
+ class TildeValidationError(TildeError):
37
+ """400/422 — request inválido."""
38
+
39
+
40
+ class TildeRateLimitError(TildeError):
41
+ """429 — rate limit."""
42
+
43
+
44
+ class TildeConflictError(TildeError):
45
+ """409 — conflicto (p.ej. idempotencia)."""
46
+
47
+
48
+ class TildeNotFoundError(TildeError):
49
+ """404 — recurso inexistente."""
50
+
51
+
52
+ class TildePaymentRequiredError(TildeError):
53
+ """402 — suscripción inactiva."""
54
+
55
+
56
+ class TildeApiError(TildeError):
57
+ """5xx u otros."""
58
+
59
+
60
+ _STATUS_MAP: dict[int, type[TildeError]] = {
61
+ 400: TildeValidationError,
62
+ 401: TildeAuthenticationError,
63
+ 402: TildePaymentRequiredError,
64
+ 403: TildeAuthorizationError,
65
+ 404: TildeNotFoundError,
66
+ 409: TildeConflictError,
67
+ 422: TildeValidationError,
68
+ 429: TildeRateLimitError,
69
+ }
70
+
71
+
72
+ def error_from_response(
73
+ status: int, body: dict[str, Any] | None, correlation_id: str | None = None
74
+ ) -> TildeError:
75
+ """Construye el error tipado a partir del status + body común + header de correlación."""
76
+ body = body or {}
77
+ code = body.get("code") or body.get("error") or f"http_{status}"
78
+ detail = body.get("detail") or body.get("message") or body.get("title") or f"HTTP {status}"
79
+ retryable = body.get("retryable", status == 429 or status >= 500)
80
+ cls = _STATUS_MAP.get(status, TildeApiError)
81
+ return cls(
82
+ detail,
83
+ status=status,
84
+ code=code,
85
+ retryable=bool(retryable),
86
+ correlation_id=correlation_id,
87
+ body={"status": status, "code": code, "retryable": retryable, **body},
88
+ )
File without changes
@@ -0,0 +1,87 @@
1
+ """Verificación de webhooks de Tilde (HMAC-SHA256, estilo Stripe).
2
+
3
+ Se firma ``"{timestamp}.{raw_body}"`` con el secreto de la suscripción. Verificá SIEMPRE con el
4
+ **raw body** ANTES de parsear, en tiempo constante, y rechazá timestamps fuera de tolerancia.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import time
12
+ from typing import Any
13
+
14
+ SIGNATURE_HEADER = "X-Tilde-Signature"
15
+ TIMESTAMP_HEADER = "X-Tilde-Timestamp"
16
+ EVENT_HEADER = "X-Tilde-Event"
17
+ EVENT_ID_HEADER = "X-Tilde-Event-Id"
18
+
19
+
20
+ class WebhookVerificationError(Exception):
21
+ """La firma del webhook es inválida, está fuera de tolerancia o el header es malformado."""
22
+
23
+
24
+ def sign(secret: str, timestamp_seconds: int, body: str) -> str:
25
+ """Firma hex (v1) de ``"{timestamp}.{body}"``."""
26
+ msg = f"{timestamp_seconds}.{body}".encode("utf-8")
27
+ return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
28
+
29
+
30
+ def _parse_signature_header(header: str) -> tuple[int, str]:
31
+ t: int | None = None
32
+ v1: str | None = None
33
+ for part in header.split(","):
34
+ key, _, value = part.partition("=")
35
+ key = key.strip()
36
+ value = value.strip()
37
+ if key == "t":
38
+ try:
39
+ t = int(value)
40
+ except ValueError:
41
+ t = None
42
+ elif key == "v1":
43
+ v1 = value
44
+ if t is None or not v1:
45
+ raise WebhookVerificationError("Header de firma malformado (se esperaba t=...,v1=...).")
46
+ return t, v1
47
+
48
+
49
+ def verify_signature(
50
+ *,
51
+ payload: str | bytes,
52
+ signature_header: str,
53
+ secret: str,
54
+ tolerance_seconds: int = 300,
55
+ now_seconds: int | None = None,
56
+ ) -> None:
57
+ """Verifica la firma. Lanza :class:`WebhookVerificationError` si es inválida. No parsea el body."""
58
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
59
+ now = int(time.time()) if now_seconds is None else now_seconds
60
+ t, v1 = _parse_signature_header(signature_header)
61
+
62
+ if abs(now - t) > tolerance_seconds:
63
+ raise WebhookVerificationError("Timestamp fuera de tolerancia (posible replay).")
64
+
65
+ expected = sign(secret, t, body)
66
+ if not hmac.compare_digest(expected, v1):
67
+ raise WebhookVerificationError("Firma inválida.")
68
+
69
+
70
+ def construct_event(
71
+ *,
72
+ payload: str | bytes,
73
+ signature_header: str,
74
+ secret: str,
75
+ tolerance_seconds: int = 300,
76
+ now_seconds: int | None = None,
77
+ ) -> dict[str, Any]:
78
+ """Verifica la firma y, sólo si es válida, parsea y devuelve el evento."""
79
+ verify_signature(
80
+ payload=payload,
81
+ signature_header=signature_header,
82
+ secret=secret,
83
+ tolerance_seconds=tolerance_seconds,
84
+ now_seconds=now_seconds,
85
+ )
86
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
87
+ return json.loads(body)
@@ -0,0 +1,78 @@
1
+ import pytest
2
+
3
+ from tilde_afip import (
4
+ ClientCredentialsTokenProvider,
5
+ TildeApiError,
6
+ TildeAuthenticationError,
7
+ TildeRateLimitError,
8
+ TildeValidationError,
9
+ error_from_response,
10
+ )
11
+ from tilde_afip._http import HttpResponse
12
+
13
+
14
+ def make_transport(responses):
15
+ """Transport falso: devuelve respuestas de una lista, registrando llamadas."""
16
+ calls = []
17
+
18
+ def transport(method, url, headers, body):
19
+ calls.append({"method": method, "url": url, "headers": dict(headers), "body": body})
20
+ return responses[len(calls) - 1]
21
+
22
+ transport.calls = calls # type: ignore[attr-defined]
23
+ return transport
24
+
25
+
26
+ def token_response(tok="tok-1", expires_in=3600):
27
+ return HttpResponse(200, {}, f'{{"access_token":"{tok}","token_type":"Bearer","expires_in":{expires_in}}}')
28
+
29
+
30
+ BASE = dict(client_id="cid", client_secret="secret", token_url="https://api.tildeafip.com/v1/oauth2/token")
31
+
32
+
33
+ def test_token_cached_until_expiry():
34
+ t = make_transport([token_response("tok-1")])
35
+ clock = {"now": 0.0}
36
+ p = ClientCredentialsTokenProvider(**BASE, transport=t, now=lambda: clock["now"])
37
+ assert p.get_token() == "tok-1"
38
+ assert p.get_token() == "tok-1"
39
+ assert len(t.calls) == 1 # type: ignore[attr-defined]
40
+
41
+
42
+ def test_token_refreshes_after_expiry():
43
+ t = make_transport([token_response("tok-1", 3600), token_response("tok-2", 3600)])
44
+ clock = {"now": 0.0}
45
+ p = ClientCredentialsTokenProvider(**BASE, transport=t, now=lambda: clock["now"])
46
+ assert p.get_token() == "tok-1"
47
+ clock["now"] = 3600.0
48
+ assert p.get_token() == "tok-2"
49
+ assert len(t.calls) == 2 # type: ignore[attr-defined]
50
+
51
+
52
+ def test_token_sends_grant_and_scope():
53
+ t = make_transport([token_response()])
54
+ p = ClientCredentialsTokenProvider(**BASE, scopes=["invoicing:write"], transport=t, now=lambda: 0.0)
55
+ p.get_token()
56
+ body = t.calls[0]["body"].decode() # type: ignore[attr-defined]
57
+ assert "grant_type=client_credentials" in body
58
+ assert "scope=invoicing%3Awrite" in body
59
+
60
+
61
+ def test_token_error_raises_auth():
62
+ t = make_transport([HttpResponse(401, {}, '{"error":"invalid_client"}')])
63
+ p = ClientCredentialsTokenProvider(**BASE, transport=t, now=lambda: 0.0)
64
+ with pytest.raises(TildeAuthenticationError):
65
+ p.get_token()
66
+
67
+
68
+ def test_error_mapping():
69
+ assert isinstance(error_from_response(422, {"code": "x"}), TildeValidationError)
70
+ assert isinstance(error_from_response(429, None), TildeRateLimitError)
71
+ assert error_from_response(429, None).retryable is True
72
+ assert isinstance(error_from_response(503, None), TildeApiError)
73
+
74
+
75
+ def test_error_legacy_shape():
76
+ e = error_from_response(404, {"error": "not_found", "message": "no existe"})
77
+ assert e.code == "not_found"
78
+ assert str(e) == "no existe"
@@ -0,0 +1,38 @@
1
+ """CT-1 — Contract test contra el golden fixture compartido por los 4 SDKs."""
2
+ import json
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from tilde_afip import construct_event, sign, verify_signature
8
+ from tilde_afip.webhooks import WebhookVerificationError
9
+
10
+ FIXTURE = json.load(
11
+ open(os.path.join(os.path.dirname(__file__), "..", "..", "..", "sdk-tests", "fixtures", "webhook-signature.json"))
12
+ )
13
+
14
+
15
+ def test_sign_matches_golden():
16
+ assert sign(FIXTURE["secret"], FIXTURE["timestamp"], FIXTURE["body"]) == FIXTURE["expectedSignature"]
17
+
18
+
19
+ def test_accepts_valid():
20
+ verify_signature(
21
+ payload=FIXTURE["body"],
22
+ signature_header=FIXTURE["signatureHeader"],
23
+ secret=FIXTURE["secret"],
24
+ now_seconds=FIXTURE["timestamp"] + 5,
25
+ )
26
+
27
+
28
+ def test_rejects_tampered_and_old():
29
+ with pytest.raises(WebhookVerificationError):
30
+ verify_signature(payload=FIXTURE["body"] + " ", signature_header=FIXTURE["signatureHeader"], secret=FIXTURE["secret"], now_seconds=FIXTURE["timestamp"])
31
+ with pytest.raises(WebhookVerificationError):
32
+ verify_signature(payload=FIXTURE["body"], signature_header=FIXTURE["signatureHeader"], secret=FIXTURE["secret"], now_seconds=FIXTURE["timestamp"] + 10_000)
33
+
34
+
35
+ def test_construct_event():
36
+ evt = construct_event(payload=FIXTURE["body"], signature_header=FIXTURE["signatureHeader"], secret=FIXTURE["secret"], now_seconds=FIXTURE["timestamp"])
37
+ assert evt["type"] == "invoice.authorized"
38
+ assert evt["data"]["cae"] == "75123456789013"
@@ -0,0 +1,46 @@
1
+ import pytest
2
+
3
+ from tilde_afip import construct_event, sign, verify_signature
4
+ from tilde_afip.webhooks import WebhookVerificationError
5
+
6
+ SECRET = "whsec_test"
7
+ BODY = '{"id":"evt_1","type":"invoice.authorized","createdAt":"2026-06-17T00:00:00Z","data":{"cae":"123"}}'
8
+ T = 1_718_900_000
9
+
10
+
11
+ def header(t: int, body: str) -> str:
12
+ return f"t={t},v1={sign(SECRET, t, body)}"
13
+
14
+
15
+ def test_accepts_valid_signature():
16
+ verify_signature(payload=BODY, signature_header=header(T, BODY), secret=SECRET, now_seconds=T + 5)
17
+
18
+
19
+ def test_rejects_tampered_body():
20
+ with pytest.raises(WebhookVerificationError):
21
+ verify_signature(payload=BODY + " ", signature_header=header(T, BODY), secret=SECRET, now_seconds=T)
22
+
23
+
24
+ def test_rejects_wrong_secret():
25
+ with pytest.raises(WebhookVerificationError, match="inválida"):
26
+ verify_signature(payload=BODY, signature_header=header(T, BODY), secret="otro", now_seconds=T)
27
+
28
+
29
+ def test_rejects_old_timestamp():
30
+ with pytest.raises(WebhookVerificationError, match="replay"):
31
+ verify_signature(payload=BODY, signature_header=header(T, BODY), secret=SECRET, now_seconds=T + 10_000)
32
+
33
+
34
+ def test_rejects_malformed_header():
35
+ with pytest.raises(WebhookVerificationError, match="malformado"):
36
+ verify_signature(payload=BODY, signature_header="garbage", secret=SECRET, now_seconds=T)
37
+
38
+
39
+ def test_accepts_bytes_payload():
40
+ verify_signature(payload=BODY.encode(), signature_header=header(T, BODY), secret=SECRET, now_seconds=T)
41
+
42
+
43
+ def test_construct_event_returns_parsed():
44
+ evt = construct_event(payload=BODY, signature_header=header(T, BODY), secret=SECRET, now_seconds=T)
45
+ assert evt["type"] == "invoice.authorized"
46
+ assert evt["data"]["cae"] == "123"