Metadata-Version: 2.4
Name: misterpato-holded-client
Version: 0.2.2
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 the Holded legacy Invoice API v1.

The package exposes a small synchronous SDK around Holded's v1 Invoice API. It is intentionally independent of Django, Colegia, 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

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

`VERSION_2` exists as a future-facing constant, but constructing a v2 client raises `NotImplementedError` until v2 is implemented.

## Configuration

```python
client = HoldedClient(
    api_key="holded-api-key",
    version=VERSION_1,
    base_url="https://api.holded.com/api",
    timeout=30,
    timezone="Europe/Madrid",
)
```

Notes:

- The Holded v1 auth header is added automatically as `key: <api_key>`.
- Blank API keys raise `HoldedConfigError`.
- `timezone` controls how naive `date` and `datetime` values are serialized to Holded Unix timestamps.

## Documents

Create an invoice with the typed resource:

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

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

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

These resources are list-only in this v1 package:

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

## Pagination

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

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`

## Development verification

```bash
python3 -m pytest -q
python3 -m compileall -q src tests
python3 - <<'PY'
from misterpato_holded import HoldedClient, VERSION_1, VERSION_2
client = HoldedClient(api_key="x", version=VERSION_1)
print(client.version, client.invoices.doc_type.value)
try:
    HoldedClient(api_key="x", version=VERSION_2)
except NotImplementedError as exc:
    print(type(exc).__name__, exc)
PY
```
