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.
- tilde_afip-0.1.0a1/.gitignore +6 -0
- tilde_afip-0.1.0a1/GENERATION.md +13 -0
- tilde_afip-0.1.0a1/LICENSE +21 -0
- tilde_afip-0.1.0a1/PKG-INFO +113 -0
- tilde_afip-0.1.0a1/README.md +69 -0
- tilde_afip-0.1.0a1/examples/issue_invoice.py +23 -0
- tilde_afip-0.1.0a1/pyproject.toml +38 -0
- tilde_afip-0.1.0a1/src/tilde_afip/__init__.py +48 -0
- tilde_afip-0.1.0a1/src/tilde_afip/_http.py +29 -0
- tilde_afip-0.1.0a1/src/tilde_afip/auth.py +72 -0
- tilde_afip-0.1.0a1/src/tilde_afip/client.py +120 -0
- tilde_afip-0.1.0a1/src/tilde_afip/environment.py +18 -0
- tilde_afip-0.1.0a1/src/tilde_afip/errors.py +88 -0
- tilde_afip-0.1.0a1/src/tilde_afip/py.typed +0 -0
- tilde_afip-0.1.0a1/src/tilde_afip/webhooks.py +87 -0
- tilde_afip-0.1.0a1/tests/test_auth_and_errors.py +78 -0
- tilde_afip-0.1.0a1/tests/test_contract.py +38 -0
- tilde_afip-0.1.0a1/tests/test_webhooks.py +46 -0
|
@@ -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"
|