Metadata-Version: 2.5
Name: nxus-qbd
Version: 1.0.0
Summary: Official Python SDK for the Nxus QuickBooks Desktop API
Project-URL: Homepage, https://nxus.app
Project-URL: Repository, https://github.com/nxus-app/nxus-qbd-python
Project-URL: Documentation, https://nx-us.net/docs/
License-Expression: MIT
License-File: LICENSE
Keywords: accounting,api,nxus,qbd,quickbooks,quickbooks-desktop,sdk
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.12
Requires-Dist: httpx>=0.25.0
Requires-Dist: pydantic>=2.0
Provides-Extra: codegen
Requires-Dist: datamodel-code-generator[http]>=0.26.0; extra == 'codegen'
Requires-Dist: ruff==0.11.10; extra == 'codegen'
Provides-Extra: dev
Requires-Dist: packaging; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-asyncio; extra == 'dev'
Requires-Dist: python-dotenv>=1.2.1; extra == 'dev'
Requires-Dist: respx; extra == 'dev'
Description-Content-Type: text/markdown

# nxus-qbd v1.0.0

Official Python SDK for the [Nxus](https://nx-us.net/docs) QuickBooks Desktop API.

Requires Python 3.12 or newer.

## Installation

```bash
pip install nxus-qbd
```

## Quick Start

```python
from nxus_qbd import NxusClient, NxusEnvironment, RequestOptions

client = NxusClient(
    api_key="sk_live_…",
    environment=NxusEnvironment.PRODUCTION,  # default
)

options = RequestOptions(connection_id="conn_abc123", timeout=30)

# List vendors (with connection scoping)
vendors = client.vendors.list(limit=50, options=options)

# Retrieve a single customer
customer = client.customers.retrieve("cust_123", options=options)

# Create an invoice
invoice = client.invoices.create(
    customer_ref_list_id="cust_123",
    options=options,
)
```

## Async Support

```python
from nxus_qbd import AsyncNxusClient, RequestOptions

async with AsyncNxusClient(api_key="sk_live_…") as client:
    vendors = await client.vendors.list(
        options=RequestOptions(connection_id="conn_abc123")
    )
```

The SDK defaults to a `100s` client timeout so normal callers can receive the
API's structured timeout responses for heavier QuickBooks operations. Advanced
callers can still override this globally or per request:

```python
client = NxusClient(api_key="sk_live_…", timeout=120)
options = RequestOptions(connection_id="conn_abc123", timeout=30)
vendors = client.vendors.list(options=options)
customer = client.customers.retrieve("cust_123", options=options)
```

If most of a workflow targets one company file, set a client-wide default
instead of repeating it on every call:

```python
client = NxusClient(api_key="sk_live_…", connection_id="conn_abc123")

vendors = client.vendors.list(limit=50)  # uses conn_abc123 by default
other_company = client.vendors.list(
    limit=50,
    options=RequestOptions(connection_id="conn_other"),
)
```

With typed `RequestOptions`, `timeout` is always a local client timeout only.
Use `server_timeout_seconds` when you also want to send the
`X-Nxus-Timeout-Seconds` backend hint. Legacy flat kwargs such as
`connection_id=...`, `timeout=...`, and `max_retries=...` still work during
the compatibility window, including the older list-only `timeout` header mirror.

## Per-request transport options

```python
from nxus_qbd import RequestOptions

options = RequestOptions(
    connection_id="conn_abc123",
    headers={"X-Tenant": "demo"},
    timeout=30,
    server_timeout_seconds=70,
    max_retries=0,
    verbose=False,
)

page = client.vendors.list(limit=100, options=options)
invoice = client.invoices.create(customer_ref_list_id="cust_123", options=options)
```

`RequestOptions` values never serialize into request bodies or query strings.
If you pass both `options=` and legacy flat transport kwargs in the same call,
the SDK raises `ValueError` so request intent stays unambiguous.

For `auth_sessions.create(...)`, use `request=` when the payload itself also
needs a `connection_id`:

```python
from nxus_qbd.models import CreateAuthSessionRequest

session = client.auth_sessions.create(
    request=CreateAuthSessionRequest(connection_id="payload-conn"),
    options=RequestOptions(connection_id="transport-conn"),
)
```

## Safe Create retries and error recovery

Supported QBD `.create()` calls generate one cryptographically random UUID
`Idempotency-Key` and reuse it across automatic attempts. Sync, async, and
`.with_response.create()` share the same behavior, including Special Items.
This requires an API deployment with Create idempotency support; an older
server that ignores the header cannot make retries safe.

Use `RequestOptions(idempotency_key=...)` for an operation that can resume in a
later call or process. Save the key, original payload, and target connection
before the first attempt. A new call without a key generates a **new** identity.

```python
import os

import httpx
from nxus_qbd import NxusApiError, NxusClient, RequestOptions
from nxus_qbd.models import CreateVendorRequest

# Reload this operation's saved key and unchanged payload when resuming.
key = os.environ["NXUS_CREATE_OPERATION_KEY"]
request = CreateVendorRequest(name="Recovery Vendor", is_active=True)
options = RequestOptions(idempotency_key=key, max_retries=2)

with NxusClient(
    api_key=os.environ["NXUS_API_KEY"],
    connection_id=os.environ["NXUS_CONNECTION_ID"],
) as client:
    try:
        result = client.vendors.with_response.create(request=request, options=options)
        print(result.data.id, result.status_code, result.request_id)
    except NxusApiError as error:
        if error.code == "IDEMPOTENCY_KEY_REUSED":
            # The key was used with a different payload. Reconcile the saved
            # operation; do not automatically generate a replacement key.
            print("The original Create payload must be reconciled.")
        raise
    except httpx.TransportError:
        # Retry budget exhausted: the outcome may still be uncertain.
        # Retain the same key/payload/connection for a later recovery attempt.
        raise
```

For async usage, use `async with AsyncNxusClient(...)` and
`await client.vendors.create(request=request, options=options)`.

- Keys must be nonblank and at most 200 characters. The first-class option
  wins over a per-request `headers={"Idempotency-Key": ...}` value; the legacy
  header form remains supported. Neither becomes a request-body/query field.
- By default, protected Creates permit two additional attempts. Network errors,
  local `httpx` timeouts, and retryable HTTP responses reuse the key. Set
  `max_retries=0` to disable retries without disabling key generation.
- `x-should-retry` takes precedence over status: `false` stops retries; `true`
  allows them within the budget. Without it, HTTP 408/429 and 5xx retry, but 409
  does not. `Retry-After` takes precedence over the body `error.retryAfter`.
- Keep retries bounded. A timeout does not prove that QuickBooks failed to
  create the record. A completed replay can return 201 and a fresh read of the
  original QuickBooks ID. nXus owns recovery state and message-set cleanup;
  callers do not manage QBXML recovery IDs or a recovery-status endpoint.
- Update/Delete/Void, Connections, Auth Sessions, and custom-field mutations
  are not opted into this Create feature. Supplying `idempotency_key` to them
  does not enable local-timeout retries. Existing exception types remain:
  HTTP failures raise `NxusApiError`; exhausted network/timeouts raise `httpx`
  transport exceptions. Async cancellation is never retried.
- `timeout` uses **seconds**, not the TypeScript SDK's milliseconds. Use
  `server_timeout_seconds` separately for the backend wait window.

Set `verbose=True` or supply a structured logger with `debug/info/warn/error`
methods to capture response statuses, timeout events, and `retry-scheduled`
events. API credentials and idempotency-key headers are redacted. Preserve
request IDs and application operation IDs for diagnostics, not secrets.

Every error the API returns arrives in one shape — the `StandardErrorResponse`
wrapper around `ErrorDetail` — so one `except NxusApiError` covers every failure
mode, including model-binding rejections, which carry their per-field map at
`error.errors` and surface on `validation_errors`.

`NxusApiError` exposes `retry_after` in seconds plus restriction metadata:
`lifecycle_state`, `restriction_reason`, `restriction_code`,
`requires_payment`, and `checkout_url`. Use `is_restriction_error` and
`is_archived_connection` for common policy checks.

`is_connector_offline` is worth handling on its own: it means the QuickBooks Web
Connector is not polling, so the API answered 503 without queuing anything and
retrying cannot succeed until someone starts the connector. Read `code` to tell
`QWC_NEVER_CONNECTED` (the `.qwc` file was never installed) from
`QWC_NOT_CONNECTED` (it authenticated once and has since stopped) — the two want
different operator instructions. Prefer `retry_after` over a guessed backoff on
429; the server knows when the window resets. `throw_if_error(...)`
accepts either an `httpx.Response` or an arbitrary SDK error payload; `None`
and successful responses are no-ops.

`NxusApiErrorCode` and `NxusApiErrorType` export every code and type the API
publishes; a test asserts they match the generated spec enums exactly, so they
cannot fall behind a regeneration. Two members are not in the spec and are
listed in `CLIENT_SYNTHESIZED_ERROR_CODES` / `CLIENT_SYNTHESIZED_ERROR_TYPES`:
`QBD_INTEGRATION_ERROR` and `INTEGRATION_ERROR_TYPE`, which the SDK synthesizes
for an HTTP 200 carrying a failed QuickBooks operation and which therefore never
appear in a response body. The runtime `code` and `type` attributes stay plain
strings, so a value newer than your installed SDK is passed through rather than
rejected.

The backend's live-verified delayed-response/completed-replay scenarios do not
establish recovery after a permanently lost QWC callback or a backend crash.
The optional Python live drill is separate from the offline unit suite.

## Response metadata

Every resource method has a `with_response` twin that returns the parsed model
*and* the response metadata that came with it:

```python
check = client.checks.create(payee_id="80000012-1234567890")
# -> Check

wrapped = client.checks.with_response.create(payee_id="80000012-1234567890")
# -> NxusResponse[Check]

wrapped.data          # the same Check
wrapped.status_code   # 200
wrapped.request_id    # 'req_abc123' — quote this in support requests
wrapped.headers       # read-only, lower-cased names
```

Both forms are the same call. The plain method delegates to the same internal
implementation, so parsing, validation, retries and error translation are
identical — only the return value differs. Errors raise `NxusApiError` in both.

`NxusResponse` is immutable: attributes cannot be reassigned and `headers` is a
read-only mapping.

The undecoded body is not retained unless you ask for it, since keeping it for
every call would hold a second full copy of every payload alive:

```python
wrapped = client.checks.with_response.retrieve(
    check_id,
    options=RequestOptions(include_raw_body=True),
)
wrapped.raw_body      # the exact bytes-as-text the server sent
```

The SDK never hands back an `httpx.Response`. Everything useful is copied out
while the response is open, so a `NxusResponse` is safe to keep, log, or pass
around long after the request finished — and httpx's connection lifetime stays
out of this SDK's contract.

For a paginated `list`, the metadata describes the **first page** request;
later pages are fetched lazily by the returned `CursorPage`, after the wrapped
call has already returned.

## Environments

Production is the default and uses `https://api.nx-us.net/`.

```python
from nxus_qbd import NxusClient

# Production (default)
client = NxusClient(api_key="sk_live_…")

# Local development convenience
client = NxusClient(api_key="sk_test_…", environment="development")

# Explicit override still wins
client = NxusClient(api_key="sk_test_…", base_url="https://staging.example.com/")
```

If you point `base_url` at `localhost` explicitly, the SDK also relaxes TLS
verification by default so local dev stays frictionless.

## Connection Scoping

Every request requires a `connection_id` to identify which QuickBooks Desktop company file to target:

```python
# Per-request
client.vendors.list(connection_id="conn_abc123")

# Preferred typed form
client.vendors.list(options=RequestOptions(connection_id="conn_abc123"))

# Global default via headers
client = NxusClient(
    api_key="sk_live_…",
    headers={"X-Connection-Id": "conn_abc123"},
)
```

## Filtering by active status

QuickBooks returns only active records unless a list request says otherwise.
`QbdActiveStatus` is a `StrEnum`, so the enum and its wire string are
interchangeable:

```python
from nxus_qbd.models import QbdActiveStatus

# Active and inactive records
page = client.vendors.list(limit=50, active_status=QbdActiveStatus.ALL)

# The same request, written as the wire string
page = client.vendors.list(limit=50, active_status="All")

# Only the records QuickBooks has marked inactive
page = client.vendors.list(limit=50, active_status=QbdActiveStatus.INACTIVE_ONLY)

# Omit it entirely for QuickBooks' ActiveOnly default
page = client.vendors.list(limit=50)
```

The API does not validate this field — it forwards the value into qbXML, where
a near miss such as `"all"` comes back as QuickBooks error 3110 after a full
round trip. For a string of unknown provenance (an environment variable, a CLI
flag, a database column), let the enum parse it and fail locally instead:

```python
active_status = QbdActiveStatus(os.environ.get("NXUS_ACTIVE_STATUS", "All"))
page = client.vendors.list(limit=50, active_status=active_status)
```

## Pagination

All list methods return a paginated response that supports both manual page navigation and auto-iteration:

```python
# Auto-paginate through all records (sync)
for vendor in client.vendors.list(limit=100):
    print(vendor.name)

# Auto-paginate through all records (async)
async for vendor in await async_client.vendors.list(limit=100):
    print(vendor.name)
```

Manual continuation also replays the original filters, connection scoping, and
transport options:

```python
page = client.vendors.list(connection_id="conn_abc123", limit=100, timeout=45)
page_2 = page.get_next_page()

typed_page = client.vendors.list(
    limit=100,
    options=RequestOptions(
        connection_id="conn_abc123",
        timeout=45,
        server_timeout_seconds=45,
    ),
)
typed_page_2 = typed_page.get_next_page()
```

Count-capable resources can ask QuickBooks for metadata without retrieving a
data page. Keep the approximation signal; do not reduce this response to a bare
integer:

```python
count = client.vendors.count(
    options=RequestOptions(connection_id="conn_abc123"),
    active_status="ActiveOnly",
)
print(count.count, count.is_approximate)

async_count = await async_client.vendors.count(
    options=RequestOptions(connection_id="conn_abc123"),
)
```

`count()` accepts the resource's normal filters but not `limit` or `cursor`.

> [!IMPORTANT]
> **Processing Constraints**: Each paginated request must either complete or be cancelled before the subsequent request for that connection can be processed by the backend.
>
> - **Async API (Primary)**: The Async API is the recommended way to handle these requests as it allows for better lifecycle management.
> - **Sync Wrappers**: While sync wrappers are provided for convenience, you may need to increase your client-side timeouts to ensure large paginated sets complete successfully.

## Examples

Runnable examples live in [`examples/`](examples/) and cover both sync and async usage:

| Example | Description |
|---|---|
| [`basic_crud.py`](examples/basic_crud.py) | Create, retrieve, update, list, and delete a vendor |
| [`auth_setup.py`](examples/auth_setup.py) | Create a connection, generate a hosted QWC auth flow URL, and check auth status |
| [`auto_pagination.py`](examples/auto_pagination.py) | Sync/async count, auto-iteration, and manual pages |
| [`error_handling.py`](examples/error_handling.py) | Error categorization, retry with backoff, validation errors |
| [`connection_scoped.py`](examples/connection_scoped.py) | Multi-company isolation with `connection_id` |
| [`timeout_tuning.py`](examples/timeout_tuning.py) | Client timeout defaults, local list timeouts, and CRUD timeout overrides |
| [`reports.py`](examples/reports.py) | Aging, general detail, and general summary reports |
| [`async_basic_crud.py`](examples/async_basic_crud.py) | Async CRUD lifecycle |
| [`async_error_handling.py`](examples/async_error_handling.py) | Async error handling and retry patterns |
| [`async_connection_scoped.py`](examples/async_connection_scoped.py) | Async multi-company data isolation |
| [`async_reports.py`](examples/async_reports.py) | Async report retrieval |

All examples auto-load a `.env` file from the project root. Copy `.env.example` to `.env` and fill in your values:

```bash
cp .env.example .env
```

```ini
NXUS_API_KEY=sk_test_your_key_here
NXUS_ENVIRONMENT=development
NXUS_CONNECTION_ID=your_connection_id_here
```

Optional overrides:

```ini
NXUS_BASE_URL=https://localhost:7242/
NXUS_DEV_MODE=true
```

Then run any example:

```bash
python examples/basic_crud.py
```

## Tests

The test suite includes focused unit coverage plus optional live integration tests:

| File | What it covers |
|---|---|
| [`tests/conftest.py`](tests/conftest.py) | Shared fixtures — loads `.env`, provides `client` and `async_client` fixtures, auto-skips when `NXUS_API_KEY` is not set |
| [`tests/unit/test_config.py`](tests/unit/test_config.py) | Environment/base URL resolution behavior |
| [`tests/unit/test_create_recovery.py`](tests/unit/test_create_recovery.py) | Sync/async Create keys, timeouts, header precedence, retry budgets, cancellation, and non-Create safety |
| [`tests/unit/test_vendor_pydantic_wiring.py`](tests/unit/test_vendor_pydantic_wiring.py) | Typed response parsing and snake_case request serialization |
| [`tests/integration/test_smoke.py`](tests/integration/test_smoke.py) | Sync tests — list vendors/accounts, pagination with limits, cursor metadata, auto-pagination iteration, 404 error handling |
| [`tests/integration/test_async_smoke.py`](tests/integration/test_async_smoke.py) | Async tests — list vendors, async auto-pagination |

Run the full suite:

```bash
# Install dev dependencies
pip install nxus-qbd[dev]
# — or with uv —
uv sync

# Run tests (skipped automatically if NXUS_API_KEY is not set)
pytest

# Run only unit tests
pytest tests/unit -q

# Run with verbose output
pytest -v

# Run only async tests
pytest tests/integration/test_async_smoke.py -v
```

## Resources

All QuickBooks Desktop resources are available as properties:

| Category | Resources |
|---|---|
| **Transactions** | `invoices`, `bills`, `checks`, `deposits`, `estimates`, `credit_memos`, `purchase_orders`, `sales_receipts`, `journal_entries`, `receive_payments`, `vendor_credits`, `credit_card_charges`, `credit_card_bills`, `credit_card_credits`, `charges`, `build_assemblies`, `ar_refund_credit_cards`, `sales_tax_payment_checks`, `item_receipts`, `check_bills`, `time_trackings`, `transactions` |
| **Lists** | `accounts`, `customers`, `vendors`, `employees`, `other_names`, `currencies`, `terms`, `date_driven_terms`, `payment_methods`, `ship_methods`, `sales_tax_codes`, `price_levels`, `qbd_classes`, `customer_types`, `vendor_types`, `billing_rates`, `inventory_sites`, `bar_codes`, `account_tax_line_infos`, `bill_to_pay`, `unit_of_measure_sets`, `special_items` |
| **Items** | `items`, `inventory_items`, `item_discounts`, `item_fixed_assets`, `item_groups`, `item_inventory_assemblies`, `item_non_inventory`, `item_other_charges`, `item_payments`, `item_sales_tax`, `item_sales_tax_groups`, `service_items`, `item_subtotals` |
| **Payroll** | `payroll_item_non_wages`, `payroll_item_wages`, `workers_comp_codes` |
| **Reports** | `reports.retrieve_general_detail()`, `reports.retrieve_aging()`, etc. |
| **Core** | `auth_sessions`, `connections` |


## Custom fields and data extensions

QuickBooks Desktop uses the same underlying mechanism for both UI-visible custom fields and application-only integration data:

| QuickBooks concept | SDK/API name | Purpose |
|---|---|---|
| Data extension definition | `DataExtDef` / custom field definition | Describes a field's owner, name, data type, and supported object types. |
| Data extension value | `DataExt` / custom field value | Stores the field's value on one specific QuickBooks list object, transaction, or transaction line. |

`custom_field_definitions.list()` returns the complete (non-paginated) collection
envelope — the definitions live under `.data`, alongside `.count`.

A definition must exist before a value can be written. Definitions are identified by `ownerId + name`; values add the specific QuickBooks target to that composite identity. QuickBooks may omit `DataExtID` for private definitions, so SDK consumers must allow `DataExtDef.id` to be `null`.

The `ownerId` determines how a definition is used:

- **Public custom field:** use `"0"`. The field is visible in the QuickBooks UI, its type must be `STR255TYPE`, and `assignToObjects` must contain at least one of `Customer`, `Employee`, `Item`, or `Vendor`. Public definitions cannot be deleted through the SDK.
- **Private data extension:** use an application-owned GUID such as `"{C3AA84E0-D242-47AB-A12B-3EDA3A2590A2}"`. The field is available only to applications that know that GUID. `assignToObjects` is optional; when supplied, it may contain any object type supported by QuickBooks for `DataExtDefAdd`. The GUID does not require separate registration with QuickBooks.

The `assignToObjects` property is therefore optional in the SDK request type, but conditionally required by QuickBooks for public definitions. QuickBooks accepts a private definition with the property omitted and returns that definition with an empty `assignToObjects` list. Supply the intended object assignments before relying on `DataExt` value writes for those object types.


The normal workflow is:

1. Create the `DataExtDef`.
2. Create a `DataExt` value using the same `ownerId` and field name, plus a target such as a Customer `ListID`, an Invoice `TxnID`, or a transaction-line `TxnLineID`.
3. Update or delete the value using that same composite identity.
4. Delete a private definition only after its values are no longer needed. Public definitions must be managed in the QuickBooks UI because QuickBooks does not support deleting them through `DataExtDefDel`.


Public fields are typically used for information users should see or edit in QuickBooks. Private extensions are commonly used for external-system identifiers, synchronization or verification markers, workflow state, migration metadata, and other integration data that should not appear in the QuickBooks UI.


## License

MIT
