fiskit 0.1.0__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.
- fiskit-0.1.0/.gitignore +3 -0
- fiskit-0.1.0/LICENSE +21 -0
- fiskit-0.1.0/PKG-INFO +69 -0
- fiskit-0.1.0/README.md +53 -0
- fiskit-0.1.0/fiskit/__init__.py +179 -0
- fiskit-0.1.0/pyproject.toml +26 -0
- fiskit-0.1.0/tests/__init__.py +0 -0
- fiskit-0.1.0/tests/test_client.py +137 -0
fiskit-0.1.0/.gitignore
ADDED
fiskit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FisKit
|
|
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.
|
fiskit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fiskit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the FisKit API: electronic invoices with ARCA (ex AFIP) in a single request.
|
|
5
|
+
Project-URL: Homepage, https://fiskit.com.ar
|
|
6
|
+
Project-URL: Documentation, https://fiskit.com.ar/developers
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: afip,arca,argentina,cae,factura electronica,fiskit,invoicing
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# FisKit para Python
|
|
18
|
+
|
|
19
|
+
Cliente oficial de la API de [FisKit](https://fiskit.com.ar): facturas electrónicas de ARCA (ex AFIP) con un solo pedido. Sin dependencias, para Python 3.10 o superior.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install fiskit
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Tu primera factura
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from fiskit import Fiskit
|
|
29
|
+
|
|
30
|
+
fiskit = Fiskit() # lee FISKIT_API_KEY
|
|
31
|
+
|
|
32
|
+
invoice = fiskit.invoices.create(
|
|
33
|
+
{"voucher_type": "invoice_c", "amounts": {"total": 1500}},
|
|
34
|
+
idempotency_key="venta-8812", # el id de tu venta: un reintento nunca duplica la factura
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
print(invoice["number"], invoice["cae"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Las claves `sk_test_...` emiten en la homologación de ARCA, sin validez fiscal. Las `sk_live_...` emiten facturas reales. Las dos se crean en el panel.
|
|
41
|
+
|
|
42
|
+
## Qué podés hacer
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
fiskit.invoices.retrieve(invoice_id) # estado actual
|
|
46
|
+
pdf = fiskit.invoices.pdf(invoice_id) # bytes del PDF con el QR de ARCA
|
|
47
|
+
fiskit.invoices.email(invoice_id, "cliente@example.com")
|
|
48
|
+
fiskit.taxpayers.retrieve(30712345671) # nombre, domicilio y condición frente al IVA
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Si ARCA no responde o se corta la conexión, el cliente reintenta solo con la misma `Idempotency-Key`. Si no pasás una, se genera automáticamente.
|
|
52
|
+
|
|
53
|
+
Los errores llegan como `FiskitError`, con `status`, `type`, `code`, `message`, `param` y `request_id`.
|
|
54
|
+
|
|
55
|
+
## Webhooks
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from fiskit import verify_webhook
|
|
59
|
+
|
|
60
|
+
# Flask: usá el cuerpo crudo, antes de parsearlo
|
|
61
|
+
@app.post("/webhooks/fiskit")
|
|
62
|
+
def webhook():
|
|
63
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["FISKIT_WEBHOOK_SECRET"])
|
|
64
|
+
if event["type"] == "invoice.authorized":
|
|
65
|
+
... # event["data"]["object"] es la factura
|
|
66
|
+
return "", 200
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Documentación completa: https://fiskit.com.ar/developers
|
fiskit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# FisKit para Python
|
|
2
|
+
|
|
3
|
+
Cliente oficial de la API de [FisKit](https://fiskit.com.ar): facturas electrónicas de ARCA (ex AFIP) con un solo pedido. Sin dependencias, para Python 3.10 o superior.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install fiskit
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Tu primera factura
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from fiskit import Fiskit
|
|
13
|
+
|
|
14
|
+
fiskit = Fiskit() # lee FISKIT_API_KEY
|
|
15
|
+
|
|
16
|
+
invoice = fiskit.invoices.create(
|
|
17
|
+
{"voucher_type": "invoice_c", "amounts": {"total": 1500}},
|
|
18
|
+
idempotency_key="venta-8812", # el id de tu venta: un reintento nunca duplica la factura
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
print(invoice["number"], invoice["cae"])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Las claves `sk_test_...` emiten en la homologación de ARCA, sin validez fiscal. Las `sk_live_...` emiten facturas reales. Las dos se crean en el panel.
|
|
25
|
+
|
|
26
|
+
## Qué podés hacer
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
fiskit.invoices.retrieve(invoice_id) # estado actual
|
|
30
|
+
pdf = fiskit.invoices.pdf(invoice_id) # bytes del PDF con el QR de ARCA
|
|
31
|
+
fiskit.invoices.email(invoice_id, "cliente@example.com")
|
|
32
|
+
fiskit.taxpayers.retrieve(30712345671) # nombre, domicilio y condición frente al IVA
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Si ARCA no responde o se corta la conexión, el cliente reintenta solo con la misma `Idempotency-Key`. Si no pasás una, se genera automáticamente.
|
|
36
|
+
|
|
37
|
+
Los errores llegan como `FiskitError`, con `status`, `type`, `code`, `message`, `param` y `request_id`.
|
|
38
|
+
|
|
39
|
+
## Webhooks
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from fiskit import verify_webhook
|
|
43
|
+
|
|
44
|
+
# Flask: usá el cuerpo crudo, antes de parsearlo
|
|
45
|
+
@app.post("/webhooks/fiskit")
|
|
46
|
+
def webhook():
|
|
47
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["FISKIT_WEBHOOK_SECRET"])
|
|
48
|
+
if event["type"] == "invoice.authorized":
|
|
49
|
+
... # event["data"]["object"] es la factura
|
|
50
|
+
return "", 200
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Documentación completa: https://fiskit.com.ar/developers
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Official Python client for the FisKit API: electronic invoices with ARCA (ex AFIP).
|
|
2
|
+
|
|
3
|
+
No dependencies: it uses only the standard library.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any, Mapping, Optional
|
|
19
|
+
|
|
20
|
+
__all__ = ["Fiskit", "FiskitError", "verify_webhook"]
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.fiskit.com.ar"
|
|
24
|
+
_RETRYABLE = {408, 409, 429, 500, 502, 503, 504}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FiskitError(Exception):
|
|
28
|
+
"""An error answered by the API, or a network failure (status 0)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, status: int, type: str, code: str, message: str, param: Optional[str] = None,
|
|
31
|
+
request_id: Optional[str] = None, body: Any = None):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.status = status
|
|
34
|
+
self.type = type
|
|
35
|
+
self.code = code
|
|
36
|
+
self.message = message
|
|
37
|
+
self.param = param
|
|
38
|
+
self.request_id = request_id
|
|
39
|
+
self.body = body
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Fiskit:
|
|
43
|
+
"""Client of the FisKit API.
|
|
44
|
+
|
|
45
|
+
>>> fiskit = Fiskit() # reads FISKIT_API_KEY
|
|
46
|
+
>>> invoice = fiskit.invoices.create({"voucher_type": "invoice_c", "amounts": {"total": 1500}},
|
|
47
|
+
... idempotency_key="sale-8812")
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: str = DEFAULT_BASE_URL, max_retries: int = 2,
|
|
51
|
+
timeout: float = 60.0, opener: Any = None):
|
|
52
|
+
self.api_key = api_key or os.environ.get("FISKIT_API_KEY")
|
|
53
|
+
if not self.api_key:
|
|
54
|
+
raise ValueError("Missing API key: pass it or set FISKIT_API_KEY")
|
|
55
|
+
self.base_url = base_url.rstrip("/")
|
|
56
|
+
self.max_retries = max_retries
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
self._open = opener or urllib.request.urlopen
|
|
59
|
+
self.invoices = _Invoices(self)
|
|
60
|
+
self.taxpayers = _Taxpayers(self)
|
|
61
|
+
|
|
62
|
+
def request(self, method: str, path: str, body: Any = None, idempotency_key: Optional[str] = None,
|
|
63
|
+
binary: bool = False) -> Any:
|
|
64
|
+
headers = {
|
|
65
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
66
|
+
"Accept": "application/pdf" if binary else "application/json",
|
|
67
|
+
"User-Agent": f"fiskit-python/{__version__}",
|
|
68
|
+
}
|
|
69
|
+
data = None
|
|
70
|
+
if body is not None:
|
|
71
|
+
headers["Content-Type"] = "application/json"
|
|
72
|
+
data = json.dumps(body).encode("utf-8")
|
|
73
|
+
if idempotency_key:
|
|
74
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
75
|
+
retry_allowed = method == "GET" or bool(idempotency_key)
|
|
76
|
+
|
|
77
|
+
attempt = 0
|
|
78
|
+
while True:
|
|
79
|
+
request = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
|
|
80
|
+
try:
|
|
81
|
+
with self._open(request, timeout=self.timeout) as response:
|
|
82
|
+
content = response.read()
|
|
83
|
+
if binary:
|
|
84
|
+
return content
|
|
85
|
+
return json.loads(content) if content else None
|
|
86
|
+
except urllib.error.HTTPError as error:
|
|
87
|
+
if error.code in _RETRYABLE and retry_allowed and attempt < self.max_retries:
|
|
88
|
+
_wait(attempt, error.headers.get("Retry-After") if error.headers else None)
|
|
89
|
+
attempt += 1
|
|
90
|
+
continue
|
|
91
|
+
raise _api_error(error) from None
|
|
92
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
|
|
93
|
+
# Without an idempotency key a POST is not repeated: it might have been processed
|
|
94
|
+
if retry_allowed and attempt < self.max_retries:
|
|
95
|
+
_wait(attempt, None)
|
|
96
|
+
attempt += 1
|
|
97
|
+
continue
|
|
98
|
+
raise FiskitError(0, "connection_error", "network", str(error)) from None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Invoices:
|
|
102
|
+
def __init__(self, client: Fiskit):
|
|
103
|
+
self._client = client
|
|
104
|
+
|
|
105
|
+
def create(self, body: Mapping[str, Any], idempotency_key: Optional[str] = None) -> dict:
|
|
106
|
+
"""Issues an invoice. Without idempotency_key one is generated, so retries never issue twice."""
|
|
107
|
+
return self._client.request("POST", "/v1/invoices", body=dict(body),
|
|
108
|
+
idempotency_key=idempotency_key or str(uuid.uuid4()))
|
|
109
|
+
|
|
110
|
+
def retrieve(self, invoice_id: str) -> dict:
|
|
111
|
+
return self._client.request("GET", f"/v1/invoices/{urllib.parse.quote(invoice_id)}")
|
|
112
|
+
|
|
113
|
+
def pdf(self, invoice_id: str) -> bytes:
|
|
114
|
+
"""The voucher PDF as bytes."""
|
|
115
|
+
return self._client.request("GET", f"/v1/invoices/{urllib.parse.quote(invoice_id)}/pdf", binary=True)
|
|
116
|
+
|
|
117
|
+
def email(self, invoice_id: str, email: Optional[str] = None) -> dict:
|
|
118
|
+
"""Emails the PDF to the customer; without email, to receiver.email of the invoice."""
|
|
119
|
+
return self._client.request("POST", f"/v1/invoices/{urllib.parse.quote(invoice_id)}/email",
|
|
120
|
+
body={"email": email} if email else {})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _Taxpayers:
|
|
124
|
+
def __init__(self, client: Fiskit):
|
|
125
|
+
self._client = client
|
|
126
|
+
|
|
127
|
+
def retrieve(self, cuit: int | str) -> dict:
|
|
128
|
+
"""Name, address and VAT condition of a CUIT, from the ARCA registry."""
|
|
129
|
+
return self._client.request("GET", f"/v1/taxpayers/{urllib.parse.quote(str(cuit))}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify_webhook(raw_body: str | bytes, headers: Mapping[str, str], secret: str, *,
|
|
133
|
+
tolerance_seconds: int = 300, now: Optional[float] = None) -> dict:
|
|
134
|
+
"""Checks a webhook (Standard Webhooks) and returns the parsed event.
|
|
135
|
+
|
|
136
|
+
Raises ValueError if the signature is wrong or too old. Pass the raw body as received.
|
|
137
|
+
"""
|
|
138
|
+
lowered = {key.lower(): value for key, value in headers.items()}
|
|
139
|
+
message_id = lowered.get("webhook-id")
|
|
140
|
+
timestamp = lowered.get("webhook-timestamp")
|
|
141
|
+
signatures = lowered.get("webhook-signature")
|
|
142
|
+
if not message_id or not timestamp or not signatures:
|
|
143
|
+
raise ValueError("Missing webhook headers")
|
|
144
|
+
current = time.time() if now is None else now
|
|
145
|
+
if abs(current - int(timestamp)) > tolerance_seconds:
|
|
146
|
+
raise ValueError("Webhook timestamp out of tolerance")
|
|
147
|
+
body = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
|
|
148
|
+
key = base64.b64decode(secret.removeprefix("whsec_"))
|
|
149
|
+
expected = base64.b64encode(
|
|
150
|
+
hmac.new(key, f"{message_id}.{timestamp}.{body}".encode("utf-8"), hashlib.sha256).digest()).decode()
|
|
151
|
+
for candidate in signatures.split(" "):
|
|
152
|
+
if hmac.compare_digest(candidate.removeprefix("v1,"), expected):
|
|
153
|
+
return json.loads(body)
|
|
154
|
+
raise ValueError("Invalid webhook signature")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _wait(attempt: int, retry_after: Optional[str]) -> None:
|
|
158
|
+
try:
|
|
159
|
+
seconds = float(retry_after) if retry_after else 0
|
|
160
|
+
except ValueError:
|
|
161
|
+
seconds = 0
|
|
162
|
+
time.sleep(min(seconds, 30) if seconds > 0 else 0.5 * 2 ** attempt)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _api_error(error: urllib.error.HTTPError) -> FiskitError:
|
|
166
|
+
try:
|
|
167
|
+
payload = json.loads(error.read() or b"null")
|
|
168
|
+
except ValueError:
|
|
169
|
+
payload = None
|
|
170
|
+
details = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
171
|
+
return FiskitError(
|
|
172
|
+
status=error.code,
|
|
173
|
+
type=details.get("type", "api_error"),
|
|
174
|
+
code=details.get("code", f"http_{error.code}"),
|
|
175
|
+
message=details.get("message", f"HTTP {error.code}"),
|
|
176
|
+
param=details.get("param"),
|
|
177
|
+
request_id=details.get("request_id") or (error.headers.get("Request-Id") if error.headers else None),
|
|
178
|
+
body=payload,
|
|
179
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fiskit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the FisKit API: electronic invoices with ARCA (ex AFIP) in a single request."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["arca", "afip", "factura electronica", "invoicing", "argentina", "cae", "fiskit"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://fiskit.com.ar"
|
|
23
|
+
Documentation = "https://fiskit.com.ar/developers"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["fiskit"]
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import unittest
|
|
7
|
+
import urllib.error
|
|
8
|
+
from email.message import Message
|
|
9
|
+
|
|
10
|
+
from fiskit import Fiskit, FiskitError, verify_webhook
|
|
11
|
+
|
|
12
|
+
INVOICE = {"id": "inv_1", "object": "invoice", "status": "authorized", "number": 42, "cae": "76451234567890"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Response(io.BytesIO):
|
|
16
|
+
def __enter__(self):
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def __exit__(self, *args):
|
|
20
|
+
self.close()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _http_error(status, body, headers=None):
|
|
24
|
+
message = Message()
|
|
25
|
+
for key, value in (headers or {}).items():
|
|
26
|
+
message[key] = value
|
|
27
|
+
return urllib.error.HTTPError("https://api.test", status, "error", message, io.BytesIO(json.dumps(body).encode()))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeOpener:
|
|
31
|
+
"""Answers from a list and records every request."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, answers):
|
|
34
|
+
self.answers = list(answers)
|
|
35
|
+
self.requests = []
|
|
36
|
+
|
|
37
|
+
def __call__(self, request, timeout):
|
|
38
|
+
self.requests.append(request)
|
|
39
|
+
answer = self.answers.pop(0)
|
|
40
|
+
if isinstance(answer, Exception):
|
|
41
|
+
raise answer
|
|
42
|
+
return _Response(answer if isinstance(answer, bytes) else json.dumps(answer).encode())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ClientTest(unittest.TestCase):
|
|
46
|
+
|
|
47
|
+
def test_creates_an_invoice_with_the_key_the_body_and_an_idempotency_key(self):
|
|
48
|
+
opener = FakeOpener([INVOICE])
|
|
49
|
+
client = Fiskit("sk_test_x", base_url="https://api.test", opener=opener)
|
|
50
|
+
|
|
51
|
+
invoice = client.invoices.create({"voucher_type": "invoice_c", "amounts": {"total": 1500}},
|
|
52
|
+
idempotency_key="sale-1")
|
|
53
|
+
|
|
54
|
+
self.assertEqual(invoice["cae"], "76451234567890")
|
|
55
|
+
request = opener.requests[0]
|
|
56
|
+
self.assertEqual(request.full_url, "https://api.test/v1/invoices")
|
|
57
|
+
self.assertEqual(request.get_method(), "POST")
|
|
58
|
+
self.assertEqual(request.get_header("Authorization"), "Bearer sk_test_x")
|
|
59
|
+
self.assertEqual(request.get_header("Idempotency-key"), "sale-1")
|
|
60
|
+
self.assertEqual(json.loads(request.data), {"voucher_type": "invoice_c", "amounts": {"total": 1500}})
|
|
61
|
+
|
|
62
|
+
def test_retries_an_outage_with_the_same_generated_idempotency_key(self):
|
|
63
|
+
opener = FakeOpener([
|
|
64
|
+
_http_error(503, {"error": {"code": "authority_unavailable"}}, {"Retry-After": "0"}),
|
|
65
|
+
urllib.error.URLError("reset"),
|
|
66
|
+
INVOICE,
|
|
67
|
+
])
|
|
68
|
+
client = Fiskit("sk_test_x", opener=opener)
|
|
69
|
+
|
|
70
|
+
client.invoices.create({"voucher_type": "invoice_c", "amounts": {"total": 1}})
|
|
71
|
+
|
|
72
|
+
keys = {request.get_header("Idempotency-key") for request in opener.requests}
|
|
73
|
+
self.assertEqual(len(opener.requests), 3)
|
|
74
|
+
self.assertEqual(len(keys), 1)
|
|
75
|
+
self.assertTrue(next(iter(keys)))
|
|
76
|
+
|
|
77
|
+
def test_api_errors_become_a_fiskit_error(self):
|
|
78
|
+
opener = FakeOpener([_http_error(400, {"error": {
|
|
79
|
+
"type": "invalid_request_error", "code": "parameter_invalid", "message": "Bad total",
|
|
80
|
+
"param": "amounts.total", "request_id": "req_1"}})])
|
|
81
|
+
client = Fiskit("sk_test_x", opener=opener)
|
|
82
|
+
|
|
83
|
+
with self.assertRaises(FiskitError) as raised:
|
|
84
|
+
client.invoices.retrieve("inv_1")
|
|
85
|
+
|
|
86
|
+
error = raised.exception
|
|
87
|
+
self.assertEqual((error.status, error.code, error.param, error.request_id),
|
|
88
|
+
(400, "parameter_invalid", "amounts.total", "req_1"))
|
|
89
|
+
|
|
90
|
+
def test_the_pdf_comes_back_as_bytes_and_the_taxpayer_by_cuit(self):
|
|
91
|
+
opener = FakeOpener([b"%PDF-1.7", {"object": "taxpayer", "vat_condition": "registered"}])
|
|
92
|
+
client = Fiskit("sk_test_x", opener=opener)
|
|
93
|
+
|
|
94
|
+
self.assertEqual(client.invoices.pdf("inv_1"), b"%PDF-1.7")
|
|
95
|
+
self.assertEqual(client.taxpayers.retrieve(30712345671)["vat_condition"], "registered")
|
|
96
|
+
self.assertEqual(opener.requests[0].get_header("Accept"), "application/pdf")
|
|
97
|
+
self.assertTrue(opener.requests[1].full_url.endswith("/v1/taxpayers/30712345671"))
|
|
98
|
+
|
|
99
|
+
def test_a_post_without_idempotency_key_is_never_retried(self):
|
|
100
|
+
opener = FakeOpener([urllib.error.URLError("reset")])
|
|
101
|
+
client = Fiskit("sk_test_x", opener=opener)
|
|
102
|
+
|
|
103
|
+
with self.assertRaises(FiskitError) as raised:
|
|
104
|
+
client.invoices.email("inv_1", "a@b.com")
|
|
105
|
+
|
|
106
|
+
self.assertEqual(raised.exception.code, "network")
|
|
107
|
+
self.assertEqual(len(opener.requests), 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WebhookTest(unittest.TestCase):
|
|
111
|
+
# Example from the Standard Webhooks specification
|
|
112
|
+
SECRET = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
|
|
113
|
+
HEADERS = {
|
|
114
|
+
"webhook-id": "msg_p5jXN8AQM9LWM0D4loKWxJek",
|
|
115
|
+
"webhook-timestamp": "1614265330",
|
|
116
|
+
"webhook-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def test_verifies_the_specification_example(self):
|
|
120
|
+
event = verify_webhook('{"test": 2432232314}', self.HEADERS, self.SECRET, now=1614265330)
|
|
121
|
+
self.assertEqual(event, {"test": 2432232314})
|
|
122
|
+
|
|
123
|
+
def test_rejects_tampering_and_old_messages(self):
|
|
124
|
+
with self.assertRaisesRegex(ValueError, "Invalid"):
|
|
125
|
+
verify_webhook('{"test": 1}', self.HEADERS, self.SECRET, now=1614265330)
|
|
126
|
+
with self.assertRaisesRegex(ValueError, "tolerance"):
|
|
127
|
+
verify_webhook('{"test": 2432232314}', self.HEADERS, self.SECRET, now=1614265330 + 3600)
|
|
128
|
+
|
|
129
|
+
def test_accepts_its_own_signature(self):
|
|
130
|
+
key = base64.b64decode(self.SECRET.removeprefix("whsec_"))
|
|
131
|
+
signature = base64.b64encode(hmac.new(key, b"evt_1.1614265330.{}", hashlib.sha256).digest()).decode()
|
|
132
|
+
headers = {"Webhook-Id": "evt_1", "Webhook-Timestamp": "1614265330", "Webhook-Signature": f"v1,{signature}"}
|
|
133
|
+
self.assertEqual(verify_webhook(b"{}", headers, self.SECRET, now=1614265330), {})
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
unittest.main()
|