diff --git a/.github/workflows/auto_merge.yml b/.github/workflows/auto_merge.yml
index 25db3d0..001e3ce 100644
--- a/.github/workflows/auto_merge.yml
+++ b/.github/workflows/auto_merge.yml
@@ -10,7 +10,7 @@ jobs:
auto-merge:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- uses: ahmadnassri/action-dependabot-auto-merge@v2
with:
github-token: ${{ secrets.DEPENDABOT_AUTOMERGE }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 0db0bd6..360c92b 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,7 +1,7 @@
name: docs
permissions:
- contents: read
+ contents: write
on:
release:
@@ -11,14 +11,14 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python 3.13
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: 3.13
- name: Install dependencies
run: make install-test
- name: Install mkdocs
- run: pip install -qU mkdocs-material mkdocstrings
+ run: python -m pip install -r requirements-docs.txt
- name: Deploy Github Page
run: mkdocs gh-deploy --force
diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml
new file mode 100644
index 0000000..1a6edb5
--- /dev/null
+++ b/.github/workflows/openapi.yml
@@ -0,0 +1,40 @@
+name: update-openapi-client
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '17 12 * * 1'
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ generate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.13'
+ - name: Install generator dependencies
+ run: python -m pip install -r requirements.txt -r requirements-test.txt
+ - name: Generate the SDK from the current OpenAPI document
+ run: python scripts/generate_openapi.py
+ - name: Verify generated client
+ run: |
+ make lint
+ pytest -x
+ - name: Create update pull request
+ uses: peter-evans/create-pull-request@v8
+ with:
+ branch: codex/update-openapi-client
+ delete-branch: true
+ commit-message: 'chore: update generated OpenAPI client'
+ title: 'chore: update generated OpenAPI client'
+ body: |
+ Automated SDK update generated from Facturapi's public OpenAPI
+ document. CI verifies formatting, typing, and tests before merge.
+ labels: |
+ automated
+ openapi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d0b2a6e..8a74174 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,27 +1,41 @@
name: release
-permissions:
- contents: read
-
on:
release:
types: [published]
jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - name: Set up Python
+ uses: actions/setup-python@v7
+ with:
+ python-version: '3.13'
+ - name: Build distributions
+ run: |
+ python -m pip install --upgrade build twine
+ python -c "import os, runpy; version = runpy.run_path('facturapi/version.py')['__version__']; assert os.environ['GITHUB_REF_NAME'] == f'v{version}', 'release tag must match package version'"
+ python -m build
+ python -m twine check dist/*
+ - uses: actions/upload-artifact@v7
+ with:
+ name: python-package-distributions
+ path: dist/
+
publish-pypi:
+ needs: build
runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/p/facturapi
+ permissions:
+ id-token: write
steps:
- - uses: actions/checkout@master
- - name: Set up Python 3.8
- uses: actions/setup-python@v2.2.2
+ - uses: actions/download-artifact@v8
with:
- python-version: 3.8
- - name: Install dependencies
- run: pip install -qU setuptools wheel twine
- - name: Generating distribution archives
- run: python setup.py sdist bdist_wheel
- - name: Publish distribution 📦 to PyPI
+ name: python-package-distributions
+ path: dist/
+ - name: Publish distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- with:
- user: __token__
- password: ${{ secrets.pypi_password }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cba0572..a6b2084 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,9 +9,9 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: 3.13
- name: Install dependencies
@@ -23,11 +23,11 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ["3.10", "3.11", "3.12", "3.13"]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
@@ -37,10 +37,13 @@ jobs:
coverage:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7
with:
python-version: 3.13
- name: Install dependencies
@@ -50,8 +53,9 @@ jobs:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
- token: ${{ secrets.CODECOV_TOKEN }}
- file: ./coverage.xml
+ use_oidc: true
+ slug: ${{ github.repository }}
+ files: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: true
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..effef3b
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,8 @@
+include LICENSE
+include README.md
+include README.es-MX.md
+include requirements-docs.txt
+recursive-include docs *.md *.png *.svg
+recursive-include examples *.py
+recursive-include scripts *.py
+recursive-include tests *.py *.yaml
diff --git a/Makefile b/Makefile
index 46e6780..b6e6a30 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ SHELL := bash
PATH := ./venv/bin:${PATH}
PYTHON = python3.13
PROJECT = facturapi
-isort = isort $(PROJECT) tests setup.py examples
-black = black -S -l 79 --target-version py313 $(PROJECT) tests setup.py examples
+isort = isort $(PROJECT) tests scripts setup.py examples
+black = black -S -l 79 --target-version py313 $(PROJECT) tests scripts setup.py examples
all: test
@@ -21,15 +21,21 @@ install-test: install
test: clean install-test lint
pytest
+generate:
+ python scripts/generate_openapi.py
+
+generate-check:
+ python scripts/generate_openapi.py --check
+
format:
$(isort)
$(black)
lint:
- flake8 $(PROJECT) tests setup.py
+ flake8 $(PROJECT) tests scripts setup.py
$(isort) --check-only
$(black) --check
- mypy $(PROJECT) tests
+ mypy $(PROJECT) tests scripts
clean:
rm -rf `find . -name __pycache__`
@@ -46,9 +52,12 @@ clean:
rm -rf build
rm -rf dist
-release: test clean
- python setup.py sdist bdist_wheel
- twine upload dist/*
+build: clean
+ python -m build
+ python -m twine check dist/*
+
+release: test build
+ @echo "Create a GitHub release to publish with PyPI Trusted Publishing."
-.PHONY: all install-test test format lint clean release
+.PHONY: all install-test test generate generate-check format lint clean build release
diff --git a/README.es-MX.md b/README.es-MX.md
index 4c4dc44..e7bedad 100644
--- a/README.es-MX.md
+++ b/README.es-MX.md
@@ -7,14 +7,11 @@
FacturAPI – Librería de Python
-
-[](https://codecov.io/gh/cuenca-mx/facturapi-python)
+
[](https://badge.fury.io/py/facturapi)
[](https://github.com/psf/black)
-Esta es una librería de Python para https://www.facturapi.io
-
-**⚠️Advertencia: Esta librería NO es oficial y aún sigue en desarrollo. No es un producto final.⚠️**
+SDK oficial de Python para https://www.facturapi.io
FacturAPI facilita la creación de facturas válidas para desarrolladores en México (Facturas Electrónicas o CFDI).
@@ -30,39 +27,28 @@ pip install facturapi
## 🚀 Primeros pasos
-### Autenticación con API Key
+### Cliente recomendado
Asegurate de crear una cuenta gratis en [FacturAPI](https://www.facturapi.io) y tener acceso a tus llaves de test y live.
-Hay dos formas de configurar tus llaves para usar la librería:
-
-1. **Variable de entorno:** Configura una variable de entorno y la librería usará la llave automáticamente:
-
- ```bash
- export FACTURAPI_KEY=TU_API_KEY
- ```
-
-2. **Usando el método `configure`:** Puedes configurar tu llave en el código usando el método `configure`:
-
- ```python
- from facturapi import configure
-
- configure(api_key='TU_API_KEY')
- ```
-
-### Crea un Cliente
-Después de configurar tu llave, puedes usar la librería para realizar varias acciones en los recursos de FacturAPI, por ejempo crear un Cliente:
+El cliente por instancia incluye todos los métodos de la referencia de API:
```python
-import facturapi
-from facturapi.resources.customers import CustomerRequest, CustomerUpdateRequest
-
-customer = facturapi.Customer.create(data=CustomerRequest(
- legal_name='Frida Kahlo',
- tax_id='ABCD111111CBA',
- email='frida_kahlo@test.com',
-))
+from facturapi import Facturapi
+
+facturapi = Facturapi("sk_test_...")
+customer = facturapi.customers.create({
+ "legal_name": "Frida Kahlo",
+ "tax_id": "ABCD111111CBA",
+ "tax_system": "601",
+ "email": "frida@example.com",
+ "address": {"zip": "01234", "country": "MEX"},
+})
```
+También puedes omitir la llave y usar la variable de entorno
+`FACTURAPI_KEY`. La API anterior basada en `facturapi.Customer` y
+`facturapi.Invoice` sigue disponible por compatibilidad.
+
Para más detalles en los datos necesarios para crear un Cliente y otros recursos, revisa la [documentación](http://docs.facturapi.io).
Puedes encontrar más ejemplos de código de cómo crear y usar recursos en el directorio de [examples](./examples/).
@@ -71,7 +57,8 @@ Puedes encontrar más ejemplos de código de cómo crear y usar recursos en el d
Encuentra más información de Facturapi y sus recursos en sus [docs](http://docs.facturapi.io).
-O para más detalles técnicos y de la librería, entra a la [documentación](https://cuenca-mx.github.io/facturapi-python/) de `facturapi-python`.
+La capa de recursos se genera automáticamente desde el documento OpenAPI de
+Facturapi. El workflow semanal propone un pull request cuando la API cambia.
## 💡 Contribuye
### ¿Encontraste un bug?
@@ -84,12 +71,5 @@ No olvides mantener la cobertura del código arriba de 98%, nos encanta tener c
### Contactos:
- **Facturapi:** contacto@facturapi.io
-- **Cuenca:** dev@cuenca.com
-
---
-Desarrollado y mantenido con 💙 por [Cuenca](https://cuenca.com/)
-
-
-
-
-
+Desarrollado y mantenido por [Facturapi](https://facturapi.io/).
diff --git a/README.md b/README.md
index 888dc65..cb11e10 100644
--- a/README.md
+++ b/README.md
@@ -1,97 +1,166 @@
-
-
-
+
+
+
-
- FacturAPI – Python Client Library
-
-
-[](https://codecov.io/gh/cuenca-mx/facturapi-python)
-[](https://badge.fury.io/py/facturapi)
-[](https://github.com/psf/black)
+# Facturapi Python SDK
+
+[](https://github.com/FacturAPI/facturapi-python/actions/workflows/test.yml)
+[](https://pypi.org/project/facturapi/)
+[](https://pypi.org/project/facturapi/)
+
+The official synchronous Python client for the
+[Facturapi API](https://docs.facturapi.io/en/api/). It supports every operation
+in the public OpenAPI document while keeping the original `Customer` and
+`Invoice` class API backwards compatible.
[Español](./README.es-MX.md)
-This is a Python library for [FacturAPI](https://www.facturapi.io)
+## Install
-**⚠️Warning: This is still an unofficial library and it is still under development. This is not a final product.⚠️**
+```bash
+pip install facturapi
+```
-FacturAPI makes it easy for developers to generate valid Invoices in Mexico (known as Factura Electrónica or CFDI).
+Python 3.10 or newer is required.
-This library is based on [`cuenca-python`](https://github.com/cuenca-mx/cuenca-python) 💙.
+## Quick start
-## 💻 Installation
+```python
+import os
+
+from facturapi import Facturapi
+
+facturapi = Facturapi(os.environ["FACTURAPI_KEY"])
+
+customer = facturapi.customers.create(
+ {
+ "legal_name": "Dunder Mifflin",
+ "tax_id": "ABC101010111",
+ "tax_system": "601",
+ "email": "accounting@example.com",
+ "address": {"zip": "01234", "country": "MEX"},
+ }
+)
+
+invoice = facturapi.invoices.create(
+ {
+ "customer": customer["id"],
+ "payment_form": "03",
+ "items": [
+ {
+ "quantity": 1,
+ "product": {
+ "description": "Consulting",
+ "product_key": "80101500",
+ "price": 1000,
+ },
+ }
+ ],
+ }
+)
+
+pdf = facturapi.invoices.download_pdf(invoice["id"])
+```
-Just use `pip` to install de library:
+If the key is omitted, `Facturapi()` reads `FACTURAPI_KEY`. The timeout can be
+set per client or through `FACTURAPI_TIMEOUT`.
-```bash
-pip install facturapi
+```python
+with Facturapi("sk_test_...", timeout=30) as facturapi:
+ result = facturapi.invoices.list(
+ {
+ "status": ["valid", "canceled"],
+ "date": {"gte": "2026-01-01T00:00:00Z"},
+ }
+ )
```
-## 🚀 Getting started
+## API resources
-### Authenticate with your API Key
-Be sure to create a free account on [FacturAPI](https://www.facturapi.io) and have access to your test or live API keys.
+The instance client follows the same resource layout as `facturapi-node`:
-There are two ways to configure your API Key to use the library:
+- `customers`
+- `products`
+- `invoices`
+- `receipts`
+- `retentions`
+- `organizations`, including users, roles, API keys, certificates, and series
+- `webhooks`
+- `tools`
+- `catalogs`
+- `nomina_catalogs`
+- `carta_porte_catalogs`
+- `comercio_exterior_catalogs`
-1. **Environment variable:** Just set an environment variable and the library will automatically use it:
-
- ```bash
- export FACTURAPI_KEY=YOUR_API_KEY
- ```
+Methods use `snake_case`, accept normal Python dictionaries, and return decoded
+JSON. Download methods return `bytes`. Multipart organization methods accept an
+`httpx`-compatible `files` mapping.
-2. **Using the `configure` method:** If you want to set it in the code, you can import and use the `configure` method:
+```python
+files = {
+ "cer": ("certificate.cer", open("certificate.cer", "rb")),
+ "key": ("certificate.key", open("certificate.key", "rb")),
+}
+facturapi.organizations.upload_certificate(
+ "org_123",
+ files,
+ {"password": "certificate-password"},
+)
+```
+
+For parameter and payload details, use the
+[Facturapi API reference](https://docs.facturapi.io/en/api/). Each generated
+method includes its OpenAPI operation and endpoint in its docstring.
- ```python
- from facturapi import configure
-
- configure(api_key='YOUR_API_KEY')
- ```
+## Backwards compatibility
-### Create a customer
-After configuring the API Key, you can use the client to perform many actions on the resources, for example to create
-a Customer:
+The original class API still works:
```python
import facturapi
-from facturapi.resources.customers import CustomerRequest, CustomerUpdateRequest
-
-customer = facturapi.Customer.create(data=CustomerRequest(
- legal_name='Frida Kahlo',
- tax_id='ABCD111111CBA',
- email='frida_kahlo@test.com',
-))
+from facturapi.resources.customers import CustomerRequest
+
+facturapi.configure(api_key="sk_test_...")
+customer = facturapi.Customer.create(
+ CustomerRequest(
+ legal_name="Dunder Mifflin",
+ tax_id="ABC101010111",
+ tax_system="601",
+ email="accounting@example.com",
+ address={"zip": "01234", "country": "MEX"},
+ )
+)
```
-For more details on the data used to create a Customer and other resources, be sure to check out the [docs](http://docs.facturapi.io).
+## OpenAPI updates
-More examples can be found on the [examples](./examples/) directory.
+The resource layer is generated from Facturapi's public OpenAPI document:
-## 📚 Documentation
+```bash
+make generate
+make generate-check
+```
-You can checkout Facturapi's [docs](http://docs.facturapi.io) for more info on the API and its resources.
+The `update-openapi-client` GitHub Actions workflow checks the current document
+weekly and opens a pull request when generated methods change. Generated code
+lives in `facturapi/_generated.py`; edit the generator instead of that file.
-Or checkout the library's [docs](https://cuenca-mx.github.io/facturapi-python/) for usage and more technical details.
+## Development and releases
-## 💡 Contribute
-### Found a bug?
-Please create an issue addressing the bug and how to replicate it.
-### Want to contribute?
-Be sure to fork the repo and send your PR! Any and all help is appreciated.
-Also don't forget to keep the coverage above 98%, we love tested code!
+```bash
+make venv
+make test
+make build
+```
-### Contacts:
-- **Facturapi:** contacto@facturapi.io
+Publishing uses PyPI Trusted Publishing. Maintainers publish a GitHub release;
+the release workflow builds, verifies, and uploads the wheel and source archive
+without a long-lived PyPI token. The PyPI project must trust the
+`FacturAPI/facturapi-python` repository, the `release.yml` workflow, and the
+`pypi` environment.
-- **Cuenca:** dev@cuenca.com
+## License
----
-Developed and maintained with 💙 by [Cuenca](https://github.com/cuenca-mx)
-
-
-
-
-
+[MIT](./LICENSE)
diff --git a/docs/images/facturapi-logo.svg b/docs/images/facturapi-logo.svg
index 0bb3233..9892a50 100644
--- a/docs/images/facturapi-logo.svg
+++ b/docs/images/facturapi-logo.svg
@@ -1,117 +1,47 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/index.md b/docs/index.md
index 410efe1..560dc04 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,97 +1,39 @@
-
-
-
+
+
+
-
- FacturAPI – Python Client Library
-
-
-[](https://codecov.io/gh/cuenca-mx/facturapi-python)
-[](https://badge.fury.io/py/facturapi)
-[](https://github.com/psf/black)
+# Facturapi Python SDK
-[Español](./README.es-MX.md)
+The official synchronous Python client for Facturapi. The generated instance
+client covers every operation in the public OpenAPI document, and the original
+`Customer` and `Invoice` classes remain available for compatibility.
-This is a Python library for [FacturAPI](https://www.facturapi.io)
-
-**⚠️Warning: This is still an unofficial library and it is still under development. This is not a final product.⚠️**
-
-FacturAPI makes it easy for developers to generate valid Invoices in Mexico (known as Factura Electrónica or CFDI).
-
-This library is based on [`cuenca-python`](https://github.com/cuenca-mx/cuenca-python) 💙.
-
-## 💻 Installation
-
-Just use `pip` to install de library:
+## Installation
```bash
pip install facturapi
```
-## 🚀 Getting started
-
-### Authenticate with your API Key
-Be sure to create a free account on [FacturAPI](https://www.facturapi.io) and have access to your test or live API keys.
-
-There are two ways to configure your API Key to use the library:
-
-1. **Environment variable:** Just set an environment variable and the library will automatically use it:
-
- ```bash
- export FACTURAPI_KEY=YOUR_API_KEY
- ```
-
-2. **Using the `configure` method:** If you want to set it in the code, you can import and use the `configure` method:
-
- ```python
- from facturapi import configure
-
- configure(api_key='YOUR_API_KEY')
- ```
-
-### Create a customer
-After configuring the API Key, you can use the client to perform many actions on the resources, for example to create
-a Customer:
+## Getting started
```python
-import facturapi
-from facturapi.resources.customers import CustomerRequest, CustomerUpdateRequest
-
-customer = facturapi.Customer.create(data=CustomerRequest(
- legal_name='Frida Kahlo',
- tax_id='ABCD111111CBA',
- email='frida_kahlo@test.com',
-))
+from facturapi import Facturapi
+
+api = Facturapi("sk_test_...")
+customer = api.customers.create(
+ {
+ "legal_name": "Dunder Mifflin",
+ "tax_id": "ABC101010111",
+ "tax_system": "601",
+ "email": "accounting@example.com",
+ "address": {"zip": "01234", "country": "MEX"},
+ }
+)
```
-For more details on the data used to create a Customer and other resources, be sure to check out the [docs](http://docs.facturapi.io).
-
-More examples can be found on the [examples](./examples/) directory.
-
-## 📚 Documentation
-
-You can checkout Facturapi's [docs](http://docs.facturapi.io) for more info on the API and its resources.
-
-Or checkout the library's [docs](https://cuenca-mx.github.io/facturapi-python/) for usage and more technical details.
-
-## 💡 Contribute
-### Found a bug?
-Please create an issue addressing the bug and how to replicate it.
-### Want to contribute?
-Be sure to fork the repo and send your PR! Any and all help is appreciated.
-Also don't forget to keep the coverage above 98%, we love tested code!
-
-### Contacts:
-- **Facturapi:** contacto@facturapi.io
-
-- **Cuenca:** dev@cuenca.com
-
----
-Developed and maintained with 💙 by [Cuenca](https://github.com/cuenca-mx)
-
-
-
-
-
+If the API key is omitted, the client reads `FACTURAPI_KEY`. See the
+[generated API client](./openapi.md) for resource coverage and the
+[Facturapi API reference](https://docs.facturapi.io/en/api/) for request and
+response fields.
diff --git a/docs/openapi.md b/docs/openapi.md
new file mode 100644
index 0000000..0b1f36d
--- /dev/null
+++ b/docs/openapi.md
@@ -0,0 +1,105 @@
+# Generated API client
+
+`Facturapi` exposes every operation in the public Facturapi OpenAPI document.
+Create an instance with an API key and use one of its resource clients:
+
+```python
+from facturapi import Facturapi
+
+api = Facturapi("sk_test_...")
+products = api.products.list({"page": 1, "limit": 50})
+```
+
+| Attribute | Coverage |
+| --- | --- |
+| `customers` | Customer CRUD, edit links, tax validation |
+| `products` | Product CRUD |
+| `invoices` | CRUD, drafts, cancellation, email, previews, downloads, ZIP requests |
+| `receipts` | CRUD, invoice conversion, previews, email, downloads |
+| `retentions` | CRUD, drafts, cancellation, email, downloads |
+| `organizations` | Legal data, files, keys, domains, series, team access, roles |
+| `webhooks` | CRUD and signature validation |
+| `tools` | Health and RFC validation |
+| `catalogs` | SAT product and unit catalogs |
+| `nomina_catalogs` | Payroll deduction and perception catalogs |
+| `carta_porte_catalogs` | Carta Porte 3.1 catalogs |
+| `comercio_exterior_catalogs` | Foreign trade tariff fractions |
+
+Python method names use `snake_case`. Request bodies and query parameters are
+passed as dictionaries. Download operations return `bytes`.
+
+::: facturapi.client.Facturapi
+ options:
+ show_source: false
+
+## Customers
+
+::: facturapi._generated.Customers
+ options:
+ show_source: false
+
+## Products
+
+::: facturapi._generated.Products
+ options:
+ show_source: false
+
+## Invoices
+
+::: facturapi._generated.Invoices
+ options:
+ show_source: false
+
+## E-Receipts
+
+::: facturapi._generated.Receipts
+ options:
+ show_source: false
+
+## Retentions
+
+::: facturapi._generated.Retentions
+ options:
+ show_source: false
+
+## Organizations
+
+::: facturapi._generated.Organizations
+ options:
+ show_source: false
+
+## Webhooks
+
+::: facturapi._generated.Webhooks
+ options:
+ show_source: false
+
+## Tools
+
+::: facturapi._generated.Tools
+ options:
+ show_source: false
+
+## SAT catalogs
+
+::: facturapi._generated.Catalogs
+ options:
+ show_source: false
+
+## Payroll catalogs
+
+::: facturapi._generated.NominaCatalogs
+ options:
+ show_source: false
+
+## Carta Porte catalogs
+
+::: facturapi._generated.CartaPorteCatalogs
+ options:
+ show_source: false
+
+## Foreign-trade catalogs
+
+::: facturapi._generated.ComercioExteriorCatalogs
+ options:
+ show_source: false
diff --git a/facturapi/__init__.py b/facturapi/__init__.py
index 8d7f753..c04666c 100644
--- a/facturapi/__init__.py
+++ b/facturapi/__init__.py
@@ -1,10 +1,13 @@
__all__ = [
'__version__',
'Customer',
+ 'FacturAPI',
+ 'Facturapi',
'Invoice',
'configure',
]
+from .client import FacturAPI, Facturapi
from .http import client
from .resources import Customer, Invoice
from .version import __version__
diff --git a/facturapi/_generated.py b/facturapi/_generated.py
new file mode 100644
index 0000000..4c4727d
--- /dev/null
+++ b/facturapi/_generated.py
@@ -0,0 +1,1737 @@
+# This file is generated by scripts/generate_openapi.py.
+# Do not edit it by hand.
+
+from collections.abc import Mapping
+from typing import Any
+from urllib.parse import quote
+
+from .http.client import Client, QueryParams
+
+OPENAPI_VERSION = '2.0'
+OPENAPI_OPERATION_COUNT = 120
+
+
+class _Service:
+ def __init__(self, client: Client) -> None:
+ self._client = client
+
+ def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ path_params: Mapping[str, Any] | None = None,
+ params: QueryParams | None = None,
+ data: Mapping[str, Any] | None = None,
+ files: Mapping[str, Any] | None = None,
+ response_type: str = 'auto',
+ ) -> Any:
+ for key, value in (path_params or {}).items():
+ encoded = quote(str(value), safe='')
+ path = path.replace('{' + key + '}', encoded)
+ return self._client.request(
+ method,
+ path,
+ params=params,
+ data=data,
+ files=files,
+ response_type=response_type,
+ )
+
+
+class Customers(_Service):
+ def create(
+ self, data: Mapping[str, Any], params: QueryParams | None = None
+ ) -> Any:
+ """Create Customer.
+
+ OpenAPI operation: ``createCustomer``.
+ Endpoint: ``POST /customers``.
+ """
+ return self._request('POST', '/customers', params=params, data=data)
+
+ def list(self, params: QueryParams | None = None) -> Any:
+ """List customers.
+
+ OpenAPI operation: ``listCustomers``.
+ Endpoint: ``GET /customers``.
+ """
+ return self._request('GET', '/customers', params=params)
+
+ def retrieve(self, customer_id: str) -> Any:
+ """Retrieve customer by ID.
+
+ OpenAPI operation: ``getCustomer``.
+ Endpoint: ``GET /customers/{customer_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/customers/{customer_id}',
+ path_params={'customer_id': customer_id},
+ )
+
+ def update(
+ self,
+ customer_id: str,
+ data: Mapping[str, Any],
+ params: QueryParams | None = None,
+ ) -> Any:
+ """Edit Customer.
+
+ OpenAPI operation: ``editCustomer``.
+ Endpoint: ``PUT /customers/{customer_id}``.
+ """
+ return self._request(
+ 'PUT',
+ '/customers/{customer_id}',
+ path_params={'customer_id': customer_id},
+ params=params,
+ data=data,
+ )
+
+ def delete(self, customer_id: str) -> Any:
+ """Delete Customer.
+
+ OpenAPI operation: ``deleteCustomer``.
+ Endpoint: ``DELETE /customers/{customer_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/customers/{customer_id}',
+ path_params={'customer_id': customer_id},
+ )
+
+ def send_edit_link_by_email(
+ self, customer_id: str, data: Mapping[str, Any] | None = None
+ ) -> Any:
+ """Send edit link by email.
+
+ OpenAPI operation: ``sendEditLinkByEmail``.
+ Endpoint: ``POST /customers/{customer_id}/email-edit-link``.
+ """
+ return self._request(
+ 'POST',
+ '/customers/{customer_id}/email-edit-link',
+ path_params={'customer_id': customer_id},
+ data=data,
+ )
+
+ def validate_tax_info(self, customer_id: str) -> Any:
+ """Validate Customer Tax Info.
+
+ OpenAPI operation: ``validateCustomerTaxInfo``.
+ Endpoint: ``GET /customers/{customer_id}/tax-info-validation``.
+ """
+ return self._request(
+ 'GET',
+ '/customers/{customer_id}/tax-info-validation',
+ path_params={'customer_id': customer_id},
+ )
+
+
+class Products(_Service):
+ def create(self, data: Mapping[str, Any]) -> Any:
+ """Create Product.
+
+ OpenAPI operation: ``createProduct``.
+ Endpoint: ``POST /products``.
+ """
+ return self._request('POST', '/products', data=data)
+
+ def list(self, params: QueryParams | None = None) -> Any:
+ """List products.
+
+ OpenAPI operation: ``listProducts``.
+ Endpoint: ``GET /products``.
+ """
+ return self._request('GET', '/products', params=params)
+
+ def retrieve(self, product_id: str) -> Any:
+ """Retrieve product by ID.
+
+ OpenAPI operation: ``getProduct``.
+ Endpoint: ``GET /products/{product_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/products/{product_id}',
+ path_params={'product_id': product_id},
+ )
+
+ def update(self, product_id: str, data: Mapping[str, Any]) -> Any:
+ """Edit product.
+
+ OpenAPI operation: ``editProduct``.
+ Endpoint: ``PUT /products/{product_id}``.
+ """
+ return self._request(
+ 'PUT',
+ '/products/{product_id}',
+ path_params={'product_id': product_id},
+ data=data,
+ )
+
+ def delete(self, product_id: str) -> Any:
+ """Delete Product.
+
+ OpenAPI operation: ``deleteProduct``.
+ Endpoint: ``DELETE /products/{product_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/products/{product_id}',
+ path_params={'product_id': product_id},
+ )
+
+
+class Invoices(_Service):
+ def create(
+ self, data: Mapping[str, Any], params: QueryParams | None = None
+ ) -> Any:
+ """Create invoice (CFDI 4.0).
+
+ OpenAPI operation: ``createInvoice``.
+ Endpoint: ``POST /invoices``.
+ """
+ return self._request('POST', '/invoices', params=params, data=data)
+
+ def list(self, params: QueryParams | None = None) -> Any:
+ """List invoices.
+
+ OpenAPI operation: ``listInvoices``.
+ Endpoint: ``GET /invoices``.
+ """
+ return self._request('GET', '/invoices', params=params)
+
+ def retrieve(self, invoice_id: str) -> Any:
+ """Retrieve invoice by ID.
+
+ OpenAPI operation: ``getInvoice``.
+ Endpoint: ``GET /invoices/{invoice_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}',
+ path_params={'invoice_id': invoice_id},
+ )
+
+ def update_draft(self, invoice_id: str, data: Mapping[str, Any]) -> Any:
+ """Edit draft invoice.
+
+ OpenAPI operation: ``updateDraftInvoice``.
+ Endpoint: ``PUT /invoices/{invoice_id}``.
+ """
+ return self._request(
+ 'PUT',
+ '/invoices/{invoice_id}',
+ path_params={'invoice_id': invoice_id},
+ data=data,
+ )
+
+ def cancel(
+ self, invoice_id: str, params: QueryParams | None = None
+ ) -> Any:
+ """Cancel invoice.
+
+ OpenAPI operation: ``cancelInvoice``.
+ Endpoint: ``DELETE /invoices/{invoice_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/invoices/{invoice_id}',
+ path_params={'invoice_id': invoice_id},
+ params=params,
+ )
+
+ def copy_to_draft(self, invoice_id: str) -> Any:
+ """Copy to draft.
+
+ OpenAPI operation: ``copyToDraftInvoice``.
+ Endpoint: ``POST /invoices/{invoice_id}/copy``.
+ """
+ return self._request(
+ 'POST',
+ '/invoices/{invoice_id}/copy',
+ path_params={'invoice_id': invoice_id},
+ )
+
+ def stamp_draft(
+ self, invoice_id: str, params: QueryParams | None = None
+ ) -> Any:
+ """Stamp draft invoice.
+
+ OpenAPI operation: ``stampDraftInvoice``.
+ Endpoint: ``POST /invoices/{invoice_id}/stamp``.
+ """
+ return self._request(
+ 'POST',
+ '/invoices/{invoice_id}/stamp',
+ path_params={'invoice_id': invoice_id},
+ params=params,
+ )
+
+ def update_status(self, invoice_id: str) -> Any:
+ """Update invoice status
+ .
+
+ OpenAPI operation: ``updateInvoiceStatus``.
+ Endpoint: ``PUT /invoices/{invoice_id}/status``.
+ """
+ return self._request(
+ 'PUT',
+ '/invoices/{invoice_id}/status',
+ path_params={'invoice_id': invoice_id},
+ )
+
+ def payment_summary(
+ self, invoice_id: str, params: QueryParams | None = None
+ ) -> Any:
+ """Payment summary.
+
+ OpenAPI operation: ``getInvoicePaymentSummary``.
+ Endpoint: ``GET /invoices/{invoice_id}/payment-summary``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/payment-summary',
+ path_params={'invoice_id': invoice_id},
+ params=params,
+ )
+
+ def preview_pdf(self, data: Mapping[str, Any]) -> Any:
+ """Preview invoice PDF.
+
+ OpenAPI operation: ``previewInvoicePdf``.
+ Endpoint: ``POST /invoices/preview/pdf``.
+ """
+ return self._request(
+ 'POST', '/invoices/preview/pdf', data=data, response_type='bytes'
+ )
+
+ def preview_pdf_url(self, data: Mapping[str, Any]) -> Any:
+ """Get invoice PDF preview URL.
+
+ OpenAPI operation: ``previewInvoicePdfUrl``.
+ Endpoint: ``POST /invoices/preview/pdf/download-url``.
+ """
+ return self._request(
+ 'POST', '/invoices/preview/pdf/download-url', data=data
+ )
+
+ def download(self, invoice_id: str, format: str) -> Any:
+ """Download invoice.
+
+ OpenAPI operation: ``downloadInvoice``.
+ Endpoint: ``GET /invoices/{invoice_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/{format}',
+ path_params={'invoice_id': invoice_id, 'format': format},
+ response_type='bytes',
+ )
+
+ def download_pdf(self, invoice_id: str) -> Any:
+ """Download invoice.
+
+ OpenAPI operation: ``downloadInvoice``.
+ Endpoint: ``GET /invoices/{invoice_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'pdf'},
+ response_type='bytes',
+ )
+
+ def download_xml(self, invoice_id: str) -> Any:
+ """Download invoice.
+
+ OpenAPI operation: ``downloadInvoice``.
+ Endpoint: ``GET /invoices/{invoice_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'xml'},
+ response_type='bytes',
+ )
+
+ def download_zip(self, invoice_id: str) -> Any:
+ """Download invoice.
+
+ OpenAPI operation: ``downloadInvoice``.
+ Endpoint: ``GET /invoices/{invoice_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'zip'},
+ response_type='bytes',
+ )
+
+ def download_url(self, invoice_id: str, format: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getInvoiceDownloadUrl``.
+ Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/download-url/{format}',
+ path_params={'invoice_id': invoice_id, 'format': format},
+ )
+
+ def download_pdf_url(self, invoice_id: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getInvoiceDownloadUrl``.
+ Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/download-url/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'pdf'},
+ )
+
+ def download_xml_url(self, invoice_id: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getInvoiceDownloadUrl``.
+ Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/download-url/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'xml'},
+ )
+
+ def download_zip_url(self, invoice_id: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getInvoiceDownloadUrl``.
+ Endpoint: ``GET /invoices/{invoice_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/download-url/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'zip'},
+ )
+
+ def download_cancellation_receipt(
+ self, invoice_id: str, format: str
+ ) -> Any:
+ """Cancellation receipt.
+
+ OpenAPI operation: ``downloadCancellationReceiptXml``.
+ Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/cancellation_receipt/{format}',
+ path_params={'invoice_id': invoice_id, 'format': format},
+ response_type='bytes',
+ )
+
+ def download_cancellation_receipt_pdf(self, invoice_id: str) -> Any:
+ """Cancellation receipt.
+
+ OpenAPI operation: ``downloadCancellationReceiptXml``.
+ Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/cancellation_receipt/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'pdf'},
+ response_type='bytes',
+ )
+
+ def download_cancellation_receipt_xml(self, invoice_id: str) -> Any:
+ """Cancellation receipt.
+
+ OpenAPI operation: ``downloadCancellationReceiptXml``.
+ Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/cancellation_receipt/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'xml'},
+ response_type='bytes',
+ )
+
+ def download_cancellation_receipt_url(
+ self, invoice_id: str, format: str
+ ) -> Any:
+ """Get cancellation receipt download URL.
+
+ OpenAPI operation: ``getCancellationReceiptDownloadUrl``.
+ Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}',
+ path_params={'invoice_id': invoice_id, 'format': format},
+ )
+
+ def download_cancellation_receipt_pdf_url(self, invoice_id: str) -> Any:
+ """Get cancellation receipt download URL.
+
+ OpenAPI operation: ``getCancellationReceiptDownloadUrl``.
+ Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'pdf'},
+ )
+
+ def download_cancellation_receipt_xml_url(self, invoice_id: str) -> Any:
+ """Get cancellation receipt download URL.
+
+ OpenAPI operation: ``getCancellationReceiptDownloadUrl``.
+ Endpoint: ``GET /invoices/{invoice_id}/cancellation_receipt/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}',
+ path_params={'invoice_id': invoice_id, 'format': 'xml'},
+ )
+
+ def send_by_email(
+ self, invoice_id: str, data: Mapping[str, Any] | None = None
+ ) -> Any:
+ """Send invoice by email.
+
+ OpenAPI operation: ``sendInvoiceByEmail``.
+ Endpoint: ``POST /invoices/{invoice_id}/email``.
+ """
+ return self._request(
+ 'POST',
+ '/invoices/{invoice_id}/email',
+ path_params={'invoice_id': invoice_id},
+ data=data,
+ )
+
+ def create_zip_request(self, data: Mapping[str, Any]) -> Any:
+ """Create or retrieve a monthly ZIP request.
+
+ OpenAPI operation: ``createInvoiceZipRequest``.
+ Endpoint: ``POST /invoices/zip-requests``.
+ """
+ return self._request('POST', '/invoices/zip-requests', data=data)
+
+ def list_zip_requests(self, params: QueryParams | None = None) -> Any:
+ """List monthly ZIP requests.
+
+ OpenAPI operation: ``listInvoiceZipRequests``.
+ Endpoint: ``GET /invoices/zip-requests``.
+ """
+ return self._request('GET', '/invoices/zip-requests', params=params)
+
+ def retrieve_zip_request(self, id: str) -> Any:
+ """Retrieve a monthly ZIP request.
+
+ OpenAPI operation: ``retrieveInvoiceZipRequest``.
+ Endpoint: ``GET /invoices/zip-requests/{id}``.
+ """
+ return self._request(
+ 'GET', '/invoices/zip-requests/{id}', path_params={'id': id}
+ )
+
+ def download_zip_request(self, id: str) -> Any:
+ """Download a monthly ZIP.
+
+ OpenAPI operation: ``downloadInvoiceZipRequest``.
+ Endpoint: ``GET /invoices/zip-requests/{id}/zip``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/zip-requests/{id}/zip',
+ path_params={'id': id},
+ response_type='bytes',
+ )
+
+ def download_zip_request_url(self, id: str) -> Any:
+ """Get monthly ZIP download URL.
+
+ OpenAPI operation: ``getInvoiceZipRequestDownloadUrl``.
+ Endpoint: ``GET /invoices/zip-requests/{id}/download-url``.
+ """
+ return self._request(
+ 'GET',
+ '/invoices/zip-requests/{id}/download-url',
+ path_params={'id': id},
+ )
+
+
+class Receipts(_Service):
+ def create(self, data: Mapping[str, Any]) -> Any:
+ """Create e-receipt.
+
+ OpenAPI operation: ``createReceipt``.
+ Endpoint: ``POST /receipts``.
+ """
+ return self._request('POST', '/receipts', data=data)
+
+ def list(self, params: QueryParams | None = None) -> Any:
+ """List e-recipts.
+
+ OpenAPI operation: ``listReceipts``.
+ Endpoint: ``GET /receipts``.
+ """
+ return self._request('GET', '/receipts', params=params)
+
+ def retrieve(self, receipt_id: str) -> Any:
+ """Retrieve e-receipt by ID.
+
+ OpenAPI operation: ``getReceipt``.
+ Endpoint: ``GET /receipts/{receipt_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/receipts/{receipt_id}',
+ path_params={'receipt_id': receipt_id},
+ )
+
+ def update_customer(self, receipt_id: str, data: Mapping[str, Any]) -> Any:
+ """Assign or reassign customer to receipt.
+
+ OpenAPI operation: ``assignReceiptCustomer``.
+ Endpoint: ``PUT /receipts/{receipt_id}``.
+ """
+ return self._request(
+ 'PUT',
+ '/receipts/{receipt_id}',
+ path_params={'receipt_id': receipt_id},
+ data=data,
+ )
+
+ def cancel(self, receipt_id: str) -> Any:
+ """Cancel e-receipt.
+
+ OpenAPI operation: ``cancelReceipt``.
+ Endpoint: ``DELETE /receipts/{receipt_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/receipts/{receipt_id}',
+ path_params={'receipt_id': receipt_id},
+ )
+
+ def invoice(self, receipt_id: str, data: Mapping[str, Any]) -> Any:
+ """Convert e-receipt to invoice.
+
+ OpenAPI operation: ``invoiceReceipt``.
+ Endpoint: ``POST /receipts/{receipt_id}/invoice``.
+ """
+ return self._request(
+ 'POST',
+ '/receipts/{receipt_id}/invoice',
+ path_params={'receipt_id': receipt_id},
+ data=data,
+ )
+
+ def to_invoice(self, data: Mapping[str, Any]) -> Any:
+ """Create invoice from multiple receipts.
+
+ OpenAPI operation: ``createToInvoiceFromReceipts``.
+ Endpoint: ``POST /receipts/to-invoice``.
+ """
+ return self._request('POST', '/receipts/to-invoice', data=data)
+
+ def preview_to_invoice_pdf(self, data: Mapping[str, Any]) -> Any:
+ """Preview PDF for multiple receipts invoice.
+
+ OpenAPI operation: ``previewToInvoiceFromReceipts``.
+ Endpoint: ``POST /receipts/to-invoice/preview``.
+ """
+ return self._request(
+ 'POST',
+ '/receipts/to-invoice/preview',
+ data=data,
+ response_type='bytes',
+ )
+
+ def preview_to_invoice_pdf_url(self, data: Mapping[str, Any]) -> Any:
+ """Get receipts invoice preview URL.
+
+ OpenAPI operation: ``previewToInvoiceFromReceiptsUrl``.
+ Endpoint: ``POST /receipts/to-invoice/preview/download-url``.
+ """
+ return self._request(
+ 'POST', '/receipts/to-invoice/preview/download-url', data=data
+ )
+
+ def create_global_invoice(self, data: Mapping[str, Any]) -> Any:
+ """Create global invoice.
+
+ OpenAPI operation: ``createGlobalInvoice``.
+ Endpoint: ``POST /receipts/global-invoice``.
+ """
+ return self._request('POST', '/receipts/global-invoice', data=data)
+
+ def download_pdf(self, receipt_id: str) -> Any:
+ """Download PDF.
+
+ OpenAPI operation: ``downloadReceiptPdf``.
+ Endpoint: ``GET /receipts/{receipt_id}/pdf``.
+ """
+ return self._request(
+ 'GET',
+ '/receipts/{receipt_id}/pdf',
+ path_params={'receipt_id': receipt_id},
+ response_type='bytes',
+ )
+
+ def download_pdf_url(self, receipt_id: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getReceiptDownloadUrl``.
+ Endpoint: ``GET /receipts/{receipt_id}/download-url/pdf``.
+ """
+ return self._request(
+ 'GET',
+ '/receipts/{receipt_id}/download-url/pdf',
+ path_params={'receipt_id': receipt_id},
+ )
+
+ def send_by_email(
+ self, receipt_id: str, data: Mapping[str, Any] | None = None
+ ) -> Any:
+ """Send e-receipt by email.
+
+ OpenAPI operation: ``sendReceiptByEmail``.
+ Endpoint: ``POST /receipts/{receipt_id}/email``.
+ """
+ return self._request(
+ 'POST',
+ '/receipts/{receipt_id}/email',
+ path_params={'receipt_id': receipt_id},
+ data=data,
+ )
+
+
+class Retentions(_Service):
+ def create(self, data: Mapping[str, Any]) -> Any:
+ """Create retention.
+
+ OpenAPI operation: ``createRetention``.
+ Endpoint: ``POST /retentions``.
+ """
+ return self._request('POST', '/retentions', data=data)
+
+ def list(self, params: QueryParams | None = None) -> Any:
+ """List retentions.
+
+ OpenAPI operation: ``listRetentions``.
+ Endpoint: ``GET /retentions``.
+ """
+ return self._request('GET', '/retentions', params=params)
+
+ def retrieve(self, retention_id: str) -> Any:
+ """Retrieve retention by ID.
+
+ OpenAPI operation: ``getRetention``.
+ Endpoint: ``GET /retentions/{retention_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}',
+ path_params={'retention_id': retention_id},
+ )
+
+ def update_draft(self, retention_id: str, data: Mapping[str, Any]) -> Any:
+ """Edit draft retention.
+
+ OpenAPI operation: ``updateDraftRetention``.
+ Endpoint: ``PUT /retentions/{retention_id}``.
+ """
+ return self._request(
+ 'PUT',
+ '/retentions/{retention_id}',
+ path_params={'retention_id': retention_id},
+ data=data,
+ )
+
+ def cancel(
+ self, retention_id: str, params: QueryParams | None = None
+ ) -> Any:
+ """Cancel retention.
+
+ OpenAPI operation: ``cancelRetention``.
+ Endpoint: ``DELETE /retentions/{retention_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/retentions/{retention_id}',
+ path_params={'retention_id': retention_id},
+ params=params,
+ )
+
+ def copy_to_draft(self, retention_id: str) -> Any:
+ """Copy to draft.
+
+ OpenAPI operation: ``copyToDraftRetention``.
+ Endpoint: ``POST /retentions/{retention_id}/copy``.
+ """
+ return self._request(
+ 'POST',
+ '/retentions/{retention_id}/copy',
+ path_params={'retention_id': retention_id},
+ )
+
+ def stamp_draft(self, retention_id: str) -> Any:
+ """Stamp draft retention.
+
+ OpenAPI operation: ``stampDraftRetention``.
+ Endpoint: ``POST /retentions/{retention_id}/stamp``.
+ """
+ return self._request(
+ 'POST',
+ '/retentions/{retention_id}/stamp',
+ path_params={'retention_id': retention_id},
+ )
+
+ def download(self, retention_id: str, format: str) -> Any:
+ """Download retention.
+
+ OpenAPI operation: ``downloadRetention``.
+ Endpoint: ``GET /retentions/{retention_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/{format}',
+ path_params={'retention_id': retention_id, 'format': format},
+ response_type='bytes',
+ )
+
+ def download_pdf(self, retention_id: str) -> Any:
+ """Download retention.
+
+ OpenAPI operation: ``downloadRetention``.
+ Endpoint: ``GET /retentions/{retention_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/{format}',
+ path_params={'retention_id': retention_id, 'format': 'pdf'},
+ response_type='bytes',
+ )
+
+ def download_xml(self, retention_id: str) -> Any:
+ """Download retention.
+
+ OpenAPI operation: ``downloadRetention``.
+ Endpoint: ``GET /retentions/{retention_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/{format}',
+ path_params={'retention_id': retention_id, 'format': 'xml'},
+ response_type='bytes',
+ )
+
+ def download_zip(self, retention_id: str) -> Any:
+ """Download retention.
+
+ OpenAPI operation: ``downloadRetention``.
+ Endpoint: ``GET /retentions/{retention_id}/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/{format}',
+ path_params={'retention_id': retention_id, 'format': 'zip'},
+ response_type='bytes',
+ )
+
+ def download_url(self, retention_id: str, format: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getRetentionDownloadUrl``.
+ Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/download-url/{format}',
+ path_params={'retention_id': retention_id, 'format': format},
+ )
+
+ def download_pdf_url(self, retention_id: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getRetentionDownloadUrl``.
+ Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/download-url/{format}',
+ path_params={'retention_id': retention_id, 'format': 'pdf'},
+ )
+
+ def download_xml_url(self, retention_id: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getRetentionDownloadUrl``.
+ Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/download-url/{format}',
+ path_params={'retention_id': retention_id, 'format': 'xml'},
+ )
+
+ def download_zip_url(self, retention_id: str) -> Any:
+ """Get download URL.
+
+ OpenAPI operation: ``getRetentionDownloadUrl``.
+ Endpoint: ``GET /retentions/{retention_id}/download-url/{format}``.
+ """
+ return self._request(
+ 'GET',
+ '/retentions/{retention_id}/download-url/{format}',
+ path_params={'retention_id': retention_id, 'format': 'zip'},
+ )
+
+ def send_by_email(
+ self, retention_id: str, data: Mapping[str, Any] | None = None
+ ) -> Any:
+ """Send retention by email.
+
+ OpenAPI operation: ``sendRetentionByEmail``.
+ Endpoint: ``POST /retentions/{retention_id}/email``.
+ """
+ return self._request(
+ 'POST',
+ '/retentions/{retention_id}/email',
+ path_params={'retention_id': retention_id},
+ data=data,
+ )
+
+
+class Organizations(_Service):
+ def create(self, data: Mapping[str, Any]) -> Any:
+ """Create organization.
+
+ OpenAPI operation: ``createOrganization``.
+ Endpoint: ``POST /organizations``.
+ """
+ return self._request('POST', '/organizations', data=data)
+
+ def list(self, params: QueryParams | None = None) -> Any:
+ """List organizations.
+
+ OpenAPI operation: ``listOrganizations``.
+ Endpoint: ``GET /organizations``.
+ """
+ return self._request('GET', '/organizations', params=params)
+
+ def me(self) -> Any:
+ """Organization detail.
+
+ OpenAPI operation: ``meOrganization``.
+ Endpoint: ``GET /organizations/me``.
+ """
+ return self._request('GET', '/organizations/me')
+
+ def retrieve(self, organization_id: str) -> Any:
+ """Retrieve organization by ID.
+
+ OpenAPI operation: ``getOrganization``.
+ Endpoint: ``GET /organizations/{organization_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}',
+ path_params={'organization_id': organization_id},
+ )
+
+ def delete(self, organization_id: str) -> Any:
+ """Delete organization.
+
+ OpenAPI operation: ``deleteOrganization``.
+ Endpoint: ``DELETE /organizations/{organization_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/organizations/{organization_id}',
+ path_params={'organization_id': organization_id},
+ )
+
+ def update_legal(
+ self, organization_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Edit legal data.
+
+ OpenAPI operation: ``editOrganizationLegal``.
+ Endpoint: ``PUT /organizations/{organization_id}/legal``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/legal',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def upload_certificate(
+ self,
+ organization_id: str,
+ files: Mapping[str, Any],
+ data: Mapping[str, Any] | None = None,
+ ) -> Any:
+ """Upload certificates (CSD).
+
+ OpenAPI operation: ``uploadOrganizationCertificate``.
+ Endpoint: ``PUT /organizations/{organization_id}/certificate``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/certificate',
+ path_params={'organization_id': organization_id},
+ data=data,
+ files=files,
+ )
+
+ def delete_certificate(self, organization_id: str) -> Any:
+ """Delete certificates (CSD).
+
+ OpenAPI operation: ``deleteOrganizationCertificate``.
+ Endpoint: ``DELETE /organizations/{organization_id}/certificate``.
+ """
+ return self._request(
+ 'DELETE',
+ '/organizations/{organization_id}/certificate',
+ path_params={'organization_id': organization_id},
+ )
+
+ def upload_fiel(
+ self,
+ organization_id: str,
+ files: Mapping[str, Any],
+ data: Mapping[str, Any] | None = None,
+ ) -> Any:
+ """Upload FIEL certificate.
+
+ OpenAPI operation: ``uploadOrganizationFiel``.
+ Endpoint: ``PUT /organizations/{organization_id}/fiel``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/fiel',
+ path_params={'organization_id': organization_id},
+ data=data,
+ files=files,
+ )
+
+ def upload_logo(
+ self,
+ organization_id: str,
+ files: Mapping[str, Any],
+ data: Mapping[str, Any] | None = None,
+ ) -> Any:
+ """Upload logo.
+
+ OpenAPI operation: ``uploadOrganizationLogo``.
+ Endpoint: ``PUT /organizations/{organization_id}/logo``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/logo',
+ path_params={'organization_id': organization_id},
+ data=data,
+ files=files,
+ )
+
+ def update_customization(
+ self, organization_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Edit customization.
+
+ OpenAPI operation: ``editOrganizationCustomization``.
+ Endpoint: ``PUT /organizations/{organization_id}/customization``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/customization',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def update_receipt_settings(
+ self, organization_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Edit receipts settings.
+
+ OpenAPI operation: ``editOrganizationReceiptsSettings``.
+ Endpoint: ``PUT /organizations/{organization_id}/receipts``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/receipts',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def update_self_invoice_settings(
+ self, organization_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Edit self-invoice settings.
+
+ OpenAPI operation: ``editOrganizationSelfInvoiceSettings``.
+ Endpoint: ``PUT /organizations/{organization_id}/self-invoice``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/self-invoice',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def check_domain_is_available(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Check domain availability.
+
+ OpenAPI operation: ``checkDomainAvailability``.
+ Endpoint: ``GET /organizations/domain-check``.
+ """
+ return self._request(
+ 'GET', '/organizations/domain-check', params=params
+ )
+
+ def update_domain(
+ self, organization_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Choose self-invoice domain.
+
+ OpenAPI operation: ``editOrganizationDomain``.
+ Endpoint: ``PUT /organizations/{organization_id}/domain``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/domain',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def get_test_api_key(self, organization_id: str) -> Any:
+ """Retrieve Test API Key.
+
+ OpenAPI operation: ``getTestApiKey``.
+ Endpoint: ``GET /organizations/{organization_id}/apikeys/test``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/apikeys/test',
+ path_params={'organization_id': organization_id},
+ )
+
+ def renew_test_api_key(self, organization_id: str) -> Any:
+ """Renew Test API Key.
+
+ OpenAPI operation: ``renewTestApiKey``.
+ Endpoint: ``PUT /organizations/{organization_id}/apikeys/test``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/apikeys/test',
+ path_params={'organization_id': organization_id},
+ )
+
+ def list_live_api_keys(self, organization_id: str) -> Any:
+ """List Live API Keys.
+
+ OpenAPI operation: ``listLiveApiKeys``.
+ Endpoint: ``GET /organizations/{organization_id}/apikeys/live``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/apikeys/live',
+ path_params={'organization_id': organization_id},
+ )
+
+ def renew_live_api_key(self, organization_id: str) -> Any:
+ """Create Live API Key.
+
+ OpenAPI operation: ``renewLiveApiKey``.
+ Endpoint: ``PUT /organizations/{organization_id}/apikeys/live``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/apikeys/live',
+ path_params={'organization_id': organization_id},
+ )
+
+ def delete_live_api_key(self, organization_id: str, id: str) -> Any:
+ """Revoke Live API Key.
+
+ OpenAPI operation: ``deleteLiveApiKey``.
+ Endpoint: ``DELETE /organizations/{organization_id}/apikeys/live/{id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/organizations/{organization_id}/apikeys/live/{id}',
+ path_params={'organization_id': organization_id, 'id': id},
+ )
+
+ def list_series_group(self, organization_id: str) -> Any:
+ """Listado de series.
+
+ OpenAPI operation: ``getSeriesGroup``.
+ Endpoint: ``GET /organizations/{organization_id}/series-group``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/series-group',
+ path_params={'organization_id': organization_id},
+ )
+
+ def create_series_group(
+ self, organization_id: str, data: Mapping[str, Any] | None = None
+ ) -> Any:
+ """Create Series.
+
+ OpenAPI operation: ``createSeriesGroup``.
+ Endpoint: ``POST /organizations/{organization_id}/series-group``.
+ """
+ return self._request(
+ 'POST',
+ '/organizations/{organization_id}/series-group',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def update_default_series(
+ self, organization_id: str, data: Mapping[str, Any] | None = None
+ ) -> Any:
+ """Set default series.
+
+ OpenAPI operation: ``updateDefaultSeries``.
+ Endpoint: ``PUT /organizations/{organization_id}/series-group/default-series``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/series-group/default-series',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def update_series_group(
+ self,
+ organization_id: str,
+ series_name: str,
+ data: Mapping[str, Any] | None = None,
+ ) -> Any:
+ """Update Series.
+
+ OpenAPI operation: ``updateSeriesGroup``.
+ Endpoint: ``PUT /organizations/{organization_id}/series-group/{series_name}``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/series-group/{series_name}',
+ path_params={
+ 'organization_id': organization_id,
+ 'series_name': series_name,
+ },
+ data=data,
+ )
+
+ def delete_series_group(
+ self, organization_id: str, series_name: str
+ ) -> Any:
+ """Delete series.
+
+ OpenAPI operation: ``deleteSeriesGroup``.
+ Endpoint: ``DELETE /organizations/{organization_id}/series-group/{series_name}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/organizations/{organization_id}/series-group/{series_name}',
+ path_params={
+ 'organization_id': organization_id,
+ 'series_name': series_name,
+ },
+ )
+
+ def list_team_access(self, organization_id: str) -> Any:
+ """List users with organization access.
+
+ OpenAPI operation: ``getOrganizationTeam``.
+ Endpoint: ``GET /organizations/{organization_id}/team``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/team',
+ path_params={'organization_id': organization_id},
+ )
+
+ def invite_user_to_team(
+ self, organization_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Invite user to organization.
+
+ OpenAPI operation: ``createOrganizationTeamInvite``.
+ Endpoint: ``POST /organizations/{organization_id}/team/invites``.
+ """
+ return self._request(
+ 'POST',
+ '/organizations/{organization_id}/team/invites',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def list_sent_team_invites(self, organization_id: str) -> Any:
+ """List sent invites.
+
+ OpenAPI operation: ``listOrganizationTeamInvites``.
+ Endpoint: ``GET /organizations/{organization_id}/team/invites``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/team/invites',
+ path_params={'organization_id': organization_id},
+ )
+
+ def retrieve_team_access(
+ self, organization_id: str, access_id: str
+ ) -> Any:
+ """Retrieve user access.
+
+ OpenAPI operation: ``getOrganizationTeamUser``.
+ Endpoint: ``GET /organizations/{organization_id}/team/{access_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/team/{access_id}',
+ path_params={
+ 'organization_id': organization_id,
+ 'access_id': access_id,
+ },
+ )
+
+ def remove_team_access(self, organization_id: str, access_id: str) -> Any:
+ """Remove user access.
+
+ OpenAPI operation: ``removeOrganizationUserAccess``.
+ Endpoint: ``DELETE /organizations/{organization_id}/team/{access_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/organizations/{organization_id}/team/{access_id}',
+ path_params={
+ 'organization_id': organization_id,
+ 'access_id': access_id,
+ },
+ )
+
+ def cancel_team_invite(self, organization_id: str, invite_key: str) -> Any:
+ """Cancel sent invite.
+
+ OpenAPI operation: ``deleteOrganizationTeamInvite``.
+ Endpoint: ``DELETE /organizations/{organization_id}/team/invites/{invite_key}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/organizations/{organization_id}/team/invites/{invite_key}',
+ path_params={
+ 'organization_id': organization_id,
+ 'invite_key': invite_key,
+ },
+ )
+
+ def list_received_team_invites(self) -> Any:
+ """List received invites.
+
+ OpenAPI operation: ``listPendingOrganizationInvites``.
+ Endpoint: ``GET /organizations/invites/pending``.
+ """
+ return self._request('GET', '/organizations/invites/pending')
+
+ def respond_team_invite(
+ self, invite_key: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Respond to invite.
+
+ OpenAPI operation: ``respondOrganizationInvite``.
+ Endpoint: ``POST /organizations/invites/{invite_key}/response``.
+ """
+ return self._request(
+ 'POST',
+ '/organizations/invites/{invite_key}/response',
+ path_params={'invite_key': invite_key},
+ data=data,
+ )
+
+ def list_team_roles(self, organization_id: str) -> Any:
+ """List organization roles.
+
+ OpenAPI operation: ``listOrganizationPermissionRoles``.
+ Endpoint: ``GET /organizations/{organization_id}/team/roles``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/team/roles',
+ path_params={'organization_id': organization_id},
+ )
+
+ def create_team_role(
+ self, organization_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Create organization role.
+
+ OpenAPI operation: ``createOrganizationPermissionRole``.
+ Endpoint: ``POST /organizations/{organization_id}/team/roles``.
+ """
+ return self._request(
+ 'POST',
+ '/organizations/{organization_id}/team/roles',
+ path_params={'organization_id': organization_id},
+ data=data,
+ )
+
+ def list_team_role_templates(self, organization_id: str) -> Any:
+ """List role templates.
+
+ OpenAPI operation: ``listOrganizationPermissionRoleTemplates``.
+ Endpoint: ``GET /organizations/{organization_id}/team/roles/templates``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/team/roles/templates',
+ path_params={'organization_id': organization_id},
+ )
+
+ def list_team_role_operations(self, organization_id: str) -> Any:
+ """List permission operations.
+
+ OpenAPI operation: ``listOrganizationPermissionOperations``.
+ Endpoint: ``GET /organizations/{organization_id}/team/roles/operations``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/team/roles/operations',
+ path_params={'organization_id': organization_id},
+ )
+
+ def retrieve_team_role(self, organization_id: str, role_id: str) -> Any:
+ """Retrieve organization role.
+
+ OpenAPI operation: ``getOrganizationPermissionRole``.
+ Endpoint: ``GET /organizations/{organization_id}/team/roles/{role_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/organizations/{organization_id}/team/roles/{role_id}',
+ path_params={
+ 'organization_id': organization_id,
+ 'role_id': role_id,
+ },
+ )
+
+ def update_team_role(
+ self, organization_id: str, role_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Update organization role.
+
+ OpenAPI operation: ``updateOrganizationPermissionRole``.
+ Endpoint: ``PUT /organizations/{organization_id}/team/roles/{role_id}``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/team/roles/{role_id}',
+ path_params={
+ 'organization_id': organization_id,
+ 'role_id': role_id,
+ },
+ data=data,
+ )
+
+ def delete_team_role(self, organization_id: str, role_id: str) -> Any:
+ """Delete organization role.
+
+ OpenAPI operation: ``deleteOrganizationPermissionRole``.
+ Endpoint: ``DELETE /organizations/{organization_id}/team/roles/{role_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/organizations/{organization_id}/team/roles/{role_id}',
+ path_params={
+ 'organization_id': organization_id,
+ 'role_id': role_id,
+ },
+ )
+
+ def update_team_access_role(
+ self, organization_id: str, access_id: str, data: Mapping[str, Any]
+ ) -> Any:
+ """Reassign role to user.
+
+ OpenAPI operation: ``updateOrganizationTeamUserRole``.
+ Endpoint: ``PUT /organizations/{organization_id}/team/{access_id}/role``.
+ """
+ return self._request(
+ 'PUT',
+ '/organizations/{organization_id}/team/{access_id}/role',
+ path_params={
+ 'organization_id': organization_id,
+ 'access_id': access_id,
+ },
+ data=data,
+ )
+
+
+class Webhooks(_Service):
+ def create(self, data: Mapping[str, Any]) -> Any:
+ """Create webhook.
+
+ OpenAPI operation: ``createWebhook``.
+ Endpoint: ``POST /webhooks``.
+ """
+ return self._request('POST', '/webhooks', data=data)
+
+ def list(self, params: QueryParams | None = None) -> Any:
+ """List webhooks.
+
+ OpenAPI operation: ``listWebhooks``.
+ Endpoint: ``GET /webhooks``.
+ """
+ return self._request('GET', '/webhooks', params=params)
+
+ def retrieve(self, webhook_id: str) -> Any:
+ """Retrieve webhook by ID.
+
+ OpenAPI operation: ``getWebhook``.
+ Endpoint: ``GET /webhooks/{webhook_id}``.
+ """
+ return self._request(
+ 'GET',
+ '/webhooks/{webhook_id}',
+ path_params={'webhook_id': webhook_id},
+ )
+
+ def update(self, webhook_id: str, data: Mapping[str, Any]) -> Any:
+ """Edit webhook.
+
+ OpenAPI operation: ``editWebhook``.
+ Endpoint: ``PUT /webhooks/{webhook_id}``.
+ """
+ return self._request(
+ 'PUT',
+ '/webhooks/{webhook_id}',
+ path_params={'webhook_id': webhook_id},
+ data=data,
+ )
+
+ def delete(self, webhook_id: str) -> Any:
+ """Delete Webhook.
+
+ OpenAPI operation: ``deleteWebhook``.
+ Endpoint: ``DELETE /webhooks/{webhook_id}``.
+ """
+ return self._request(
+ 'DELETE',
+ '/webhooks/{webhook_id}',
+ path_params={'webhook_id': webhook_id},
+ )
+
+ def validate_signature(self, data: Mapping[str, Any]) -> Any:
+ """Validate Webhook Signature.
+
+ OpenAPI operation: ``validateWebhookSignature``.
+ Endpoint: ``POST /webhooks/validate-signature``.
+ """
+ return self._request('POST', '/webhooks/validate-signature', data=data)
+
+
+class Tools(_Service):
+ def health_check(self) -> Any:
+ """Health check.
+
+ OpenAPI operation: ``checkApiHealth``.
+ Endpoint: ``GET /check``.
+ """
+ return self._request('GET', '/check')
+
+ def validate_tax_id(self, tax_id: str) -> Any:
+ """Validate RFC (tax_id).
+
+ OpenAPI operation: ``validateTaxId``.
+ Endpoint: ``GET /tools/tax_id_validation``.
+ """
+ return self._request(
+ 'GET', '/tools/tax_id_validation', params={'tax_id': tax_id}
+ )
+
+
+class Catalogs(_Service):
+ def search_products(self, params: QueryParams | None = None) -> Any:
+ """Product/Service Key.
+
+ OpenAPI operation: ``searchProducts``.
+ Endpoint: ``GET /catalogs/products``.
+ """
+ return self._request('GET', '/catalogs/products', params=params)
+
+ def search_units(self, params: QueryParams | None = None) -> Any:
+ """Units of Measure.
+
+ OpenAPI operation: ``searchUnits``.
+ Endpoint: ``GET /catalogs/units``.
+ """
+ return self._request('GET', '/catalogs/units', params=params)
+
+
+class NominaCatalogs(_Service):
+ def search_deductions(self, params: QueryParams | None = None) -> Any:
+ """Search payroll deduction types.
+
+ OpenAPI operation: ``searchNominaDeductions``.
+ Endpoint: ``GET /catalogs/deductions``.
+ """
+ return self._request('GET', '/catalogs/deductions', params=params)
+
+ def search_perceptions(self, params: QueryParams | None = None) -> Any:
+ """Search payroll perception types.
+
+ OpenAPI operation: ``searchNominaPerceptions``.
+ Endpoint: ``GET /catalogs/perceptions``.
+ """
+ return self._request('GET', '/catalogs/perceptions', params=params)
+
+
+class CartaPorteCatalogs(_Service):
+ def search_air_transport_codes(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search air transport codes.
+
+ OpenAPI operation: ``searchCartaPorteAirTransportCodes``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/air-transport-codes``.
+ """
+ return self._request(
+ 'GET',
+ '/catalogs/cartaporte/3.1/air-transport-codes',
+ params=params,
+ )
+
+ def search_transport_configs(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search auto transport configurations.
+
+ OpenAPI operation: ``searchCartaPorteTransportConfigs``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/transport-configs``.
+ """
+ return self._request(
+ 'GET', '/catalogs/cartaporte/3.1/transport-configs', params=params
+ )
+
+ def search_rights_of_passage(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search rights of passage.
+
+ OpenAPI operation: ``searchCartaPorteRightsOfPassage``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/rights-of-passage``.
+ """
+ return self._request(
+ 'GET', '/catalogs/cartaporte/3.1/rights-of-passage', params=params
+ )
+
+ def search_customs_documents(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search customs documents.
+
+ OpenAPI operation: ``searchCartaPorteCustomsDocuments``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/customs-documents``.
+ """
+ return self._request(
+ 'GET', '/catalogs/cartaporte/3.1/customs-documents', params=params
+ )
+
+ def search_packaging_types(self, params: QueryParams | None = None) -> Any:
+ """Search packaging types.
+
+ OpenAPI operation: ``searchCartaPortePackagingTypes``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/packaging-types``.
+ """
+ return self._request(
+ 'GET', '/catalogs/cartaporte/3.1/packaging-types', params=params
+ )
+
+ def search_trailer_types(self, params: QueryParams | None = None) -> Any:
+ """Search trailer types.
+
+ OpenAPI operation: ``searchCartaPorteTrailerTypes``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/trailer-types``.
+ """
+ return self._request(
+ 'GET', '/catalogs/cartaporte/3.1/trailer-types', params=params
+ )
+
+ def search_hazardous_materials(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search hazardous materials.
+
+ OpenAPI operation: ``searchCartaPorteHazardousMaterials``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/hazardous-materials``.
+ """
+ return self._request(
+ 'GET',
+ '/catalogs/cartaporte/3.1/hazardous-materials',
+ params=params,
+ )
+
+ def search_naval_authorizations(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search naval authorizations.
+
+ OpenAPI operation: ``searchCartaPorteNavalAuthorizations``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/naval-authorizations``.
+ """
+ return self._request(
+ 'GET',
+ '/catalogs/cartaporte/3.1/naval-authorizations',
+ params=params,
+ )
+
+ def search_port_stations(self, params: QueryParams | None = None) -> Any:
+ """Search port stations.
+
+ OpenAPI operation: ``searchCartaPortePortStations``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/port-stations``.
+ """
+ return self._request(
+ 'GET', '/catalogs/cartaporte/3.1/port-stations', params=params
+ )
+
+ def search_marine_containers(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search marine containers.
+
+ OpenAPI operation: ``searchCartaPorteMarineContainers``.
+ Endpoint: ``GET /catalogs/cartaporte/3.1/marine-containers``.
+ """
+ return self._request(
+ 'GET', '/catalogs/cartaporte/3.1/marine-containers', params=params
+ )
+
+
+class ComercioExteriorCatalogs(_Service):
+ def search_tariff_fractions(
+ self, params: QueryParams | None = None
+ ) -> Any:
+ """Search tariff fractions.
+
+ OpenAPI operation: ``searchComercioExteriorTariffFractions``.
+ Endpoint: ``GET /catalogs/comercioexterior/2.0/tariff-fractions``.
+ """
+ return self._request(
+ 'GET',
+ '/catalogs/comercioexterior/2.0/tariff-fractions',
+ params=params,
+ )
diff --git a/facturapi/client.py b/facturapi/client.py
new file mode 100644
index 0000000..cd40d6a
--- /dev/null
+++ b/facturapi/client.py
@@ -0,0 +1,85 @@
+"""Modern, instance-based Facturapi client.
+
+The resource classes imported here are generated from Facturapi's OpenAPI
+document. The legacy ``facturapi.Customer`` and ``facturapi.Invoice`` APIs
+remain available for backwards compatibility.
+"""
+
+from collections.abc import Mapping
+from typing import Any
+
+from ._generated import (
+ CartaPorteCatalogs,
+ Catalogs,
+ ComercioExteriorCatalogs,
+ Customers,
+ Invoices,
+ NominaCatalogs,
+ Organizations,
+ Products,
+ Receipts,
+ Retentions,
+ Tools,
+ Webhooks,
+)
+from .http.client import API_HOST, FACTURAPI_TIMEOUT, Client
+
+
+class Facturapi:
+ """Synchronous client for every operation in the Facturapi API.
+
+ Args:
+ api_key: Test, Live, or User API key. If omitted, ``FACTURAPI_KEY`` is
+ read from the environment.
+ base_url: API base URL. Override this for tests or compatible proxies.
+ timeout: Request timeout in seconds.
+ headers: Extra default request headers.
+ """
+
+ def __init__(
+ self,
+ api_key: str | None = None,
+ *,
+ base_url: str = API_HOST,
+ timeout: float = FACTURAPI_TIMEOUT,
+ headers: Mapping[str, str] | None = None,
+ http_client: Client | None = None,
+ ) -> None:
+ self.http = http_client or Client(
+ api_key,
+ base_url=base_url,
+ timeout=timeout,
+ headers=headers,
+ )
+ self.customers = Customers(self.http)
+ self.products = Products(self.http)
+ self.invoices = Invoices(self.http)
+ self.receipts = Receipts(self.http)
+ self.retentions = Retentions(self.http)
+ self.organizations = Organizations(self.http)
+ self.webhooks = Webhooks(self.http)
+ self.tools = Tools(self.http)
+ self.catalogs = Catalogs(self.http)
+ self.nomina_catalogs = NominaCatalogs(self.http)
+ self.carta_porte_catalogs = CartaPorteCatalogs(self.http)
+ self.comercio_exterior_catalogs = ComercioExteriorCatalogs(self.http)
+
+ @property
+ def base_url(self) -> str:
+ return self.http.host
+
+ @base_url.setter
+ def base_url(self, value: str) -> None:
+ self.http.host = value.rstrip('/') + '/'
+
+ def close(self) -> None:
+ self.http.close()
+
+ def __enter__(self) -> 'Facturapi':
+ return self
+
+ def __exit__(self, *_: Any) -> None:
+ self.close()
+
+
+FacturAPI = Facturapi
diff --git a/facturapi/http/client.py b/facturapi/http/client.py
index 4afb927..ebabba8 100644
--- a/facturapi/http/client.py
+++ b/facturapi/http/client.py
@@ -1,5 +1,8 @@
import os
-from typing import Any, MutableMapping
+from collections.abc import Mapping, Sequence
+from datetime import date, datetime
+from enum import Enum
+from typing import Any
from urllib.parse import urljoin
import httpx
@@ -8,9 +11,11 @@
from ..types.exc import FacturapiResponseException
from ..version import CLIENT_VERSION
-API_HOST = 'www.facturapi.io/v2'
+API_HOST = 'https://www.facturapi.io/v2/'
FACTURAPI_TIMEOUT = float(os.getenv('FACTURAPI_TIMEOUT', 10.0))
+QueryParams = Mapping[str, Any] | Sequence[tuple[str, Any]]
+
class Client:
"""Client to perform http requests to Facturapi.
@@ -31,18 +36,28 @@ class Client:
host: str = API_HOST
client: httpx.Client
- def __init__(self) -> None:
- self.client = httpx.Client(timeout=FACTURAPI_TIMEOUT)
+ def __init__(
+ self,
+ api_key: str | None = None,
+ *,
+ base_url: str = API_HOST,
+ timeout: float = FACTURAPI_TIMEOUT,
+ headers: Mapping[str, str] | None = None,
+ ) -> None:
+ self.host = base_url.rstrip('/') + '/'
+ self.client = httpx.Client(timeout=timeout)
self.client.headers.update(
{
'User-Agent': f'facturapi-python/{CLIENT_VERSION}',
- 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
}
)
+ if headers:
+ self.client.headers.update(headers)
# Auth
- self.api_key = os.getenv('FACTURAPI_KEY', '')
- self.client.auth = httpx.BasicAuth(self.api_key, '')
+ self.api_key = api_key or os.getenv('FACTURAPI_KEY', '')
+ self._set_auth()
def configure(self, api_key: str) -> None:
"""Configure the http client.
@@ -55,36 +70,61 @@ def configure(self, api_key: str) -> None:
"""
self.api_key = api_key
- self.client.auth = httpx.BasicAuth(self.api_key, '')
+ self._set_auth()
+
+ def _set_auth(self) -> None:
+ """Set the current Bearer token without leaking it to other clients."""
+ if self.api_key:
+ self.client.headers['Authorization'] = f'Bearer {self.api_key}'
+ else:
+ self.client.headers.pop('Authorization', None)
def get(
self,
endpoint: str,
- params: bytes | MutableMapping[str, str] | None = None,
- ) -> dict[str, Any]:
+ params: QueryParams | None = None,
+ **kwargs: Any,
+ ) -> Any:
"""Performs GET request to Facturapi."""
- return self.request('get', endpoint, params=params)
+ return self.request('get', endpoint, params=params, **kwargs)
- def post(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]:
+ def post(
+ self,
+ endpoint: str,
+ data: Mapping[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> Any:
"""Performs POST request to Facturapi."""
- return self.request('post', endpoint, data=data)
+ return self.request('post', endpoint, data=data, **kwargs)
- def put(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]:
+ def put(
+ self,
+ endpoint: str,
+ data: Mapping[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> Any:
"""Performs PUT request to Facturapi."""
- return self.request('put', endpoint, data=data)
+ return self.request('put', endpoint, data=data, **kwargs)
- def delete(self, endpoint: str) -> dict[str, Any]:
+ def delete(
+ self,
+ endpoint: str,
+ params: QueryParams | None = None,
+ **kwargs: Any,
+ ) -> Any:
"""Performs DELETE request to Facturapi."""
- return self.request('delete', endpoint)
+ return self.request('delete', endpoint, params=params, **kwargs)
def request(
self,
method: str,
endpoint: str,
- params: bytes | MutableMapping[str, str] | None = None,
- data: dict[str, int | str] | None = None,
- **kwargs,
- ) -> dict[str, Any]:
+ params: QueryParams | None = None,
+ data: Mapping[str, Any] | None = None,
+ files: Mapping[str, Any] | None = None,
+ response_type: str = 'auto',
+ **kwargs: Any,
+ ) -> Any:
"""Performs a request to Facturapi.
Given a `method` and `endpoint`, perform a request to
@@ -105,15 +145,37 @@ def request(
successful.
"""
+ request_kwargs: dict[str, Any] = dict(kwargs)
+ if files is not None:
+ request_kwargs['files'] = files
+ request_kwargs['data'] = dict(data or {})
+ elif data is not None:
+ request_kwargs['json'] = self._serialize(data)
+
response = self.client.request(
method=method,
- url=('https://' + self.host + urljoin('/', endpoint)),
- json=data,
- params=params,
- **kwargs,
+ url=urljoin(self.host, endpoint.lstrip('/')),
+ params=(
+ httpx.QueryParams(self._query_items(params))
+ if params is not None
+ else None
+ ),
+ **request_kwargs,
)
self._check_response(response)
- return response.json()
+ if response_type == 'bytes':
+ return response.content
+ if response_type == 'text':
+ return response.text
+
+ content_type = response.headers.get('content-type', '').lower()
+ if 'json' in content_type:
+ return response.json()
+ if response_type == 'json':
+ return response.json()
+ if content_type.startswith('text/'):
+ return response.text
+ return response.content
def download_request(
self,
@@ -134,18 +196,80 @@ def download_request(
successful.
"""
- response = self.client.request(
- method='GET',
- url=('https://' + self.host + urljoin('/', endpoint)),
- **kwargs,
- )
- self._check_response(response)
- return response.content
+ return self.request('GET', endpoint, response_type='bytes', **kwargs)
+
+ def close(self) -> None:
+ """Close the underlying HTTP connection pool."""
+ self.client.close()
+
+ def __enter__(self) -> 'Client':
+ return self
+
+ def __exit__(self, *_: Any) -> None:
+ self.close()
+
+ @classmethod
+ def _serialize(cls, value: Any) -> Any:
+ if hasattr(value, 'model_dump'):
+ return cls._serialize(
+ value.model_dump(exclude_none=True, exclude_unset=True)
+ )
+ if isinstance(value, Mapping):
+ return {key: cls._serialize(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [cls._serialize(item) for item in value]
+ if isinstance(value, (datetime, date)):
+ return value.isoformat()
+ if isinstance(value, Enum):
+ return value.value
+ return value
+
+ @classmethod
+ def _query_items(
+ cls, params: QueryParams | None
+ ) -> list[tuple[str, str | int | float | bool | None]] | None:
+ """Encode deep objects and arrays according to the OpenAPI contract."""
+ if params is None:
+ return None
+ source = params.items() if isinstance(params, Mapping) else params
+ items: list[tuple[str, str | int | float | bool | None]] = []
+
+ def append(key: str, value: Any) -> None:
+ if value is None:
+ return
+ if isinstance(value, Mapping):
+ for child_key, child_value in value.items():
+ append(f'{key}[{child_key}]', child_value)
+ return
+ if isinstance(value, (list, tuple, set)):
+ for child_value in value:
+ append(key, child_value)
+ return
+ value = cls._serialize(value)
+ if isinstance(value, bool):
+ rendered = str(value).lower()
+ else:
+ rendered = str(value)
+ items.append((key, rendered))
+
+ for key, value in source:
+ append(str(key), value)
+ return items
@staticmethod
def _check_response(response: Response) -> None:
if not response.is_success:
+ try:
+ parsed = response.json()
+ body = (
+ parsed
+ if isinstance(parsed, dict)
+ else {'message': str(parsed)}
+ )
+ except ValueError:
+ body = {'message': response.text or response.reason_phrase}
raise FacturapiResponseException(
- json=response.json(),
+ json=body,
status_code=response.status_code,
+ headers=dict(response.headers),
)
diff --git a/facturapi/resources/base.py b/facturapi/resources/base.py
index c9ee833..4cafd2a 100644
--- a/facturapi/resources/base.py
+++ b/facturapi/resources/base.py
@@ -290,8 +290,7 @@ def count(cls, **query_params) -> int:
"""
q = cls._query_params(**query_params)
response = client.get(cls._resource, q.dict())
- items = response['data']
- return len(items)
+ return response.get('total_results', len(response['data']))
@classmethod
def all(cls, **query_params) -> Generator[Resource, None, None]:
@@ -309,13 +308,26 @@ def all(cls, **query_params) -> Generator[Resource, None, None]:
"""
q = cls._query_params(**query_params)
- next_page_uri = f'{cls._resource}?{urlencode(q.dict())}'
+ params = q.dict()
+ encoded_params = urlencode(params)
+ next_page_uri = (
+ f'{cls._resource}?{encoded_params}'
+ if encoded_params
+ else cls._resource
+ )
current_page = 1
while next_page_uri:
page = client.get(next_page_uri)
yield from (cls._from_dict(item) for item in page['data'])
next_page_uri = ''
- if current_page < page['total_pages']:
+
+ if q.pagination == 'cursor':
+ next_cursor = page.get('next_cursor')
+ if next_cursor:
+ params.pop('before', None)
+ params['after'] = next_cursor
+ next_page_uri = f'{cls._resource}?{urlencode(params)}'
+ elif current_page < page['total_pages']:
current_page += 1
- q.page = current_page
- next_page_uri = f'{cls._resource}?{urlencode(q.dict())}'
+ params['page'] = current_page
+ next_page_uri = f'{cls._resource}?{urlencode(params)}'
diff --git a/facturapi/resources/invoices.py b/facturapi/resources/invoices.py
index 1d328d3..aee737d 100644
--- a/facturapi/resources/invoices.py
+++ b/facturapi/resources/invoices.py
@@ -106,7 +106,7 @@ class InvoiceRequest(BaseModel):
relation: InvoiceRelation | None = None
pdf_custom_section: str | None = None
addenda: str | None = None
- namespaces: Namespace | None = None
+ namespaces: list[Namespace] | None = None
@dataclass
diff --git a/facturapi/types/enums.py b/facturapi/types/enums.py
index 6ffb74c..e148fdb 100644
--- a/facturapi/types/enums.py
+++ b/facturapi/types/enums.py
@@ -71,7 +71,7 @@ class PaymentForm(str, Enum):
tarjeta_de_credito = '04'
monedero_electronico = '05'
dinero_electronico = '06'
- vales_de_despensa = '06'
+ vales_de_despensa = '08'
dacion_en_pago = '12'
pago_por_subrogacion = '13'
pago_por_consignacion = '14'
diff --git a/facturapi/types/exc.py b/facturapi/types/exc.py
index a80b92c..a10cf61 100644
--- a/facturapi/types/exc.py
+++ b/facturapi/types/exc.py
@@ -18,6 +18,16 @@ class MultipleResultsFound(FacturapiException):
class FacturapiResponseException(FacturapiException):
json: dict[str, Any]
status_code: int
+ headers: dict[str, str] | None = None
+
+ @property
+ def message(self) -> str:
+ return str(self.json.get('message', 'Facturapi request failed'))
+
+ @property
+ def code(self) -> str | None:
+ value = self.json.get('code')
+ return str(value) if value is not None else None
def __str__(self) -> str:
- return repr(self)
+ return self.message
diff --git a/facturapi/types/queries.py b/facturapi/types/queries.py
index da058ee..3ae9cb3 100644
--- a/facturapi/types/queries.py
+++ b/facturapi/types/queries.py
@@ -3,7 +3,7 @@
from pydantic import BaseModel, Field
-MAX_PAGE_SIZE = 50
+MAX_PAGE_SIZE = 100
MIN_PAGE = 1
PageSize = Annotated[int, Field(gt=0, le=MAX_PAGE_SIZE)]
@@ -52,6 +52,9 @@ class BaseQuery(BaseModel):
"""
q: str | None = None
+ pagination: str | None = None
+ after: str | None = None
+ before: str | None = None
limit: PageSize | None = MAX_PAGE_SIZE
page: Page | None = MIN_PAGE
date: DateFilter | None = None
diff --git a/facturapi/version.py b/facturapi/version.py
index bf5ac87..0c141bf 100644
--- a/facturapi/version.py
+++ b/facturapi/version.py
@@ -1,2 +1,2 @@
-__version__ = '1.0.1'
+__version__ = '2.0.0'
CLIENT_VERSION = __version__
diff --git a/mkdocs.yml b/mkdocs.yml
index 7631f6d..b852c40 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,7 +1,7 @@
site_name: "facturapi-python"
site_description: "Facturapi Python Library"
-repo_url: "https://github.com/cuenca-mx/facturapi-python"
-repo_name: "cuenca-mx/facturapi-python"
+repo_url: "https://github.com/FacturAPI/facturapi-python"
+repo_name: "FacturAPI/facturapi-python"
theme:
name: material
@@ -21,12 +21,11 @@ plugins:
rendering:
show_root_full_path: no
show_source: no
- watch:
- - src/facturapi
nav:
- Overview: index.md
- Code Reference:
- Base code: resources/resources.md
- Customers: resources/customers.md
- - Invoices: resources/invoices.md
\ No newline at end of file
+ - Invoices: resources/invoices.md
+ - Generated API: openapi.md
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8fbb1d7
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,52 @@
+[build-system]
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "facturapi"
+dynamic = ["version"]
+description = "Official Python SDK for the Facturapi API"
+readme = "README.md"
+requires-python = ">=3.10"
+license = "MIT"
+authors = [
+ { name = "Facturapi", email = "contacto@facturapi.io" },
+]
+keywords = [
+ "cfdi",
+ "factura",
+ "facturacion-electronica",
+ "mexico",
+ "sat",
+]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Operating System :: OS Independent",
+ "Typing :: Typed",
+]
+dependencies = [
+ "httpx>=0.28.0,<1.0.0",
+ "pydantic>=2.13.0,<3.0.0",
+]
+
+[project.urls]
+Homepage = "https://www.facturapi.io"
+Documentation = "https://docs.facturapi.io"
+Repository = "https://github.com/FacturAPI/facturapi-python"
+Issues = "https://github.com/FacturAPI/facturapi-python/issues"
+
+[tool.setuptools.dynamic]
+version = { attr = "facturapi.version.__version__" }
+
+[tool.setuptools.package-data]
+facturapi = ["py.typed"]
+
+[tool.setuptools.packages.find]
+include = ["facturapi*"]
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 0000000..404e523
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1,2 @@
+mkdocs-material==9.7.*
+mkdocstrings[python]==1.0.*
diff --git a/requirements-test.txt b/requirements-test.txt
index 80fa901..05f1ea0 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -5,3 +5,7 @@ pytest-vcr==1.*
isort==6.*
flake8==7.*
mypy==1.*
+build==1.*
+twine==6.*
+PyYAML==6.*
+types-PyYAML==6.*
diff --git a/requirements.txt b/requirements.txt
index 8c3b52c..ab25b6e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
httpx==0.28.1
-pydantic==2.10.6
+pydantic==2.13.3
diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py
new file mode 100644
index 0000000..bc9ecc9
--- /dev/null
+++ b/scripts/generate_openapi.py
@@ -0,0 +1,447 @@
+#!/usr/bin/env python3
+"""Generate Facturapi resource clients from the public OpenAPI document.
+
+The generator intentionally creates a small, stable SDK layer rather than a
+large model tree. Request and response payloads remain ordinary mappings while
+every documented operation gets an explicit, discoverable Python method.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import keyword
+import re
+import urllib.request
+from collections import defaultdict
+from pathlib import Path
+from typing import Any
+
+OPENAPI_URL = 'https://docs.facturapi.io/redocusaurus/api-en.yaml'
+HTTP_METHODS = {'get', 'post', 'put', 'patch', 'delete'}
+
+TAG_SERVICES = {
+ 'carta_porte_keys': ('carta_porte_catalogs', 'CartaPorteCatalogs'),
+ 'comercio_exterior_keys': (
+ 'comercio_exterior_catalogs',
+ 'ComercioExteriorCatalogs',
+ ),
+ 'customer': ('customers', 'Customers'),
+ 'product': ('products', 'Products'),
+ 'invoice': ('invoices', 'Invoices'),
+ 'receipt': ('receipts', 'Receipts'),
+ 'retention': ('retentions', 'Retentions'),
+ 'organization': ('organizations', 'Organizations'),
+ 'organization_access': ('organizations', 'Organizations'),
+ 'webhooks': ('webhooks', 'Webhooks'),
+ 'tools': ('tools', 'Tools'),
+ 'sat_keys': ('catalogs', 'Catalogs'),
+ 'nomina_keys': ('nomina_catalogs', 'NominaCatalogs'),
+}
+
+FALLBACK_OPERATIONS = {
+ ('GET', '/check'): ('tools', 'health_check'),
+ ('GET', '/tools/tax_id_validation'): ('tools', 'validate_tax_id'),
+ ('GET', '/catalogs/products'): ('catalogs', 'search_products'),
+ ('GET', '/catalogs/units'): ('catalogs', 'search_units'),
+}
+
+QUERY_ARGUMENTS = {
+ ('GET', '/tools/tax_id_validation'): ('tax_id',),
+}
+
+METHOD_OVERRIDES = {
+ 'uploadOrganizationFiel': 'upload_fiel',
+ # Keep parity with the current Node SDK where the OpenAPI samples lag.
+ 'getSeriesGroup': 'list_series_group',
+ 'createSeriesGroup': 'create_series_group',
+ 'updateSeriesGroup': 'update_series_group',
+ 'deleteSeriesGroup': 'delete_series_group',
+}
+
+# A single OpenAPI operation accepts ``format``. These aliases provide the
+# same convenient methods as facturapi-node while retaining a generic method.
+FORMAT_ALIASES = {
+ ('GET', '/invoices/{invoice_id}/{format}'): (
+ 'download',
+ ('pdf', 'xml', 'zip'),
+ ),
+ ('GET', '/invoices/{invoice_id}/cancellation_receipt/{format}'): (
+ 'download_cancellation_receipt',
+ ('pdf', 'xml'),
+ ),
+ ('GET', '/invoices/{invoice_id}/download-url/{format}'): (
+ 'download_url',
+ ('pdf', 'xml', 'zip'),
+ ),
+ (
+ 'GET',
+ '/invoices/{invoice_id}/cancellation_receipt/download-url/{format}',
+ ): ('download_cancellation_receipt_url', ('pdf', 'xml')),
+ ('GET', '/retentions/{retention_id}/{format}'): (
+ 'download',
+ ('pdf', 'xml', 'zip'),
+ ),
+ ('GET', '/retentions/{retention_id}/download-url/{format}'): (
+ 'download_url',
+ ('pdf', 'xml', 'zip'),
+ ),
+}
+
+
+def camel_to_snake(value: str) -> str:
+ value = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', value)
+ value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', value)
+ return re.sub(r'[^a-zA-Z0-9_]+', '_', value).strip('_').lower()
+
+
+def python_name(value: str) -> str:
+ value = camel_to_snake(value)
+ if keyword.iskeyword(value):
+ value += '_'
+ return value
+
+
+def load_document(source: str) -> dict[str, Any]:
+ if source.startswith(('http://', 'https://')):
+ with urllib.request.urlopen(source) as response: # noqa: S310
+ raw = response.read().decode()
+ else:
+ raw = Path(source).read_text()
+
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError:
+ try:
+ import yaml
+ except ImportError as exc: # pragma: no cover - developer guidance
+ raise SystemExit(
+ 'PyYAML is required for YAML specs: pip install PyYAML'
+ ) from exc
+ return yaml.safe_load(raw)
+
+
+def resolve(document: dict[str, Any], value: Any) -> Any:
+ while isinstance(value, dict) and '$ref' in value:
+ ref = value['$ref']
+ if not ref.startswith('#/'):
+ return value
+ target: Any = document
+ for part in ref[2:].split('/'):
+ target = target[part.replace('~1', '/').replace('~0', '~')]
+ value = target
+ return value
+
+
+def node_method(operation: dict[str, Any]) -> tuple[str, str] | None:
+ pattern = re.compile(
+ r'facturapi\s*\.\s*([A-Za-z]\w*)\s*\.\s*' r'([A-Za-z]\w*)\s*\('
+ )
+ for sample in operation.get('x-codeSamples', []):
+ lang = str(sample.get('lang', '')).lower()
+ label = str(sample.get('label', '')).lower()
+ if lang not in {'javascript', 'node.js'} and label != 'node.js':
+ continue
+ match = pattern.search(sample.get('source', ''))
+ if match:
+ service, method = match.groups()
+ if method == 'del':
+ method = 'delete'
+ return camel_to_snake(service), camel_to_snake(method)
+ return None
+
+
+def fallback_method(operation_id: str, service: str) -> str:
+ if operation_id in METHOD_OVERRIDES:
+ return METHOD_OVERRIDES[operation_id]
+ method = camel_to_snake(operation_id)
+ tokens = service.rstrip('s').split('_')
+ for token in tokens:
+ method = re.sub(rf'(^|_){re.escape(token)}(?=_|$)', r'\1', method)
+ return method.replace('__', '_').strip('_')
+
+
+def path_parameters(path: str) -> list[str]:
+ return [python_name(item) for item in re.findall(r'{([^}]+)}', path)]
+
+
+def request_body(
+ document: dict[str, Any], operation: dict[str, Any]
+) -> tuple[bool, bool, bool]:
+ body = operation.get('requestBody')
+ if not body:
+ return False, False, False
+ body = resolve(document, body)
+ content = body.get('content', {})
+ multipart = 'multipart/form-data' in content
+ return True, bool(body.get('required')), multipart
+
+
+def has_query_parameters(
+ document: dict[str, Any],
+ path_item: dict[str, Any],
+ operation: dict[str, Any],
+) -> bool:
+ parameters = [
+ *path_item.get('parameters', []),
+ *operation.get('parameters', []),
+ ]
+ return any(
+ resolve(document, item).get('in') == 'query' for item in parameters
+ )
+
+
+def binary_response(
+ document: dict[str, Any], operation: dict[str, Any]
+) -> bool:
+ for status, response in operation.get('responses', {}).items():
+ if not str(status).startswith('2'):
+ continue
+ response = resolve(document, response)
+ content_types = set(response.get('content', {}))
+ if content_types and not any('json' in item for item in content_types):
+ return True
+ return False
+
+
+def operation_name(
+ method: str,
+ path: str,
+ operation: dict[str, Any],
+ tag: str,
+) -> tuple[str, str]:
+ fallback = FALLBACK_OPERATIONS.get((method, path))
+ if fallback:
+ return fallback
+ service, _ = TAG_SERVICES[tag]
+ operation_id = operation.get('operationId')
+ if operation_id in METHOD_OVERRIDES:
+ return service, METHOD_OVERRIDES[operation_id]
+ sample_name = node_method(operation)
+ if sample_name:
+ sample_service, sample_method = sample_name
+ return sample_service, sample_method
+ if not operation_id:
+ raise ValueError(f'Missing operationId/name for {method} {path}')
+ return service, fallback_method(operation_id, service)
+
+
+def render_method(
+ *,
+ document: dict[str, Any],
+ path_item: dict[str, Any],
+ method: str,
+ path: str,
+ operation: dict[str, Any],
+ name: str,
+ fixed_format: str | None = None,
+) -> list[str]:
+ path_args = path_parameters(path)
+ if fixed_format:
+ path_args.remove('format')
+ has_body, body_required, multipart = request_body(document, operation)
+ has_query = has_query_parameters(document, path_item, operation)
+ query_arguments = QUERY_ARGUMENTS.get((method, path), ())
+ parameters = [f'{item}: str' for item in path_args]
+ if multipart:
+ parameters.append('files: Mapping[str, Any]')
+ parameters.append('data: Mapping[str, Any] | None = None')
+ elif has_body:
+ default = '' if body_required else ' | None = None'
+ parameters.append(f'data: Mapping[str, Any]{default}')
+ parameters.extend(f'{item}: str' for item in query_arguments)
+ if has_query and not query_arguments:
+ parameters.append('params: QueryParams | None = None')
+
+ signature = ', '.join(['self', *parameters])
+ summary = operation.get('summary') or operation.get('operationId') or name
+ operation_id = operation.get('operationId') or f'{method} {path}'
+ values = ', '.join(f"'{item}': {item}" for item in path_args)
+ if fixed_format:
+ values = ', '.join(
+ filter(None, [values, f"'format': '{fixed_format}'"])
+ )
+ args = [repr(method), repr(path)]
+ if values:
+ args.append(f'path_params={{{values}}}')
+ if query_arguments:
+ query_values = ', '.join(
+ f"'{item}': {item}" for item in query_arguments
+ )
+ args.append(f'params={{{query_values}}}')
+ elif has_query:
+ args.append('params=params')
+ if multipart:
+ args.extend(['data=data', 'files=files'])
+ elif has_body:
+ args.append('data=data')
+ if binary_response(document, operation):
+ args.append("response_type='bytes'")
+
+ return [
+ f' def {name}({signature}) -> Any:',
+ f' """{summary}.',
+ '',
+ f' OpenAPI operation: ``{operation_id}``.',
+ f' Endpoint: ``{method} {path}``.',
+ ' """',
+ f" return self._request({', '.join(args)})",
+ '',
+ ]
+
+
+def generate(document: dict[str, Any]) -> str:
+ services: dict[str, dict[str, Any]] = defaultdict(dict)
+ operations: list[tuple[Any, ...]] = []
+
+ for path, path_item in document['paths'].items():
+ for verb, operation in path_item.items():
+ if verb.lower() not in HTTP_METHODS:
+ continue
+ method = verb.upper()
+ tag = operation.get('tags', ['tools'])[0]
+ service, class_name = TAG_SERVICES[tag]
+ resolved_service, name = operation_name(
+ method, path, operation, tag
+ )
+ if resolved_service != service:
+ service = resolved_service
+ class_name = next(
+ item[1]
+ for item in TAG_SERVICES.values()
+ if item[0] == service
+ )
+ record = (document, path_item, method, path, operation)
+
+ format_alias = FORMAT_ALIASES.get((method, path))
+ if format_alias:
+ generic_name, formats = format_alias
+ services[class_name][generic_name] = (
+ *record,
+ generic_name,
+ None,
+ )
+ for value in formats:
+ alias = f'{generic_name}_{value}'
+ if generic_name.endswith('_url'):
+ alias = f'{generic_name[:-4]}_{value}_url'
+ services[class_name][alias] = (*record, alias, value)
+ else:
+ if name in services[class_name]:
+ raise ValueError(
+ f'Duplicate generated method {class_name}.{name}'
+ )
+ services[class_name][name] = (*record, name, None)
+ operations.append((method, path, operation.get('operationId')))
+
+ class_order = [
+ 'Customers',
+ 'Products',
+ 'Invoices',
+ 'Receipts',
+ 'Retentions',
+ 'Organizations',
+ 'Webhooks',
+ 'Tools',
+ 'Catalogs',
+ 'NominaCatalogs',
+ 'CartaPorteCatalogs',
+ 'ComercioExteriorCatalogs',
+ ]
+ output = [
+ '# This file is generated by scripts/generate_openapi.py.',
+ '# Do not edit it by hand.',
+ '',
+ 'from collections.abc import Mapping',
+ 'from typing import Any',
+ 'from urllib.parse import quote',
+ '',
+ 'from .http.client import Client, QueryParams',
+ '',
+ f"OPENAPI_VERSION = {document['info']['version']!r}",
+ f'OPENAPI_OPERATION_COUNT = {len(operations)}',
+ '',
+ '',
+ 'class _Service:',
+ ' def __init__(self, client: Client) -> None:',
+ ' self._client = client',
+ '',
+ ' def _request(',
+ ' self,',
+ ' method: str,',
+ ' path: str,',
+ ' *,',
+ ' path_params: Mapping[str, Any] | None = None,',
+ ' params: QueryParams | None = None,',
+ ' data: Mapping[str, Any] | None = None,',
+ ' files: Mapping[str, Any] | None = None,',
+ " response_type: str = 'auto',",
+ ' ) -> Any:',
+ ' for key, value in (path_params or {}).items():',
+ " encoded = quote(str(value), safe='')",
+ " path = path.replace('{' + key + '}', encoded)",
+ ' return self._client.request(',
+ ' method,',
+ ' path,',
+ ' params=params,',
+ ' data=data,',
+ ' files=files,',
+ ' response_type=response_type,',
+ ' )',
+ '',
+ '',
+ ]
+ for class_name in class_order:
+ output.extend([f'class {class_name}(_Service):'])
+ methods = services.get(class_name, {})
+ if not methods:
+ output.extend([' pass', '', ''])
+ continue
+ for record in methods.values():
+ doc, path_item, method, path, operation, name, fixed_format = (
+ record
+ )
+ output.extend(
+ render_method(
+ document=doc,
+ path_item=path_item,
+ method=method,
+ path=path,
+ operation=operation,
+ name=name,
+ fixed_format=fixed_format,
+ )
+ )
+ output.append('')
+ rendered = '\n'.join(output).rstrip() + '\n'
+ try:
+ import black
+ except ImportError:
+ return rendered
+ return black.format_str(
+ rendered,
+ mode=black.Mode(line_length=79, string_normalization=False),
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--input', default=OPENAPI_URL)
+ parser.add_argument('--output', default='facturapi/_generated.py')
+ parser.add_argument('--check', action='store_true')
+ args = parser.parse_args()
+
+ rendered = generate(load_document(args.input))
+ output = Path(args.output)
+ if args.check:
+ if not output.exists() or output.read_text() != rendered:
+ raise SystemExit(
+ f'{output} is stale; run scripts/generate_openapi.py'
+ )
+ return
+ output.write_text(rendered)
+ print(f'Generated {output}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/setup.cfg b/setup.cfg
index bc2565a..3f50781 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -4,9 +4,15 @@ test=pytest
[tool:pytest]
addopts = -p no:warnings -v --cov=facturapi --cov-report term-missing
+[coverage:run]
+omit =
+ facturapi/_generated.py
+
[flake8]
inline-quotes = '
multiline-quotes = """
+per-file-ignores =
+ facturapi/_generated.py:E501
[isort]
multi_line_output=3
@@ -24,4 +30,4 @@ ignore_missing_imports = True
ignore_missing_imports = True
[mypy-iso4217]
-ignore_missing_imports = True
\ No newline at end of file
+ignore_missing_imports = True
diff --git a/setup.py b/setup.py
index 8cc9c39..3c2b884 100644
--- a/setup.py
+++ b/setup.py
@@ -1,35 +1,5 @@
-from importlib.machinery import SourceFileLoader
+"""Compatibility shim for tools that still invoke ``setup.py`` directly."""
-from setuptools import find_packages, setup
+from setuptools import setup
-version = SourceFileLoader('version', 'facturapi/version.py').load_module()
-
-
-with open('README.md', 'r') as f:
- long_description = f.read()
-
-
-setup(
- name='facturapi',
- version=version.__version__,
- author='Cuenca',
- author_email='dev@cuenca.com',
- description='Facturapi API Client',
- long_description=long_description,
- long_description_content_type='text/markdown',
- url='https://github.com/cuenca-mx/facturapi-python',
- packages=find_packages(),
- include_package_data=True,
- package_data=dict(facturapi=['py.typed']),
- python_requires='>=3.10',
- install_requires=['httpx>=0.28.0,<1.0.0', 'pydantic>=2.10.0,<3.0.0'],
- classifiers=[
- 'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.10',
- 'Programming Language :: Python :: 3.11',
- 'Programming Language :: Python :: 3.12',
- 'Programming Language :: Python :: 3.13',
- 'License :: OSI Approved :: MIT License',
- 'Operating System :: OS Independent',
- ],
-)
+setup()
diff --git a/tests/http/test_client.py b/tests/http/test_client.py
index cc2c8f5..e2d1e0c 100644
--- a/tests/http/test_client.py
+++ b/tests/http/test_client.py
@@ -1,6 +1,10 @@
+import datetime as dt
+
+import httpx
import pytest
from facturapi.http.client import Client
+from facturapi.types import BaseQuery
from facturapi.types.exc import FacturapiResponseException
@@ -35,3 +39,145 @@ def test_override_with_configure():
client = Client()
client.configure('some_api_key2')
assert client.api_key == 'some_api_key2'
+
+
+def test_query_params_use_openapi_deep_object_encoding():
+ request = None
+
+ def handler(value: httpx.Request) -> httpx.Response:
+ nonlocal request
+ request = value
+ return httpx.Response(
+ 200,
+ json={'ok': True},
+ headers={'Content-Type': 'application/json'},
+ )
+
+ client = Client('test_key')
+ headers = dict(client.client.headers)
+ client.client.close()
+ client.client = httpx.Client(
+ transport=httpx.MockTransport(handler), headers=headers
+ )
+ client.get(
+ '/invoices',
+ params={
+ 'date': {'gte': dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)},
+ 'status': ['valid', 'canceled'],
+ 'draft': False,
+ 'empty': None,
+ },
+ )
+
+ assert request is not None
+ assert request.headers['authorization'] == 'Bearer test_key'
+ assert request.url.params.multi_items() == [
+ ('date[gte]', '2026-01-01T00:00:00+00:00'),
+ ('status', 'valid'),
+ ('status', 'canceled'),
+ ('draft', 'false'),
+ ]
+
+
+def test_binary_response_is_returned_as_bytes():
+ def handler(_: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 200,
+ content=b'%PDF',
+ headers={'Content-Type': 'application/pdf'},
+ )
+
+ client = Client('test_key')
+ headers = dict(client.client.headers)
+ client.client.close()
+ client.client = httpx.Client(
+ transport=httpx.MockTransport(handler), headers=headers
+ )
+
+ assert client.get('/invoices/inv_123/pdf') == b'%PDF'
+
+
+def test_non_json_error_response():
+ def handler(_: httpx.Request) -> httpx.Response:
+ return httpx.Response(503, text='temporarily unavailable')
+
+ client = Client('test_key')
+ headers = dict(client.client.headers)
+ client.client.close()
+ client.client = httpx.Client(
+ transport=httpx.MockTransport(handler), headers=headers
+ )
+
+ with pytest.raises(FacturapiResponseException) as exc_info:
+ client.get('/check')
+
+ assert exc_info.value.status_code == 503
+ assert str(exc_info.value) == 'temporarily unavailable'
+
+
+@pytest.mark.parametrize('body', ['gateway unavailable', ['bad gateway']])
+def test_non_object_json_error_response(body):
+ def handler(_: httpx.Request) -> httpx.Response:
+ return httpx.Response(502, json=body)
+
+ client = Client('test_key')
+ headers = dict(client.client.headers)
+ client.client.close()
+ client.client = httpx.Client(
+ transport=httpx.MockTransport(handler), headers=headers
+ )
+
+ with pytest.raises(FacturapiResponseException) as exc_info:
+ client.get('/check')
+
+ assert exc_info.value.status_code == 502
+ assert str(exc_info.value) == str(body)
+
+
+def test_response_modes_and_multipart_request():
+ requests = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ if request.url.path.endswith('/explicit-json'):
+ return httpx.Response(200, content=b'{"ok": true}', headers={})
+ return httpx.Response(
+ 200,
+ text='plain response',
+ headers={'Content-Type': 'text/plain'},
+ )
+
+ client = Client('test_key', headers={'X-Test': 'true'})
+ headers = dict(client.client.headers)
+ client.client.close()
+ client.client = httpx.Client(
+ transport=httpx.MockTransport(handler), headers=headers
+ )
+
+ assert client.get('/text', response_type='text') == 'plain response'
+ assert client.get('/text') == 'plain response'
+ assert client.get('/explicit-json', response_type='json') == {'ok': True}
+ client.post(
+ '/upload',
+ {'password': 'secret'},
+ files={'file': ('file.txt', b'content', 'text/plain')},
+ )
+
+ content_type = requests[-1].headers['content-type']
+ assert content_type.startswith('multipart/form-data')
+ assert requests[-1].headers['x-test'] == 'true'
+
+
+def test_client_helpers_and_context_manager():
+ assert Client._serialize(BaseQuery(q='test')) == {'q': 'test'}
+ assert Client._query_items(None) is None
+
+ with Client('test_key') as client:
+ assert client.__enter__() is client
+
+
+def test_response_exception_code():
+ error = FacturapiResponseException(
+ json={'message': 'bad request', 'code': 123}, status_code=400
+ )
+ assert error.code == '123'
diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py
index 25ad73a..810c37e 100644
--- a/tests/resources/test_resources.py
+++ b/tests/resources/test_resources.py
@@ -1,8 +1,47 @@
import pytest
+from facturapi.resources import base
+from facturapi.resources.base import Queryable
from facturapi.resources.resources import retrieve_property
def test_retrieve_wrong_uri():
with pytest.raises(ValueError):
retrieve_property('wrong_uri')
+
+
+class CursorResource(Queryable):
+ _resource = 'resources'
+
+
+def test_all_follows_cursor_pagination(monkeypatch):
+ pages = iter(
+ [
+ {
+ 'data': [{'id': 'resource_1'}],
+ 'next_cursor': 'cursor/2',
+ },
+ {
+ 'data': [{'id': 'resource_2'}],
+ 'next_cursor': None,
+ },
+ ]
+ )
+ requested_uris = []
+
+ def get(uri):
+ requested_uris.append(uri)
+ return next(pages)
+
+ monkeypatch.setattr(base.client, 'get', get)
+
+ resources = list(CursorResource.all(pagination='cursor', limit=1))
+
+ assert [resource.id for resource in resources] == [
+ 'resource_1',
+ 'resource_2',
+ ]
+ assert requested_uris == [
+ 'resources?pagination=cursor&limit=1',
+ 'resources?pagination=cursor&limit=1&after=cursor%2F2',
+ ]
diff --git a/tests/test_generated_client.py b/tests/test_generated_client.py
new file mode 100644
index 0000000..09cba09
--- /dev/null
+++ b/tests/test_generated_client.py
@@ -0,0 +1,127 @@
+from typing import Any
+
+from facturapi import Facturapi
+from facturapi._generated import OPENAPI_OPERATION_COUNT
+
+
+class RecordingClient:
+ def __init__(self) -> None:
+ self.host = 'https://example.test/v2/'
+ self.calls: list[tuple[str, str, dict[str, Any]]] = []
+ self.closed = False
+
+ def request(self, method: str, path: str, **kwargs: Any) -> dict:
+ self.calls.append((method, path, kwargs))
+ return {'ok': True}
+
+ def close(self) -> None:
+ self.closed = True
+
+
+def test_generated_client_has_openapi_operations():
+ assert OPENAPI_OPERATION_COUNT > 0
+
+
+def test_resource_clients_are_exposed():
+ http = RecordingClient()
+ api = Facturapi(http_client=http) # type: ignore[arg-type]
+
+ assert api.customers
+ assert api.products
+ assert api.invoices
+ assert api.receipts
+ assert api.retentions
+ assert api.organizations
+ assert api.webhooks
+ assert api.tools
+ assert api.catalogs
+ assert api.nomina_catalogs
+ assert api.carta_porte_catalogs
+ assert api.comercio_exterior_catalogs
+ assert api.base_url == 'https://example.test/v2/'
+ api.base_url = 'https://api.example.test/custom'
+ assert http.host == 'https://api.example.test/custom/'
+
+
+def test_generated_method_builds_path_body_and_query():
+ http = RecordingClient()
+ api = Facturapi(http_client=http) # type: ignore[arg-type]
+
+ api.customers.update(
+ 'cus/with spaces',
+ {'email': 'test@example.com'},
+ {'createEditLink': True},
+ )
+
+ assert http.calls == [
+ (
+ 'PUT',
+ '/customers/cus%2Fwith%20spaces',
+ {
+ 'params': {'createEditLink': True},
+ 'data': {'email': 'test@example.com'},
+ 'files': None,
+ 'response_type': 'auto',
+ },
+ )
+ ]
+
+
+def test_format_convenience_methods_match_node_sdk():
+ http = RecordingClient()
+ api = Facturapi(http_client=http) # type: ignore[arg-type]
+
+ api.invoices.download_pdf('inv_123')
+ api.invoices.download_xml_url('inv_123')
+ api.retentions.download_zip('ret_123')
+
+ assert [call[:2] for call in http.calls] == [
+ ('GET', '/invoices/inv_123/pdf'),
+ ('GET', '/invoices/inv_123/download-url/xml'),
+ ('GET', '/retentions/ret_123/zip'),
+ ]
+ assert http.calls[0][2]['response_type'] == 'bytes'
+ assert http.calls[1][2]['response_type'] == 'auto'
+
+
+def test_multipart_operation_is_generated():
+ http = RecordingClient()
+ api = Facturapi(http_client=http) # type: ignore[arg-type]
+ files = {'file': ('logo.png', b'png', 'image/png')}
+
+ api.organizations.upload_logo('org_123', files)
+
+ _, path, kwargs = http.calls[0]
+ assert path == '/organizations/org_123/logo'
+ assert kwargs['files'] == files
+
+
+def test_tool_method_matches_node_sdk_signature():
+ http = RecordingClient()
+ api = Facturapi(http_client=http) # type: ignore[arg-type]
+
+ api.tools.validate_tax_id('ABC101010111')
+
+ assert http.calls[0][1] == '/tools/tax_id_validation'
+ assert http.calls[0][2]['params'] == {'tax_id': 'ABC101010111'}
+
+
+def test_nomina_catalog_methods_are_exposed():
+ http = RecordingClient()
+ api = Facturapi(http_client=http) # type: ignore[arg-type]
+
+ api.nomina_catalogs.search_deductions({'q': '001', 'limit': 10})
+ api.nomina_catalogs.search_perceptions({'q': '001', 'limit': 10})
+
+ assert [call[:2] for call in http.calls] == [
+ ('GET', '/catalogs/deductions'),
+ ('GET', '/catalogs/perceptions'),
+ ]
+ assert http.calls[0][2]['params'] == {'q': '001', 'limit': 10}
+
+
+def test_context_manager_closes_http_client():
+ http = RecordingClient()
+ with Facturapi(http_client=http): # type: ignore[arg-type]
+ pass
+ assert http.closed
diff --git a/tests/types/test_queries.py b/tests/types/test_queries.py
index bb980cf..227dfb5 100644
--- a/tests/types/test_queries.py
+++ b/tests/types/test_queries.py
@@ -13,7 +13,7 @@ def test_base_query():
def test_base_query_page_size():
with pytest.raises(ValidationError):
- _ = BaseQuery(q='Frida', limit=51)
+ _ = BaseQuery(q='Frida', limit=101)
def test_base_query_page_min():