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.
Files changed (50) hide show
  1. {fiscalapi-4.0.439/fiscalapi.egg-info → fiscalapi-4.0.462}/PKG-INFO +66 -1
  2. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/README.md +65 -0
  3. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/__init__.py +20 -0
  4. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/__init__.py +12 -0
  5. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/ticket_models.py +2 -2
  6. fiscalapi-4.0.462/fiscalapi/models/webhook_models.py +101 -0
  7. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/base_service.py +3 -0
  8. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/invoice_service.py +11 -3
  9. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/stamp_service.py +11 -3
  10. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/ticket_service.py +6 -6
  11. fiscalapi-4.0.462/fiscalapi/webhooks.py +263 -0
  12. {fiscalapi-4.0.439 → fiscalapi-4.0.462/fiscalapi.egg-info}/PKG-INFO +66 -1
  13. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/SOURCES.txt +6 -1
  14. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/setup.py +1 -1
  15. fiscalapi-4.0.462/tests/test_idempotency_key.py +87 -0
  16. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_ticket_service.py +6 -7
  17. fiscalapi-4.0.462/tests/test_webhook_back_interop.py +159 -0
  18. fiscalapi-4.0.462/tests/test_webhook_verify.py +432 -0
  19. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/LICENSE.txt +0 -0
  20. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/MANIFEST.in +0 -0
  21. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/carta_porte_models.py +0 -0
  22. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/comercio_exterior_models.py +0 -0
  23. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/common_models.py +0 -0
  24. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/fiscalapi_models.py +0 -0
  25. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/manifest_models.py +0 -0
  26. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/models/sat_validation_models.py +0 -0
  27. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/py.typed +0 -0
  28. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/__init__.py +0 -0
  29. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/api_key_service.py +0 -0
  30. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/catalog_service.py +0 -0
  31. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/download_catalog_service.py +0 -0
  32. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/download_request_service.py +0 -0
  33. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/download_rule_service.py +0 -0
  34. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/employee_service.py +0 -0
  35. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/employer_service.py +0 -0
  36. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/fiscalapi_client.py +0 -0
  37. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/manifest_service.py +0 -0
  38. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/people_service.py +0 -0
  39. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/product_service.py +0 -0
  40. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/sat_validation_service.py +0 -0
  41. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi/services/tax_file_service.py +0 -0
  42. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/dependency_links.txt +0 -0
  43. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/requires.txt +0 -0
  44. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/fiscalapi.egg-info/top_level.txt +0 -0
  45. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/pyproject.toml +0 -0
  46. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/setup.cfg +0 -0
  47. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_cycle_contract.py +0 -0
  48. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_error_trace_identifier.py +0 -0
  49. {fiscalapi-4.0.439 → fiscalapi-4.0.462}/tests/test_invoice_item_ticket_id.py +0 -0
  50. {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.439
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``: la misma forma en el 201 de un ticket nuevo y en el 200 de una repetición con la
136
- misma Idempotency-Key y el mismo cuerpo."""
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
- return self.send_request("POST", endpoint, Invoice, payload=invoice)
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
- return self.send_request("POST", endpoint, bool, payload=request)
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`` (up to 64 characters) the ``Idempotency-Key`` header is sent: repeating the key with
20
- the same body answers 200 with the original ticket without charging again; with another body, 422. Without it
21
- the header is not sent. An active folio and series of the issuer answers 409.
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.