Metadata-Version: 2.4
Name: ziptax-sdk
Version: 0.3.0b0
Summary: Official Python SDK for the Ziptax API
Author-email: Ziptax Team <support@zip.tax>
License: MIT
Project-URL: Homepage, https://github.com/ziptax/ziptax-python
Project-URL: Repository, https://github.com/ziptax/ziptax-python
Project-URL: Documentation, https://github.com/ziptax/ziptax-python#readme
Keywords: tax,ziptax,api,sdk,sales tax
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: types-requests>=2.28.0; extra == "dev"
Dynamic: license-file

# Ziptax Python SDK

Official Python SDK for the [Ziptax API](https://zip.tax) - Get accurate sales and use tax rates for any US or Canadian address, with optional TaxCloud order management support.

[![Python Version](https://img.shields.io/pypi/pyversions/ziptax-sdk)](https://pypi.org/project/ziptax-sdk/)
[![License](https://img.shields.io/github/license/ziptax/ziptax-python)](LICENSE)

## Features

### Core Features (ZipTax API)
- 🚀 Simple and intuitive API
- 🛒 Cart tax calculation with per-item tax rates
- 🏷️ Product code (TIC) search, AI-powered recommendation, and full TIC data
- 📐 Product rate rules, extended address components, and shipping rules on v6.0 lookups
- 🔄 Automatic retry logic with exponential backoff
- ✅ Input validation
- 🔍 Type hints for better IDE support
- 📦 Pydantic models for response validation
- 🔒 Comprehensive error handling
- ⚡ Support for concurrent operations
- 🧪 Well-tested with high code coverage

### Merchant Compliance (Platform Integrations)
- 🏬 **Merchant Management**: Create, update, delete, and list merchants
- 🧾 **Transactions**: Cart calculation, orders, and refunds per merchant
- 📄 **Exemption Certificates**: Create, retrieve, list, and delete
- 🔀 **One API Key**: Everything routes through `api.zip-tax.com` with your Ziptax key
- 🧭 **Both Compliance Models**: Self-managed merchants use the Ziptax rate engine;
  TaxCloud-connected merchants forward to TaxCloud using stored credentials

### TaxCloud Integration (Optional, Deprecated)
- 📋 **Order Management**: Create, retrieve, and update orders
- 💰 **Refund Processing**: Full and partial refund support
- 🔐 **Optional Configuration**: TaxCloud features only enabled when credentials provided
- ⚠️ **Deprecated in 0.3.0**: These call the TaxCloud API directly, which is no longer
  the documented integration path. Use the merchant functions instead
  (see [Migrating to the merchant layer](#migrating-to-the-merchant-layer))

## Installation

```bash
pip install ziptax-sdk
```

## Quick Start

```python
from ziptax import ZipTaxClient

# Initialize the client with your API key
client = ZipTaxClient.api_key("your-api-key-here")

# Get sales tax by address
response = client.request.GetSalesTaxByAddress(
    "200 Spectrum Center Drive, Irvine, CA 92618"
)

print(f"Address: {response.address_detail.normalized_address}")
if response.tax_summaries:
    for summary in response.tax_summaries:
        print(f"{summary.summary_name}: {summary.rate * 100:.2f}%")

# Always close the client when done
client.close()
```

## Usage

### Initialize the Client

```python
from ziptax import ZipTaxClient

# Basic initialization
client = ZipTaxClient.api_key("your-api-key-here")

# With custom configuration
client = ZipTaxClient.api_key(
    "your-api-key-here",
    timeout=60,           # Request timeout in seconds
    max_retries=5,        # Maximum retry attempts
    retry_delay=2.0,      # Base delay between retries
)

# Using as a context manager (recommended)
with ZipTaxClient.api_key("your-api-key-here") as client:
    response = client.request.GetSalesTaxByAddress("123 Main St")
```

### Get Sales Tax by Address

```python
response = client.request.GetSalesTaxByAddress(
    address="200 Spectrum Center Drive, Irvine, CA 92618",
    country_code="USA",      # Optional: USA, CAN, PRI, ASM, GUM, MNP, VIR
    historical="202401",     # Optional: Historical date (YYYYMM format)
    format="json",           # Optional: Response format (default: "json")
)

# Access response data
print(response.address_detail.normalized_address)
print(response.address_detail.geo_lat)
print(response.address_detail.geo_lng)

# Response code
print(f"Response: {response.metadata.response.code} - {response.metadata.response.message}")

# Tax summaries with display rates
if response.tax_summaries:
    for summary in response.tax_summaries:
        print(f"{summary.summary_name}: {summary.rate}")
        for display_rate in summary.display_rates:
            print(f"  {display_rate.name}: {display_rate.rate}")

# Base rates by jurisdiction
if response.base_rates:
    for rate in response.base_rates:
        print(f"{rate.jur_name} ({rate.jur_type}): {rate.rate}")

# Sourcing rules
if response.sourcing_rules:
    print(f"Sourcing: {response.sourcing_rules.value}")
```

#### Extended v6.0 Options

`GetSalesTaxByAddress` and `GetSalesTaxByGeoLocation` accept additional
parameters that enrich the response.

```python
response = client.request.GetSalesTaxByAddress(
    address="200 Spectrum Center Drive, Irvine, CA 92618",
    taxability_code="20010",         # Adds product_detail with rate rules
    adjustment="auto",               # "auto" (default), "origin", "destination"
    address_detail_extended=True,    # Adds address_detail.address components
    shipping_extended=True,          # Adds shipping.shipping_extended
    city="Irvine",                   # Narrow the lookup
    state="CA",
)

# Product rate rules for the requested TIC
if response.product_detail:
    code = response.product_detail.taxability_code
    print(f"{code.title} ({code.rate_action_code}): {code.rate_action_message}")
    for rule in code.rate_rules or []:
        print(f"  {rule.jur_tax_code}: rate={rule.effective_tax_rate}")

# Geocoded address broken into parts
if response.address_detail.address:
    parts = response.address_detail.address
    print(f"{parts.house_number} {parts.street}, {parts.city} {parts.postal_code}")

# Detailed shipping rule.
# response.shipping is itself Optional - it is None for some regions.
if response.shipping and response.shipping.shipping_extended:
    rule = response.shipping.shipping_extended
    print(f"{rule.state_code}: {rule.rule} - {rule.description}")
```

`rate_action_code` reports the outcome of the TIC lookup: `T00` (valid, rules
listed), `T01` (valid, no applicable rules), `T02` (invalid TIC), `T03`
(invalid TIC format).

### Get Sales Tax by Geolocation

```python
response = client.request.GetSalesTaxByGeoLocation(
    lat="33.6489",
    lng="-117.8386",
    country_code="USA",
    format="json",
)

print(response.address_detail.normalized_address)
```

### Get Rates by Postal Code

```python
response = client.request.GetRatesByPostalCode(
    postal_code="92694",
    format="json",
)

# Response includes all tax jurisdictions for the postal code
for result in response.results:
    print(f"{result.geo_city}, {result.geo_state}")
    print(f"Sales Tax: {result.tax_sales * 100:.2f}%")
    print(f"Use Tax: {result.tax_use * 100:.2f}%")
```

Postal-code lookups also accept `state`, `city`, `county`, `historical`, and
`sat_item_total`. Supplying `sat_item_total` on a Tennessee lookup adds a
Single Article Tax breakdown:

```python
response = client.request.GetRatesByPostalCode("37201", sat_item_total=1600.0)

if response.sat_tax_detail:
    print(f"Local tax total: {response.sat_tax_detail.local_tax_total}")
```

### Get Account Metrics

```python
# Simplified v6.0 counters (GET /account/v60/metrics)
metrics = client.request.GetAccountMetrics()

print(f"Requests: {metrics.request_count:,} / {metrics.request_limit:,}")
print(f"Usage: {metrics.usage_percent:.2f}%")
print(f"Account Active: {metrics.is_active}")
print(f"Message: {metrics.message}")

# Per-pool breakdown (GET /account/metrics)
usage = client.request.GetAccountUsage()

print(f"Core:     {usage.core_request_count:,} / {usage.core_request_limit:,}")
print(f"Geo:      {usage.geo_request_count:,} / {usage.geo_request_limit:,}")
print(f"Merchant: {usage.merchant_request_count:,} / {usage.merchant_request_limit:,}")
print(f"Geocoding enabled: {usage.geo_enabled}")
```

### Get TIC Data

```python
data = client.request.GetTicData()

for entry in data.tic_list[:5]:
    tic = entry.tic
    print(f"{tic.id}: {tic.title} (parent: {tic.parent or 'top-level'})")
```

### Health and System Metadata

```python
health = client.request.GetHealth()
print(f"{health.status} - taxdata {health.components.taxdata}")

metadata = client.request.GetSystemMetadata()
print(f"{metadata.hostname} on {metadata.go_version}")
```

### Search Product Codes (TIC)

Search for Taxability Information Codes (TICs) using a natural language product description. Returns all matching codes ranked and scored by relevance.

```python
response = client.request.SearchProductCodes(
    "baked goods sold in plastic packaging"
)

for result in response.results:
    print(f"TIC {result.tic_id}: {result.label}")
    print(f"  Score: {result.score:.4f} (Rank: {result.rank})")
    print(f"  Description: {result.description}")

# The API also returns a pagination cursor and its schema URL
print(response.next_cursor, response.schema_url)
```

Use the returned `tic_id` as the `taxability_code` parameter in rate requests or cart line items. For v60 rate requests, convert to `str` first:

```python
# Use with rate requests (taxability_code is a string parameter)
tic = response.results[0].tic_id
tax = client.request.GetSalesTaxByAddress(
    "200 Spectrum Center Dr, Irvine, CA 92618",
    taxability_code=str(tic),
)

# Use with cart line items (taxability_code is an integer)
CartLineItem(item_id="item-1", price=10.00, quantity=1, taxability_code=tic)
```

### Recommend Product Code (TIC)

Get an AI-powered product code recommendation. Returns a single best-match TIC with higher accuracy than the standard search. Has slightly higher latency due to the AI processing step.

```python
response = client.request.RecommendProductCode(
    "baked goods sold in plastic packaging"
)

prediction = response.predictions[0]
if prediction.status == "success":
    print(f"Recommended TIC: {prediction.tic_id} ({prediction.label})")
    print(f"  TIC Description: {prediction.tic_description}")
    print(f"  Product Description: {prediction.product_description}")
else:
    print(f"Recommendation failed: {prediction.error}")
```

### Calculate Cart Tax

Calculate sales tax for a shopping cart with multiple line items. `CalculateCart` uses **dual-routing**: when TaxCloud credentials are configured on the client, the request is automatically routed to the TaxCloud API; otherwise it is sent to the ZipTax API. The input is the same `CalculateCartRequest` in both cases, but the response type differs:

- **Without TaxCloud credentials** -- returns a `CalculateCartResponse` (ZipTax API)
- **With TaxCloud credentials** -- returns a `TaxCloudCalculateCartResponse` (TaxCloud API)

```python
from ziptax.models import (
    CalculateCartRequest,
    CartItem,
    CartAddress,
    CartCurrency,
    CartLineItem,
)

# Build the cart request
request = CalculateCartRequest(
    items=[
        CartItem(
            customer_id="customer-453",
            currency=CartCurrency(currency_code="USD"),
            destination=CartAddress(
                address="200 Spectrum Center Dr, Irvine, CA 92618"
            ),
            origin=CartAddress(
                address="323 Washington Ave N, Minneapolis, MN 55401"
            ),
            line_items=[
                CartLineItem(
                    item_id="item-1",
                    price=10.75,
                    quantity=1.5,
                ),
                CartLineItem(
                    item_id="item-2",
                    price=25.00,
                    quantity=2.0,
                    taxability_code=0,
                ),
            ],
        )
    ]
)

# Calculate tax (routes to ZipTax or TaxCloud based on client config)
result = client.request.CalculateCart(request)

# Access results
cart = result.items[0]
print(f"Cart ID: {cart.cart_id}")
for item in cart.line_items:
    print(f"  {item.item_id}: rate={item.tax.rate}, amount=${item.tax.amount:.2f}")
```

#### Validation

The cart models enforce constraints at construction time via Pydantic:

- `items` must contain exactly 1 cart
- `line_items` must contain 1-250 items
- `price` and `quantity` must be greater than 0
- `currency_code` must be `"USD"`

```python
from pydantic import ValidationError

try:
    CartLineItem(item_id="item-1", price=-5.00, quantity=1.0)
except ValidationError as e:
    print(e)  # price must be greater than 0
```

## Merchant Compliance

Platform integrations that serve multiple merchants use the merchant layer.
Everything runs through `api.zip-tax.com` with your Ziptax API key; each call
identifies the merchant with a `merchant_id`.

Ziptax routes on the merchant's compliance model:

| | Self-managed | TaxCloud-connected |
|---|---|---|
| Created with | `merchant_type="self-managed"` | `merchant_type="taxcloud"` (default) |
| Active | Immediately, no invite | Once the merchant connects |
| Tax calculation | Ziptax rate engine, in-process | Forwarded to TaxCloud |
| Persisted | No | Yes |
| Discounts | Not supported | Supported |
| Orders / refunds / certificates | Not available (403) | Available |
| Plan | Pro and Enterprise | Enterprise |

### Create a Merchant

```python
from ziptax import ZipTaxClient
from ziptax.models import CreateMerchantRequest, MerchantType

client = ZipTaxClient.api_key("your-ziptax-api-key")

merchant = client.request.CreateMerchant(
    CreateMerchantRequest(
        merchant_name="Acme Supply Co",
        contact_email="ops@acme.example",
        reference_id="your-internal-id-1042",
        merchant_type=MerchantType.SELF_MANAGED,
    )
)
print(merchant.merchant_id)  # UUID to use on every later call
```

To connect a merchant who already has a TaxCloud account, create them with
`merchant_type="taxcloud"` and store their credentials:

```python
from ziptax.models import SetMerchantCredentialsRequest

client.request.SetMerchantCredentials(
    SetMerchantCredentialsRequest(
        merchant_id=merchant.merchant_id,
        connection_id="25eb9b97-5acb-492d-b720-c03e79cf715a",
        api_key="the-merchants-taxcloud-api-key",
    )
)
```

To invite a merchant who does not yet use TaxCloud, set
`send_taxcloud_invite=True` on `CreateMerchant` instead.

Check where a merchant stands with `GetMerchant`:

```python
merchant = client.request.GetMerchant("9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13")
# status is one of: taxcloud_invited, taxcloud_connected,
# taxcloud_disconnected, external_compliance (self-managed)
print(merchant.status)
```

### Calculate Cart Tax for a Merchant

The same request body works for both compliance models.

```python
from ziptax.models import (
    MerchantAddress,
    MerchantCalculateCartRequest,
    MerchantCart,
    MerchantCartLineItem,
    MerchantCurrency,
)

result = client.request.MerchantCalculateCart(
    MerchantCalculateCartRequest(
        merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13",
        items=[
            MerchantCart(
                cart_id="my-cart-1",
                customer_id="customer-453",
                currency=MerchantCurrency(currency_code="USD"),
                origin=MerchantAddress(
                    line1="1600 Amphitheatre Pkwy",
                    city="Mountain View",
                    state="CA",
                    zip="94043",
                ),
                destination=MerchantAddress(
                    line1="350 5th Ave",
                    city="New York",
                    state="NY",
                    zip="10118",
                ),
                line_items=[
                    MerchantCartLineItem(
                        index=0, item_id="sku-1", price=10.75, quantity=1.5, tic=0
                    ),
                    MerchantCartLineItem(
                        index=1, item_id="ship", price=8.95, quantity=1, tic=10001
                    ),
                ],
            )
        ],
    )
)

for item in result.items:
    for line in item.line_items:
        print(f"{line.item_id}: rate={line.tax.rate} amount={line.tax.amount}")
```

`connection_id` and `transaction_date` are present only on the
TaxCloud-connected path. On the self-managed path they are `None`, because
nothing is persisted and no TaxCloud call is made.

### Orders and Refunds

Available to TaxCloud-connected merchants only.

```python
from ziptax.models import (
    MerchantCreateOrderFromCartRequest,
    MerchantCreateRefundRequest,
    MerchantGetOrderRequest,
    MerchantRefundItem,
)

# Capture a calculated cart as an order
order = client.request.MerchantCreateOrderFromCart(
    MerchantCreateOrderFromCartRequest(
        merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13",
        cart_id="my-cart-1",
        order_id="my-order-1",
        completed=True,
    )
)

# Retrieve it, including refunds
order = client.request.MerchantGetOrder(
    MerchantGetOrderRequest(
        merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13",
        order_id="my-order-1",
        expand="refunds",
    )
)

# Partial refund (omit items for a full refund)
refund = client.request.MerchantCreateRefund(
    MerchantCreateRefundRequest(
        merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13",
        order_id="my-order-1",
        items=[MerchantRefundItem(item_id="sku-1", quantity=1.0)],
    )
)
```

### Exemption Certificates

```python
from ziptax.models import (
    CreateExemptionCertificateRequest,
    ExemptionCertificateBusinessType,
    ExemptionCertificateReason,
    ExemptState,
    ListExemptionCertificatesRequest,
    MerchantAddress,
)

cert = client.request.CreateExemptionCertificate(
    CreateExemptionCertificateRequest(
        merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13",
        customer_id="customer-453",
        customer_name="Acme Resale LLC",
        customer_business_type=ExemptionCertificateBusinessType.RETAIL_TRADE,
        reason=ExemptionCertificateReason.RESALE,
        reason_description="Resale",
        address=MerchantAddress(
            line1="350 5th Ave", city="New York", state="NY", zip="10118"
        ),
        states=[ExemptState(abbreviation="NY")],
    )
)

# Reference cert.certificate_id as exemption_id on later carts and orders.

# List with pagination
page = client.request.ListExemptionCertificates(
    ListExemptionCertificatesRequest(
        merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13", limit=20
    )
)
while page.next_cursor:
    page = client.request.ListExemptionCertificates(
        ListExemptionCertificatesRequest(
            merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13",
            limit=20,
            cursor=page.next_cursor,
        )
    )
```

## Migrating to the Merchant Layer

The direct-to-TaxCloud functions still work but emit a `DeprecationWarning` as
of 0.3.0. The merchant equivalents take a `merchant_id` instead of client-level
TaxCloud credentials, and use structured addresses.

| Deprecated | Replacement |
|---|---|
| `CreateOrder(request)` | `MerchantCreateOrder(request)` |
| `CreateOrderFromCart(request)` | `MerchantCreateOrderFromCart(request)` |
| `GetOrder(order_id)` | `MerchantGetOrder(request)` |
| `UpdateOrder(order_id, request)` | `MerchantUpdateOrder(request)` |
| `RefundOrder(order_id, request)` | `MerchantCreateRefund(request)` |

To migrate:

1. Call `CreateMerchant` once per merchant, then `SetMerchantCredentials` with
   the Connection ID and TaxCloud API key you currently pass to
   `ZipTaxClient.api_key(...)`. Store the returned `merchant_id`.
2. Drop `taxcloud_connection_id` and `taxcloud_api_key` from client init.
3. Swap each call for its replacement above, passing `merchant_id`.

`CalculateCart` is not deprecated. It uses the account-level
`POST /calculate/cart` endpoint, which still runs but is no longer part of the
published v6.0 API surface. For platform integrations serving multiple
merchants, prefer `MerchantCalculateCart`.

## TaxCloud Order Management

> **Deprecated as of 0.3.0.** These functions call the TaxCloud API directly,
> which is no longer the documented integration path. See
> [Migrating to the merchant layer](#migrating-to-the-merchant-layer).

The SDK includes optional support for TaxCloud order management features. To use these features, you need both a ZipTax API key and TaxCloud credentials (Connection ID and API Key).

### Initialize Client with TaxCloud Support

```python
from ziptax import ZipTaxClient

# Initialize with TaxCloud credentials
client = ZipTaxClient.api_key(
    api_key="your-ziptax-api-key",
    taxcloud_connection_id="25eb9b97-5acb-492d-b720-c03e79cf715a",
    taxcloud_api_key="your-taxcloud-api-key",
)

# TaxCloud features are now available via client.request
```

### Create an Order

```python
from ziptax.models import (
    CreateOrderRequest,
    TaxCloudAddress,
    CartItemWithTax,
    Tax,
    Currency,
)

# Prepare order request
order_request = CreateOrderRequest(
    order_id="my-order-1",
    customer_id="customer-453",
    transaction_date="2024-01-15T09:30:00Z",
    completed_date="2024-01-15T09:30:00Z",
    origin=TaxCloudAddress(
        line1="323 Washington Ave N",
        city="Minneapolis",
        state="MN",
        zip="55401-2427",
    ),
    destination=TaxCloudAddress(
        line1="323 Washington Ave N",
        city="Minneapolis",
        state="MN",
        zip="55401-2427",
    ),
    line_items=[
        CartItemWithTax(
            index=0,
            item_id="item-1",
            price=10.8,
            quantity=1.5,
            tax=Tax(amount=1.31, rate=0.0813),
        )
    ],
    currency=Currency(currency_code="USD"),
)

# Create the order
order = client.request.CreateOrder(order_request)
print(f"Created order: {order.order_id}")
print(f"Tax amount: ${order.line_items[0].tax.amount}")
```

### Retrieve an Order

```python
# Get an existing order by ID
order = client.request.GetOrder("my-order-1")

print(f"Order ID: {order.order_id}")
print(f"Customer ID: {order.customer_id}")
print(f"Completed Date: {order.completed_date}")
print(f"Total Tax: ${sum(item.tax.amount for item in order.line_items)}")
```

### Update an Order

```python
from ziptax.models import UpdateOrderRequest

# Update the order's completed date
update_request = UpdateOrderRequest(
    completed_date="2024-01-16T10:00:00Z"
)

updated_order = client.request.UpdateOrder("my-order-1", update_request)
print(f"Updated completed date: {updated_order.completed_date}")
```

### Create a Refund

```python
from ziptax.models import (
    RefundTransactionRequest,
    CartItemRefundWithTaxRequest,
)

# Partial refund - specify items and quantities
refund_request = RefundTransactionRequest(
    items=[
        CartItemRefundWithTaxRequest(
            item_id="item-1",
            quantity=1.0,
        )
    ]
)
refunds = client.request.RefundOrder("my-order-1", refund_request)
print(f"Refunded tax: ${refunds[0].items[0].tax.amount}")

# Full refund - omit items parameter
full_refunds = client.request.RefundOrder("my-order-2")
print("Full refund created")
```

### Create an Order from Cart

If you've already calculated cart tax via `CalculateCart` with TaxCloud credentials, you can convert that cart directly into a finalized order using the returned `cart_id`:

```python
from ziptax.models import CreateOrderFromCartRequest, UpdateOrderRequest

# Use the cart_id from a previous CalculateCart response
request = CreateOrderFromCartRequest(
    cart_id="ce4a1234-5678-90ab-cdef-1234567890ab",
    order_id="my-order-1",
)

order = client.request.CreateOrderFromCart(request)
print(f"Created order: {order.order_id}")
print(f"Transaction date: {order.transaction_date}")
print(f"Tax amount: ${order.line_items[0].tax.amount}")

# TaxCloud automatically commits the order at creation time.
# To set a completed date, use UpdateOrder after creation:
update_request = UpdateOrderRequest(completed_date="2024-01-16T10:00:00Z")
updated = client.request.UpdateOrder(order.order_id, update_request)
```

### TaxCloud Error Handling

```python
from ziptax import ZipTaxCloudConfigError

try:
    # Attempt to use TaxCloud feature without credentials
    order = client.request.GetOrder("my-order-1")

except ZipTaxCloudConfigError as e:
    # TaxCloud credentials not configured
    print(f"TaxCloud error: {e.message}")
    print("Please provide taxcloud_connection_id and taxcloud_api_key")

except ZipTaxNotFoundError as e:
    # Order not found
    print(f"Order not found: {e.message}")
```

### Configuration

You can configure the client using dict-style access:

```python
client = ZipTaxClient.api_key("your-api-key-here")

# Set configuration options
client.config["format"] = "json"
client.config["timeout"] = 60

# Get configuration options
timeout = client.config["timeout"]
```

## Error Handling

The SDK provides comprehensive error handling with specific exception types:

```python
from ziptax import (
    ZipTaxClient,
    ZipTaxValidationError,
    ZipTaxAuthenticationError,
    ZipTaxRateLimitError,
    ZipTaxServerError,
    ZipTaxError,
)

client = ZipTaxClient.api_key("your-api-key-here")

try:
    response = client.request.GetSalesTaxByAddress("123 Main St")

except ZipTaxValidationError as e:
    # Input validation errors
    print(f"Validation error: {e.message}")

except ZipTaxAuthenticationError as e:
    # Authentication failures (401)
    print(f"Authentication error: {e.message}")

except ZipTaxRateLimitError as e:
    # Rate limit exceeded (429)
    print(f"Rate limit error: {e.message}")
    if e.retry_after:
        print(f"Retry after {e.retry_after} seconds")

except ZipTaxServerError as e:
    # Server errors (5xx)
    print(f"Server error: {e.message}")

except ZipTaxError as e:
    # General Ziptax errors
    print(f"Ziptax error: {e.message}")
```

### Exception Hierarchy

```
ZipTaxError
├── ZipTaxAPIError
│   ├── ZipTaxAuthenticationError (401)
│   ├── ZipTaxAuthorizationError (403)
│   ├── ZipTaxNotFoundError (404)
│   ├── ZipTaxRateLimitError (429)
│   └── ZipTaxServerError (5xx)
├── ZipTaxValidationError
├── ZipTaxConnectionError
├── ZipTaxTimeoutError
├── ZipTaxRetryError
└── ZipTaxCloudConfigError (TaxCloud credentials not configured)
```

## Async Operations

For concurrent operations, you can use asyncio with the SDK:

```python
import asyncio
from concurrent.futures import ThreadPoolExecutor
from ziptax import ZipTaxClient

async def get_tax_rates_async(client, addresses):
    loop = asyncio.get_event_loop()
    with ThreadPoolExecutor() as executor:
        tasks = [
            loop.run_in_executor(
                executor,
                client.request.GetSalesTaxByAddress,
                address
            )
            for address in addresses
        ]
        return await asyncio.gather(*tasks)

# Usage
client = ZipTaxClient.api_key("your-api-key-here")
addresses = ["123 Main St, CA", "456 Oak Ave, NY"]
responses = asyncio.run(get_tax_rates_async(client, addresses))
```

See [examples/async_usage.py](examples/async_usage.py) for more examples.

## Response Models

All API responses are validated using Pydantic models:

### V60Response

```python
class V60Response:
    metadata: V60Metadata                           # Response metadata with code/message
    base_rates: Optional[List[V60BaseRate]]        # Tax rates by jurisdiction
    service: Optional[V60Service]                   # Service taxability (None for some regions)
    shipping: Optional[V60Shipping]                 # Shipping taxability (None for some regions)
    sourcing_rules: Optional[V60SourcingRules]     # Origin/Destination rules
    tax_summaries: Optional[List[V60TaxSummary]]   # Tax summaries with display rates
    address_detail: V60AddressDetail                # Address details
    product_detail: Optional[V60ProductDetail]     # Product rate rules (with taxability_code)
```

`address_detail.address` (a `V60AddressComponents`) is populated only when the
request set `address_detail_extended=True`. `shipping.shipping_extended` (a
`V60ShippingExtended`) is populated only when the request set
`shipping_extended=True`.

### V60Metadata

```python
class V60Metadata:
    version: str                    # API version (e.g., "v60")
    response: V60ResponseInfo       # Response info object

class V60ResponseInfo:
    code: int                       # Response code (100 = success)
    name: str                       # Response code name
    message: str                    # Response message
    definition: str                 # Schema definition URL
```

### V60TaxSummary

```python
class V60TaxSummary:
    rate: float                                    # Summary tax rate
    tax_type: str                                  # Tax type (e.g., "SALES_TAX")
    summary_name: str                              # Summary description
    display_rates: List[V60DisplayRate]           # Display rates breakdown

class V60DisplayRate:
    name: str                                      # Display rate name
    rate: float                                    # Display rate value
```

### V60AccountMetrics

```python
class V60AccountMetrics:
    request_count: int       # Number of API requests made
    request_limit: int       # Maximum allowed API requests
    usage_percent: float     # Percentage of request limit used
    is_active: bool          # Whether the account is currently active
    message: str             # Account status or informational message
```

**Note:** Uses `extra="allow"` to accept any additional fields the API may return.

### AccountMetrics

Returned by `GetAccountUsage()`.

```python
class AccountMetrics:
    core_request_count: int          # Core (tax lookup) requests consumed
    core_request_limit: int          # Core request limit
    core_usage_percent: float        # Core usage as a percentage of the limit
    geo_enabled: bool                # Whether geocoding is entitled
    geo_request_count: int           # Geocoding requests consumed
    geo_request_limit: int           # Geocoding request limit
    geo_usage_percent: float         # Geocoding usage as a percentage
    merchant_request_count: int      # Merchant requests consumed
    merchant_request_limit: int      # Merchant request limit
    merchant_usage_percent: float    # Merchant usage as a percentage
    is_active: bool                  # Whether the account is active
    message: str                     # Informational message
```

### ProductCodeSearchResponse

```python
class ProductCodeSearchResponse:
    query: str                                          # The original search query
    results: List[ProductCodeSearchResult]              # Ranked results
    next_cursor: Optional[str]                          # Cursor for the next page
    schema_url: Optional[str]                           # JSON Schema URL ($schema)

class ProductCodeSearchResult:
    tic_id: int             # Taxability Information Code (parsed from string)
    label: str              # TIC label
    natural_label: str      # Natural language label
    description: str        # Full TIC description
    documentation: str      # Long-form TIC documentation
    rank: int               # Result rank (1 = best match, parsed from string)
    score: float            # Confidence score 0.0-1.0 (parsed from string)
```

### ProductCodeRecommendationResponse

```python
class ProductCodeRecommendationResponse:
    predictions: List[ProductCodeRecommendation]        # AI recommendations

class ProductCodeRecommendation:
    status: str                              # "success" or "fail"
    error: Optional[str]                     # Error message when status is "fail"
    tic_id: Optional[int]                    # Recommended TIC
    label: Optional[str]                     # TIC label
    natural_label: Optional[str]             # Natural language label
    tic_description: Optional[str]           # Full TIC description
    product_description: Optional[str]       # Original product description from query
```

**Note:** Only `status` is guaranteed. When `status` is `"fail"` the API returns
`error` populated and every other field null, so always branch on `status`
before reading `tic_id`.

See the [models documentation](src/ziptax/models/responses.py) for complete model definitions.

## Development

### Setup

```bash
# Clone the repository
git clone https://github.com/ziptax/ziptax-python.git
cd ziptax-python

# Install dependencies
pip install -e ".[dev]"
```

### Running Tests

```bash
# Run all tests
pytest

# Run with coverage
pytest --cov=src/ziptax --cov-report=html

# Run specific test file
pytest tests/test_client.py
```

### Code Quality

```bash
# Format code
black src/ tests/

# Lint code
ruff src/ tests/

# Type checking
mypy src/
```

## Examples

See the [examples/](examples/) directory for complete examples:

- [basic_usage.py](examples/basic_usage.py) - Basic SDK usage
- [async_usage.py](examples/async_usage.py) - Concurrent operations
- [error_handling.py](examples/error_handling.py) - Error handling patterns
- [taxcloud_orders.py](examples/taxcloud_orders.py) - TaxCloud order management

## API Reference

### ZipTaxClient

Main client for interacting with the Ziptax API.

#### Methods

- `api_key(api_key, **kwargs)` - Create a client instance with an API key
- `close()` - Close the HTTP client session

#### Properties

- `config` - Configuration object (dict-like access)
- `request` - Functions object for making API requests

### Functions

API endpoint functions accessible via `client.request`.

#### ZipTax API Methods

- `GetSalesTaxByAddress(address, **kwargs)` - Get tax rates by address
- `GetSalesTaxByGeoLocation(lat, lng, **kwargs)` - Get tax rates by coordinates
- `GetRatesByPostalCode(postal_code, **kwargs)` - Get tax rates by US postal code
- `GetAccountMetrics(**kwargs)` - Get account metrics in the simplified v6.0 format
- `GetAccountUsage(**kwargs)` - Get usage across the core, geo, and merchant pools
- `SearchProductCodes(query)` - Search for product codes (TICs) by description
- `RecommendProductCode(query)` - Get an AI-powered TIC recommendation
- `GetTicData()` - Retrieve the full TIC list, including the category hierarchy
- `GetTicSearchSchema()` - Retrieve the JSON Schema for the TIC search response
- `CalculateCart(request)` - Calculate sales tax for a shopping cart
- `GetHealth()` - Check API availability and component health
- `GetSystemMetadata()` - Retrieve build and host information

#### Merchant Methods

All merchant endpoints authenticate with your Ziptax API key and take a
`merchant_id`. No TaxCloud credentials are needed on the client.

**Management** (Pro and Enterprise plans)

- `CreateMerchant(request)` - Create a merchant
- `UpdateMerchant(request)` - Update a merchant
- `DeleteMerchant(merchant_id)` - Soft-delete a merchant
- `GetMerchant(merchant_id)` - Retrieve a merchant
- `ListMerchants()` - List every active merchant on the account
- `SetMerchantCredentials(request)` - Store a merchant's TaxCloud credentials
- `DeleteMerchantCredentials(merchant_id)` - Remove stored credentials

**Transactions**

- `MerchantCalculateCart(request)` - Calculate cart tax (both compliance models)
- `MerchantCreateOrder(request)` - Record an order *(Enterprise)*
- `MerchantCreateOrderFromCart(request)` - Capture a calculated cart as an order *(Enterprise)*
- `MerchantGetOrder(request)` - Retrieve an order *(Enterprise)*
- `MerchantUpdateOrder(request)` - Update an order's completed date *(Enterprise)*
- `MerchantCreateRefund(request)` - Full or partial refund *(Enterprise)*

**Exemption certificates** *(Enterprise)*

- `CreateExemptionCertificate(request)` - Create a certificate
- `GetExemptionCertificate(request)` - Retrieve a certificate
- `ListExemptionCertificates(request)` - List certificates (paginated)
- `DeleteExemptionCertificate(request)` - Delete a certificate

> `MerchantCalculateCart` is the only transaction endpoint available to
> self-managed merchants. The order, refund, and certificate endpoints return
> 403 for them.

#### TaxCloud API Methods (Optional, Deprecated)

Requires `taxcloud_connection_id` and `taxcloud_api_key` in client initialization.
Each of these emits a `DeprecationWarning`; see
[Migrating to the merchant layer](#migrating-to-the-merchant-layer).

- `CreateOrder(request, **kwargs)` - Create an order in TaxCloud
- `CreateOrderFromCart(request)` - Create an order from a previously calculated cart
- `GetOrder(order_id)` - Retrieve an order by ID
- `UpdateOrder(order_id, request)` - Update an order's completed date
- `RefundOrder(order_id, request)` - Create a full or partial refund

## Requirements

- Python 3.8+
- requests >= 2.28.0
- pydantic >= 2.0.0

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

## Support

- Documentation: [https://github.com/ziptax/ziptax-python#readme](https://github.com/ziptax/ziptax-python#readme)
- Issues: [https://github.com/ziptax/ziptax-python/issues](https://github.com/ziptax/ziptax-python/issues)
- Email: support@zip.tax

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes and write tests
4. **Bump the version** using `python scripts/bump_version.py patch` (or `minor`/`major`)
5. Update CHANGELOG.md with your changes
6. Commit your changes (`git commit -m 'Add some amazing feature'`)
7. Push to the branch (`git push origin feature/amazing-feature`)
8. Open a Pull Request

**Note**: All PRs require a version bump. See [docs/VERSIONING.md](docs/VERSIONING.md) for details on our versioning strategy.

## Changelog

See [CHANGELOG.md](CHANGELOG.md) for version history and changes.

---

Made with ❤️ by the Ziptax Team
