fiscalapi 4.0.433__tar.gz → 4.0.450__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.433/fiscalapi.egg-info → fiscalapi-4.0.450}/PKG-INFO +86 -4
  2. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/README.md +85 -3
  3. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/__init__.py +48 -0
  4. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/models/__init__.py +38 -0
  5. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/models/common_models.py +1 -0
  6. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/models/fiscalapi_models.py +10 -6
  7. fiscalapi-4.0.450/fiscalapi/models/ticket_models.py +183 -0
  8. fiscalapi-4.0.450/fiscalapi/models/webhook_models.py +101 -0
  9. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/__init__.py +2 -0
  10. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/base_service.py +2 -0
  11. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/fiscalapi_client.py +2 -0
  12. fiscalapi-4.0.450/fiscalapi/services/ticket_service.py +102 -0
  13. fiscalapi-4.0.450/fiscalapi/webhooks.py +263 -0
  14. {fiscalapi-4.0.433 → fiscalapi-4.0.450/fiscalapi.egg-info}/PKG-INFO +86 -4
  15. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi.egg-info/SOURCES.txt +9 -1
  16. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/setup.py +1 -1
  17. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/tests/test_cycle_contract.py +12 -11
  18. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/tests/test_error_trace_identifier.py +1 -1
  19. fiscalapi-4.0.450/tests/test_invoice_item_ticket_id.py +167 -0
  20. fiscalapi-4.0.450/tests/test_response_tolerance.py +193 -0
  21. fiscalapi-4.0.450/tests/test_ticket_service.py +368 -0
  22. fiscalapi-4.0.450/tests/test_webhook_back_interop.py +159 -0
  23. fiscalapi-4.0.450/tests/test_webhook_verify.py +432 -0
  24. fiscalapi-4.0.433/tests/test_response_tolerance.py +0 -143
  25. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/LICENSE.txt +0 -0
  26. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/MANIFEST.in +0 -0
  27. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/models/carta_porte_models.py +0 -0
  28. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/models/comercio_exterior_models.py +0 -0
  29. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/models/manifest_models.py +0 -0
  30. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/models/sat_validation_models.py +0 -0
  31. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/py.typed +0 -0
  32. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/api_key_service.py +0 -0
  33. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/catalog_service.py +0 -0
  34. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/download_catalog_service.py +0 -0
  35. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/download_request_service.py +0 -0
  36. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/download_rule_service.py +0 -0
  37. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/employee_service.py +0 -0
  38. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/employer_service.py +0 -0
  39. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/invoice_service.py +0 -0
  40. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/manifest_service.py +0 -0
  41. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/people_service.py +0 -0
  42. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/product_service.py +0 -0
  43. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/sat_validation_service.py +0 -0
  44. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/stamp_service.py +0 -0
  45. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi/services/tax_file_service.py +0 -0
  46. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi.egg-info/dependency_links.txt +0 -0
  47. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi.egg-info/requires.txt +0 -0
  48. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/fiscalapi.egg-info/top_level.txt +0 -0
  49. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/pyproject.toml +0 -0
  50. {fiscalapi-4.0.433 → fiscalapi-4.0.450}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fiscalapi
3
- Version: 4.0.433
3
+ Version: 4.0.450
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
@@ -87,13 +87,14 @@ Dynamic: summary
87
87
  - **Búsqueda de información** en catálogos del SAT con filtros avanzados
88
88
  - **Acceso y búsqueda** en catálogos completos
89
89
 
90
- ## 🎫 Gestión de Timbres y Créditos de Validación
90
+ ## 🎫 Gestión de Timbres, Créditos de Validación y Créditos de Ticket
91
91
  - **Gestión de folios fiscales**: compra timbres a FiscalAPI y transfiere/retira a las personas de tu organización según tus reglas de negocio
92
92
  - **Listar transacciones** del ledger con paginación
93
93
  - **Consultar transacciones** por ID
94
94
  - **Transferir timbres** entre personas
95
95
  - **Retirar timbres** de una persona
96
96
  - **Transferir y retirar créditos de validación SAT** con `credit_type=CreditType.VALIDATION`
97
+ - **Transferir y retirar créditos de ticket** con `credit_type=CreditType.TICKET`; su saldo se lee en `balances` de la persona
97
98
 
98
99
  ## 🛡️ Validaciones SAT
99
100
  - **Estructura del XML** contra el Anexo 20 y los complementos declarados
@@ -437,6 +438,25 @@ params = StampTransactionParams(
437
438
  api_response = client.stamps.transfer_stamps(params)
438
439
  ```
439
440
 
441
+ Los créditos de ticket se transfieren igual, con `credit_type=CreditType.TICKET`, y su saldo se lee en `balances` de la persona (un tipo ausente tiene saldo 0):
442
+
443
+ ```python
444
+ from fiscalapi import CreditType, StampTransactionParams
445
+
446
+ params = StampTransactionParams(
447
+ from_person_id="3f3478b4-60fd-459e-8bfc-f8239fc96257",
448
+ to_person_id="96b46762-d246-4a67-a562-510a25dbafa9",
449
+ amount=100,
450
+ comments="Asignación de créditos de ticket",
451
+ credit_type=CreditType.TICKET
452
+ )
453
+
454
+ api_response = client.stamps.transfer_stamps(params)
455
+
456
+ person = client.people.get_by_id("96b46762-d246-4a67-a562-510a25dbafa9").data
457
+ ticket_balance = next((b.available for b in person.balances or [] if b.credit_type == CreditType.TICKET), 0)
458
+ ```
459
+
440
460
  ### 10. Factura de Comercio Exterior
441
461
 
442
462
  El complemento se envía en `Invoice.complement.comercio_exterior`. Puede combinarse con carta porte en el mismo comprobante.
@@ -568,6 +588,63 @@ if api_response.succeeded:
568
588
 
569
589
  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.
570
590
 
591
+ ### 12. Recibir webhooks
592
+
593
+ 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.
594
+
595
+ **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.
596
+
597
+ ```python
598
+ import os
599
+ import queue
600
+ import threading
601
+
602
+ from flask import Flask, request
603
+
604
+ from fiscalapi import FiscalApiClient, FiscalApiSettings, WebhookEventTypes, WebhookVerificationException, webhooks
605
+
606
+ app = Flask(__name__)
607
+ client = FiscalApiClient(settings=FiscalApiSettings(api_url="...", api_key="...", tenant="..."))
608
+ webhook_secret = os.environ["FISCALAPI_WEBHOOK_SECRET"] # whsec_… del webhook, desde su configuración segura
609
+
610
+ # La cola en memoria es ilustrativa: en producción use una cola durable (Celery, RQ, SQS…).
611
+ events = queue.Queue()
612
+
613
+
614
+ @app.post("/webhooks/fiscalapi")
615
+ def receive_fiscalapi_webhook():
616
+ try:
617
+ event = webhooks.verify(request.headers, request.get_data(), webhook_secret)
618
+ except WebhookVerificationException:
619
+ return "", 400 # firma inválida, header faltante o timestamp fuera de tolerancia
620
+
621
+ # Responda 2xx rápido y procese después: FiscalAPI corta cada intento a los 15 s y lo reintenta.
622
+ events.put(event)
623
+ return "", 200
624
+
625
+
626
+ def process_events():
627
+ while True:
628
+ event = events.get()
629
+ # La entrega es al menos una vez: event.id (el header webhook-id) se repite en cada reintento; descarte los ya
630
+ # procesados. El orden de llegada no está garantizado: el estado autoritativo se lee del recurso.
631
+ if event.type == WebhookEventTypes.INVOICE_CANCELED:
632
+ invoice = client.invoices.get_by_id(event.data.related_object.id)
633
+ elif event.type == WebhookEventTypes.DOWNLOAD_REQUEST_COMPLETED:
634
+ download_request = client.download_requests.get_by_id(event.data.related_object.id)
635
+ # Un tipo que no conoce se ignora: el catálogo puede crecer.
636
+
637
+
638
+ threading.Thread(target=process_events, daemon=True).start()
639
+ ```
640
+
641
+ - `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`.
642
+ - `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`.
643
+ - 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.
644
+ - 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])`.
645
+ - La tolerancia es configurable (`webhooks.verify(request.headers, body, secreto, timedelta(minutes=2))`); el valor por omisión es `webhooks.DEFAULT_TOLERANCE`.
646
+ - 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.
647
+
571
648
  ## 📋 Operaciones Principales
572
649
 
573
650
  - **Facturas (CFDI)**
@@ -576,17 +653,22 @@ Tras una firma exitosa, todas las personas del tenant con el RFC del certificado
576
653
  Alta y administración de personas, gestión de certificados (CSD).
577
654
  - **Productos y Servicios**
578
655
  Administración de catálogos de productos, búsqueda en catálogos SAT.
579
- - **Timbres y créditos de validación**
580
- Listar transacciones, transferir y retirar timbres o créditos de validación entre personas.
656
+ - **Timbres, créditos de validación y créditos de ticket**
657
+ Listar transacciones, transferir y retirar timbres, créditos de validación o créditos de ticket entre personas.
581
658
  - **Validaciones SAT**
582
659
  Validar estructura, certificado, sellos, estatus en el SAT y listas negras 69-B de un CFDI o un RFC.
583
660
  - **Carta manifiesto**
584
661
  Firmar la carta manifiesto de una persona con su e.firma (FIEL) y obtener el PDF resultante.
662
+ - **Tickets (Auto CFDI)**
663
+ Ingerir ventas pagadas del punto de venta con Idempotency-Key opcional, consultarlas con filtros, reimprimir su código y cancelarlas.
664
+ - **Webhooks**
665
+ Verificación de la firma Standard Webhooks de las entregas y lectura del evento (`webhooks.verify`, `WebhookEvent`).
585
666
 
586
667
  ## 📂 Más Ejemplos
587
668
 
588
669
  - [Gestión de Timbres](examples/ejemplos-timbres.py)
589
670
  - [Validaciones SAT](examples/ejemplos-validaciones-sat.py)
671
+ - [Tickets de Auto CFDI](examples/ejemplos-tickets.py)
590
672
  - [Complementos de Pago](examples/ejemplos-facturas-de-complemento-pago.py)
591
673
  - [Facturas de Nómina](examples/ejemplos-facturas-de-nomina.py)
592
674
  - [Impuestos Locales (Por Valores)](examples/ejemplos-factura-impuestos-locales-valores.py)
@@ -50,13 +50,14 @@
50
50
  - **Búsqueda de información** en catálogos del SAT con filtros avanzados
51
51
  - **Acceso y búsqueda** en catálogos completos
52
52
 
53
- ## 🎫 Gestión de Timbres y Créditos de Validación
53
+ ## 🎫 Gestión de Timbres, Créditos de Validación y Créditos de Ticket
54
54
  - **Gestión de folios fiscales**: compra timbres a FiscalAPI y transfiere/retira a las personas de tu organización según tus reglas de negocio
55
55
  - **Listar transacciones** del ledger con paginación
56
56
  - **Consultar transacciones** por ID
57
57
  - **Transferir timbres** entre personas
58
58
  - **Retirar timbres** de una persona
59
59
  - **Transferir y retirar créditos de validación SAT** con `credit_type=CreditType.VALIDATION`
60
+ - **Transferir y retirar créditos de ticket** con `credit_type=CreditType.TICKET`; su saldo se lee en `balances` de la persona
60
61
 
61
62
  ## 🛡️ Validaciones SAT
62
63
  - **Estructura del XML** contra el Anexo 20 y los complementos declarados
@@ -400,6 +401,25 @@ params = StampTransactionParams(
400
401
  api_response = client.stamps.transfer_stamps(params)
401
402
  ```
402
403
 
404
+ Los créditos de ticket se transfieren igual, con `credit_type=CreditType.TICKET`, y su saldo se lee en `balances` de la persona (un tipo ausente tiene saldo 0):
405
+
406
+ ```python
407
+ from fiscalapi import CreditType, StampTransactionParams
408
+
409
+ params = StampTransactionParams(
410
+ from_person_id="3f3478b4-60fd-459e-8bfc-f8239fc96257",
411
+ to_person_id="96b46762-d246-4a67-a562-510a25dbafa9",
412
+ amount=100,
413
+ comments="Asignación de créditos de ticket",
414
+ credit_type=CreditType.TICKET
415
+ )
416
+
417
+ api_response = client.stamps.transfer_stamps(params)
418
+
419
+ person = client.people.get_by_id("96b46762-d246-4a67-a562-510a25dbafa9").data
420
+ ticket_balance = next((b.available for b in person.balances or [] if b.credit_type == CreditType.TICKET), 0)
421
+ ```
422
+
403
423
  ### 10. Factura de Comercio Exterior
404
424
 
405
425
  El complemento se envía en `Invoice.complement.comercio_exterior`. Puede combinarse con carta porte en el mismo comprobante.
@@ -531,6 +551,63 @@ if api_response.succeeded:
531
551
 
532
552
  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.
533
553
 
554
+ ### 12. Recibir webhooks
555
+
556
+ 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.
557
+
558
+ **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.
559
+
560
+ ```python
561
+ import os
562
+ import queue
563
+ import threading
564
+
565
+ from flask import Flask, request
566
+
567
+ from fiscalapi import FiscalApiClient, FiscalApiSettings, WebhookEventTypes, WebhookVerificationException, webhooks
568
+
569
+ app = Flask(__name__)
570
+ client = FiscalApiClient(settings=FiscalApiSettings(api_url="...", api_key="...", tenant="..."))
571
+ webhook_secret = os.environ["FISCALAPI_WEBHOOK_SECRET"] # whsec_… del webhook, desde su configuración segura
572
+
573
+ # La cola en memoria es ilustrativa: en producción use una cola durable (Celery, RQ, SQS…).
574
+ events = queue.Queue()
575
+
576
+
577
+ @app.post("/webhooks/fiscalapi")
578
+ def receive_fiscalapi_webhook():
579
+ try:
580
+ event = webhooks.verify(request.headers, request.get_data(), webhook_secret)
581
+ except WebhookVerificationException:
582
+ return "", 400 # firma inválida, header faltante o timestamp fuera de tolerancia
583
+
584
+ # Responda 2xx rápido y procese después: FiscalAPI corta cada intento a los 15 s y lo reintenta.
585
+ events.put(event)
586
+ return "", 200
587
+
588
+
589
+ def process_events():
590
+ while True:
591
+ event = events.get()
592
+ # La entrega es al menos una vez: event.id (el header webhook-id) se repite en cada reintento; descarte los ya
593
+ # procesados. El orden de llegada no está garantizado: el estado autoritativo se lee del recurso.
594
+ if event.type == WebhookEventTypes.INVOICE_CANCELED:
595
+ invoice = client.invoices.get_by_id(event.data.related_object.id)
596
+ elif event.type == WebhookEventTypes.DOWNLOAD_REQUEST_COMPLETED:
597
+ download_request = client.download_requests.get_by_id(event.data.related_object.id)
598
+ # Un tipo que no conoce se ignora: el catálogo puede crecer.
599
+
600
+
601
+ threading.Thread(target=process_events, daemon=True).start()
602
+ ```
603
+
604
+ - `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`.
605
+ - `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`.
606
+ - 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.
607
+ - 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])`.
608
+ - La tolerancia es configurable (`webhooks.verify(request.headers, body, secreto, timedelta(minutes=2))`); el valor por omisión es `webhooks.DEFAULT_TOLERANCE`.
609
+ - 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.
610
+
534
611
  ## 📋 Operaciones Principales
535
612
 
536
613
  - **Facturas (CFDI)**
@@ -539,17 +616,22 @@ Tras una firma exitosa, todas las personas del tenant con el RFC del certificado
539
616
  Alta y administración de personas, gestión de certificados (CSD).
540
617
  - **Productos y Servicios**
541
618
  Administración de catálogos de productos, búsqueda en catálogos SAT.
542
- - **Timbres y créditos de validación**
543
- Listar transacciones, transferir y retirar timbres o créditos de validación entre personas.
619
+ - **Timbres, créditos de validación y créditos de ticket**
620
+ Listar transacciones, transferir y retirar timbres, créditos de validación o créditos de ticket entre personas.
544
621
  - **Validaciones SAT**
545
622
  Validar estructura, certificado, sellos, estatus en el SAT y listas negras 69-B de un CFDI o un RFC.
546
623
  - **Carta manifiesto**
547
624
  Firmar la carta manifiesto de una persona con su e.firma (FIEL) y obtener el PDF resultante.
625
+ - **Tickets (Auto CFDI)**
626
+ Ingerir ventas pagadas del punto de venta con Idempotency-Key opcional, consultarlas con filtros, reimprimir su código y cancelarlas.
627
+ - **Webhooks**
628
+ Verificación de la firma Standard Webhooks de las entregas y lectura del evento (`webhooks.verify`, `WebhookEvent`).
548
629
 
549
630
  ## 📂 Más Ejemplos
550
631
 
551
632
  - [Gestión de Timbres](examples/ejemplos-timbres.py)
552
633
  - [Validaciones SAT](examples/ejemplos-validaciones-sat.py)
634
+ - [Tickets de Auto CFDI](examples/ejemplos-tickets.py)
553
635
  - [Complementos de Pago](examples/ejemplos-facturas-de-complemento-pago.py)
554
636
  - [Facturas de Nómina](examples/ejemplos-facturas-de-nomina.py)
555
637
  - [Impuestos Locales (Por Valores)](examples/ejemplos-factura-impuestos-locales-valores.py)
@@ -75,6 +75,28 @@ from .models.sat_validation_models import (
75
75
  SatValidationStatus,
76
76
  SatValidationResult,
77
77
  )
78
+ # Modelos de tickets (Auto CFDI)
79
+ from .models.ticket_models import (
80
+ TicketStatus,
81
+ TicketEventType,
82
+ TicketActorType,
83
+ TicketIssuer,
84
+ TicketItemTax,
85
+ TicketItem,
86
+ TicketEvent,
87
+ Ticket,
88
+ TicketIngestionResult,
89
+ TicketCodeResult,
90
+ TicketListFilters,
91
+ )
92
+
93
+ # Modelos de webhooks
94
+ from .models.webhook_models import (
95
+ WebhookEventTypes,
96
+ WebhookRelatedObject,
97
+ WebhookEventData,
98
+ WebhookEvent,
99
+ )
78
100
 
79
101
  # Modelos de dominio
80
102
  from .models.fiscalapi_models import (
@@ -175,10 +197,15 @@ from .services.tax_file_service import TaxFileService
175
197
  from .services.sat_validation_service import SatValidationService
176
198
  from .services.stamp_service import StampService
177
199
  from .services.manifest_service import ManifestService
200
+ from .services.ticket_service import TicketService
178
201
 
179
202
  # Cliente principal
180
203
  from .services.fiscalapi_client import FiscalApiClient
181
204
 
205
+ # Verificación de webhooks (fiscalapi.webhooks.verify)
206
+ from . import webhooks
207
+ from .webhooks import WebhookVerificationException
208
+
182
209
  __all__ = [
183
210
  # Modelos base
184
211
  "ApiResponse",
@@ -311,6 +338,23 @@ __all__ = [
311
338
  "SatValidationTypeStatus",
312
339
  "SatValidationStatus",
313
340
  "SatValidationResult",
341
+ # Ticket models (Auto CFDI)
342
+ "TicketStatus",
343
+ "TicketEventType",
344
+ "TicketActorType",
345
+ "TicketIssuer",
346
+ "TicketItemTax",
347
+ "TicketItem",
348
+ "TicketEvent",
349
+ "Ticket",
350
+ "TicketIngestionResult",
351
+ "TicketCodeResult",
352
+ "TicketListFilters",
353
+ # Webhook models
354
+ "WebhookEventTypes",
355
+ "WebhookRelatedObject",
356
+ "WebhookEventData",
357
+ "WebhookEvent",
314
358
  # Servicios
315
359
  "BaseService",
316
360
  "ApiKeyService",
@@ -327,6 +371,10 @@ __all__ = [
327
371
  "SatValidationService",
328
372
  "StampService",
329
373
  "ManifestService",
374
+ "TicketService",
330
375
  # Cliente principal
331
376
  "FiscalApiClient",
377
+ # Webhooks
378
+ "webhooks",
379
+ "WebhookVerificationException",
332
380
  ]
@@ -61,6 +61,27 @@ from .sat_validation_models import (
61
61
  SatValidationStatus,
62
62
  SatValidationResult,
63
63
  )
64
+ from .ticket_models import (
65
+ # Ticket models (Auto CFDI)
66
+ TicketStatus,
67
+ TicketEventType,
68
+ TicketActorType,
69
+ TicketIssuer,
70
+ TicketItemTax,
71
+ TicketItem,
72
+ TicketEvent,
73
+ Ticket,
74
+ TicketIngestionResult,
75
+ TicketCodeResult,
76
+ TicketListFilters,
77
+ )
78
+ from .webhook_models import (
79
+ # Webhook models
80
+ WebhookEventTypes,
81
+ WebhookRelatedObject,
82
+ WebhookEventData,
83
+ WebhookEvent,
84
+ )
64
85
  from .fiscalapi_models import (
65
86
  # Product models
66
87
  ProductTax,
@@ -275,4 +296,21 @@ __all__ = [
275
296
  "SatValidationTypeStatus",
276
297
  "SatValidationStatus",
277
298
  "SatValidationResult",
299
+ # Ticket models (Auto CFDI)
300
+ "TicketStatus",
301
+ "TicketEventType",
302
+ "TicketActorType",
303
+ "TicketIssuer",
304
+ "TicketItemTax",
305
+ "TicketItem",
306
+ "TicketEvent",
307
+ "Ticket",
308
+ "TicketIngestionResult",
309
+ "TicketCodeResult",
310
+ "TicketListFilters",
311
+ # Webhook models
312
+ "WebhookEventTypes",
313
+ "WebhookRelatedObject",
314
+ "WebhookEventData",
315
+ "WebhookEvent",
278
316
  ]
@@ -11,6 +11,7 @@ class ApiResponse(BaseModel, Generic[T]):
11
11
  data: Optional[T] = Field(default=None, alias="data")
12
12
  http_status_code: Optional[int] = Field(default=None, alias="httpStatusCode")
13
13
  trace_identifier: Optional[str] = Field(default=None, alias="traceIdentifier", description="Identificador de la petición en los registros de FiscalAPI. Viene en los errores (por ejemplo, un 500 o un 502): inclúyalo al escribir a soporte@fiscalapi.com.")
14
+ error_data: Optional[Any] = Field(default=None, alias="errorData", description="El data crudo de una respuesta de error (dict o lista tal cual), por ejemplo existingTicketId en el 409 de tickets.create o expectedTotal y computedTotal en su 422. None en las respuestas exitosas y en los errores sin data; data no cambia (en un error sigue en None).")
14
15
 
15
16
  model_config = ConfigDict(populate_by_name=True)
16
17
 
@@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field
4
4
  from fiscalapi.models.common_models import BaseDto, CatalogDto
5
5
  from fiscalapi.models.carta_porte_models import CartaPorteComplement
6
6
  from fiscalapi.models.comercio_exterior_models import ComercioExteriorComplement
7
- from typing import Optional
7
+ from typing import Optional, Union
8
8
  from datetime import datetime
9
9
 
10
10
  # products models
@@ -54,7 +54,7 @@ class CreditBalance(BaseModel):
54
54
  Un tipo ausente tiene saldo 0; un tipo que este SDK no conoce conserva su número en credit_type.
55
55
  """
56
56
 
57
- credit_type: int = Field(default=..., alias="creditType", description="Tipo de crédito: 1 timbres, 2 créditos de validación.")
57
+ credit_type: int = Field(default=..., alias="creditType", description="Tipo de crédito: 1 timbres, 2 créditos de validación, 3 créditos de ticket.")
58
58
  available: int = Field(default=..., alias="available", description="Saldo disponible.")
59
59
 
60
60
  model_config = ConfigDict(populate_by_name=True)
@@ -304,6 +304,7 @@ class InvoiceItem(BaseDto):
304
304
  customs_info: Optional[list[ItemCustomsInfo]] = Field(default=None, alias="customsInfo", description="Información aduanera.")
305
305
  property_info: Optional[list[ItemPropertyInfo]] = Field(default=None, alias="propertyInfo", description="Información predial.")
306
306
  parts: Optional[list[ItemPart]] = Field(default=None, alias="parts", description="Partes del concepto.")
307
+ ticket_id: Optional[str] = Field(default=None, alias="ticketId", description="Id del ticket de Auto CFDI que este concepto factura. Solo en una factura global de ingreso (tipo I con global_information) con el emisor por referencia (issuer.id); un ticket puede ir en varios conceptos, uno por grupo de objeto de impuesto e impuestos. En None no se envía.")
307
308
 
308
309
  model_config = ConfigDict(populate_by_name=True)
309
310
 
@@ -1011,12 +1012,13 @@ class Xml(BaseDto):
1011
1012
  class CreditType(IntEnum):
1012
1013
  """Tipo de crédito de una transacción de timbres.
1013
1014
 
1014
- Los saldos nunca se mezclan: STAMP afecta ``Person.available_balance`` y VALIDATION afecta
1015
- ``Person.available_validation_balance``.
1015
+ Los saldos nunca se mezclan: STAMP afecta ``Person.available_balance``, VALIDATION afecta
1016
+ ``Person.available_validation_balance`` y TICKET afecta el saldo de tipo TICKET en ``Person.balances``.
1016
1017
  """
1017
1018
 
1018
1019
  STAMP = 1
1019
1020
  VALIDATION = 2
1021
+ TICKET = 3
1020
1022
 
1021
1023
 
1022
1024
  class StampTransactionType(IntEnum):
@@ -1055,7 +1057,9 @@ class StampTransaction(BaseDto):
1055
1057
  transaction_status: Optional[int] = Field(default=None, alias="transactionStatus", description="Estado de la transacción.")
1056
1058
  reference_id: Optional[str] = Field(default=None, alias="referenceId", description="ID de referencia de la transacción.")
1057
1059
  comments: Optional[str] = Field(default=None, alias="comments", description="Comentarios de la transacción.")
1058
- credit_type: Optional[CreditType] = Field(default=None, alias="creditType", description="Tipo de crédito que mueve la transacción: timbres o créditos de validación.")
1060
+ # left_to_right: un tipo conocido llega como miembro de CreditType y uno que este SDK todavía no conoce
1061
+ # conserva su número (int) en lugar de lanzar ValidationError y perder la respuesta completa.
1062
+ credit_type: Optional[Union[CreditType, int]] = Field(default=None, alias="creditType", union_mode="left_to_right", description="Tipo de crédito que mueve la transacción: timbres, créditos de validación o créditos de ticket. Un tipo que este SDK no conoce conserva su número (int).")
1059
1063
 
1060
1064
  model_config = ConfigDict(populate_by_name=True)
1061
1065
 
@@ -1066,6 +1070,6 @@ class StampTransactionParams(BaseModel):
1066
1070
  to_person_id: str = Field(default=..., alias="toPersonId", description="ID de la persona destino.")
1067
1071
  amount: int = Field(default=..., alias="amount", description="Cantidad de timbres a transferir.")
1068
1072
  comments: Optional[str] = Field(default=None, alias="comments", description="Comentarios de la transferencia.")
1069
- credit_type: CreditType = Field(default=CreditType.STAMP, alias="creditType", description="Tipo de crédito a transferir: STAMP (timbres, por defecto) o VALIDATION (créditos de validación SAT).")
1073
+ credit_type: CreditType = Field(default=CreditType.STAMP, alias="creditType", description="Tipo de crédito a transferir: STAMP (timbres, por defecto), VALIDATION (créditos de validación SAT) o TICKET (créditos de ticket).")
1070
1074
 
1071
1075
  model_config = ConfigDict(populate_by_name=True)
@@ -0,0 +1,183 @@
1
+ """Modelos de tickets de Auto CFDI (endpoints /api/v4/tickets).
2
+
3
+ Un ticket es una venta pagada del punto de venta que el receptor puede autofacturar o que entra a una factura global.
4
+ Ingerirlo cobra 1 crédito de ticket. Los conceptos e impuestos llevan los nombres de los conceptos de las facturas.
5
+ """
6
+
7
+ from datetime import date, datetime
8
+ from decimal import Decimal
9
+ from enum import Enum
10
+ from typing import Optional, Union
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+
14
+ class TicketStatus(str, Enum):
15
+ """Estados de un ticket, como llegan en ``Ticket.status`` (texto camelCase)."""
16
+
17
+ PENDING = "pending"
18
+ INVOICING = "invoicing"
19
+ INVOICED = "invoiced"
20
+ GLOBALIZING = "globalizing"
21
+ GLOBALIZED = "globalized"
22
+ CANCELLED = "cancelled"
23
+
24
+
25
+ class TicketEventType(str, Enum):
26
+ """Tipos de hecho de la historia de un ticket, como llegan en ``TicketEvent.type``."""
27
+
28
+ INGESTED = "ingested"
29
+ INVOICING_STARTED = "invoicingStarted"
30
+ INVOICED = "invoiced"
31
+ INVOICE_REJECTED = "invoiceRejected"
32
+ OUTCOME_UNKNOWN = "outcomeUnknown"
33
+ LEASE_EXPIRED = "leaseExpired"
34
+ GLOBALIZING = "globalizing"
35
+ GLOBALIZED = "globalized"
36
+ RELEASED_FROM_GLOBAL = "releasedFromGlobal"
37
+ CANCELLED = "cancelled"
38
+ RECOVERY_FAILED = "recoveryFailed"
39
+ RECOVERED = "recovered"
40
+ RESENT = "resent"
41
+ RETURNED_TO_PENDING = "returnedToPending"
42
+
43
+
44
+ class TicketActorType(str, Enum):
45
+ """Quién actuó en un hecho del ticket, como llega en ``TicketEvent.actor_type``."""
46
+
47
+ TENANT = "tenant"
48
+ PORTAL = "portal"
49
+ SYSTEM = "system"
50
+
51
+
52
+ class TicketIssuer(BaseModel):
53
+ """Emisor de un ticket: una persona del tenant, por su id."""
54
+
55
+ id: Optional[str] = Field(default=None, alias="id", description="Id de la persona emisora.")
56
+
57
+ model_config = ConfigDict(populate_by_name=True)
58
+
59
+
60
+ class TicketItemTax(BaseModel):
61
+ """Impuesto de un concepto del ticket."""
62
+
63
+ tax_code: Optional[str] = Field(default=None, alias="taxCode", description="Impuesto: 001 (ISR), 002 (IVA) o 003 (IEPS).")
64
+ tax_type_code: Optional[str] = Field(default=None, alias="taxTypeCode", description="Tipo de factor: Tasa, Cuota o Exento.")
65
+ tax_rate: Optional[Decimal] = Field(default=None, alias="taxRate", description="Tasa o cuota; se envía tal cual, sin redondear.")
66
+ tax_flag_code: Optional[str] = Field(default=None, alias="taxFlagCode", description="T (traslado) o R (retención).")
67
+
68
+ model_config = ConfigDict(populate_by_name=True)
69
+
70
+
71
+ class TicketItem(BaseModel):
72
+ """Concepto de un ticket, con los nombres de los conceptos de las facturas (sin terceros, aduanas, predial ni partes)."""
73
+
74
+ item_code: Optional[str] = Field(default=None, alias="itemCode", description="Clave de producto o servicio (c_ClaveProdServ).")
75
+ item_sku: Optional[str] = Field(default=None, alias="itemSku", description="Número de identificación (SKU) del producto en el punto de venta.")
76
+ quantity: Optional[Decimal] = Field(default=None, alias="quantity", description="Cantidad.")
77
+ unit_of_measurement_code: Optional[str] = Field(default=None, alias="unitOfMeasurementCode", description="Clave de unidad (c_ClaveUnidad).")
78
+ description: Optional[str] = Field(default=None, alias="description", description="Descripción.")
79
+ unit_price: Optional[Decimal] = Field(default=None, alias="unitPrice", description="Valor unitario.")
80
+ discount: Optional[Decimal] = Field(default=None, alias="discount", description="Descuento del concepto. Opcional.")
81
+ tax_object_code: Optional[str] = Field(default=None, alias="taxObjectCode", description="Objeto de impuesto (c_ObjetoImp): 02 exige impuestos; 01, 03, 04 y 05 no admiten.")
82
+ item_taxes: Optional[list[TicketItemTax]] = Field(default=None, alias="itemTaxes", description="Impuestos del concepto.")
83
+
84
+ model_config = ConfigDict(populate_by_name=True)
85
+
86
+
87
+ class TicketEvent(BaseModel):
88
+ """Un hecho de la historia del ticket (solo lectura; llega con ``details=True``).
89
+
90
+ ``type`` y ``actor_type`` llegan como miembro del enum si el SDK los conoce y como texto si no.
91
+ """
92
+
93
+ type: Optional[Union[TicketEventType, str]] = Field(default=None, alias="type", union_mode="left_to_right", description="Tipo de hecho; uno que el SDK no conoce llega como texto.")
94
+ occurred_at: Optional[datetime] = Field(default=None, alias="occurredAt", description="Momento del hecho.")
95
+ actor_type: Optional[Union[TicketActorType, str]] = Field(default=None, alias="actorType", union_mode="left_to_right", description="Quién actuó; uno que el SDK no conoce llega como texto.")
96
+ invoice_id: Optional[str] = Field(default=None, alias="invoiceId", description="Factura del hecho; None si no aplica.")
97
+
98
+ model_config = ConfigDict(populate_by_name=True)
99
+
100
+
101
+ class Ticket(BaseModel):
102
+ """Ticket de Auto CFDI: se envía en ``tickets.create`` y llega en ``tickets.get_list`` y ``tickets.get_by_id``.
103
+
104
+ Los miembros de solo lectura (id, person_id, importes calculados, status, vigencia, facturas, created_at y events)
105
+ los asigna el API: al crear quedan en None y no se envían.
106
+ """
107
+
108
+ id: Optional[str] = Field(default=None, alias="id", description="Id del ticket. Solo lectura.")
109
+ issuer: Optional[TicketIssuer] = Field(default=None, alias="issuer", description="Emisor por referencia. Se envía al crear; no llega en las consultas.")
110
+ person_id: Optional[str] = Field(default=None, alias="personId", description="Persona emisora del tenant. Solo lectura.")
111
+ folio: Optional[str] = Field(default=None, alias="folio", description="Folio del punto de venta (no es el folio del CFDI).")
112
+ series: Optional[str] = Field(default=None, alias="series", description="Serie del punto de venta. Opcional.")
113
+ payment_date: Optional[datetime] = Field(default=None, alias="paymentDate", description="Momento del pago en hora civil de México; un datetime sin zona viaja como yyyy-MM-ddTHH:mm:ss.")
114
+ payment_form_code: Optional[str] = Field(default=None, alias="paymentFormCode", description="Forma de pago (c_FormaPago), por ejemplo 01 (efectivo).")
115
+ currency_code: Optional[str] = Field(default=None, alias="currencyCode", description="Moneda (c_Moneda). Opcional: sin ella, el API usa MXN.")
116
+ exchange_rate: Optional[Decimal] = Field(default=None, alias="exchangeRate", description="Tipo de cambio. Opcional.")
117
+ expedition_zip_code: Optional[str] = Field(default=None, alias="expeditionZipCode", description="Código postal de expedición. Opcional: sin él, el API usa el del emisor.")
118
+ subtotal: Optional[Decimal] = Field(default=None, alias="subtotal", description="Subtotal calculado por el API. Solo lectura.")
119
+ total_transferred_taxes: Optional[Decimal] = Field(default=None, alias="totalTransferredTaxes", description="Total de impuestos trasladados calculado por el API. Solo lectura.")
120
+ total_withheld_taxes: Optional[Decimal] = Field(default=None, alias="totalWithheldTaxes", description="Total de impuestos retenidos calculado por el API. Solo lectura.")
121
+ total: Optional[Decimal] = Field(default=None, alias="total", description="Total que cobró el punto de venta: debe cuadrar al centavo con el calculado (si no, 422).")
122
+ status: Optional[Union[TicketStatus, str]] = Field(default=None, alias="status", union_mode="left_to_right", description="Estado del ticket. Solo lectura; uno que el SDK no conoce llega como texto.")
123
+ self_invoice_eligible: Optional[bool] = Field(default=None, alias="selfInvoiceEligible", description="Si hoy el receptor puede autofacturarlo. Solo lectura.")
124
+ eligible_until: Optional[datetime] = Field(default=None, alias="eligibleUntil", description="Último momento de la ventana de autofactura; None si no cierra. Solo lectura.")
125
+ invoice_id: Optional[str] = Field(default=None, alias="invoiceId", description="Factura individual (estado invoiced). Solo lectura.")
126
+ global_invoice_id: Optional[str] = Field(default=None, alias="globalInvoiceId", description="Factura global (estado globalized). Solo lectura.")
127
+ created_at: Optional[datetime] = Field(default=None, alias="createdAt", description="Fecha de alta. Solo lectura.")
128
+ items: Optional[list[TicketItem]] = Field(default=None, alias="items", description="Conceptos. Obligatorios al crear; en las consultas solo llegan con details=True.")
129
+ events: Optional[list[TicketEvent]] = Field(default=None, alias="events", description="Historia del ticket en orden. Solo lectura; solo llega con details=True.")
130
+
131
+ model_config = ConfigDict(populate_by_name=True)
132
+
133
+
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."""
137
+
138
+ id: Optional[str] = Field(default=None, alias="id", description="Id del ticket.")
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).")
140
+ qr_url: Optional[str] = Field(default=None, alias="qrUrl", description="URL del portal de autofactura que abre el ticket.")
141
+ status: Optional[Union[TicketStatus, str]] = Field(default=None, alias="status", union_mode="left_to_right", description="Estado actual (al ingerir, pending); uno que el SDK no conoce llega como texto.")
142
+ self_invoice_eligible: Optional[bool] = Field(default=None, alias="selfInvoiceEligible", description="Si hoy el receptor puede autofacturarlo.")
143
+ eligible_until: Optional[datetime] = Field(default=None, alias="eligibleUntil", description="Último momento de la ventana de autofactura; None si no cierra.")
144
+ subtotal: Optional[Decimal] = Field(default=None, alias="subtotal", description="Subtotal calculado.")
145
+ total_transferred_taxes: Optional[Decimal] = Field(default=None, alias="totalTransferredTaxes", description="Total de impuestos trasladados calculado.")
146
+ total_withheld_taxes: Optional[Decimal] = Field(default=None, alias="totalWithheldTaxes", description="Total de impuestos retenidos calculado.")
147
+ total: Optional[Decimal] = Field(default=None, alias="total", description="Total calculado (cuadra con el enviado).")
148
+
149
+ model_config = ConfigDict(populate_by_name=True)
150
+
151
+
152
+ class TicketCodeResult(BaseModel):
153
+ """Código de un ticket y la URL de su QR, para reimprimirlo (GET /api/v4/tickets/{id}/code)."""
154
+
155
+ ticket_code: Optional[str] = Field(default=None, alias="ticketCode", description="Código sin guiones, el mismo de la ingesta.")
156
+ qr_url: Optional[str] = Field(default=None, alias="qrUrl", description="URL del portal de autofactura que abre el ticket.")
157
+
158
+ model_config = ConfigDict(populate_by_name=True)
159
+
160
+
161
+ class TicketListFilters(BaseModel):
162
+ """Filtros de ``tickets.get_list`` (GET /api/v4/tickets). Cada filtro aplica solo si se asigna; los que quedan en
163
+ None no se envían. ``from_`` lleva el guion bajo porque ``from`` es palabra reservada de Python."""
164
+
165
+ status: Optional[list[Union[TicketStatus, str]]] = Field(default=None, alias="status", description="Uno o más estados; se envían repitiendo la llave status.")
166
+ person_id: Optional[str] = Field(default=None, alias="personId", description="Persona emisora.")
167
+ details: Optional[bool] = Field(default=None, alias="details", description="Con true, cada ticket trae sus conceptos con impuestos (sin la historia).")
168
+ global_invoice_id: Optional[str] = Field(default=None, alias="globalInvoiceId", description="Factura global que ampara los tickets.")
169
+ from_: Optional[date] = Field(default=None, alias="from", description="Primer día del pago (incluido), en fecha civil de México.")
170
+ to: Optional[date] = Field(default=None, alias="to", description="Último día del pago (incluido), en fecha civil de México.")
171
+ page_number: Optional[int] = Field(default=None, alias="pageNumber", description="Página, desde 1. Sin ella, el API devuelve la primera.")
172
+ page_size: Optional[int] = Field(default=None, alias="pageSize", description="Tamaño de página (1 a 50). Sin él, el API usa 10.")
173
+
174
+ model_config = ConfigDict(populate_by_name=True)
175
+
176
+ def to_query_params(self) -> list[tuple[str, str]]:
177
+ """Los filtros asignados como pares de la query string: ``status`` se repite por cada estado, y ``details``
178
+ solo viaja como ``true`` (``False`` no se envía)."""
179
+ params: list[tuple[str, str]] = []
180
+ for key, value in self.model_dump(mode="json", by_alias=True, exclude_none=True).items():
181
+ values = value if isinstance(value, list) else [value]
182
+ params.extend((key, "true" if item is True else str(item)) for item in values if item is not False)
183
+ return params