fiscalapi 4.0.439__tar.gz → 4.0.462__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.
- {fiscalapi-4.0.439/fiscalapi.egg-info → fiscalapi-4.0.462}/PKG-INFO +66 -1
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/README.md +65 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/__init__.py +20 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/__init__.py +12 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/ticket_models.py +2 -2
- fiscalapi-4.0.462/fiscalapi/models/webhook_models.py +101 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/base_service.py +3 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/invoice_service.py +11 -3
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/stamp_service.py +11 -3
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/ticket_service.py +6 -6
- fiscalapi-4.0.462/fiscalapi/webhooks.py +263 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462/fiscalapi.egg-info}/PKG-INFO +66 -1
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/SOURCES.txt +6 -1
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/setup.py +1 -1
- fiscalapi-4.0.462/tests/test_idempotency_key.py +87 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_ticket_service.py +6 -7
- fiscalapi-4.0.462/tests/test_webhook_back_interop.py +159 -0
- fiscalapi-4.0.462/tests/test_webhook_verify.py +432 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/LICENSE.txt +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/MANIFEST.in +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/carta_porte_models.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/comercio_exterior_models.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/common_models.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/fiscalapi_models.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/manifest_models.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/sat_validation_models.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/py.typed +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/__init__.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/api_key_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/catalog_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/download_catalog_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/download_request_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/download_rule_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/employee_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/employer_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/fiscalapi_client.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/manifest_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/people_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/product_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/sat_validation_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/tax_file_service.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/dependency_links.txt +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/requires.txt +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/top_level.txt +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/pyproject.toml +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/setup.cfg +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_cycle_contract.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_error_trace_identifier.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_invoice_item_ticket_id.py +0 -0
- {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_response_tolerance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fiscalapi
|
|
3
|
-
Version: 4.0.
|
|
3
|
+
Version: 4.0.462
|
|
4
4
|
Summary: Genera facturas CFDI válidas ante el SAT consumiendo el API de https://www.fiscalapi.com
|
|
5
5
|
Home-page: https://www.fiscalapi.com
|
|
6
6
|
Author: Fiscalapi
|
|
@@ -284,6 +284,12 @@ invoice = Invoice(
|
|
|
284
284
|
api_response = client.invoices.create(invoice)
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
+
Para reintentar la creación sin duplicar la factura, pasa una `idempotency_key` propia (hasta 64 caracteres). El SDK la envía como header `Idempotency-Key`; repetir la misma llave de idempotencia con la misma factura en 24 h devuelve la respuesta original:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
api_response = client.invoices.create(invoice, idempotency_key="550e8400-e29b-41d4-a716-446655440000")
|
|
291
|
+
```
|
|
292
|
+
|
|
287
293
|
### 6. Crear la Misma Factura de Ingreso (Por Valores)
|
|
288
294
|
|
|
289
295
|
```python
|
|
@@ -588,6 +594,63 @@ if api_response.succeeded:
|
|
|
588
594
|
|
|
589
595
|
Tras una firma exitosa, todas las personas del tenant con el RFC del certificado quedan con `manifest_status_id` en `Signed`; `client.people.get_by_id(...)` lo refleja.
|
|
590
596
|
|
|
597
|
+
### 12. Recibir webhooks
|
|
598
|
+
|
|
599
|
+
FiscalAPI firma cada entrega con [Standard Webhooks](https://www.standardwebhooks.com) y el secreto `whsec_…` de su webhook. `webhooks.verify` comprueba la firma (en tiempo constante) y que `webhook-timestamp` no difiera más de 5 minutos del reloj del receptor, y devuelve el `WebhookEvent`. La firma es la única prueba de que la entrega viene de FiscalAPI.
|
|
600
|
+
|
|
601
|
+
**Verifique el cuerpo crudo**: pase los bytes tal como llegaron (`request.get_data()` en Flask, `request.body` en Django, `await request.body()` en FastAPI), antes de deserializarlos. Un cuerpo deserializado y vuelto a serializar (por ejemplo, `json.dumps(request.json)`) cambia sus bytes y la firma deja de coincidir.
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
import os
|
|
605
|
+
import queue
|
|
606
|
+
import threading
|
|
607
|
+
|
|
608
|
+
from flask import Flask, request
|
|
609
|
+
|
|
610
|
+
from fiscalapi import FiscalApiClient, FiscalApiSettings, WebhookEventTypes, WebhookVerificationException, webhooks
|
|
611
|
+
|
|
612
|
+
app = Flask(__name__)
|
|
613
|
+
client = FiscalApiClient(settings=FiscalApiSettings(api_url="...", api_key="...", tenant="..."))
|
|
614
|
+
webhook_secret = os.environ["FISCALAPI_WEBHOOK_SECRET"] # whsec_… del webhook, desde su configuración segura
|
|
615
|
+
|
|
616
|
+
# La cola en memoria es ilustrativa: en producción use una cola durable (Celery, RQ, SQS…).
|
|
617
|
+
events = queue.Queue()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@app.post("/webhooks/fiscalapi")
|
|
621
|
+
def receive_fiscalapi_webhook():
|
|
622
|
+
try:
|
|
623
|
+
event = webhooks.verify(request.headers, request.get_data(), webhook_secret)
|
|
624
|
+
except WebhookVerificationException:
|
|
625
|
+
return "", 400 # firma inválida, header faltante o timestamp fuera de tolerancia
|
|
626
|
+
|
|
627
|
+
# Responda 2xx rápido y procese después: FiscalAPI corta cada intento a los 15 s y lo reintenta.
|
|
628
|
+
events.put(event)
|
|
629
|
+
return "", 200
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def process_events():
|
|
633
|
+
while True:
|
|
634
|
+
event = events.get()
|
|
635
|
+
# La entrega es al menos una vez: event.id (el header webhook-id) se repite en cada reintento; descarte los ya
|
|
636
|
+
# procesados. El orden de llegada no está garantizado: el estado autoritativo se lee del recurso.
|
|
637
|
+
if event.type == WebhookEventTypes.INVOICE_CANCELED:
|
|
638
|
+
invoice = client.invoices.get_by_id(event.data.related_object.id)
|
|
639
|
+
elif event.type == WebhookEventTypes.DOWNLOAD_REQUEST_COMPLETED:
|
|
640
|
+
download_request = client.download_requests.get_by_id(event.data.related_object.id)
|
|
641
|
+
# Un tipo que no conoce se ignora: el catálogo puede crecer.
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
threading.Thread(target=process_events, daemon=True).start()
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
- `WebhookEvent` es el mismo sobre para todo evento: `id`, `type` (texto; compárelo con las constantes de `WebhookEventTypes`), `created_at` (`datetime` en UTC), `person_id` (`None` si el recurso no tiene persona) y `data` con `related_object` (`id`, `type`, `url`), `status` y `previous_status`.
|
|
648
|
+
- `related_object.url` es la ruta relativa del GET v4 del recurso (`/api/v4/invoices/{id}`); para leerlo, use el servicio de su tipo con `related_object.id`.
|
|
649
|
+
- El botón "Enviar prueba" del webhook (`POST /api/v4/webhooks/{id}/test`) envía `WebhookEventTypes.WEBHOOK_TEST` (`webhook.test`), cuyo `related_object` es el webhook mismo (`type` `webhook`, `url` `/api/v4/webhooks/{id}`), sin persona ni estados; no se suscribe.
|
|
650
|
+
- Durante una rotación del secreto, FiscalAPI firma con el nuevo y con el anterior por 24 h: pase ambos, `webhooks.verify(request.headers, body, [secreto_nuevo, secreto_anterior])`.
|
|
651
|
+
- La tolerancia es configurable (`webhooks.verify(request.headers, body, secreto, timedelta(minutes=2))`); el valor por omisión es `webhooks.DEFAULT_TOLERANCE`.
|
|
652
|
+
- Un secreto mal copiado (no es base64), una lista de secretos vacía o una tolerancia negativa lanzan `ValueError`, y un argumento `None` lanza `TypeError`, con la posición del secreto (nunca su valor): es un error de configuración, no de la entrega.
|
|
653
|
+
|
|
591
654
|
## 📋 Operaciones Principales
|
|
592
655
|
|
|
593
656
|
- **Facturas (CFDI)**
|
|
@@ -604,6 +667,8 @@ Tras una firma exitosa, todas las personas del tenant con el RFC del certificado
|
|
|
604
667
|
Firmar la carta manifiesto de una persona con su e.firma (FIEL) y obtener el PDF resultante.
|
|
605
668
|
- **Tickets (Auto CFDI)**
|
|
606
669
|
Ingerir ventas pagadas del punto de venta con Idempotency-Key opcional, consultarlas con filtros, reimprimir su código y cancelarlas.
|
|
670
|
+
- **Webhooks**
|
|
671
|
+
Verificación de la firma Standard Webhooks de las entregas y lectura del evento (`webhooks.verify`, `WebhookEvent`).
|
|
607
672
|
|
|
608
673
|
## 📂 Más Ejemplos
|
|
609
674
|
|
|
@@ -247,6 +247,12 @@ invoice = Invoice(
|
|
|
247
247
|
api_response = client.invoices.create(invoice)
|
|
248
248
|
```
|
|
249
249
|
|
|
250
|
+
Para reintentar la creación sin duplicar la factura, pasa una `idempotency_key` propia (hasta 64 caracteres). El SDK la envía como header `Idempotency-Key`; repetir la misma llave de idempotencia con la misma factura en 24 h devuelve la respuesta original:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
api_response = client.invoices.create(invoice, idempotency_key="550e8400-e29b-41d4-a716-446655440000")
|
|
254
|
+
```
|
|
255
|
+
|
|
250
256
|
### 6. Crear la Misma Factura de Ingreso (Por Valores)
|
|
251
257
|
|
|
252
258
|
```python
|
|
@@ -551,6 +557,63 @@ if api_response.succeeded:
|
|
|
551
557
|
|
|
552
558
|
Tras una firma exitosa, todas las personas del tenant con el RFC del certificado quedan con `manifest_status_id` en `Signed`; `client.people.get_by_id(...)` lo refleja.
|
|
553
559
|
|
|
560
|
+
### 12. Recibir webhooks
|
|
561
|
+
|
|
562
|
+
FiscalAPI firma cada entrega con [Standard Webhooks](https://www.standardwebhooks.com) y el secreto `whsec_…` de su webhook. `webhooks.verify` comprueba la firma (en tiempo constante) y que `webhook-timestamp` no difiera más de 5 minutos del reloj del receptor, y devuelve el `WebhookEvent`. La firma es la única prueba de que la entrega viene de FiscalAPI.
|
|
563
|
+
|
|
564
|
+
**Verifique el cuerpo crudo**: pase los bytes tal como llegaron (`request.get_data()` en Flask, `request.body` en Django, `await request.body()` en FastAPI), antes de deserializarlos. Un cuerpo deserializado y vuelto a serializar (por ejemplo, `json.dumps(request.json)`) cambia sus bytes y la firma deja de coincidir.
|
|
565
|
+
|
|
566
|
+
```python
|
|
567
|
+
import os
|
|
568
|
+
import queue
|
|
569
|
+
import threading
|
|
570
|
+
|
|
571
|
+
from flask import Flask, request
|
|
572
|
+
|
|
573
|
+
from fiscalapi import FiscalApiClient, FiscalApiSettings, WebhookEventTypes, WebhookVerificationException, webhooks
|
|
574
|
+
|
|
575
|
+
app = Flask(__name__)
|
|
576
|
+
client = FiscalApiClient(settings=FiscalApiSettings(api_url="...", api_key="...", tenant="..."))
|
|
577
|
+
webhook_secret = os.environ["FISCALAPI_WEBHOOK_SECRET"] # whsec_… del webhook, desde su configuración segura
|
|
578
|
+
|
|
579
|
+
# La cola en memoria es ilustrativa: en producción use una cola durable (Celery, RQ, SQS…).
|
|
580
|
+
events = queue.Queue()
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@app.post("/webhooks/fiscalapi")
|
|
584
|
+
def receive_fiscalapi_webhook():
|
|
585
|
+
try:
|
|
586
|
+
event = webhooks.verify(request.headers, request.get_data(), webhook_secret)
|
|
587
|
+
except WebhookVerificationException:
|
|
588
|
+
return "", 400 # firma inválida, header faltante o timestamp fuera de tolerancia
|
|
589
|
+
|
|
590
|
+
# Responda 2xx rápido y procese después: FiscalAPI corta cada intento a los 15 s y lo reintenta.
|
|
591
|
+
events.put(event)
|
|
592
|
+
return "", 200
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def process_events():
|
|
596
|
+
while True:
|
|
597
|
+
event = events.get()
|
|
598
|
+
# La entrega es al menos una vez: event.id (el header webhook-id) se repite en cada reintento; descarte los ya
|
|
599
|
+
# procesados. El orden de llegada no está garantizado: el estado autoritativo se lee del recurso.
|
|
600
|
+
if event.type == WebhookEventTypes.INVOICE_CANCELED:
|
|
601
|
+
invoice = client.invoices.get_by_id(event.data.related_object.id)
|
|
602
|
+
elif event.type == WebhookEventTypes.DOWNLOAD_REQUEST_COMPLETED:
|
|
603
|
+
download_request = client.download_requests.get_by_id(event.data.related_object.id)
|
|
604
|
+
# Un tipo que no conoce se ignora: el catálogo puede crecer.
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
threading.Thread(target=process_events, daemon=True).start()
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
- `WebhookEvent` es el mismo sobre para todo evento: `id`, `type` (texto; compárelo con las constantes de `WebhookEventTypes`), `created_at` (`datetime` en UTC), `person_id` (`None` si el recurso no tiene persona) y `data` con `related_object` (`id`, `type`, `url`), `status` y `previous_status`.
|
|
611
|
+
- `related_object.url` es la ruta relativa del GET v4 del recurso (`/api/v4/invoices/{id}`); para leerlo, use el servicio de su tipo con `related_object.id`.
|
|
612
|
+
- El botón "Enviar prueba" del webhook (`POST /api/v4/webhooks/{id}/test`) envía `WebhookEventTypes.WEBHOOK_TEST` (`webhook.test`), cuyo `related_object` es el webhook mismo (`type` `webhook`, `url` `/api/v4/webhooks/{id}`), sin persona ni estados; no se suscribe.
|
|
613
|
+
- Durante una rotación del secreto, FiscalAPI firma con el nuevo y con el anterior por 24 h: pase ambos, `webhooks.verify(request.headers, body, [secreto_nuevo, secreto_anterior])`.
|
|
614
|
+
- La tolerancia es configurable (`webhooks.verify(request.headers, body, secreto, timedelta(minutes=2))`); el valor por omisión es `webhooks.DEFAULT_TOLERANCE`.
|
|
615
|
+
- Un secreto mal copiado (no es base64), una lista de secretos vacía o una tolerancia negativa lanzan `ValueError`, y un argumento `None` lanza `TypeError`, con la posición del secreto (nunca su valor): es un error de configuración, no de la entrega.
|
|
616
|
+
|
|
554
617
|
## 📋 Operaciones Principales
|
|
555
618
|
|
|
556
619
|
- **Facturas (CFDI)**
|
|
@@ -567,6 +630,8 @@ Tras una firma exitosa, todas las personas del tenant con el RFC del certificado
|
|
|
567
630
|
Firmar la carta manifiesto de una persona con su e.firma (FIEL) y obtener el PDF resultante.
|
|
568
631
|
- **Tickets (Auto CFDI)**
|
|
569
632
|
Ingerir ventas pagadas del punto de venta con Idempotency-Key opcional, consultarlas con filtros, reimprimir su código y cancelarlas.
|
|
633
|
+
- **Webhooks**
|
|
634
|
+
Verificación de la firma Standard Webhooks de las entregas y lectura del evento (`webhooks.verify`, `WebhookEvent`).
|
|
570
635
|
|
|
571
636
|
## 📂 Más Ejemplos
|
|
572
637
|
|
|
@@ -90,6 +90,14 @@ from .models.ticket_models import (
|
|
|
90
90
|
TicketListFilters,
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
+
# Modelos de webhooks
|
|
94
|
+
from .models.webhook_models import (
|
|
95
|
+
WebhookEventTypes,
|
|
96
|
+
WebhookRelatedObject,
|
|
97
|
+
WebhookEventData,
|
|
98
|
+
WebhookEvent,
|
|
99
|
+
)
|
|
100
|
+
|
|
93
101
|
# Modelos de dominio
|
|
94
102
|
from .models.fiscalapi_models import (
|
|
95
103
|
# Product models
|
|
@@ -194,6 +202,10 @@ from .services.ticket_service import TicketService
|
|
|
194
202
|
# Cliente principal
|
|
195
203
|
from .services.fiscalapi_client import FiscalApiClient
|
|
196
204
|
|
|
205
|
+
# Verificación de webhooks (fiscalapi.webhooks.verify)
|
|
206
|
+
from . import webhooks
|
|
207
|
+
from .webhooks import WebhookVerificationException
|
|
208
|
+
|
|
197
209
|
__all__ = [
|
|
198
210
|
# Modelos base
|
|
199
211
|
"ApiResponse",
|
|
@@ -338,6 +350,11 @@ __all__ = [
|
|
|
338
350
|
"TicketIngestionResult",
|
|
339
351
|
"TicketCodeResult",
|
|
340
352
|
"TicketListFilters",
|
|
353
|
+
# Webhook models
|
|
354
|
+
"WebhookEventTypes",
|
|
355
|
+
"WebhookRelatedObject",
|
|
356
|
+
"WebhookEventData",
|
|
357
|
+
"WebhookEvent",
|
|
341
358
|
# Servicios
|
|
342
359
|
"BaseService",
|
|
343
360
|
"ApiKeyService",
|
|
@@ -357,4 +374,7 @@ __all__ = [
|
|
|
357
374
|
"TicketService",
|
|
358
375
|
# Cliente principal
|
|
359
376
|
"FiscalApiClient",
|
|
377
|
+
# Webhooks
|
|
378
|
+
"webhooks",
|
|
379
|
+
"WebhookVerificationException",
|
|
360
380
|
]
|
|
@@ -75,6 +75,13 @@ from .ticket_models import (
|
|
|
75
75
|
TicketCodeResult,
|
|
76
76
|
TicketListFilters,
|
|
77
77
|
)
|
|
78
|
+
from .webhook_models import (
|
|
79
|
+
# Webhook models
|
|
80
|
+
WebhookEventTypes,
|
|
81
|
+
WebhookRelatedObject,
|
|
82
|
+
WebhookEventData,
|
|
83
|
+
WebhookEvent,
|
|
84
|
+
)
|
|
78
85
|
from .fiscalapi_models import (
|
|
79
86
|
# Product models
|
|
80
87
|
ProductTax,
|
|
@@ -301,4 +308,9 @@ __all__ = [
|
|
|
301
308
|
"TicketIngestionResult",
|
|
302
309
|
"TicketCodeResult",
|
|
303
310
|
"TicketListFilters",
|
|
311
|
+
# Webhook models
|
|
312
|
+
"WebhookEventTypes",
|
|
313
|
+
"WebhookRelatedObject",
|
|
314
|
+
"WebhookEventData",
|
|
315
|
+
"WebhookEvent",
|
|
304
316
|
]
|
|
@@ -132,8 +132,8 @@ class Ticket(BaseModel):
|
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
class TicketIngestionResult(BaseModel):
|
|
135
|
-
"""Respuesta de ``tickets.create``:
|
|
136
|
-
|
|
135
|
+
"""Respuesta de ``tickets.create``: un ticket nuevo (201) y la repetición con la misma llave de idempotencia y el mismo
|
|
136
|
+
cuerpo devuelven la misma respuesta original."""
|
|
137
137
|
|
|
138
138
|
id: Optional[str] = Field(default=None, alias="id", description="Id del ticket.")
|
|
139
139
|
ticket_code: Optional[str] = Field(default=None, alias="ticketCode", description="Código sin guiones, como lo lleva el QR; se imprime en grupos (XXXX-XXXX-XXXX).")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Modelos de los eventos de webhook de FiscalAPI: el mismo sobre para todo tipo de evento.
|
|
2
|
+
|
|
3
|
+
Un ``WebhookEvent`` lo devuelve ``fiscalapi.webhooks.verify`` después de validar la firma de la entrega. Es una
|
|
4
|
+
notificación ligera: para el estado autoritativo del recurso (el orden de llegada no está garantizado), consulte
|
|
5
|
+
``data.related_object`` con el servicio de su tipo (por ejemplo ``client.invoices.get_by_id``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Final, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
13
|
+
|
|
14
|
+
# createdAt como lo envía el back: fecha, hora con segundos (fracción opcional) y zona (Z o desfase), en dígitos ASCII.
|
|
15
|
+
_ISO_8601_WITH_TIME_AND_ZONE = re.compile(
|
|
16
|
+
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebhookEventTypes:
|
|
21
|
+
"""Tipos de evento de webhook, como llegan en ``WebhookEvent.type``.
|
|
22
|
+
|
|
23
|
+
Los de un cambio de estado nombran el estado al que llegó el recurso. Son una ayuda para comparar
|
|
24
|
+
(``event.type == WebhookEventTypes.INVOICE_CANCELED``): el catálogo puede crecer y un tipo desconocido se ignora.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
INVOICE_CANCELLATION_REQUESTED: Final = "invoice.cancellation_requested"
|
|
28
|
+
"""La factura pasó de vigente a cancelación en proceso (``V`` → ``C``)."""
|
|
29
|
+
|
|
30
|
+
INVOICE_CANCELED: Final = "invoice.canceled"
|
|
31
|
+
"""La cancelación en proceso terminó y la factura quedó cancelada (``C`` → ``X``)."""
|
|
32
|
+
|
|
33
|
+
INVOICE_CANCELLATION_REJECTED: Final = "invoice.cancellation_rejected"
|
|
34
|
+
"""La cancelación en proceso se rechazó y la factura volvió a vigente (``C`` → ``V``)."""
|
|
35
|
+
|
|
36
|
+
DOWNLOAD_REQUEST_COMPLETED: Final = "download_request.completed"
|
|
37
|
+
"""La solicitud de descarga masiva terminó con éxito (→ ``3``)."""
|
|
38
|
+
|
|
39
|
+
DOWNLOAD_REQUEST_ABANDONED: Final = "download_request.abandoned"
|
|
40
|
+
"""La solicitud de descarga masiva se abandonó (→ ``-1``). No implica un error: el motivo está en la solicitud
|
|
41
|
+
(``client.download_requests.get_by_id``)."""
|
|
42
|
+
|
|
43
|
+
DOWNLOAD_RULE_SUSPENDED: Final = "download_rule.suspended"
|
|
44
|
+
"""La regla de descarga masiva quedó suspendida (``2`` → ``4``)."""
|
|
45
|
+
|
|
46
|
+
WEBHOOK_TEST: Final = "webhook.test"
|
|
47
|
+
"""Prueba de un webhook: no se suscribe; llega solo al pulsar "Enviar prueba"
|
|
48
|
+
(``POST /api/v4/webhooks/{id}/test``), y solo a ese webhook. Su ``related_object`` es el webhook mismo
|
|
49
|
+
(``type`` ``webhook``, ``url`` ``/api/v4/webhooks/{id}``), sin persona ni estados."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WebhookRelatedObject(BaseModel):
|
|
53
|
+
"""El recurso al que se refiere un ``WebhookEvent``."""
|
|
54
|
+
|
|
55
|
+
id: Optional[str] = Field(default=None, alias="id", description="Id del recurso: el que reciben los get_by_id de los servicios del SDK.")
|
|
56
|
+
type: Optional[str] = Field(default=None, alias="type", description="Tipo de recurso: invoice, download_request, download_rule o webhook (el de WebhookEventTypes.WEBHOOK_TEST); el conjunto puede crecer.")
|
|
57
|
+
url: Optional[str] = Field(default=None, alias="url", description="Ruta relativa del GET v4 del recurso (por ejemplo /api/v4/invoices/{id}), sin host: el mismo evento es idéntico en pruebas y en producción. Para leer el recurso use su servicio con id.")
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WebhookEventData(BaseModel):
|
|
63
|
+
"""Datos de un ``WebhookEvent``."""
|
|
64
|
+
|
|
65
|
+
related_object: Optional[WebhookRelatedObject] = Field(default=None, alias="relatedObject", description="El recurso que cambió de estado.")
|
|
66
|
+
status: Optional[str] = Field(default=None, alias="status", description="Estado al que llegó el recurso, con los códigos públicos de la API v4 (factura V, C, X; solicitud de descarga 1, 2, 3, -1; regla de descarga 1..4, -1); None si el tipo de evento no lo lleva.")
|
|
67
|
+
previous_status: Optional[str] = Field(default=None, alias="previousStatus", description="Estado del que salió el recurso, con los mismos códigos que status; None si no aplica.")
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class WebhookEvent(BaseModel):
|
|
73
|
+
"""Un evento de webhook de FiscalAPI: el mismo sobre para todo tipo de evento, sin clases por tipo.
|
|
74
|
+
|
|
75
|
+
Lo devuelve ``fiscalapi.webhooks.verify``. Los campos que esta versión del SDK no conoce se ignoran sin error.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
id: Optional[str] = Field(default=None, alias="id", description="Id del evento; es también el header webhook-id y no cambia entre reintentos: úselo para descartar entregas repetidas.")
|
|
79
|
+
type: Optional[str] = Field(default=None, alias="type", description="Tipo de evento (ver WebhookEventTypes). Es texto y no un enum: un tipo que esta versión del SDK no conoce llega tal cual, sin error, y se ignora.")
|
|
80
|
+
created_at: Optional[datetime] = Field(default=None, alias="createdAt", description="Momento del cambio de estado, en UTC (tzinfo timezone.utc), al segundo. Llega como ISO 8601 con fecha, hora y zona; otro formato rechaza la entrega.")
|
|
81
|
+
person_id: Optional[str] = Field(default=None, alias="personId", description="Persona del tenant dueña del recurso; None si el recurso no tiene una (factura emitida por valores).")
|
|
82
|
+
data: Optional[WebhookEventData] = Field(default=None, alias="data", description="El recurso que cambió y sus estados.")
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
85
|
+
|
|
86
|
+
@field_validator("created_at", mode="before")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _iso_8601_with_time_and_zone(cls, value: object) -> object:
|
|
89
|
+
# Estricto, como el back lo envía: un epoch, una fecha sin hora o una hora sin zona no son un createdAt. En
|
|
90
|
+
# código, un datetime con zona también se acepta.
|
|
91
|
+
if value is None or (isinstance(value, datetime) and value.tzinfo is not None):
|
|
92
|
+
return value
|
|
93
|
+
if isinstance(value, str) and _ISO_8601_WITH_TIME_AND_ZONE.fullmatch(value):
|
|
94
|
+
return value
|
|
95
|
+
raise ValueError("createdAt debe ser ISO 8601 con fecha, hora y zona, por ejemplo 2026-10-12T18:47:53Z.")
|
|
96
|
+
|
|
97
|
+
@field_validator("created_at")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _in_utc(cls, value: Optional[datetime]) -> Optional[datetime]:
|
|
100
|
+
# El back envía UTC con Z; un valor con otro desfase se convierte.
|
|
101
|
+
return None if value is None else value.astimezone(timezone.utc)
|
|
@@ -8,6 +8,9 @@ from fiscalapi.models.common_models import ApiResponse, FiscalApiSettings, Valid
|
|
|
8
8
|
|
|
9
9
|
T = TypeVar('T')
|
|
10
10
|
|
|
11
|
+
# Header que el integrador usa para reintentar una petición sin duplicarla (crear factura, timbres y tickets).
|
|
12
|
+
IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"
|
|
13
|
+
|
|
11
14
|
|
|
12
15
|
class BaseService:
|
|
13
16
|
def __init__(self, settings: FiscalApiSettings):
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
1
3
|
from fiscalapi.models.common_models import ApiResponse, PagedList
|
|
2
4
|
from fiscalapi.models.fiscalapi_models import (
|
|
3
5
|
CancelInvoiceRequest,
|
|
@@ -9,7 +11,7 @@ from fiscalapi.models.fiscalapi_models import (
|
|
|
9
11
|
InvoiceStatusResponse,
|
|
10
12
|
SendInvoiceRequest,
|
|
11
13
|
)
|
|
12
|
-
from fiscalapi.services.base_service import BaseService
|
|
14
|
+
from fiscalapi.services.base_service import IDEMPOTENCY_KEY_HEADER, BaseService
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
class InvoiceService(BaseService):
|
|
@@ -25,7 +27,7 @@ class InvoiceService(BaseService):
|
|
|
25
27
|
endpoint = f"invoices/{invoice_id}"
|
|
26
28
|
return self.send_request("GET", endpoint, Invoice, details=details)
|
|
27
29
|
|
|
28
|
-
def create(self, invoice: Invoice) -> ApiResponse[Invoice]:
|
|
30
|
+
def create(self, invoice: Invoice, idempotency_key: Optional[str] = None) -> ApiResponse[Invoice]:
|
|
29
31
|
"""
|
|
30
32
|
Crea una nueva factura CFDI.
|
|
31
33
|
|
|
@@ -36,14 +38,20 @@ class InvoiceService(BaseService):
|
|
|
36
38
|
- 'N': Nómina
|
|
37
39
|
- 'T': Traslado
|
|
38
40
|
|
|
41
|
+
Con idempotency_key (hasta 64 caracteres ASCII visibles, sin espacios) se envía el header Idempotency-Key:
|
|
42
|
+
repetir la llave de idempotencia con la misma petición en 24 h devuelve la respuesta original; con otra petición
|
|
43
|
+
responde 422, y mientras la original sigue en proceso, 409. Sin llave de idempotencia el header no se envía.
|
|
44
|
+
|
|
39
45
|
Args:
|
|
40
46
|
invoice: Modelo de factura con los datos requeridos.
|
|
47
|
+
idempotency_key: Valor del header Idempotency-Key, o None para no enviarlo.
|
|
41
48
|
|
|
42
49
|
Returns:
|
|
43
50
|
ApiResponse con la factura creada y timbrada.
|
|
44
51
|
"""
|
|
45
52
|
endpoint = "invoices"
|
|
46
|
-
|
|
53
|
+
headers = {} if idempotency_key is None else {IDEMPOTENCY_KEY_HEADER: idempotency_key}
|
|
54
|
+
return self.send_request("POST", endpoint, Invoice, payload=invoice, headers=headers)
|
|
47
55
|
|
|
48
56
|
def cancel(self, cancel_invoice_request: CancelInvoiceRequest) -> ApiResponse[CancelInvoiceResponse]:
|
|
49
57
|
"""Cancela una factura."""
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Servicio para gestionar transacciones de timbres (stamps)."""
|
|
2
2
|
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
3
5
|
from fiscalapi.models import (
|
|
4
6
|
ApiResponse,
|
|
5
7
|
FiscalApiSettings,
|
|
@@ -7,7 +9,7 @@ from fiscalapi.models import (
|
|
|
7
9
|
StampTransaction,
|
|
8
10
|
StampTransactionParams,
|
|
9
11
|
)
|
|
10
|
-
from fiscalapi.services.base_service import BaseService
|
|
12
|
+
from fiscalapi.services.base_service import IDEMPOTENCY_KEY_HEADER, BaseService
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
class StampService(BaseService):
|
|
@@ -41,20 +43,26 @@ class StampService(BaseService):
|
|
|
41
43
|
endpoint = f"stamps/{transaction_id}"
|
|
42
44
|
return self.send_request("GET", endpoint, StampTransaction)
|
|
43
45
|
|
|
44
|
-
def transfer_stamps(self, request: StampTransactionParams) -> ApiResponse[bool]:
|
|
46
|
+
def transfer_stamps(self, request: StampTransactionParams, idempotency_key: Optional[str] = None) -> ApiResponse[bool]:
|
|
45
47
|
"""Transfer stamps or validation credits from one person to another.
|
|
46
48
|
|
|
47
49
|
Use ``request.credit_type`` to pick which balance moves: ``CreditType.STAMP`` (default) moves
|
|
48
50
|
stamps, ``CreditType.VALIDATION`` moves SAT validation credits. The two balances never mix.
|
|
49
51
|
|
|
52
|
+
With ``idempotency_key`` (up to 64 visible ASCII characters, no spaces) the ``Idempotency-Key`` header is
|
|
53
|
+
sent: repeating the idempotency key with the same transfer within 24 hours answers the original response; with another
|
|
54
|
+
transfer, 422; while the original is still in progress, 409. Without it the header is not sent.
|
|
55
|
+
|
|
50
56
|
Args:
|
|
51
57
|
request: StampTransactionParams containing transfer details.
|
|
58
|
+
idempotency_key: Value of the Idempotency-Key header, or None to not send it.
|
|
52
59
|
|
|
53
60
|
Returns:
|
|
54
61
|
ApiResponse containing a boolean indicating success.
|
|
55
62
|
"""
|
|
56
63
|
endpoint = "stamps"
|
|
57
|
-
|
|
64
|
+
headers = {} if idempotency_key is None else {IDEMPOTENCY_KEY_HEADER: idempotency_key}
|
|
65
|
+
return self.send_request("POST", endpoint, bool, payload=request, headers=headers)
|
|
58
66
|
|
|
59
67
|
def withdraw_stamps(self, request: StampTransactionParams) -> ApiResponse[bool]:
|
|
60
68
|
"""Withdraw stamps or validation credits from a person.
|
|
@@ -5,9 +5,7 @@ from urllib.parse import urlencode
|
|
|
5
5
|
|
|
6
6
|
from fiscalapi.models.common_models import ApiResponse, PagedList
|
|
7
7
|
from fiscalapi.models.ticket_models import Ticket, TicketCodeResult, TicketIngestionResult, TicketListFilters
|
|
8
|
-
from fiscalapi.services.base_service import BaseService
|
|
9
|
-
|
|
10
|
-
IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"
|
|
8
|
+
from fiscalapi.services.base_service import IDEMPOTENCY_KEY_HEADER, BaseService
|
|
11
9
|
|
|
12
10
|
|
|
13
11
|
class TicketService(BaseService):
|
|
@@ -16,9 +14,11 @@ class TicketService(BaseService):
|
|
|
16
14
|
def create(self, ticket: Ticket, idempotency_key: Optional[str] = None) -> ApiResponse[TicketIngestionResult]:
|
|
17
15
|
"""Ingest a ticket and charge 1 ticket credit. POST /api/v4/tickets
|
|
18
16
|
|
|
19
|
-
With ``idempotency_key`` (
|
|
20
|
-
the same
|
|
21
|
-
the
|
|
17
|
+
With ``idempotency_key`` (1 to 64 visible ASCII characters, no spaces) the ``Idempotency-Key`` header is sent:
|
|
18
|
+
repeating the same idempotency key with the same body, within 24 hours, returns the original response (201, with
|
|
19
|
+
the ``Idempotent-Replayed: true`` header) without registering another ticket or charging again; with another
|
|
20
|
+
body, 422; with the original still in progress, 409 with Retry-After. Without a key the header is not sent. An
|
|
21
|
+
active folio and series of the issuer answers 409.
|
|
22
22
|
|
|
23
23
|
Args:
|
|
24
24
|
ticket: Ticket to ingest.
|