fiskit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fiskit/__init__.py ADDED
@@ -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,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
@@ -0,0 +1,5 @@
1
+ fiskit/__init__.py,sha256=MJs2GjnJTse5Vso4k2KWZv3x4GIMJlM9ZGSShmSVmHo,7499
2
+ fiskit-0.1.0.dist-info/METADATA,sha256=VH5_AbMfzH4LOboaDV7vJAyE-YDrQzpeo8NUhMxuQyU,2417
3
+ fiskit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ fiskit-0.1.0.dist-info/licenses/LICENSE,sha256=uAbzTAzk_DVm9eYaHN898U1Y7DUm_urgFVxjLEwGdgc,1063
5
+ fiskit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 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.