Metadata-Version: 2.4
Name: misterpato-holded-client
Version: 0.3.0
Summary: Application-agnostic Holded API client for MisterPato projects.
Author: MisterPato
License-Expression: LicenseRef-Proprietary
Keywords: holded,api-client,misterpato
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.0
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: responses>=0.25; extra == "test"
Provides-Extra: publish
Requires-Dist: build>=1.2; extra == "publish"
Requires-Dist: twine>=6.0; extra == "publish"
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=6.0; extra == "dev"
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: responses>=0.25; extra == "dev"

# misterpato-holded-client

Application-agnostic Python client package for Holded Invoice API v1 and v2.

The package exposes a small synchronous SDK around Holded's v1 and v2 Invoice API. It is intentionally independent of Django, Celery, databases, or any app-specific idempotency layer.

## Installation

```bash
pip install misterpato-holded-client
```

Pin an explicit version in production (for example `misterpato-holded-client==0.3.0`).

Runtime dependency: `requests`.

Supported Python: `>=3.9`.

## Basic usage

Choose the Holded API version once at client construction. Resources are wired directly on the client instance for both versions.

### v1

```python
from misterpato_holded import HoldedClient, VERSION_1

client = HoldedClient(api_key="holded-api-key", version=VERSION_1)
```

`HoldedClient` wires v1 resources directly on the client instance:

- Generic resources: `client.documents`, `client.contacts`, `client.treasuries`, `client.payment_methods`
- Typed document resources: `client.invoices`, `client.credit_notes`, `client.sales_receipts`, `client.sales_orders`, `client.proformas`, `client.waybills`, `client.estimates`, `client.purchases`, `client.purchase_orders`, `client.purchase_refunds`

MVP document resources support the full essential document action set for every document type, especially `client.invoices` and `client.credit_notes`: `list`, `iter_all`, `list_all`, `create`, `get`, `update`, `approve`, `pay`, `delete`, `get_pdf_base64`, and `get_pdf`.

### v2

```python
from misterpato_holded import HoldedClient, VERSION_2

client = HoldedClient(api_key="holded-api-key", version=VERSION_2)
```

v2 currently wires:

- `client.contacts`
- `client.invoices`
- `client.credit_notes`
- `client.proformas`
- `client.products`
- `client.sales_orders`
- `client.taxes`
- `client.treasuries`
- `client.waybills`
- `client.estimates`
- `client.accounting_accounts`
- `client.ledger_entries`

Other v2 document types (purchases, purchase orders, purchase refunds, and sales receipts) are not implemented yet. Use v1 if you need those resources today.

v2 invoice actions implemented today: `list`, `iter_all`, `list_all`, `create`, `get`, `update`, `approve`, `bulk_approve`, `pay`, `delete`, `get_pdf`, and `get_pdf_base64`.

v2 credit note actions implemented today: `list`, `iter_all`, `list_all`, `create`, `get`, `update`, `approve`, `pay`, `delete`, `get_pdf`, and `get_pdf_base64`.

v2 proforma actions implemented today: `list`, `iter_all`, `list_all`, `create`, `get`, `update`, `approve`, `delete`, `get_pdf`, and `get_pdf_base64` (no `pay` — proformas have no payments in the Holded v2 API).

v2 product actions implemented today: `list`, `iter_all`, `list_all`, `create`, `get`, `update`, `update_stock`, and `delete`.

v2 sales order and waybill actions implemented today: `list`, `iter_all`, `list_all`, `create`, `get`, `update`, `approve`, `delete`, `get_pdf`, and `get_pdf_base64` (no `pay` — same shape as proformas).

v2 estimate actions implemented today: `list`, `iter_all`, `list_all`, `get`, `delete`, `get_pdf`, `get_pdf_base64`, `attach_file`, `list_attachments`, `iter_all_attachments`, `list_all_attachments`, and `get_attachment` (no `create`, `update`, `approve`, or `pay` — Holded's v2 estimates API is read/delete/PDF/attachments only).

v2 accounting actions implemented today: `client.accounting_accounts` supports `create` and `list` (optional filters); `client.ledger_entries` supports `list`, `iter_all`, and `list_all` with required `start_date` and `end_date`.

v2 contacts support `list`, `iter_all`, `list_all`, `create`, `get`, `update`, and `delete`.

v2 treasury account actions implemented today: `list`, `iter_all`, `list_all`, `create`, and `get` (Holded documents no update, delete, or archive endpoint for treasury accounts).

v2 tax actions implemented today: `list` only (Holded returns the full configured tax catalog in one response; no cursor pagination).

## Configuration

```python
client = HoldedClient(
    api_key="holded-api-key",
    version=VERSION_1,  # or VERSION_2
    base_url="https://api.holded.com/api",
    timeout=30,
    timezone="Europe/Madrid",  # v1 only; ignored for v2 wiring
)
```

Notes:

- Blank API keys raise `HoldedConfigError`.
- Unknown `version` values raise `HoldedConfigError`.
- **v1 auth:** the client sends `key: <api_key>` automatically.
- **v2 auth:** the client sends `Authorization: Bearer <api_key>` automatically and does not send the v1 `key` header.
- **URLs:** both versions use the same default `base_url` (`https://api.holded.com/api`). v2 resource paths are `/v2/...` (for example `/v2/invoices`, `/v2/contacts`). You do not need a special v2 `base_url` for normal use.
- **v1 timezone:** `timezone` controls how naive `date` and `datetime` values are serialized to Holded Unix timestamps. v2 date serialization uses ISO/date strings instead (see below).

## Holded API v2

v2 is a separate surface under `misterpato_holded.v2` with its own dataclasses, pagination, date formats, and invoice status vocabulary. v1 behavior is unchanged.

### v2 models and `.raw`

Import v2 models from `misterpato_holded.v2.models` (or `misterpato_holded.v2` for the public subset):

```python
from misterpato_holded.v2.models import (
    CreateContactRequest,
    CreateInvoiceRequest,
    Estimate,
    EstimateAttachment,
    EstimateLine,
    Invoice,
    InvoiceStatus,
    LedgerEntry,
    PayInvoiceRequest,
    Product,
    SalesOrder,
    UpdateContactRequest,
    Waybill,
)
```

Or import resources and models from the package root:

```python
from misterpato_holded.v2 import (
    Product,
    ProductsResource,
    SalesOrder,
    SalesOrdersResource,
    Waybill,
    WaybillsResource,
)
```

v2 response dataclasses are separate from v1 models. Every parsed response preserves the original decoded payload on `.raw`.

### v2 invoice listing and `InvoiceStatus`

v2 invoice listing uses cursor pagination and a native `status=` filter. Supported values are validated locally before HTTP:

```python
from misterpato_holded.v2.models import InvoiceStatus

pending = client.invoices.list(status=InvoiceStatus.PENDING)
# or: client.invoices.list(status="pending")
```

Allowed status values: `pending`, `completed`, `partial`, `cancelled`, `failed`, `overdue`.

v2 does **not** support v1's boolean `paid=` filter. Passing `paid=` raises `TypeError` because the argument is not part of the v2 API. Use explicit `status=` values instead.

v2 list methods also do not accept v1's `page=` argument. Use `cursor=` on `list()` or `iter_all()` / `list_all()` for multi-page reads.

### v2 invoice creation

Create with an existing contact id:

```python
from datetime import date

created = client.invoices.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Service", "units": 1, "subtotal": "120.00"}],
})

print(created.id, created.raw)
```

Or supply contact creation fields when you do not have a `contact_id`. The client creates the contact first, then creates the invoice with the returned id. It does **not** search existing contacts first:

```python
created = client.invoices.create({
    "contactName": "Cliente Demo",
    "contactEmail": "cliente@example.com",
    "date": "2026-06-29",
    "items": [{"name": "Service", "units": 1, "subtotal": "120.00"}],
})
```

Recognized contact auto-creation aliases: `contactName` / `contact_name` and `contactEmail` / `contact_email`. The caller payload is copied internally; your original mapping is not mutated.

Contact auto-creation requires both contact-write and invoice-write permissions on the Holded API key. If contact creation succeeds but invoice creation fails, the new contact remains in Holded; callers that need idempotency must handle that at the application layer.

v2 invoice create does **not** emulate v1's `approve=True`. Passing `approve=True` raises `HoldedRequestValidationError` locally with no HTTP request. Approve invoices explicitly after creation:

```python
created = client.invoices.create({...})
approved = client.invoices.approve(created.id)
```

Create responses usually return only `{id}`; call `get()` when you need the full invoice body.

### v2 invoice actions

```python
invoice = client.invoices.get("invoice-id")
updated = client.invoices.update("invoice-id", {"notes": "Updated notes"})
approved = client.invoices.approve("invoice-id")
paid = client.invoices.pay("invoice-id", {"date": "2026-06-29", "amount": "120.00"})
deleted = client.invoices.delete("invoice-id")
pdf_bytes = client.invoices.get_pdf("invoice-id")
pdf_base64 = client.invoices.get_pdf_base64("invoice-id")
```

`update()` is documented by Holded as full replacement. Send a complete payload or risk clearing omitted fields.

`get_pdf()` requests the binary PDF endpoint and returns raw `bytes`. `get_pdf_base64()` base64-encodes those bytes locally for compatibility with v1-style callers.

`delete()` handles Holded's `204` no-content response.

### v2 invoice bulk approve

> **Known Holded-side issue (checked 2026-07-02):** the live `/v2/invoices/bulk/approve` endpoint currently returns `422 {"message": "None of the invoices could be approved"}` for every request, including freshly created, individually approvable draft invoices on an account with Verifactu disabled — reproduced with this client, with raw `requests`, and with `curl`. Until Holded fixes the endpoint, `bulk_approve()` will raise `HoldedValidationError`; approve invoices one by one with `approve()` instead. The method is kept because it matches the documented API contract and needs no client change once Holded resolves it.

`bulk_approve()` posts `{"ids": [...]}` to `/v2/invoices/bulk/approve`. Holded processes each invoice independently:

```python
result = client.invoices.bulk_approve(["invoice-id-1", "invoice-id-2"])
print(result.status_code, result.message, result.raw)
```

- `204`: all invoices approved; `message` is empty and `raw` is `{}`.
- `207`: some invoices could not be approved; `message` carries Holded's explanation and `raw` preserves the response body.
- `422` (none approved) and other errors raise the usual typed exceptions (`HoldedValidationError`, ...).

An empty sequence or blank ids raise `HoldedRequestValidationError` locally with no HTTP request.

### v2 credit notes

`client.credit_notes` targets `/v2/credit-notes` (Holded's `facturas rectificativas`) with the same action set as invoices except `bulk_approve`:

```python
created = client.credit_notes.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Refund", "units": 1, "price": "10.00"}],
})

credit_note = client.credit_notes.get(created.id)
approved = client.credit_notes.approve(created.id)
paid = client.credit_notes.pay(created.id, {"amount": "10.00", "date": "2026-06-29"})
pdf_bytes = client.credit_notes.get_pdf(created.id)
deleted = client.credit_notes.delete(created.id)
```

Differences from invoices:

- **No contact auto-creation.** `create()` requires an existing `contact_id`; `contactName` / `contactEmail` aliases are not recognized, and a payload without `contact_id` raises `HoldedRequestValidationError` locally with no HTTP request.
- **Mapping payloads only.** There are no typed request dataclasses for credit notes; pass plain mappings. Dates and money fields are serialized with the same ISO/Decimal rules as invoices.
- **List filters** are `limit`, `cursor`, `contact_id`, `status`, `start_date`, `end_date`, `sort`, and `approval_status`. The invoice-only `due_date_start`, `due_date_end`, and `accounting_account_num` filters are not accepted (`TypeError`). Status values reuse the invoice vocabulary (`pending`, `completed`, `partial`, `cancelled`, `failed`, `overdue`).

Responses parse into `CreditNote` and the generic `CreateDocumentResponse` / `UpdateDocumentResponse` / `ApproveDocumentResponse` / `PayDocumentResponse` / `DeleteDocumentResponse` dataclasses, all preserving `.raw`.

### v2 proformas

`client.proformas` targets `/v2/proformas` with the same action set as credit notes minus `pay` — proformas carry no payment fields in the Holded v2 API, so there is no `pay` method at all:

```python
created = client.proformas.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})

proforma = client.proformas.get(created.id)
approved = client.proformas.approve(created.id)
pdf_bytes = client.proformas.get_pdf(created.id)
deleted = client.proformas.delete(created.id)
```

Proforma `create()` supports the same **contact auto-creation** as invoices: when the payload has no `contact_id` but includes `contactName` / `contact_name` (optionally `contactEmail` / `contact_email`), the contact is created first via `POST /v2/contacts`, the returned id is used as `contact_id`, and the alias keys are stripped from the proforma payload. There is no contact search or deduplication — an existing contact with the same name is not reused. Without `contact_id` and without alias fields, `create()` raises `HoldedRequestValidationError` locally with no HTTP request:

```python
created = client.proformas.create({
    "contactName": "New Client SL",
    "contactEmail": "billing@newclient.example",
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})
```

List filters are `limit`, `cursor`, `contact_id`, `status`, `start_date`, `end_date`, and `sort` — no `approval_status` and no due-date filters (`TypeError`). Payloads are plain mappings, serialized with the same ISO/Decimal rules as invoices. Responses parse into `Proforma` (no payment fields) and the same generic document response dataclasses, all preserving `.raw`.

### v2 products

`client.products` targets `/v2/products`. Products are a catalog resource (not a document): no `contact_id`, no line items, no PDF. Payloads are plain mappings sent **verbatim** — product `price`/`cost`/`stock` are decimal strings on the wire and are **not** run through invoice-style money serialization.

```python
created = client.products.create({
    "name": "Widget",
    "kind": "simple",
    "has_stock": True,
    "for_sale": True,
    "for_purchase": True,
    "price": "99.95",
})

product = client.products.get(created.id)
print(product.price, product.stock)  # decimal strings — parse with Decimal in your app

client.products.update(created.id, {
    "name": "Widget v2",
    "for_sale": True,
    "for_purchase": False,
    "archived": False,
})

client.products.update_stock(created.id, {
    "warehouse_id": "warehouse-id",
    "stock_variation": -3,
    "description": "Inventory count",
})

for product in client.products.iter_all(limit=100):
    print(product.id, product.name, product.kind)

deleted = client.products.delete(created.id)
```

`create()` validates locally (no HTTP) that `name`, `kind`, `has_stock`, `for_sale`, and `for_purchase` are present (`False` counts as present). `update()` requires `name`, `for_sale`, `for_purchase`, and `archived`. `update_stock()` requires `warehouse_id` and `stock_variation` (`0` is valid). List filters are `cursor` and `limit` only. Responses parse into `Product` with money/stock/weight kept as strings and `.raw` preserved; `create()`/`update()` return the generic `CreateDocumentResponse` / `UpdateDocumentResponse`; `update_stock()` returns `UpdateStockResponse` with the HTTP status code; `delete()` returns `DeleteDocumentResponse`.

Product `kind` is stored as the raw Holded string (for example `simple`, `serialnumbers`, or `serial_numbers`) — there is no enum normalization.

### v2 sales orders

`client.sales_orders` targets `/v2/sales-orders` with the same action set as proformas (including inherited `get_pdf` / `get_pdf_base64`, no `pay`):

```python
created = client.sales_orders.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})

order = client.sales_orders.get(created.id)
approved = client.sales_orders.approve(created.id)
pdf_bytes = client.sales_orders.get_pdf(created.id)
deleted = client.sales_orders.delete(created.id)
```

`create()` supports the same **contact auto-creation** as proformas and invoices (`contactName` / `contact_name`, optionally `contactEmail` / `contact_email`). List filters are `limit`, `cursor`, `contact_id`, `status`, `start_date`, `end_date`, and `sort` — no `approval_status` and no due-date filters (`TypeError`). Payloads are plain mappings with ISO/Decimal serialization like invoices. Responses parse into `SalesOrder` (flat string fields, no payment trio) and the same generic document response dataclasses, all preserving `.raw`.

### v2 waybills

`client.waybills` targets `/v2/waybills` and is structurally identical to `client.sales_orders` — same methods, list filters, contact auto-create, and response shape, but responses parse into `Waybill`:

```python
created = client.waybills.create({
    "contactName": "Ship To SL",
    "items": [{"name": "Line", "units": 1, "price": "10.00"}],
})

waybill = client.waybills.get(created.id)
pdf_bytes = client.waybills.get_pdf(created.id)
```

There is no `pay` method on either resource.

### v2 estimates

`client.estimates` targets `/v2/estimates`. Holded's v2 estimates API is read/delete/PDF/attachments only — there is no `create`, `update`, `approve`, or `pay`:

```python
estimate = client.estimates.get("estimate-id")
pdf_bytes = client.estimates.get_pdf("estimate-id")
deleted = client.estimates.delete("estimate-id")

for estimate in client.estimates.iter_all(status="pending", approval_status="draft"):
    print(estimate.id, estimate.document_number, estimate.total)
```

List filters are `limit`, `cursor`, `contact_id`, `status`, `start_date`, `end_date`, `sort`, and `approval_status` — the same shape as credit notes, including `approval_status` and without due-date filters (`TypeError`). Responses parse into `Estimate` (with `lines` as `EstimateLine` objects) and the generic `DeleteDocumentResponse`, all preserving `.raw`.

Attachment helpers target `/v2/estimates/{id}/attachments`:

```python
created = client.estimates.attach_file("estimate-id", file=open("quote.pdf", "rb"), filename="quote.pdf")

for attachment in client.estimates.iter_all_attachments("estimate-id"):
    print(attachment.id)

file_bytes = client.estimates.get_attachment("estimate-id", attachment.id)
```

`attach_file()` posts multipart form data and returns `CreateDocumentResponse`. Holded's docs describe a response with the new attachment `id`, but the live endpoint may return only `{"status": "attached"}` — the client accepts both shapes. When `id` is present, `CreateDocumentResponse.id` is set; when only `status: "attached"` is returned, `id` is empty and you can resolve the attachment via `list_attachments()` / `iter_all_attachments()` (or inspect `.raw`). `list_attachments()` / `iter_all_attachments()` parse attachment metadata into `EstimateAttachment`. `get_attachment()` returns raw `bytes` from the binary download endpoint.

### v2 treasuries

`client.treasuries` targets `/v2/treasury/accounts`. Holded documents only create, list, and get — there is **no update, delete, or archive endpoint**, so the resource has no such methods:

```python
created = client.treasuries.create({"name": "Main bank", "type": "bank", "currency": "EUR"})

account = client.treasuries.get(created.id)
print(account.balance)  # decimal string, e.g. "1250.55" — parse with Decimal in your app

banks = client.treasuries.list(type="bank", archived=False)
for account in client.treasuries.iter_all():
    print(account.id, account.name, account.type)
```

`create()` validates locally (no HTTP) that `name` is present and `type` is one of `bank`, `card`, `gateway`, or `cash`. List filters are `cursor`, `limit`, `type` (same allowed values), and `archived` — `archived=True` returns only archived accounts, `archived=False` only active ones (sent as lowercase `true`/`false` on the wire), and omitting it returns both. Accounts parse into `Treasury` with `balance` kept as a decimal string (no float conversion) and `.raw` preserved; `create()` returns the generic `CreateDocumentResponse`.

### v2 taxes

`client.taxes` targets `/v2/taxes`. Holded documents only list — there is **no create, get, update, or delete endpoint**, and the catalog is returned in a single response (no `iter_all` / `list_all`):

```python
taxes = client.taxes.list()
for tax in taxes:
    print(tax.id, tax.name, tax.amount, tax.key)  # amount is a decimal string, e.g. "21"
```

Taxes parse into `Tax` with `amount` kept as a decimal string (no float conversion), sub-item keys on `.items`, and `.raw` preserved. Use `tax.id` or `tax.key` in product and document line `taxes` arrays when building payloads.

### v2 accounting

`client.accounting_accounts` targets `/v2/accounting-accounts`. The resource supports `create()` and `list()` — there is no get, update, or delete endpoint, and Holded returns the chart in one `{items:[...]}` response (no package `iter_all` / `list_all` helpers):

```python
created = client.accounting_accounts.create({
    "prefix": 7,
    "name": "Sales",
    "number": 70000013,
})
```

`create()` validates locally (no HTTP) that `prefix` and `name` are present. Responses return the generic `CreateDocumentResponse` with `.raw` preserved.

```python
accounts = client.accounting_accounts.list(
    archived=False,
    include_empty=True,
    start_date="2026-01-01",
    end_date="2026-12-31",
)
```

Optional list filters are `archived`, `start_date`, `end_date`, and `include_empty`. When filtering by date, Holded requires **both** `start_date` and `end_date`, and they must be **different** dates (same-day ranges return `400`: "End timestamp must be different than start."). Omitting only one of the two also returns `400`. Accounts parse into `AccountingAccount` with `debit` / `credit` / `balance` kept as decimal strings and `.raw` preserved.

`client.ledger_entries` targets `/v2/ledger-entries` with cursor pagination. `start_date` and `end_date` are required on every list call:

```python
from datetime import date

entries = client.ledger_entries.list(
    start_date=date(2026, 1, 1),
    end_date=date(2026, 1, 31),
    account=70000013,
)

for entry in client.ledger_entries.iter_all(
    start_date="2026-01-01",
    end_date="2026-01-31",
    limit=100,
):
    print(entry.entry_number, entry.account, entry.debit, entry.credit)
```

Optional list filters are `cursor`, `limit`, and `account`. Entries parse into `LedgerEntry` with `debit`/`credit` kept as decimal strings and `.raw` preserved.

### v2 contacts

```python
from misterpato_holded.v2.models import CreateContactRequest, UpdateContactRequest

contact = client.contacts.create(CreateContactRequest(
    name="Cliente Demo",
    email="cliente@example.com",
))

same_contact = client.contacts.get(contact.id)
updated = client.contacts.update(contact.id, UpdateContactRequest(name="Cliente Demo", email="cliente@example.com"))
deleted = client.contacts.delete(contact.id)
```

Raw mapping payloads are also supported. Contact `update()` is full replacement, same caveat as invoices.

Contact `list()` parses the v2 cursor envelope (`items`, `has_more`, `cursor`). `iter_all()` / `list_all()` follow cursors with the same semantics as invoices, preserving filters on every page:

```python
for contact in client.contacts.iter_all(limit=100):
    print(contact.id, contact.name)

all_matching = client.contacts.list_all(email="cliente@example.com", max_pages=20)
```

### v2 pagination

v2 invoice pagination is cursor-based, not page-number based:

```python
first_page = client.invoices.list(status="pending", limit=50)

for invoice in client.invoices.iter_all(status="pending", start_date="2026-01-01"):
    print(invoice.id, invoice.document_number, invoice.raw)

all_pending = client.invoices.list_all(status="pending", max_pages=20)
```

`iter_all()` follows `cursor` values until `has_more` is false. Filters passed to the first call are preserved on later cursor requests. If `max_pages` is reached before pagination finishes, `HoldedPaginationLimitError` is raised instead of silently returning partial data.

For a single page, pass `cursor=` to `list()` directly.

### v2 date and money serialization

v2 sends ISO date strings (for example `2026-06-29`) rather than v1 Unix timestamp strings. List date filters reject Unix timestamp ints or numeric strings.

Money values still use `Decimal(str(amount))`, are quantized to `0.01` with `ROUND_HALF_UP`, then sent as JSON numbers.

```python
client.invoices.create({
    "contact_id": "contact-id",
    "date": date(2026, 6, 29),
    "items": [{"name": "Service", "units": 1, "subtotal": "10.235"}],  # sends 10.24
})
```

### Migrating from v1 to v2

| Topic | v1 | v2 |
| --- | --- | --- |
| Client init | `version=VERSION_1` | `version=VERSION_2` |
| Auth header | `key: <api_key>` | `Authorization: Bearer <api_key>` |
| Resources | documents, contacts, treasuries, payment methods, all document types | contacts, invoices, credit notes, proformas, products, sales orders, waybills, estimates, treasuries, taxes, accounting accounts, and ledger entries |
| Models | `misterpato_holded.v1.models` | `misterpato_holded.v2.models` |
| Invoice paid filter | `paid=True/False` | not supported; use `status=` |
| Pagination | `page=N`, empty page stops | `cursor`, `has_more` envelope |
| Dates on wire | Unix timestamp strings | ISO/date strings |
| Create + approve | `create(..., approve=True)` | create, then explicit `approve()` |
| Contact on create | inline `contact_name` on document payload | `contact_id` or auto-create via contact fields |
| PDF | base64 JSON field and/or binary helper | binary PDF bytes; base64 helper encodes locally |

## Webhooks

`misterpato_holded.webhooks` provides framework-agnostic helpers for receiving Holded webhooks: HMAC-SHA256 signature verification, header parsing, typed payload dataclasses, and event-name constants. There is no HTTP server, routing, or persistence — plug the helpers into whatever framework the app uses.

```python
from misterpato_holded import webhooks
from misterpato_holded.exceptions import HoldedWebhookSignatureError

def receive(raw_body: bytes, request_headers: dict, secret: str):
    try:
        event = webhooks.parse_event(raw_body, request_headers, secret)
    except HoldedWebhookSignatureError:
        return 401  # reject: missing or invalid signature

    if event.event in webhooks.DOCUMENT_EVENTS:
        handle_document(event.payload, dedupe_key=event.idempotency_key)
    elif event.event in webhooks.EXTENDED_DOCUMENT_EVENTS:
        handle_extended_document(event.payload, dedupe_key=event.idempotency_key)
    elif event.event in webhooks.PRODUCT_EVENTS:
        handle_product(event.payload, dedupe_key=event.idempotency_key)
    elif event.event in webhooks.CONTACT_EVENTS:
        handle_contact(event.payload, dedupe_key=event.idempotency_key)
    # unknown events arrive with event.payload = None; ignore or log them
    return 204
```

Notes:

- `parse_event` verifies the signature against the **exact raw body bytes** before parsing anything, and raises `HoldedWebhookSignatureError` on failure. Always pass the raw bytes as received, never re-serialized JSON.
- Header lookup is case-insensitive; all `x-holded-webhook-*` metadata is exposed on the returned `HoldedWebhookEvent` (`event`, `webhook_id`, `account_id`, `delivered_at`, `webhook_version`).
- Delivery is at-least-once: deduplicate on `event.idempotency_key` (`(account_id, webhook_id)`). Dedup storage, queuing, and retry handling are the consuming app's responsibility.
- Event-name constants and frozensets group related events: `DOCUMENT_EVENTS` (invoice/credit note/proform), `EXTENDED_DOCUMENT_EVENTS` (`salesorder.*`, `waybill.*`, `estimate.*`), `PRODUCT_EVENTS` (`product.*`), and `CONTACT_EVENTS`.
- Payload dataclasses map camelCase JSON to snake_case fields, keep nullable fields as `None`, keep money fields as strings (parse with `Decimal`), and preserve the original payload on `.raw`:
  - `WebhookDocumentPayload` — invoice/credit note/proform create/update
  - `WebhookDeletedDocumentPayload` — invoice/credit note/proform delete, and **also** `salesorder.delete`, `waybill.delete`, and `estimate.delete`
  - `WebhookExtendedDocumentPayload` — sales order/waybill/estimate create/update (includes tracking/shipping fields; arrays and `currencyChange` pass through as-is)
  - `WebhookProductPayload` — product create/update (`price`/`cost`/`stock` as strings; `kind` raw, no enum)
  - `WebhookDeletedProductPayload` — product delete
  - `WebhookContactPayload` / `WebhookDeletedContactPayload` — contact events
- `webhooks.verify_signature(raw_body, signature_header, secret)` is available standalone when you only need the boolean check.

For Holded's upstream webhook contract (headers, payload fields, setup), see the [Holded developer webhooks documentation](https://www.holded.com/es/desarrolladores/webhooks).

## Documents (v1)

Create an invoice with the typed resource:

```python
from datetime import date
from misterpato_holded import HoldedClient, VERSION_1
from misterpato_holded.v1.models import CreateDocumentRequest, DocumentItem

client = HoldedClient(api_key="holded-api-key", version=VERSION_1)

invoice = client.invoices.create(CreateDocumentRequest(
    date=date(2026, 1, 15),
    contact_name="Cliente Demo",
    currency="EUR",
    items=[DocumentItem(name="Service", units=1, subtotal="120.00")],
))

print(invoice.id, invoice.doc_number, invoice.raw)
```

The same operation can be done through the generic documents resource:

```python
from misterpato_holded.v1.documents import DocumentType

invoice = client.documents.create(
    DocumentType.INVOICE,
    CreateDocumentRequest(date=date.today(), contact_name="Cliente Demo"),
)
```

Pass `approve=True` to approve a document during creation, which sends Holded's
`approveDoc: true` field. Pass `custom_fields=[...]` to send Holded's
`customFields` array:

```python
custom_fields = [{"field": "project", "value": "MisterPato"}]

proforma = client.proformas.create(
    CreateDocumentRequest(date=date.today(), contact_name="Cliente Demo"),
    approve=True,
    custom_fields=custom_fields,
)
```

Other document operations:

```python
document = client.invoices.get("document-id")
updated = client.invoices.update("document-id", {"notes": "Updated notes"})
paid = client.invoices.pay("document-id", {"date": date.today(), "amount": "120.00"})
approved = client.invoices.approve("document-id")
pdf_base64 = client.invoices.get_pdf_base64("document-id")
pdf_bytes = client.invoices.get_pdf("document-id")
deleted = client.invoices.delete("document-id")
```

Raw mappings are accepted for pragmatic use. Known money and timestamp fields are serialized before sending, and locally known required fields are validated before the HTTP request.

## Contacts (v1)

```python
from misterpato_holded.v1.models import CreateContactRequest, UpdateContactRequest

contact = client.contacts.create(CreateContactRequest(
    name="Cliente Demo",
    email="cliente@example.com",
))

same_contact = client.contacts.get(contact.id)
updated_contact = client.contacts.update(contact.id, UpdateContactRequest(phone="+34 600 000 000"))
deleted = client.contacts.delete(contact.id)
```

Raw mapping payloads are also supported:

```python
contact = client.contacts.create({"name": "Cliente Demo", "email": "cliente@example.com"})
```

## Treasuries and payment methods (v1)

These resources are list-only in this v1 package:

```python
treasuries = client.treasuries.list()
payment_methods = client.payment_methods.list()
```

## Pagination (v1)

List resources support one-page `list(page=N)`, lazy `iter_all(...)`, and materialized `list_all(...)`.

```python
page_2 = client.invoices.list(page=2)

for invoice in client.invoices.iter_all(starttmp=date(2026, 1, 1), endtmp=date(2026, 1, 31)):
    print(invoice.id, invoice.doc_number)

all_contacts = client.contacts.list_all(max_pages=20)
```

`iter_all()` starts at page 1 by default and stops when Holded returns an empty list. If `max_pages` is reached before an empty page is observed, `HoldedPaginationLimitError` is raised instead of silently returning partial data.

## Date and datetime serialization (v1)

Holded v1 expects string Unix timestamps for document/payment dates and `starttmp`/`endtmp` filters. This package accepts `int`, `str`, `date`, and `datetime` values and emits canonical decimal strings on the wire.

Rules:

- Default timezone for naive `date`/`datetime`: `Europe/Madrid`.
- Configure it with `HoldedClient(..., timezone="UTC")` or another IANA timezone name.
- `starttmp=date(...)` uses local `00:00:00`.
- `endtmp=date(...)` uses local `23:59:59`.
- Naive `datetime` is interpreted in the client timezone.
- Aware `datetime` preserves its instant.

```python
from datetime import date, datetime, timezone

client = HoldedClient(api_key="holded-api-key", timezone="Europe/Madrid")

client.invoices.list(starttmp=date(2026, 1, 1), endtmp=date(2026, 1, 31))
client.invoices.create(CreateDocumentRequest(date=datetime(2026, 1, 15, 10, 30), contact_name="Demo"))
client.invoices.pay("document-id", {"date": datetime.now(timezone.utc), "amount": "120.00"})
```

## Money serialization

Money values use `Decimal(str(amount))`, are quantized to `0.01` with `ROUND_HALF_UP`, then sent as JSON numbers.

```python
DocumentItem(name="Service", units=1, subtotal="10.235")  # sends 10.24
```

## Error handling

All package exceptions inherit from `HoldedError`.

```python
from misterpato_holded.exceptions import (
    HoldedAuthError,
    HoldedConfigError,
    HoldedError,
    HoldedNotFoundError,
    HoldedPaginationLimitError,
    HoldedRequestValidationError,
    HoldedServerError,
    HoldedValidationError,
)

try:
    invoice = client.invoices.create(CreateDocumentRequest(contact_name="Missing date"))
except HoldedRequestValidationError:
    # Local validation failed before any HTTP request was sent.
    raise
except HoldedValidationError as exc:
    # Holded returned a remote 400/422 validation error.
    print(exc.status_code, exc.response_json)
except HoldedAuthError:
    print("Invalid or unauthorized Holded API key")
except HoldedNotFoundError:
    print("Document/contact not found")
except HoldedServerError as exc:
    if exc.retryable:
        print("Holded server error; safe for caller-controlled retry policy")
except HoldedPaginationLimitError:
    print("Pagination reached max_pages before an empty page")
except HoldedConfigError:
    print("Invalid local client configuration")
except HoldedError as exc:
    print(f"Holded client failure: {exc}")
```

Status mapping:

- `400` and `422`: `HoldedValidationError`
- `401` and `403`: `HoldedAuthError`
- `404`: `HoldedNotFoundError`
- `409`: `HoldedConflictError`
- `5xx`: `HoldedServerError` with `retryable=True`
- Invalid successful JSON or unexpected response shape: `HoldedResponseError`
