Metadata-Version: 2.4
Name: misterpato-holded-client
Version: 0.2.5
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

Install from a checkout in editable mode:

```bash
python3 -m pip install -e .
```

Install with test dependencies for development:

```bash
python3 -m pip install -e .[test]
python3 -m pytest -q
```

Runtime dependency: `requests`.

Supported Python: `>=3.9`.

## Development environment

A local virtual environment can be created with:

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e .[dev]
python -m pytest -q
```

This checkout includes `.python-version` set to `3.10.12` for pyenv-compatible tooling. The package itself supports Python `>=3.9`.

## Publishing

Build and upload instructions are documented in [`PUBLISHING.md`](PUBLISHING.md). Short version:

```bash
source .venv/bin/activate
python -m pytest -q
rm -rf dist build *.egg-info src/*.egg-info
python -m build
python -m twine check dist/*
python -m twine upload dist/*
```

Do not commit real PyPI/TestPyPI tokens. `.pypirc.example` contains only placeholders.

## 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.treasuries`
- `client.waybills`

Other v2 document types (purchases, estimates, and so on) 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 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).

## 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,
    Invoice,
    InvoiceStatus,
    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 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 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, and treasuries |
| 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.*`), `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`
  - `WebhookExtendedDocumentPayload` — sales order/waybill 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.

The full local reference for events, headers, payload schemas, and receiver guidance is `docs/holded-v2-documentation/webhooks.md`.

## 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`

## Live v2 tests

The live suite under `tests/live/` hits the real Holded v2 API at `api.holded.com`. It is
opt-in only: default `python -m pytest -q` does not collect or run these tests.

Only human developers run the live suite (unless he explicitly delegates otherwise). Agents diagnose
from shared logs and verify with the mocked suite only (`python -m pytest -q`).

```bash
export HOLDED_V2_API_KEY="your-v2-api-key"
pytest tests/live > live.log 2>&1
```

If a live run fails, share `live.log` with an agent — never the API key. The agent edits
code/tests and re-verifies with `python -m pytest -q`; the human developer re-runs `pytest tests/live`.

Without `HOLDED_V2_API_KEY` exported, `pytest tests/live` skips every test with a clear
reason and makes no network calls.

**Cleanup caveat:** an interrupted run (Ctrl-C, crash) may leave `live-test-` contacts or
invoices in the Holded account. Identify them by the `live-test-` prefix and delete manually
in the Holded UI.

**Residual risk:** invoice lifecycle tests that call `pay()` may leave a real payment record
on the account even after the throwaway invoice is deleted.

## Development verification

```bash
python3 -m pytest -q
python3 -m compileall -q src tests
python3 - <<'PY'
from misterpato_holded import HoldedClient, VERSION_1, VERSION_2

v1 = HoldedClient(api_key="x", version=VERSION_1)
print(v1.version, v1.invoices.doc_type.value)

v2 = HoldedClient(api_key="x", version=VERSION_2)
print(
    v2.version,
    type(v2.invoices).__name__,
    type(v2.products).__name__,
    type(v2.sales_orders).__name__,
    type(v2.waybills).__name__,
)
PY
```
