From dee4acf23fec748a3cfdff9342dd65faae331aae Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 7 Aug 2026 09:44:52 -0700 Subject: [PATCH 1/5] Add v6.0 merchant layer, extended rate options, and TIC data endpoints Brings the SDK up to the v6.0 API surface documented at https://docs.zip.tax. The headline addition is the merchant layer: 19 endpoints on api.zip-tax.com that let a platform manage merchants and their transactions with a single Ziptax API key, replacing per-merchant TaxCloud credentials on the client. Added: - Merchant management: CreateMerchant, UpdateMerchant, DeleteMerchant, GetMerchant, ListMerchants, SetMerchantCredentials, DeleteMerchantCredentials - Merchant transactions: MerchantCalculateCart, MerchantCreateOrder, MerchantCreateOrderFromCart, MerchantGetOrder, MerchantUpdateOrder, MerchantCreateRefund - Exemption certificates: Create, Get, List, Delete - TIC data and system: GetTicData, GetTicSearchSchema, GetAccountUsage, GetHealth, GetSystemMetadata - v6.0 rate-lookup parameters: adjustment, address_detail_extended, shipping_extended, sat_item_total, plus city/state on address lookups and taxability_code on geolocation lookups - Postal code parameters: state, city, county, historical, sat_item_total - US territory country codes (PRI, ASM, GUM, MNP, VIR) - Response models for productDetail, address components, extended shipping, Tennessee SAT, and TIC search pagination Fixed: - RecommendProductCode no longer raises on a failed prediction. The API returns status="fail" with every other field null; all ProductCodeRecommendation fields except status are now Optional. Deprecated: - CreateOrder, GetOrder, UpdateOrder, RefundOrder, and CreateOrderFromCart emit a DeprecationWarning. They call TaxCloud directly, which is no longer the documented path. Behaviour is unchanged. Deliberately not exposed: POST /merchant/credentials/get returns stored TaxCloud credentials and is absent from the docs and OpenAPI spec. All changes are additive; no existing function changed its signature, endpoint, or return type. Suite is 249 tests (90 new) at 98% coverage; black, ruff, and mypy clean. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 110 +++ CLAUDE.md | 89 +- README.md | 428 ++++++++- docs/spec.yaml | 260 +++++- examples/merchant_compliance.py | 261 ++++++ examples/taxcloud_orders.py | 11 +- pyproject.toml | 2 +- src/ziptax/__init__.py | 124 ++- src/ziptax/models/__init__.py | 124 +++ src/ziptax/models/merchant.py | 1423 +++++++++++++++++++++++++++++ src/ziptax/models/responses.py | 293 +++++- src/ziptax/resources/functions.py | 904 +++++++++++++++++- src/ziptax/utils/validation.py | 39 +- tests/test_functions.py | 434 +++++++++ tests/test_merchant.py | 1219 ++++++++++++++++++++++++ 15 files changed, 5676 insertions(+), 45 deletions(-) create mode 100644 examples/merchant_compliance.py create mode 100644 src/ziptax/models/merchant.py create mode 100644 tests/test_merchant.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0565e..e7668b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,116 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0-beta] - 2026-08-07 + +Brings the SDK up to the v6.0 API surface documented at https://docs.zip.tax. +The headline addition is the **merchant layer**: 19 endpoints on +`api.zip-tax.com` that let a platform manage merchants and their transactions +using a single Ziptax API key, replacing per-merchant TaxCloud credentials. + +Everything here is additive. No existing function changed its signature, +endpoint, or return type. + +### Added + +- **Merchant management** (Pro and Enterprise plans): + - `CreateMerchant(request)` -> `POST /merchant/create` + - `UpdateMerchant(request)` -> `POST /merchant/update` + - `DeleteMerchant(merchant_id)` -> `POST /merchant/delete` + - `GetMerchant(merchant_id)` -> `POST /merchant/get` + - `ListMerchants()` -> `GET /merchant/list` + - `SetMerchantCredentials(request)` -> `POST /merchant/credentials/set` + - `DeleteMerchantCredentials(merchant_id)` -> `POST /merchant/credentials/delete` +- **Merchant transactions**: + - `MerchantCalculateCart(request)` -> `POST /merchant/cart/calculate` + - `MerchantCreateOrder(request)` -> `POST /merchant/order/create` + - `MerchantCreateOrderFromCart(request)` -> `POST /merchant/order/create-from-cart` + - `MerchantGetOrder(request)` -> `POST /merchant/order/get` + - `MerchantUpdateOrder(request)` -> `POST /merchant/order/update` + - `MerchantCreateRefund(request)` -> `POST /merchant/refund/create` +- **Exemption certificates** (Enterprise): + - `CreateExemptionCertificate(request)` -> `POST /merchant/cert/create` + - `GetExemptionCertificate(request)` -> `POST /merchant/cert/get` + - `ListExemptionCertificates(request)` -> `POST /merchant/cert/list` + - `DeleteExemptionCertificate(request)` -> `POST /merchant/cert/delete` +- **TIC data and system**: + - `GetTicData()` -> `GET /data/tic` (full TIC list with category hierarchy) + - `GetTicSearchSchema()` -> `GET /schemas/ticsearch` + - `GetAccountUsage()` -> `GET /account/metrics` (core, geo, and merchant pools) + - `GetHealth()` -> `GET /system/health` + - `GetSystemMetadata()` -> `GET /system/metadata` +- **New v6.0 rate-lookup parameters** on `GetSalesTaxByAddress` and + `GetSalesTaxByGeoLocation`: + - `adjustment` ("auto", "origin", "destination") + - `address_detail_extended` - adds `address_detail.address` components + - `shipping_extended` - adds `shipping.shipping_extended` + - `sat_item_total` - Tennessee Single Article Tax + - `city` and `state` on the address lookup, for disambiguation + - `taxability_code` on the geolocation lookup +- **New parameters on `GetRatesByPostalCode`**: `state`, `city`, `county`, + `historical`, and `sat_item_total` +- **US territory country codes**: `country_code` now accepts `PRI`, `ASM`, + `GUM`, `MNP`, and `VIR` alongside `USA` and `CAN` +- **New response models**: `V60ProductDetail`, `V60TaxabilityCode`, + `V60RateRule`, `V60AddressComponents`, `V60ShippingExtended`, + `V60SingleArticleTax`, plus the full merchant, certificate, TIC data, and + system model set in the new `src/ziptax/models/merchant.py` module +- **Pagination fields on TIC search**: `ProductCodeSearchResponse.next_cursor` + and `.schema_url` (`$schema`) +- **Validators**: `validate_adjustment` and `validate_merchant_id` +- **90 new tests** across `tests/test_merchant.py` (57) and + `tests/test_functions.py` (33), covering both merchant compliance models, + request serialization, response parsing, and validation. Suite is 249 tests + at 98% coverage + +### Fixed + +- **`RecommendProductCode` no longer raises on a failed prediction.** The API + returns `status: "fail"` with `error` populated and every other field null. + All `ProductCodeRecommendation` fields except `status` are now `Optional`, + so a failed prediction parses instead of raising a `ValidationError`. + Branch on `status` before reading `tic_id`. + +### Deprecated + +- The five direct-to-TaxCloud functions now emit a `DeprecationWarning`. They + call `api.v3.taxcloud.com` directly, which is no longer the documented + integration path. They continue to work unchanged. + + | Deprecated | Replacement | + |---|---| + | `CreateOrder` | `MerchantCreateOrder` | + | `CreateOrderFromCart` | `MerchantCreateOrderFromCart` | + | `GetOrder` | `MerchantGetOrder` | + | `UpdateOrder` | `MerchantUpdateOrder` | + | `RefundOrder` | `MerchantCreateRefund` | + + See "Migrating to the Merchant Layer" in the README. + +### Notes + +- `CalculateCart` is **not** deprecated and is unchanged. It uses the + account-level `POST /calculate/cart` endpoint, which still runs but is no + longer part of the published v6.0 API surface. Platform integrations serving + multiple merchants should prefer `MerchantCalculateCart`. +- `POST /merchant/credentials/get` exists in the API but is not published in + the documentation or OpenAPI spec, and returns stored TaxCloud credentials. + It is deliberately **not** exposed by the SDK. +- The merchant proxy rejects request bodies containing `apiKey`, + `connectionId`, or `xApiKey` with a 400. The SDK never sends these on + merchant transaction calls; credentials go only to + `SetMerchantCredentials`, which is a management endpoint. +- v6.0 postal-code-only lookups continue to return the legacy (v5.0-shaped) + flat response, so `GetRatesByPostalCode` still returns + `V60PostalCodeResponse`. + +### Changed + +- Version bumped from `0.2.6-beta` to `0.3.0-beta` +- `GetAccountMetrics` docstring clarifies that it targets + `GET /account/v60/metrics`; the new `GetAccountUsage` covers + `GET /account/metrics` + ## [0.2.4-beta] - 2026-03-11 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f38b221..0e6d035 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,8 @@ ziptax-python/ │ ├── exceptions.py # Custom exceptions │ ├── models/ # Pydantic data models │ │ ├── __init__.py -│ │ └── responses.py # API response models +│ │ ├── responses.py # Rate lookup, TIC search, cart, TaxCloud models +│ │ └── merchant.py # Merchant, certificate, TIC data, system models │ ├── resources/ # API endpoint functions │ │ ├── __init__.py │ │ └── functions.py # ZipTax and TaxCloud functions @@ -57,7 +58,8 @@ ziptax-python/ │ ├── http.py # HTTP client wrapper │ ├── retry.py # Retry logic │ └── validation.py # Input validation -├── tests/ # Test suite +├── tests/ # Test suite (see tests/test_merchant.py +│ # for the merchant layer) ├── examples/ # Usage examples ├── docs/ # Documentation │ └── spec.yaml # OpenAPI-style specification @@ -334,7 +336,8 @@ coverage report --fail-under=80 ``` tests/ ├── test_client.py # Client initialization and lifecycle -├── test_functions.py # API endpoint functions +├── test_functions.py # Rate lookups, TIC search, cart, TaxCloud +├── test_merchant.py # Merchant layer, TIC data, system endpoints ├── test_http.py # HTTP client functionality ├── test_retry.py # Retry logic └── conftest.py # Shared fixtures @@ -555,9 +558,39 @@ This file is used as a reference for code generation and documentation. **Authentication**: X-API-Key header **Endpoints**: -- `GET /request/v60/` - Tax rate lookup by address or geolocation -- `GET /account/v60/metrics` - Account usage metrics -- `POST /calculate/cart` - Cart tax calculation with per-item rates +- `GET /request/v60/` - Tax rate lookup by address, geolocation, or postal code +- `GET /account/v60/metrics` - Account metrics, simplified v6.0 shape +- `GET /account/metrics` - Account usage across core, geo, and merchant pools +- `GET /data/tic` - Full TIC list with category hierarchy +- `GET /schemas/ticsearch` - JSON Schema for the TIC search response +- `POST /search/tic` - Product code search +- `POST /search/tic/recommend` - AI-powered product code recommendation +- `GET /system/health` - Health check +- `GET /system/metadata` - Build and host info +- `POST /calculate/cart` - Cart tax calculation (legacy; see note below) + +**Merchant endpoints** (all `POST` unless noted, all on `api.zip-tax.com`): +- `/merchant/create`, `/merchant/update`, `/merchant/delete`, `/merchant/get` +- `GET /merchant/list` +- `/merchant/credentials/set`, `/merchant/credentials/delete` +- `/merchant/cart/calculate` +- `/merchant/order/create`, `/merchant/order/create-from-cart`, + `/merchant/order/get`, `/merchant/order/update` +- `/merchant/refund/create` +- `/merchant/cert/create`, `/merchant/cert/get`, `/merchant/cert/list`, + `/merchant/cert/delete` + +**Do not expose these** (present in the API, absent from the docs): +- `POST /merchant/credentials/get` - returns stored TaxCloud credentials +- `GET /request/v10` through `/v50` and their `/account/vN0/metrics` siblings +- `GET /request/v60/schema`, `GET /account/metadata`, + `GET /metadata/response.json`, `GET /request/error` +- The `tracerate=true` query parameter (undocumented diagnostic) + +The rule: anything documented at https://docs.zip.tax may be exposed as an SDK +function. Anything present only in the API source must not be. When adding a +function, confirm it appears in +`https://docs.zip.tax/openapi/api-reference.json` first. **Response Format**: JSON with nested structure @@ -573,7 +606,42 @@ This file is used as a reference for code generation and documentation. } ``` -### TaxCloud API +### Merchant Layer (preferred path for transactions) + +**Base URL**: `https://api.zip-tax.com/` + +**Authentication**: the caller's Ziptax `X-API-Key`. Merchants are addressed by +`merchantId` in the request body; per-merchant TaxCloud credentials are stored +server-side via `/merchant/credentials/set`. + +Ziptax routes each call on the merchant's compliance model: + +| | Self-managed (`external_compliance`) | TaxCloud-connected | +|---|---|---| +| Calculation | In-process Ziptax rate engine | Forwarded to TaxCloud | +| Persisted | No | Yes | +| Discounts | Not supported | Supported | +| Endpoints | `/merchant/cart/calculate` only | All | +| Plan | Pro and Enterprise | Enterprise | + +Two constraints that shape the SDK code: + +1. **Reserved keys are rejected.** The proxy 400s on any request body + containing `apiKey`, `connectionId`, or `xApiKey` (case-insensitive). Never + put credentials in a transaction request body. `SetMerchantCredentials` is a + management endpoint, not a proxy endpoint, so it is the one exception. +2. **Response shape varies by model.** `/merchant/cart/calculate` returns + `connectionId`, `transactionDate`, `deliveredBySeller`, and `exemption` only + on the TaxCloud path. `MerchantCalculateCartResponse` marks all four + `Optional` so a single model covers both. + +### TaxCloud API (Deprecated) + +Deprecated as of 0.3.0. `CreateOrder`, `GetOrder`, `UpdateOrder`, +`RefundOrder`, and `CreateOrderFromCart` call TaxCloud directly and emit a +`DeprecationWarning` via `_warn_taxcloud_direct()`. They still work; do not +remove them without a major version bump. New transaction work goes through the +merchant layer above. **Base URL**: `https://api.v3.taxcloud.com/` @@ -765,7 +833,8 @@ The project uses GitHub Actions (when configured) to: ### Code Examples - `examples/basic_usage.py` - Basic ZipTax usage -- `examples/taxcloud_orders.py` - TaxCloud order management +- `examples/merchant_compliance.py` - Merchant management and transactions +- `examples/taxcloud_orders.py` - TaxCloud order management (deprecated path) - `examples/async_usage.py` - Concurrent operations - `examples/error_handling.py` - Error handling patterns @@ -786,6 +855,6 @@ For API-specific questions: --- -**Last Updated**: 2025-02-19 -**SDK Version**: 0.2.6-beta +**Last Updated**: 2026-08-07 +**SDK Version**: 0.3.0-beta **Maintained By**: ZipTax Team diff --git a/README.md b/README.md index 72194cc..0194717 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Official Python SDK for the [Ziptax API](https://zip.tax) - Get accurate sales a ### Core Features (ZipTax API) - 🚀 Simple and intuitive API - 🛒 Cart tax calculation with per-item tax rates -- 🏷️ Product code (TIC) search and AI-powered recommendation +- 🏷️ 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 @@ -19,11 +20,21 @@ Official Python SDK for the [Ziptax API](https://zip.tax) - Get accurate sales a - ⚡ Support for concurrent operations - 🧪 Well-tested with high code coverage -### TaxCloud Integration (Optional) +### 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 -- 🔗 **Dual API Support**: Seamlessly integrate both ZipTax and TaxCloud - 🔐 **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 @@ -81,7 +92,7 @@ with ZipTaxClient.api_key("your-api-key-here") as client: ```python response = client.request.GetSalesTaxByAddress( address="200 Spectrum Center Drive, Irvine, CA 92618", - country_code="USA", # Optional: "USA" or "CAN" (default: "USA") + 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") ) @@ -111,6 +122,44 @@ 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 +if 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 @@ -139,15 +188,55 @@ for result in response.results: 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) @@ -163,6 +252,9 @@ 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: @@ -270,8 +362,244 @@ 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 @@ -549,8 +877,14 @@ class V60Response: 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 @@ -592,12 +926,34 @@ class V60AccountMetrics: **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) @@ -616,15 +972,19 @@ 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: int # Recommended TIC (parsed from string) - label: str # TIC label - natural_label: str # Natural language label - tic_description: str # Full TIC description - product_description: str # Original product description from query + 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 @@ -700,14 +1060,56 @@ API endpoint functions accessible via `client.request`. - `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 usage metrics +- `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) +#### 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 diff --git a/docs/spec.yaml b/docs/spec.yaml index cff2501..17e6257 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -9,7 +9,7 @@ project: name: "ziptax-sdk" language: "python" - version: "0.2.6-beta" + version: "0.3.0-beta" description: "Official Python SDK for the ZipTax API with optional TaxCloud order management support" # Repository information @@ -42,8 +42,10 @@ api: # API specification source spec: type: "openapi" - version: "3.0.0" - source: "https://api.zip-tax.com/openapi.json" + version: "3.1.0" + source: "https://docs.zip.tax/openapi/api-reference.json" + documentation: "https://docs.zip.tax" + llms_index: "https://docs.zip.tax/v-6-0/llms.txt" # Authentication methods authentication: @@ -78,6 +80,55 @@ api: - "TaxCloud features are OPTIONAL and only available when both Connection ID and API Key are provided during client initialization" - "Order management functions will return error if TaxCloud credentials not configured" - "Uses Header authentication with the X-API-KEY header for both APIs, but TaxCloud also requires the connectionId in the path for order endpoints" + - "DEPRECATED as of SDK 0.3.0: direct TaxCloud calls are no longer the documented integration path. Use the merchant layer on api.zip-tax.com instead (see the merchant section below). The direct functions still work and emit a DeprecationWarning." + + # Merchant layer (ZipTax API) - the documented path for platform integrations + merchant: + name: "Ziptax Merchant Layer" + base_url: "https://api.zip-tax.com/" + documentation: "https://docs.zip.tax/guides/merchant-compliance-solutions/merchant-management" + + authentication: + type: "api_key" + location: "header" + parameter_name: "X-API-Key" + required: true + notes: "Uses the caller's Ziptax API key. Merchants are addressed by merchantId; per-merchant TaxCloud credentials are stored server-side via /merchant/credentials/set." + + compliance_models: + - name: "self-managed" + merchant_status: "external_compliance" + plan: "Pro and Enterprise" + calculation: "In-process Ziptax rate engine, no TaxCloud call" + persisted: false + discounts_supported: false + available_endpoints: + - "/merchant/cart/calculate" + notes: "Order, refund, and exemption certificate endpoints return 403." + - name: "taxcloud" + merchant_status: "taxcloud_invited | taxcloud_connected | taxcloud_disconnected" + plan: "Enterprise" + calculation: "Forwarded to TaxCloud using the merchant's stored credentials" + persisted: true + discounts_supported: true + available_endpoints: "all" + + notes: + - "The proxy rejects request bodies containing the reserved keys apiKey, connectionId, or xApiKey (case-insensitive) with a 400. The SDK never sends these on transaction calls." + - "Routing keys (merchantId, orderId, certificateId) are consumed by the Ziptax layer and stripped before forwarding to TaxCloud." + + # Endpoints present in the API but deliberately NOT exposed by the SDK + excluded_endpoints: + - path: "/merchant/credentials/get" + reason: "Not published in the documentation or OpenAPI spec; returns stored TaxCloud credentials." + - path: "/calculate/cart" + reason: "Still served, but removed from the published v6.0 surface. Retained in the SDK as CalculateCart for backward compatibility; MerchantCalculateCart is the documented replacement." + - path: "/request/v10 .. /request/v50" + reason: "Legacy versions, outside the v6.0 documented surface." + - path: "/request/v60/schema, /account/metadata, /metadata/response.json, /request/error" + reason: "Not part of the published v6.0 API reference." + - parameter: "tracerate=true" + reason: "Documented in the API source as an undocumented diagnostic field." # ----------------------------------------------------------------------------- # SDK Configuration @@ -708,6 +759,209 @@ resources: 500: - "Internal server error" + # ------------------------------------------------------------------- + # Merchant Layer (added in SDK 0.3.0) + # ------------------------------------------------------------------- + # All take the caller's Ziptax X-API-KEY and address the merchant by + # merchantId. See api.merchant above for the compliance-model routing. + + - name: "CreateMerchant" + http_method: "POST" + path: "/merchant/create" + operation_id: "create-merchant" + description: "Create a merchant under the authenticated account." + request_model: "CreateMerchantRequest" + response_model: "MerchantMutationResponse" + plan: "Pro and Enterprise" + + - name: "UpdateMerchant" + http_method: "POST" + path: "/merchant/update" + operation_id: "update-merchant" + description: "Update an existing merchant. Caller must own the merchant." + request_model: "UpdateMerchantRequest" + response_model: "MerchantMutationResponse" + plan: "Pro and Enterprise" + + - name: "DeleteMerchant" + http_method: "POST" + path: "/merchant/delete" + operation_id: "delete-merchant" + description: "Soft-delete a merchant by setting deleted_at to now." + request_body: '{"merchantId": ""}' + response_model: "MerchantMutationResponse" + plan: "Pro and Enterprise" + + - name: "GetMerchant" + http_method: "POST" + path: "/merchant/get" + operation_id: "get-merchant" + description: "Retrieve a single merchant by UUID. Soft-deleted merchants return 404." + request_body: '{"merchantId": ""}' + response_model: "MerchantResponse" + plan: "Pro and Enterprise" + + - name: "ListMerchants" + http_method: "GET" + path: "/merchant/list" + operation_id: "list-merchants" + description: "List every active merchant owned by the calling account." + response_model: "array of MerchantResponse" + notes: "Returns a bare JSON array, not an envelope." + plan: "Pro and Enterprise" + + - name: "SetMerchantCredentials" + http_method: "POST" + path: "/merchant/credentials/set" + operation_id: "set-merchant-credentials" + description: "Store a merchant's TaxCloud credentials. Encrypted at rest with AES-256-GCM; fires an async webhook on success." + request_model: "SetMerchantCredentialsRequest" + response_model: "MerchantMutationResponse" + plan: "Enterprise" + + - name: "DeleteMerchantCredentials" + http_method: "POST" + path: "/merchant/credentials/delete" + operation_id: "delete-merchant-credentials" + description: "Delete a merchant's stored TaxCloud credentials." + request_body: '{"merchantId": ""}' + response_model: "MerchantMutationResponse" + plan: "Enterprise" + + - name: "MerchantCalculateCart" + http_method: "POST" + path: "/merchant/cart/calculate" + operation_id: "merchant-cart-calculate" + description: "Calculate sales tax per line item for up to 100 carts. Serves both compliance models." + request_model: "MerchantCalculateCartRequest" + response_model: "MerchantCalculateCartResponse" + plan: "Pro and Enterprise" + notes: + - "The self-managed response omits connectionId, transactionDate, deliveredBySeller, and exemption. The SDK model marks these Optional so one model covers both shapes." + - "The only transaction endpoint available to self-managed merchants." + + - name: "MerchantCreateOrder" + http_method: "POST" + path: "/merchant/order/create" + operation_id: "merchant-order-create" + description: "Record a merchant order directly." + request_model: "MerchantCreateOrderRequest" + response_model: "MerchantOrderResponse" + plan: "Enterprise" + + - name: "MerchantCreateOrderFromCart" + http_method: "POST" + path: "/merchant/order/create-from-cart" + operation_id: "merchant-order-create-from-cart" + description: "Capture a previously calculated cart as an order." + request_model: "MerchantCreateOrderFromCartRequest" + response_model: "MerchantOrderResponse" + plan: "Enterprise" + + - name: "MerchantGetOrder" + http_method: "POST" + path: "/merchant/order/get" + operation_id: "merchant-order-get" + description: "Retrieve a merchant order. Set expand='refunds' to include refunds." + request_model: "MerchantGetOrderRequest" + response_model: "MerchantOrderResponse" + plan: "Enterprise" + + - name: "MerchantUpdateOrder" + http_method: "POST" + path: "/merchant/order/update" + operation_id: "merchant-order-update" + description: "Update a merchant order's completed date." + request_model: "MerchantUpdateOrderRequest" + response_model: "MerchantOrderResponse" + plan: "Enterprise" + + - name: "MerchantCreateRefund" + http_method: "POST" + path: "/merchant/refund/create" + operation_id: "merchant-refund-create" + description: "Refund a merchant order. Omit items for a full refund." + request_model: "MerchantCreateRefundRequest" + response_model: "MerchantRefundResponse" + plan: "Enterprise" + + - name: "CreateExemptionCertificate" + http_method: "POST" + path: "/merchant/cert/create" + operation_id: "merchant-cert-create" + description: "Create an exemption certificate for a customer." + request_model: "CreateExemptionCertificateRequest" + response_model: "ExemptionCertificateResponse" + plan: "Enterprise" + + - name: "GetExemptionCertificate" + http_method: "POST" + path: "/merchant/cert/get" + operation_id: "merchant-cert-get" + description: "Retrieve an exemption certificate by ID." + request_model: "GetExemptionCertificateRequest" + response_model: "ExemptionCertificateResponse" + plan: "Enterprise" + + - name: "ListExemptionCertificates" + http_method: "POST" + path: "/merchant/cert/list" + operation_id: "merchant-cert-list" + description: "List a merchant's exemption certificates, cursor-paginated." + request_model: "ListExemptionCertificatesRequest" + response_model: "ExemptionCertificateListResponse" + plan: "Enterprise" + + - name: "DeleteExemptionCertificate" + http_method: "POST" + path: "/merchant/cert/delete" + operation_id: "merchant-cert-delete" + description: "Delete an exemption certificate." + request_model: "DeleteExemptionCertificateRequest" + response_model: "Dict[str, Any]" + notes: "Response body is an unspecified passthrough; the SDK returns it raw." + plan: "Enterprise" + + # ------------------------------------------------------------------- + # TIC Data and System (added in SDK 0.3.0) + # ------------------------------------------------------------------- + + - name: "GetTicData" + http_method: "GET" + path: "/data/tic" + operation_id: "get-tic-data" + description: "Retrieve the full Taxability Information Code list, including the category hierarchy." + response_model: "TicDataResponse" + + - name: "GetTicSearchSchema" + http_method: "GET" + path: "/schemas/ticsearch" + operation_id: "get-tic-search-schema" + description: "Retrieve the JSON Schema for the TIC search response. Public, no authentication." + response_model: "Dict[str, Any]" + + - name: "GetAccountUsage" + http_method: "GET" + path: "/account/metrics" + operation_id: "get-account-metrics" + description: "Account usage across the core, geo, and merchant request pools." + response_model: "AccountMetrics" + notes: "Distinct from GetAccountMetrics, which targets /account/v60/metrics and returns V60AccountMetrics." + + - name: "GetHealth" + http_method: "GET" + path: "/system/health" + operation_id: "get-health" + description: "API availability and per-component health." + response_model: "HealthResponse" + + - name: "GetSystemMetadata" + http_method: "GET" + path: "/system/metadata" + operation_id: "get-metadata" + description: "Build and host information for the serving instance." + response_model: "SystemMetadataResponse" + # ----------------------------------------------------------------------------- # Data Models # ----------------------------------------------------------------------------- diff --git a/examples/merchant_compliance.py b/examples/merchant_compliance.py new file mode 100644 index 0000000..2994273 --- /dev/null +++ b/examples/merchant_compliance.py @@ -0,0 +1,261 @@ +"""Merchant compliance example for the Ziptax Python SDK. + +Walks the full platform integration flow: + +1. Create a merchant (self-managed or TaxCloud-connected) +2. Calculate cart tax for that merchant +3. For TaxCloud-connected merchants only: capture the cart as an order, + create an exemption certificate, and issue a refund + +Everything authenticates with a single Ziptax API key. Per-merchant TaxCloud +credentials, when needed, are stored server-side with SetMerchantCredentials. + +Run: + export ZIPTAX_API_KEY="your-api-key" + python examples/merchant_compliance.py +""" + +import os +import sys + +from ziptax import ZipTaxClient +from ziptax.exceptions import ZipTaxAPIError, ZipTaxAuthorizationError +from ziptax.models import ( + CreateExemptionCertificateRequest, + CreateMerchantRequest, + ExemptionCertificateBusinessType, + ExemptionCertificateReason, + ExemptState, + ListExemptionCertificatesRequest, + MerchantAddress, + MerchantCalculateCartRequest, + MerchantCart, + MerchantCartLineItem, + MerchantCreateOrderFromCartRequest, + MerchantCreateRefundRequest, + MerchantCurrency, + MerchantGetOrderRequest, + MerchantRefundItem, + MerchantType, +) + +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", +) + + +def create_merchant(client: ZipTaxClient) -> str: + """Create a self-managed merchant and return its UUID.""" + print("=== Creating merchant ===") + + result = client.request.CreateMerchant( + CreateMerchantRequest( + merchant_name="Acme Supply Co", + contact_email="ops@acme.example", + reference_id="your-internal-id-1042", + # "self-managed" is active immediately with no TaxCloud invite. + # Use MerchantType.TAXCLOUD (the default) to invite the merchant + # to TaxCloud, or pair it with SetMerchantCredentials if they + # already have a TaxCloud account. + merchant_type=MerchantType.SELF_MANAGED, + ) + ) + + print(f" merchant_id: {result.merchant_id}") + print(f" {result.status}: {result.message}\n") + return result.merchant_id + + +def show_merchant(client: ZipTaxClient, merchant_id: str) -> str: + """Print a merchant's record and return its compliance status.""" + print("=== Merchant record ===") + + merchant = client.request.GetMerchant(merchant_id) + + print(f" name: {merchant.merchant_name}") + print(f" status: {merchant.status}") + print(f" reference: {merchant.reference_id}\n") + return merchant.status + + +def calculate_cart(client: ZipTaxClient, merchant_id: str) -> str: + """Calculate tax for a two-line cart and return the cart ID.""" + print("=== Calculating cart tax ===") + + result = client.request.MerchantCalculateCart( + MerchantCalculateCartRequest( + merchant_id=merchant_id, + items=[ + MerchantCart( + cart_id="example-cart-1", + customer_id="customer-453", + currency=MerchantCurrency(currency_code="USD"), + origin=ORIGIN, + destination=DESTINATION, + line_items=[ + MerchantCartLineItem( + index=0, + item_id="sku-1", + price=10.75, + quantity=1.5, + tic=0, # General tangible goods + ), + MerchantCartLineItem( + index=1, + item_id="shipping", + price=8.95, + quantity=1, + tic=10001, # Shipping + ), + ], + ) + ], + ) + ) + + cart = result.items[0] + total_tax = 0.0 + for line in cart.line_items or []: + print( + f" {line.item_id}: rate={line.tax.rate:.5f} " + f"amount={line.tax.amount:.5f}" + ) + total_tax += line.tax.amount + print(f" total tax: {total_tax:.5f}") + + # connection_id is present only on the TaxCloud-connected path. On the + # self-managed path nothing is persisted, so it comes back None. + if result.connection_id: + print(f" connection: {result.connection_id}") + else: + print(" self-managed: calculation only, nothing persisted") + print() + + return cart.cart_id + + +def run_taxcloud_flow(client: ZipTaxClient, merchant_id: str, cart_id: str) -> None: + """Capture the cart as an order, then refund a line item. + + Only valid for TaxCloud-connected merchants. + """ + print("=== Capturing cart as an order ===") + + order = client.request.MerchantCreateOrderFromCart( + MerchantCreateOrderFromCartRequest( + merchant_id=merchant_id, + cart_id=cart_id, + order_id="example-order-1", + completed=True, + ) + ) + print(f" order_id: {order.order_id}") + print(f" completed: {order.completed_date}\n") + + print("=== Retrieving the order with refunds ===") + order = client.request.MerchantGetOrder( + MerchantGetOrderRequest( + merchant_id=merchant_id, + order_id="example-order-1", + expand="refunds", + ) + ) + print(f" line items: {len(order.line_items or [])}") + print(f" refunds: {len(order.refunds or [])}\n") + + print("=== Refunding one unit of sku-1 ===") + refund = client.request.MerchantCreateRefund( + MerchantCreateRefundRequest( + merchant_id=merchant_id, + order_id="example-order-1", + # Omit items entirely for a full refund. + items=[MerchantRefundItem(item_id="sku-1", quantity=1.0)], + ) + ) + for item in refund.items or []: + amount = item.tax.amount if item.tax else 0.0 + print(f" {item.item_id}: qty={item.quantity} tax refunded={amount:.5f}") + print() + + +def manage_exemption_certificate(client: ZipTaxClient, merchant_id: str) -> None: + """Create a resale certificate and list the merchant's certificates. + + Only valid for TaxCloud-connected merchants. + """ + print("=== Creating an exemption certificate ===") + + cert = client.request.CreateExemptionCertificate( + CreateExemptionCertificateRequest( + merchant_id=merchant_id, + customer_id="customer-453", + customer_name="Acme Resale LLC", + customer_business_type=ExemptionCertificateBusinessType.RETAIL_TRADE, + reason=ExemptionCertificateReason.RESALE, + reason_description="Resale", # 20 characters maximum + address=DESTINATION, + states=[ExemptState(abbreviation="NY")], + ) + ) + print(f" certificate_id: {cert.certificate_id}") + print(" pass this as exemption_id on later carts and orders\n") + + print("=== Listing certificates ===") + page = client.request.ListExemptionCertificates( + ListExemptionCertificatesRequest(merchant_id=merchant_id, limit=20) + ) + for item in page.items or []: + print(f" {item.certificate_id}: {item.customer_name} ({item.reason})") + if page.next_cursor: + print(f" more results available, cursor={page.next_cursor}") + print() + + +def main() -> int: + """Run the merchant compliance walkthrough.""" + api_key = os.environ.get("ZIPTAX_API_KEY") + if not api_key: + print("Set ZIPTAX_API_KEY before running this example.") + return 1 + + with ZipTaxClient.api_key(api_key) as client: + try: + merchant_id = create_merchant(client) + status = show_merchant(client, merchant_id) + cart_id = calculate_cart(client, merchant_id) + + if status == "taxcloud_connected": + run_taxcloud_flow(client, merchant_id, cart_id) + manage_exemption_certificate(client, merchant_id) + else: + print( + "Merchant is not TaxCloud-connected, so orders, refunds, " + "and exemption certificates are unavailable. Cart " + "calculation is the only transaction endpoint for " + "self-managed merchants." + ) + + except ZipTaxAuthorizationError as e: + # 403 also covers calling a TaxCloud-only endpoint on a + # self-managed merchant. + print(f"Not permitted: {e}") + return 1 + except ZipTaxAPIError as e: + print(f"API error: {e}") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/taxcloud_orders.py b/examples/taxcloud_orders.py index a3e22ba..64c6cf8 100644 --- a/examples/taxcloud_orders.py +++ b/examples/taxcloud_orders.py @@ -1,4 +1,13 @@ -"""Example usage of TaxCloud order management features.""" +"""Example usage of TaxCloud order management features. + +DEPRECATED as of SDK 0.3.0. The functions shown here call the TaxCloud API +directly, which is no longer the documented integration path, and each emits a +DeprecationWarning. They continue to work. + +For new integrations see examples/merchant_compliance.py, which uses the +merchant layer: one Ziptax API key, merchants addressed by merchantId, and +per-merchant TaxCloud credentials stored server-side. +""" from ziptax import ZipTaxClient from ziptax.models import ( diff --git a/pyproject.toml b/pyproject.toml index 5e3b46c..bced0fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ziptax-sdk" -version = "0.2.6-beta" +version = "0.3.0-beta" description = "Official Python SDK for the Ziptax API" readme = "README.md" requires-python = ">=3.8" diff --git a/src/ziptax/__init__.py b/src/ziptax/__init__.py index 0b3c4fc..b5c674a 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -29,6 +29,7 @@ ZipTaxValidationError, ) from .models import ( + AccountMetrics, CalculateCartRequest, CalculateCartResponse, CartAddress, @@ -42,13 +43,57 @@ CartLineItem, CartLineItemResponse, CartTax, + CreateExemptionCertificateRequest, + CreateMerchantRequest, CreateOrderFromCartRequest, CreateOrderRequest, Currency, CurrencyResponse, + DeleteExemptionCertificateRequest, Exemption, + ExemptionCertificateBusinessType, + ExemptionCertificateListResponse, + ExemptionCertificateReason, + ExemptionCertificateResponse, + ExemptState, + GetExemptionCertificateRequest, + HealthComponents, + HealthResponse, JurisdictionName, JurisdictionType, + LineItemDiscount, + ListExemptionCertificatesRequest, + MerchantAddress, + MerchantAddressResponse, + MerchantCalculateCartRequest, + MerchantCalculateCartResponse, + MerchantCart, + MerchantCartLineItem, + MerchantCartLineItemResponse, + MerchantCartResponse, + MerchantCreateOrderFromCartRequest, + MerchantCreateOrderRequest, + MerchantCreateRefundRequest, + MerchantCurrency, + MerchantCurrencyResponse, + MerchantDiscounts, + MerchantExemption, + MerchantGetOrderRequest, + MerchantMutationResponse, + MerchantOrderLineItem, + MerchantOrderLineItemResponse, + MerchantOrderResponse, + MerchantRefundItem, + MerchantRefundItemResponse, + MerchantRefundResponse, + MerchantRefundTax, + MerchantResponse, + MerchantStatus, + MerchantTax, + MerchantType, + MerchantUpdateFields, + MerchantUpdateOrderRequest, + OrderLevelDiscount, OrderResponse, ProductCodeRecommendation, ProductCodeRecommendationResponse, @@ -58,6 +103,8 @@ RefundTax, RefundTransactionRequest, RefundTransactionResponse, + SetMerchantCredentialsRequest, + SystemMetadataResponse, Tax, TaxCloudAddress, TaxCloudAddressResponse, @@ -65,8 +112,13 @@ TaxCloudCartItemResponse, TaxCloudCartLineItemResponse, TaxType, + TicDataResponse, + TicEntry, + TicListItem, + UpdateMerchantRequest, UpdateOrderRequest, V60AccountMetrics, + V60AddressComponents, V60AddressDetail, V60BaseRate, V60DisplayRate, @@ -74,15 +126,20 @@ V60PostalCodeAddressDetail, V60PostalCodeResponse, V60PostalCodeResult, + V60ProductDetail, + V60RateRule, V60Response, V60ResponseInfo, V60Service, V60Shipping, + V60ShippingExtended, + V60SingleArticleTax, V60SourcingRules, + V60TaxabilityCode, V60TaxSummary, ) -__version__ = "0.2.6-beta" +__version__ = "0.3.0-beta" __all__ = [ "ZipTaxClient", @@ -107,14 +164,20 @@ "V60BaseRate", "V60Service", "V60Shipping", + "V60ShippingExtended", "V60SourcingRules", "V60TaxSummary", "V60DisplayRate", "V60AddressDetail", + "V60AddressComponents", + "V60ProductDetail", + "V60TaxabilityCode", + "V60RateRule", "V60AccountMetrics", "V60PostalCodeResponse", "V60PostalCodeResult", "V60PostalCodeAddressDetail", + "V60SingleArticleTax", "JurisdictionType", "JurisdictionName", "TaxType", @@ -156,4 +219,63 @@ "CartItemRefundWithTaxResponse", "RefundTransactionRequest", "RefundTransactionResponse", + # Shared Merchant Models + "MerchantType", + "MerchantStatus", + "MerchantAddress", + "MerchantAddressResponse", + "MerchantCurrency", + "MerchantCurrencyResponse", + "MerchantTax", + "MerchantRefundTax", + "MerchantExemption", + "LineItemDiscount", + "OrderLevelDiscount", + "MerchantDiscounts", + # Merchant Management Models + "CreateMerchantRequest", + "UpdateMerchantRequest", + "MerchantUpdateFields", + "MerchantMutationResponse", + "MerchantResponse", + "SetMerchantCredentialsRequest", + # Merchant Cart Models + "MerchantCalculateCartRequest", + "MerchantCalculateCartResponse", + "MerchantCart", + "MerchantCartResponse", + "MerchantCartLineItem", + "MerchantCartLineItemResponse", + # Merchant Order Models + "MerchantCreateOrderRequest", + "MerchantCreateOrderFromCartRequest", + "MerchantGetOrderRequest", + "MerchantUpdateOrderRequest", + "MerchantOrderResponse", + "MerchantOrderLineItem", + "MerchantOrderLineItemResponse", + # Merchant Refund Models + "MerchantCreateRefundRequest", + "MerchantRefundItem", + "MerchantRefundItemResponse", + "MerchantRefundResponse", + # Exemption Certificate Models + "ExemptionCertificateReason", + "ExemptionCertificateBusinessType", + "ExemptState", + "CreateExemptionCertificateRequest", + "GetExemptionCertificateRequest", + "DeleteExemptionCertificateRequest", + "ListExemptionCertificatesRequest", + "ExemptionCertificateResponse", + "ExemptionCertificateListResponse", + # TIC Data Models + "TicEntry", + "TicListItem", + "TicDataResponse", + # System Models + "HealthComponents", + "HealthResponse", + "SystemMetadataResponse", + "AccountMetrics", ] diff --git a/src/ziptax/models/__init__.py b/src/ziptax/models/__init__.py index 7e86408..7e8db63 100644 --- a/src/ziptax/models/__init__.py +++ b/src/ziptax/models/__init__.py @@ -1,5 +1,58 @@ """Models module for ZipTax SDK.""" +from .merchant import ( + AccountMetrics, + CreateExemptionCertificateRequest, + CreateMerchantRequest, + DeleteExemptionCertificateRequest, + ExemptionCertificateBusinessType, + ExemptionCertificateListResponse, + ExemptionCertificateReason, + ExemptionCertificateResponse, + ExemptState, + GetExemptionCertificateRequest, + HealthComponents, + HealthResponse, + LineItemDiscount, + ListExemptionCertificatesRequest, + MerchantAddress, + MerchantAddressResponse, + MerchantCalculateCartRequest, + MerchantCalculateCartResponse, + MerchantCart, + MerchantCartLineItem, + MerchantCartLineItemResponse, + MerchantCartResponse, + MerchantCreateOrderFromCartRequest, + MerchantCreateOrderRequest, + MerchantCreateRefundRequest, + MerchantCurrency, + MerchantCurrencyResponse, + MerchantDiscounts, + MerchantExemption, + MerchantGetOrderRequest, + MerchantMutationResponse, + MerchantOrderLineItem, + MerchantOrderLineItemResponse, + MerchantOrderResponse, + MerchantRefundItem, + MerchantRefundItemResponse, + MerchantRefundResponse, + MerchantRefundTax, + MerchantResponse, + MerchantStatus, + MerchantTax, + MerchantType, + MerchantUpdateFields, + MerchantUpdateOrderRequest, + OrderLevelDiscount, + SetMerchantCredentialsRequest, + SystemMetadataResponse, + TicDataResponse, + TicEntry, + TicListItem, + UpdateMerchantRequest, +) from .responses import ( CalculateCartRequest, CalculateCartResponse, @@ -39,6 +92,7 @@ TaxType, UpdateOrderRequest, V60AccountMetrics, + V60AddressComponents, V60AddressDetail, V60BaseRate, V60DisplayRate, @@ -46,11 +100,16 @@ V60PostalCodeAddressDetail, V60PostalCodeResponse, V60PostalCodeResult, + V60ProductDetail, + V60RateRule, V60Response, V60ResponseInfo, V60Service, V60Shipping, + V60ShippingExtended, + V60SingleArticleTax, V60SourcingRules, + V60TaxabilityCode, V60TaxSummary, ) @@ -62,14 +121,20 @@ "V60BaseRate", "V60Service", "V60Shipping", + "V60ShippingExtended", "V60SourcingRules", "V60TaxSummary", "V60DisplayRate", "V60AddressDetail", + "V60AddressComponents", + "V60ProductDetail", + "V60TaxabilityCode", + "V60RateRule", "V60AccountMetrics", "V60PostalCodeResponse", "V60PostalCodeResult", "V60PostalCodeAddressDetail", + "V60SingleArticleTax", "JurisdictionType", "JurisdictionName", "TaxType", @@ -111,4 +176,63 @@ "CartItemRefundWithTaxResponse", "RefundTransactionRequest", "RefundTransactionResponse", + # Shared Merchant Models + "MerchantType", + "MerchantStatus", + "MerchantAddress", + "MerchantAddressResponse", + "MerchantCurrency", + "MerchantCurrencyResponse", + "MerchantTax", + "MerchantRefundTax", + "MerchantExemption", + "LineItemDiscount", + "OrderLevelDiscount", + "MerchantDiscounts", + # Merchant Management Models + "CreateMerchantRequest", + "UpdateMerchantRequest", + "MerchantUpdateFields", + "MerchantMutationResponse", + "MerchantResponse", + "SetMerchantCredentialsRequest", + # Merchant Cart Models + "MerchantCalculateCartRequest", + "MerchantCalculateCartResponse", + "MerchantCart", + "MerchantCartResponse", + "MerchantCartLineItem", + "MerchantCartLineItemResponse", + # Merchant Order Models + "MerchantCreateOrderRequest", + "MerchantCreateOrderFromCartRequest", + "MerchantGetOrderRequest", + "MerchantUpdateOrderRequest", + "MerchantOrderResponse", + "MerchantOrderLineItem", + "MerchantOrderLineItemResponse", + # Merchant Refund Models + "MerchantCreateRefundRequest", + "MerchantRefundItem", + "MerchantRefundItemResponse", + "MerchantRefundResponse", + # Exemption Certificate Models + "ExemptionCertificateReason", + "ExemptionCertificateBusinessType", + "ExemptState", + "CreateExemptionCertificateRequest", + "GetExemptionCertificateRequest", + "DeleteExemptionCertificateRequest", + "ListExemptionCertificatesRequest", + "ExemptionCertificateResponse", + "ExemptionCertificateListResponse", + # TIC Data Models + "TicEntry", + "TicListItem", + "TicDataResponse", + # System Models + "HealthComponents", + "HealthResponse", + "SystemMetadataResponse", + "AccountMetrics", ] diff --git a/src/ziptax/models/merchant.py b/src/ziptax/models/merchant.py new file mode 100644 index 0000000..3795a97 --- /dev/null +++ b/src/ziptax/models/merchant.py @@ -0,0 +1,1423 @@ +"""Merchant, transaction, and system models for the Ziptax API. + +These models cover the ``/merchant/*``, ``/data/*``, and ``/system/*`` +endpoints documented at https://docs.zip.tax. + +The merchant layer routes on a merchant's compliance model: + +- **TaxCloud-connected** merchants forward to TaxCloud using the credentials + stored via ``SetMerchantCredentials``. All transaction endpoints are + available. +- **Self-managed** merchants are calculated in-process by the Ziptax rate + engine. Only ``MerchantCalculateCart`` is available; the order, refund, and + exemption certificate endpoints return 403. +""" + +from enum import Enum +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================= +# Shared Merchant Types +# ============================================================================= + + +class MerchantType(str, Enum): + """Compliance model a merchant is created under.""" + + TAXCLOUD = "taxcloud" + SELF_MANAGED = "self-managed" + + +class MerchantStatus(str, Enum): + """Derived TaxCloud lifecycle status of a merchant.""" + + TAXCLOUD_INVITED = "taxcloud_invited" + TAXCLOUD_CONNECTED = "taxcloud_connected" + TAXCLOUD_DISCONNECTED = "taxcloud_disconnected" + EXTERNAL_COMPLIANCE = "external_compliance" + + +class ExemptionCertificateReason(str, Enum): + """Reason a customer is exempt from sales tax.""" + + FEDERAL_GOVERNMENT = "FederalGovernment" + STATE_OR_LOCAL_GOVERNMENT = "StateOrLocalGovernment" + TRIBAL_GOVERNMENT = "TribalGovernment" + FOREIGN_DIPLOMAT = "ForeignDiplomat" + CHARITABLE_ORGANIZATION = "CharitableOrganization" + EDUCATIONAL_ORGANIZATION = "EducationalOrganization" + RESALE = "Resale" + AGRICULTURAL_PRODUCTION = "AgriculturalProduction" + INDUSTRIAL_PRODUCTION_OR_MANUFACTURING = "IndustrialProductionOrManufacturing" + DIRECT_PAY_PERMIT = "DirectPayPermit" + DIRECT_MAIL = "DirectMail" + OTHER = "Other" + RELIGIOUS_ORGANIZATION = "ReligiousOrganization" + + +class ExemptionCertificateBusinessType(str, Enum): + """Type of business an exemption certificate is issued to.""" + + ACCOMMODATION_AND_FOOD_SERVICES = "AccommodationAndFoodServices" + AGRICULTURAL_FORESTRY_FISHING_HUNTING = "AgriculturalForestryFishingHunting" + CONSTRUCTION = "Construction" + FINANCE_AND_INSURANCE = "FinanceAndInsurance" + INFORMATION_PUBLISHING_AND_COMMUNICATIONS = "InformationPublishingAndCommunications" + MANUFACTURING = "Manufacturing" + MINING = "Mining" + REAL_ESTATE = "RealEstate" + RENTAL_AND_LEASING = "RentalAndLeasing" + RETAIL_TRADE = "RetailTrade" + TRANSPORTATION_AND_WAREHOUSING = "TransportationAndWarehousing" + UTILITIES = "Utilities" + WHOLESALE_TRADE = "WholesaleTrade" + BUSINESS_SERVICES = "BusinessServices" + PROFESSIONAL_SERVICES = "ProfessionalServices" + EDUCATION_AND_HEALTH_CARE_SERVICES = "EducationAndHealthCareServices" + NONPROFIT_ORGANIZATION = "NonprofitOrganization" + GOVERNMENT = "Government" + NOT_A_BUSINESS = "NotABusiness" + OTHER = "Other" + + +class MerchantAddress(BaseModel): + """Structured address used by merchant cart, order, and certificate calls.""" + + model_config = ConfigDict(populate_by_name=True) + + line1: str = Field( + ..., + description=( + "First line of the address: street number and name, PO Box, or " + "building. Values longer than 50 characters are truncated." + ), + ) + city: str = Field(..., description="City or post-town of the address") + state: str = Field( + ..., + description="Two-letter state, province, or territory abbreviation", + ) + zip: str = Field( + ..., + description="Postal or ZIP code (5-digit or ZIP+4 for US addresses)", + ) + line2: Optional[str] = Field( + None, description="Second line of the address (apartment, suite, unit)" + ) + country_code: Optional[Literal["US", "CA"]] = Field( + "US", + alias="countryCode", + description="ISO 3166-1 alpha-2 country code (US or CA). Defaults to US", + ) + + +class MerchantAddressResponse(BaseModel): + """Address returned on merchant cart and order responses.""" + + model_config = ConfigDict(populate_by_name=True) + + line1: Optional[str] = Field(None, description="First line of the address") + line2: Optional[str] = Field(None, description="Second line of the address") + city: Optional[str] = Field(None, description="City or post-town") + state: Optional[str] = Field(None, description="State abbreviation") + zip: Optional[str] = Field(None, description="Postal or ZIP code") + country_code: Optional[str] = Field( + None, alias="countryCode", description="ISO 3166-1 alpha-2 country code" + ) + + +class MerchantCurrency(BaseModel): + """Currency the line-item prices are denominated in.""" + + model_config = ConfigDict(populate_by_name=True) + + currency_code: Literal["USD", "CAD"] = Field( + "USD", + alias="currencyCode", + description="ISO 4217 currency code (USD or CAD). Defaults to USD", + ) + + +class MerchantCurrencyResponse(BaseModel): + """Currency returned on merchant cart and order responses.""" + + model_config = ConfigDict(populate_by_name=True) + + currency_code: Optional[str] = Field( + None, alias="currencyCode", description="ISO 4217 currency code" + ) + + +class MerchantTax(BaseModel): + """Calculated tax rate and amount for a line item.""" + + model_config = ConfigDict(populate_by_name=True) + + amount: float = Field( + ..., description="Calculated tax amount in the transaction currency" + ) + rate: float = Field( + ..., + description="Combined tax rate as a decimal fraction (0.08125 = 8.125%)", + ) + + +class MerchantRefundTax(BaseModel): + """Tax amount refunded for a line item.""" + + model_config = ConfigDict(populate_by_name=True) + + amount: float = Field(..., description="Tax amount refunded for the item") + + +class MerchantExemption(BaseModel): + """Exemption applied to a cart or order.""" + + model_config = ConfigDict(populate_by_name=True) + + exemption_id: Optional[str] = Field( + None, + alias="exemptionId", + description=( + "Identifier of an exemption certificate previously created for the " + "customer. When provided, the customer is treated as exempt." + ), + ) + is_exempt: Optional[bool] = Field( + None, + alias="isExempt", + description=( + "Whether the customer is exempt. Assumed true when exemption_id is set" + ), + ) + + +class LineItemDiscount(BaseModel): + """Discount applied to a specific line item.""" + + model_config = ConfigDict(populate_by_name=True) + + item_id: str = Field( + ..., + alias="itemId", + description="The itemId of the line item this discount applies to", + ) + type: Literal["percentage", "amount"] = Field( + ..., + description="'percentage' (fraction of the price) or 'amount' (fixed value)", + ) + value: float = Field( + ..., + description=( + "Decimal fraction between 0 and 1 for 'percentage' (0.1 = 10% off), " + "or a currency amount for 'amount'" + ), + ) + + +class OrderLevelDiscount(BaseModel): + """Discount applied to the order as a whole.""" + + model_config = ConfigDict(populate_by_name=True) + + type: Literal["percentage", "amount"] = Field( + ..., + description="'percentage' (fraction of the total) or 'amount' (fixed value)", + ) + value: float = Field( + ..., + description=( + "Decimal fraction between 0 and 1 for 'percentage', " + "or a currency amount for 'amount'" + ), + ) + + +class MerchantDiscounts(BaseModel): + """Line-item and order-level discounts. + + When discounts are provided, line-item prices must be the pre-discount + (original) prices. Discounts are not supported for self-managed merchants. + """ + + model_config = ConfigDict(populate_by_name=True) + + line_item_discounts: Optional[List[LineItemDiscount]] = Field( + None, + alias="lineItemDiscounts", + description="Discounts applied before any order-level discount", + ) + order_discount: Optional[OrderLevelDiscount] = Field( + None, alias="orderDiscount", description="Discount applied to the order total" + ) + + +# ============================================================================= +# Merchant Management +# ============================================================================= + + +class CreateMerchantRequest(BaseModel): + """Request payload for creating a merchant.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_name: str = Field( + ..., + alias="merchantName", + min_length=1, + max_length=255, + description="Legal or trading name of the merchant business", + ) + contact_first: Optional[str] = Field( + None, alias="contactFirst", description="First name of the primary contact" + ) + contact_last: Optional[str] = Field( + None, alias="contactLast", description="Last name of the primary contact" + ) + contact_email: Optional[str] = Field( + None, + alias="contactEmail", + description="Email of the primary contact, used for TaxCloud invitations", + ) + send_taxcloud_invite: Optional[bool] = Field( + None, + alias="sendTaxcloudInvite", + description=( + "Send an invite for a merchant who does not already use TaxCloud. " + "For a merchant who already has TaxCloud, use SetMerchantCredentials " + "instead. Ignored when merchant_type is 'self-managed'" + ), + ) + merchant_type: Optional[MerchantType] = Field( + None, + description=( + "Compliance model. 'taxcloud' (the default) starts the TaxCloud " + "invite process and requires Enterprise; 'self-managed' is active " + "immediately with no invite" + ), + ) + reference_id: Optional[str] = Field( + None, + alias="referenceId", + max_length=255, + description="The ID you use in your own system to identify this merchant", + ) + + +class MerchantMutationResponse(BaseModel): + """Result of a merchant create, update, delete, or credentials operation.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., alias="merchantId", description="UUID of the affected merchant" + ) + message: str = Field(..., description="Human-readable description of the result") + status: str = Field(..., description="Result status of the operation") + + +class MerchantUpdateFields(BaseModel): + """New field values applied by UpdateMerchant.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_name: str = Field( + ..., + alias="merchantName", + min_length=1, + max_length=255, + description="New legal or trading name of the merchant business", + ) + contact_first: Optional[str] = Field( + None, alias="contactFirst", description="Updated first name of the contact" + ) + contact_last: Optional[str] = Field( + None, alias="contactLast", description="Updated last name of the contact" + ) + contact_email: Optional[str] = Field( + None, alias="contactEmail", description="Updated email of the contact" + ) + reference_id: Optional[str] = Field( + None, + alias="referenceId", + description="The ID you use in your own system to identify this merchant", + ) + + +class UpdateMerchantRequest(BaseModel): + """Request payload for updating a merchant.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant to update", + ) + update: MerchantUpdateFields = Field( + ..., description="New field values for the merchant" + ) + + +class MerchantResponse(BaseModel): + """A merchant record returned by GetMerchant and ListMerchants.""" + + model_config = ConfigDict(populate_by_name=True, use_enum_values=True) + + merchant_id: str = Field( + ..., alias="merchantId", description="UUID of the merchant" + ) + merchant_name: str = Field( + ..., + alias="merchantName", + description="Legal or trading name of the merchant business", + ) + status: str = Field( + ..., + description=( + "Derived TaxCloud lifecycle status: taxcloud_invited, " + "taxcloud_connected, taxcloud_disconnected, or external_compliance" + ), + ) + contact_first: Optional[str] = Field( + None, alias="contactFirst", description="First name of the primary contact" + ) + contact_last: Optional[str] = Field( + None, alias="contactLast", description="Last name of the primary contact" + ) + contact_email: Optional[str] = Field( + None, alias="contactEmail", description="Email of the primary contact" + ) + reference_id: Optional[str] = Field( + None, + alias="referenceId", + description="The ID you use in your own system to identify this merchant", + ) + + +class SetMerchantCredentialsRequest(BaseModel): + """Request payload for storing a merchant's TaxCloud credentials. + + Credentials are encrypted at rest by the API with AES-256-GCM. + """ + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant whose credentials are being set", + ) + connection_id: str = Field( + ..., + alias="connectionId", + min_length=1, + description="TaxCloud connection ID that pairs with the API key", + ) + api_key: str = Field( + ..., + alias="apiKey", + min_length=1, + description="TaxCloud API key to associate with the merchant", + ) + + +# ============================================================================= +# Merchant Cart Calculation +# ============================================================================= + + +class MerchantCartLineItem(BaseModel): + """A line item submitted for merchant cart tax calculation.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field( + ..., + ge=0, + description="Zero-based position of the item within the cart. Must be unique", + ) + item_id: str = Field( + ..., + alias="itemId", + description="Your unique identifier for the line item (e.g. SKU)", + ) + price: float = Field( + ..., + description=( + "Unit price in the cart's currency. When discounts are provided, " + "this must be the pre-discount price" + ), + ) + quantity: float = Field( + ..., description="Quantity of the item. Fractional quantities are allowed" + ) + tic: Optional[int] = Field( + None, + description=( + "Taxability Information Code classifying the product. " + "Defaults to 0 (general tangible goods) when omitted" + ), + ) + product_id: Optional[str] = Field( + None, + alias="productId", + description="Unique ID of the product in the merchant's TaxCloud catalog", + ) + + +class MerchantCart(BaseModel): + """A single cart submitted for tax calculation.""" + + model_config = ConfigDict(populate_by_name=True) + + customer_id: str = Field( + ..., + alias="customerId", + description="Your identifier for the customer in your own system", + ) + currency: MerchantCurrency = Field(..., description="Currency information") + origin: MerchantAddress = Field(..., description="Origin (ship-from) address") + destination: MerchantAddress = Field( + ..., description="Destination (ship-to) address" + ) + line_items: List[MerchantCartLineItem] = Field( + ..., + alias="lineItems", + min_length=1, + description="The line items in the cart. Tax is calculated per item", + ) + cart_id: Optional[str] = Field( + None, + alias="cartId", + description=( + "Your identifier for this cart. If omitted, TaxCloud generates one. " + "Pass it to CreateOrderFromCart to capture the cart as an order" + ), + ) + delivered_by_seller: Optional[bool] = Field( + None, + alias="deliveredBySeller", + description="Whether the seller delivers directly rather than via carrier", + ) + discounts: Optional[MerchantDiscounts] = Field( + None, description="Line-item and order-level discounts" + ) + exemption: Optional[MerchantExemption] = Field( + None, description="Exemption applied to this cart" + ) + + +class MerchantCalculateCartRequest(BaseModel): + """Request payload for merchant cart tax calculation. + + The request body is the same for every merchant; Ziptax routes on the + merchant's compliance model. + """ + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + items: List[MerchantCart] = Field( + ..., + min_length=1, + max_length=100, + description="The carts to calculate tax for (up to 100 per call)", + ) + transaction_date: Optional[str] = Field( + None, + alias="transactionDate", + description=( + "RFC3339 datetime the carts are calculated for. " + "Defaults to the current time when omitted" + ), + ) + + +class MerchantCartLineItemResponse(BaseModel): + """A calculated line item in a merchant cart response.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field(..., description="Zero-based position within the cart") + item_id: str = Field( + ..., alias="itemId", description="Your identifier for the line item" + ) + price: float = Field( + ..., + description=( + "The unit price tax was calculated on. When discounts were applied, " + "this is the discounted unit price" + ), + ) + quantity: float = Field(..., description="Quantity of the item") + tax: MerchantTax = Field(..., description="Calculated tax rate and amount") + original_price: Optional[float] = Field( + None, + alias="originalPrice", + description="The original (pre-discount) unit price, as submitted", + ) + tic: Optional[int] = Field( + None, description="Taxability Information Code the item was calculated under" + ) + + +class MerchantCartResponse(BaseModel): + """A calculated cart returned by MerchantCalculateCart.""" + + model_config = ConfigDict(populate_by_name=True) + + cart_id: str = Field( + ..., + alias="cartId", + description=( + "Identifier of the calculated cart. For TaxCloud-connected merchants, " + "pass this to CreateOrderFromCart. Self-managed calculations are not " + "persisted, so the identifier is for correlation only" + ), + ) + customer_id: str = Field( + ..., alias="customerId", description="Your identifier for the customer" + ) + currency: MerchantCurrencyResponse = Field(..., description="Currency information") + origin: MerchantAddressResponse = Field(..., description="Origin address") + destination: MerchantAddressResponse = Field(..., description="Destination address") + line_items: Optional[List[MerchantCartLineItemResponse]] = Field( + None, + alias="lineItems", + description="The submitted line items with calculated tax", + ) + delivered_by_seller: Optional[bool] = Field( + None, + alias="deliveredBySeller", + description=( + "Whether the seller delivers directly. " "Omitted on the self-managed path" + ), + ) + exemption: Optional[MerchantExemption] = Field( + None, + description="Exemption information. Omitted on the self-managed path", + ) + + +class MerchantCalculateCartResponse(BaseModel): + """Response from merchant cart tax calculation. + + Shape differs slightly by compliance model. TaxCloud-connected merchants + receive ``connection_id`` and ``transaction_date``; the self-managed path + omits both because nothing is persisted and no TaxCloud call is made. + """ + + model_config = ConfigDict(populate_by_name=True) + + items: List[MerchantCartResponse] = Field( + ..., + description="One calculated cart per submitted cart, in the same order", + ) + connection_id: Optional[str] = Field( + None, + alias="connectionId", + description=( + "The TaxCloud connection the calculation ran under. " + "Omitted for self-managed merchants" + ), + ) + transaction_date: Optional[str] = Field( + None, + alias="transactionDate", + description="RFC3339 datetime the carts were calculated for", + ) + + +# ============================================================================= +# Merchant Orders +# ============================================================================= + + +class MerchantOrderLineItem(BaseModel): + """A line item submitted when creating a merchant order.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field( + ..., + ge=0, + description="Zero-based position within the order. Must be unique", + ) + item_id: str = Field( + ..., alias="itemId", description="Your unique identifier for the line item" + ) + price: float = Field(..., description="Unit price the tax was calculated on") + quantity: float = Field(..., description="Quantity of the item") + tax: MerchantTax = Field( + ..., description="The tax rate and amount that was collected" + ) + tic: Optional[int] = Field( + None, + description=( + "Taxability Information Code. Defaults to 0 (general tangible goods)" + ), + ) + product_id: Optional[str] = Field( + None, + alias="productId", + description="Unique ID of the product in the merchant's TaxCloud catalog", + ) + + +class MerchantCreateOrderRequest(BaseModel): + """Request payload for recording a merchant order directly.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + order_id: str = Field( + ..., + alias="orderId", + min_length=1, + description="Your identifier for the order in your own system", + ) + customer_id: str = Field( + ..., alias="customerId", description="Your identifier for the customer" + ) + transaction_date: str = Field( + ..., + alias="transactionDate", + description="RFC3339 datetime the order was purchased on", + ) + completed_date: str = Field( + ..., + alias="completedDate", + description=( + "RFC3339 datetime the order was shipped on, " + "which created the tax liability" + ), + ) + origin: MerchantAddress = Field(..., description="Origin address of the order") + destination: MerchantAddress = Field( + ..., description="Destination address of the order" + ) + line_items: List[MerchantOrderLineItem] = Field( + ..., + alias="lineItems", + description="The items on the order, each with its collected tax", + ) + currency: MerchantCurrency = Field(..., description="Currency information") + batch_id: Optional[str] = Field( + None, alias="batchId", description="Batch ID for grouping related orders" + ) + channel: Optional[str] = Field( + None, + description=( + "Sales channel. Pass amazon, ebay, or walmart to exclude " + "marketplace-collected tax from filing" + ), + ) + delivered_by_seller: Optional[bool] = Field( + None, + alias="deliveredBySeller", + description="Whether the seller delivers directly rather than via carrier", + ) + discounts: Optional[MerchantDiscounts] = Field( + None, description="Line-item and order-level discounts" + ) + exclude_from_filing: Optional[bool] = Field( + None, + alias="excludeFromFiling", + description="Whether to exclude the order from tax filing", + ) + exemption: Optional[MerchantExemption] = Field( + None, description="Exemption applied to this order" + ) + kind: Optional[Literal["order", "credit"]] = Field( + None, description="'order' for a sale or 'credit' for a credit order" + ) + + +class MerchantCreateOrderFromCartRequest(BaseModel): + """Request payload for capturing a calculated cart as an order. + + Only available for TaxCloud-connected merchants; self-managed cart + calculations are not persisted and cannot be converted into orders. + """ + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + cart_id: str = Field( + ..., + alias="cartId", + min_length=1, + description="The cartId identifying the calculated cart to convert", + ) + order_id: str = Field( + ..., + alias="orderId", + min_length=1, + description="Your identifier for the resulting order", + ) + completed: Optional[bool] = Field( + None, + description=( + "Whether the order has shipped, creating a tax liability. " + "Defaults to false. Ignored when completed_date is provided" + ), + ) + completed_date: Optional[str] = Field( + None, + alias="completedDate", + description=( + "RFC3339 datetime the order shipped on. " + "Takes precedence over the completed field" + ), + ) + kind: Optional[Literal["order", "credit"]] = Field( + None, description="'order' for a sale or 'credit' for a credit order" + ) + + +class MerchantGetOrderRequest(BaseModel): + """Request payload for retrieving a merchant order.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + order_id: str = Field( + ..., + alias="orderId", + min_length=1, + description="Your identifier for the order to retrieve", + ) + expand: Optional[Literal["refunds"]] = Field( + None, + description="Set to 'refunds' to include the order's refunds in the response", + ) + + +class MerchantUpdateOrderRequest(BaseModel): + """Request payload for updating a merchant order's completed date.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + order_id: str = Field( + ..., + alias="orderId", + min_length=1, + description="Your identifier for the order to update", + ) + completed_date: Optional[str] = Field( + None, + alias="completedDate", + description=( + "RFC3339 datetime the order was shipped on, " + "which creates the tax liability" + ), + ) + + +class MerchantOrderLineItemResponse(BaseModel): + """A line item on a merchant order response.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field(..., description="Zero-based position within the order") + item_id: str = Field( + ..., alias="itemId", description="Your identifier for the line item" + ) + price: float = Field(..., description="The unit price tax was calculated on") + quantity: float = Field(..., description="Quantity of the item") + tax: MerchantTax = Field(..., description="Tax rate and amount for the item") + original_price: Optional[float] = Field( + None, + alias="originalPrice", + description="The original (pre-discount) unit price, as submitted", + ) + tic: Optional[int] = Field( + None, description="Taxability Information Code the item was calculated under" + ) + + +class MerchantRefundItem(BaseModel): + """A line item and quantity to refund.""" + + model_config = ConfigDict(populate_by_name=True) + + item_id: str = Field( + ..., + alias="itemId", + description="The itemId of the line item to refund, from the original order", + ) + quantity: float = Field( + ..., + description=( + "Quantity to refund. May be fractional and must not exceed " + "the quantity on the original order" + ), + ) + + +class MerchantRefundItemResponse(BaseModel): + """A refunded line item returned by CreateRefund.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field(..., description="Zero-based position within the refund") + item_id: str = Field( + ..., alias="itemId", description="The itemId of the refunded line item" + ) + price: float = Field(..., description="The unit price refunded") + quantity: float = Field(..., description="The quantity refunded") + tax: Optional[MerchantRefundTax] = Field( + None, description="The tax amount refunded for the item" + ) + tic: Optional[int] = Field( + None, description="Taxability Information Code of the refunded item" + ) + + +class MerchantRefundResponse(BaseModel): + """A refund recorded against a merchant order.""" + + model_config = ConfigDict(populate_by_name=True) + + connection_id: str = Field( + ..., + alias="connectionId", + description="The TaxCloud connection the refund was recorded under", + ) + batch_id: Optional[str] = Field( + None, alias="batchId", description="Batch ID grouping related refunds" + ) + created_date: Optional[str] = Field( + None, + alias="createdDate", + description="RFC3339 datetime the refund was created", + ) + items: Optional[List[MerchantRefundItemResponse]] = Field( + None, description="The refunded line items" + ) + returned_date: Optional[str] = Field( + None, + alias="returnedDate", + description="RFC3339 datetime the refund took effect", + ) + + +class MerchantCreateRefundRequest(BaseModel): + """Request payload for refunding a merchant order. + + Omit ``items`` (or send an empty list) to refund the entire order. + """ + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + order_id: str = Field( + ..., + alias="orderId", + min_length=1, + description="Your identifier for the order to refund", + ) + items: Optional[List[MerchantRefundItem]] = Field( + None, + description="Line items and quantities to refund. Omit for a full refund", + ) + batch_id: Optional[str] = Field( + None, alias="batchId", description="Batch ID for grouping related refunds" + ) + returned_date: Optional[str] = Field( + None, + alias="returnedDate", + description=( + "Include only if this return amends a previously filed sales tax " + "return; providing it triggers an Amended Sales Tax Return" + ), + ) + + +class MerchantOrderResponse(BaseModel): + """A merchant order returned by the order endpoints.""" + + model_config = ConfigDict(populate_by_name=True) + + order_id: str = Field( + ..., alias="orderId", description="Your identifier for the order" + ) + connection_id: str = Field( + ..., + alias="connectionId", + description="The TaxCloud connection the order was recorded under", + ) + customer_id: Optional[str] = Field( + None, alias="customerId", description="Your identifier for the customer" + ) + origin: Optional[MerchantAddressResponse] = Field( + None, description="Origin address" + ) + destination: Optional[MerchantAddressResponse] = Field( + None, description="Destination address" + ) + currency: Optional[MerchantCurrencyResponse] = Field( + None, description="Currency information" + ) + line_items: Optional[List[MerchantOrderLineItemResponse]] = Field( + None, alias="lineItems", description="The order's line items with tax" + ) + kind: Optional[str] = Field( + None, description="'order' for a sale or 'credit' for a credit order" + ) + channel: Optional[str] = Field(None, description="The sales channel") + batch_id: Optional[str] = Field( + None, alias="batchId", description="Batch ID grouping related orders" + ) + transaction_date: Optional[str] = Field( + None, + alias="transactionDate", + description="RFC3339 datetime the order was purchased on", + ) + completed_date: Optional[str] = Field( + None, + alias="completedDate", + description="RFC3339 datetime the order was shipped/completed on", + ) + delivered_by_seller: Optional[bool] = Field( + None, + alias="deliveredBySeller", + description="Whether the seller delivered the order directly", + ) + exclude_from_filing: Optional[bool] = Field( + None, + alias="excludeFromFiling", + description="Whether the order is excluded from tax filing", + ) + exemption: Optional[MerchantExemption] = Field( + None, description="Exemption information" + ) + refunds: Optional[List[MerchantRefundResponse]] = Field( + None, + description=( + "Refunds recorded against this order. " + "Only included when the request set expand to 'refunds'" + ), + ) + + +# ============================================================================= +# Exemption Certificates +# ============================================================================= + + +class ExemptState(BaseModel): + """A state an exemption certificate is valid in.""" + + model_config = ConfigDict(populate_by_name=True) + + abbreviation: str = Field(..., description="Two-letter state abbreviation") + + +class CreateExemptionCertificateRequest(BaseModel): + """Request payload for creating an exemption certificate.""" + + model_config = ConfigDict(populate_by_name=True, use_enum_values=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + customer_id: str = Field( + ..., + alias="customerId", + description=( + "Your identifier for the exempt customer. Carts and orders submitted " + "with this customerId can use the certificate" + ), + ) + customer_name: str = Field( + ..., + alias="customerName", + description="Name of the customer the certificate is issued to", + ) + customer_business_type: ExemptionCertificateBusinessType = Field( + ..., + alias="customerBusinessType", + description="The type of business the customer is", + ) + reason: ExemptionCertificateReason = Field( + ..., description="The reason the customer is exempt" + ) + reason_description: str = Field( + ..., + alias="reasonDescription", + max_length=20, + description="Short elaboration of the exemption reason (max 20 characters)", + ) + address: MerchantAddress = Field(..., description="Address of the customer") + states: List[ExemptState] = Field( + ..., description="The states the certificate is valid in" + ) + customer_business_description: Optional[str] = Field( + None, + alias="customerBusinessDescription", + description=( + "Free-text description of the business. " + "Provide when customer_business_type is Other" + ), + ) + + +class GetExemptionCertificateRequest(BaseModel): + """Request payload for retrieving an exemption certificate.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + certificate_id: str = Field( + ..., + alias="certificateId", + min_length=1, + description="The certificateId returned when the certificate was created", + ) + + +class DeleteExemptionCertificateRequest(BaseModel): + """Request payload for deleting an exemption certificate.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + certificate_id: str = Field( + ..., + alias="certificateId", + min_length=1, + description="The certificateId returned when the certificate was created", + ) + + +class ListExemptionCertificatesRequest(BaseModel): + """Request payload for listing a merchant's exemption certificates.""" + + model_config = ConfigDict(populate_by_name=True) + + merchant_id: str = Field( + ..., + alias="merchantId", + min_length=1, + description="UUID of the merchant. Must be owned by the calling account", + ) + customer_id: Optional[str] = Field( + None, + alias="customerId", + description="Filter to certificates belonging to this customerId", + ) + cursor: Optional[str] = Field( + None, + description=( + "Opaque pagination cursor from the next_cursor of a previous response" + ), + ) + limit: Optional[int] = Field( + None, + ge=1, + le=100, + description="Maximum certificates per page. Defaults to 20, maximum 100", + ) + ascending: Optional[bool] = Field( + None, description="Sort ascending. Defaults to false (descending)" + ) + disabled: Optional[bool] = Field( + None, + description="List disabled (revoked) certificates instead of active ones", + ) + sort_by: Optional[Literal["createdDate", "id"]] = Field( + None, alias="sortBy", description="Field to sort results by" + ) + + +class ExemptionCertificateResponse(BaseModel): + """An exemption certificate stored in TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + certificate_id: str = Field( + ..., + alias="certificateId", + description=( + "TaxCloud's identifier for the certificate. Use it with " + "GetExemptionCertificate, DeleteExemptionCertificate, and as " + "exemption_id on carts and orders" + ), + ) + connection_id: str = Field( + ..., + alias="connectionId", + description="The TaxCloud connection the certificate belongs to", + ) + account_id: int = Field( + ..., + alias="accountId", + description="The TaxCloud account id the certificate belongs to", + ) + customer_id: str = Field( + ..., alias="customerId", description="Your identifier for the exempt customer" + ) + customer_name: str = Field( + ..., + alias="customerName", + description="Name of the customer the certificate was issued to", + ) + customer_business_type: str = Field( + ..., + alias="customerBusinessType", + description="The type of business the customer is", + ) + reason: str = Field(..., description="The reason the customer is exempt") + reason_description: str = Field( + ..., + alias="reasonDescription", + description="Free-text elaboration of the exemption reason", + ) + created_date: str = Field( + ..., + alias="createdDate", + description="RFC3339 datetime the certificate was created", + ) + single_purchase: bool = Field( + ..., + alias="singlePurchase", + description=( + "Whether the certificate covers a single purchase only, " + "rather than being a blanket certificate" + ), + ) + address: Optional[MerchantAddressResponse] = Field( + None, description="Address of the customer" + ) + states: Optional[List[ExemptState]] = Field( + None, description="The states the certificate is valid in" + ) + customer_business_description: Optional[str] = Field( + None, + alias="customerBusinessDescription", + description="Free-text description of the business", + ) + disabled_at: Optional[str] = Field( + None, + alias="disabledAt", + description=( + "RFC3339 datetime the certificate was disabled, " + "or null while it is active" + ), + ) + + +class ExemptionCertificateListResponse(BaseModel): + """A page of exemption certificates.""" + + model_config = ConfigDict(populate_by_name=True) + + items: Optional[List[ExemptionCertificateResponse]] = Field( + None, description="The certificates on this page of results" + ) + limit: Optional[int] = Field( + None, description="The maximum number of results per page that was applied" + ) + next_cursor: Optional[str] = Field( + None, + alias="nextCursor", + description=( + "Opaque cursor to pass as 'cursor' on the next call. " + "Null when there are no further results" + ), + ) + + +# ============================================================================= +# TIC Data +# ============================================================================= + + +class TicEntry(BaseModel): + """A single Taxability Information Code in the TIC hierarchy.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., description="TIC identifier (numeric string)") + title: str = Field(..., description="Short, localized title of the TIC category") + label: str = Field( + ..., description="Longer, localized description of what the category covers" + ) + nl_title: str = Field( + ..., description="Non-localized (base English) title of the category" + ) + nl_label: str = Field( + ..., description="Non-localized (base English) description of the category" + ) + parent: str = Field( + ..., + description=( + "TIC code of this code's parent category; empty for top-level categories" + ), + ) + + +class TicListItem(BaseModel): + """Wrapper object around a single TIC entry in the TIC data list.""" + + model_config = ConfigDict(populate_by_name=True) + + tic: TicEntry = Field(..., description="The Taxability Information Code entry") + + +class TicDataResponse(BaseModel): + """Full list of Taxability Information Codes available to the account.""" + + model_config = ConfigDict(populate_by_name=True) + + tic_list: List[TicListItem] = Field( + default_factory=list, description="Full list of available TICs" + ) + + +# ============================================================================= +# System +# ============================================================================= + + +class HealthComponents(BaseModel): + """Per-component health detail.""" + + model_config = ConfigDict(populate_by_name=True) + + dynamo: str = Field( + ..., + description=( + "DynamoDB connectivity: 'ok', 'config_error', or 'connection_error'" + ), + ) + taxdata: str = Field( + ..., description="Tax-data cache status: 'ok', 'empty', or 'partial'" + ) + taxdata_count: int = Field( + ..., description="Number of tax-data records loaded in the in-memory cache" + ) + + +class HealthResponse(BaseModel): + """API health check result.""" + + model_config = ConfigDict(populate_by_name=True) + + status: str = Field(..., description="Overall health of the API") + components: HealthComponents = Field(..., description="Per-component detail") + + +class SystemMetadataResponse(BaseModel): + """Build and host information for the instance serving the request.""" + + model_config = ConfigDict(populate_by_name=True) + + go_version: str = Field( + ..., description="Go runtime version the running binary was built with" + ) + hostname: str = Field( + ..., description="Hostname of the instance serving the request" + ) + + +class AccountMetrics(BaseModel): + """Account usage metrics across core, geo, and merchant request pools. + + Returned by ``GetAccountMetrics``. For the simplified v6.0 shape, see + ``V60AccountMetrics`` returned by ``GetAccountMetricsV60``. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + core_request_count: int = Field( + ..., description="Core (tax lookup) requests consumed in the current period" + ) + core_request_limit: int = Field( + ..., description="Maximum core requests allowed in the current period" + ) + core_usage_percent: float = Field( + ..., description="Core request usage as a percentage of the limit (0-100)" + ) + geo_enabled: bool = Field( + ..., + description=( + "Whether the account has the geocoding entitlement that allows " + "address and coordinate lookups" + ), + ) + geo_request_count: int = Field( + ..., description="Geocoding requests consumed in the current period" + ) + geo_request_limit: int = Field( + ..., description="Maximum geocoding requests allowed in the current period" + ) + geo_usage_percent: float = Field( + ..., description="Geocoding request usage as a percentage of the limit" + ) + merchant_request_count: int = Field( + ..., description="Merchant requests consumed in the current period" + ) + merchant_request_limit: int = Field( + ..., description="Maximum merchant requests allowed in the current period" + ) + merchant_usage_percent: float = Field( + ..., description="Merchant request usage as a percentage of the limit" + ) + is_active: bool = Field(..., description="Whether the account is currently active") + message: str = Field(..., description="Informational message about the account") + + +# The delete-certificate endpoint returns an unspecified passthrough body. +DeleteExemptionCertificateResponse = Dict[str, Any] diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index 93c81f5..f143f49 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -86,6 +86,40 @@ class V60Service(BaseModel): description: str = Field(..., description="Service description") +class V60ShippingExtended(BaseModel): + """Detailed shipping taxability rule. + + Returned only when ``shipping_extended=True`` is passed on the request. + """ + + model_config = ConfigDict(populate_by_name=True) + + rule: str = Field( + ..., + description=( + "General shipping-taxability rule for the state: EXEMPT, " + "EXEMPT_WHEN_SEPARATELY_STATED, ITEM_SPECIFIC, CONDITIONAL, or TAXABLE" + ), + ) + exempt_when_separately_stated: str = Field( + ..., + alias="exemptWhenSeparatelyStated", + description=( + "Whether shipping is exempt when separately stated on the invoice, " + "as the string 'true' or 'false'" + ), + ) + description: str = Field( + ..., description="Human-readable description of the shipping rule" + ) + state_code: str = Field( + ..., alias="stateCode", description="Two-letter state code the rule applies to" + ) + state_name: str = Field( + ..., alias="stateName", description="Full state name the rule applies to" + ) + + class V60Shipping(BaseModel): """Shipping taxability information.""" @@ -96,6 +130,14 @@ class V60Shipping(BaseModel): ) taxable: str = Field(..., description="Taxability indicator") description: str = Field(..., description="Shipping description") + shipping_extended: Optional[V60ShippingExtended] = Field( + None, + alias="shippingExtended", + description=( + "Detailed shipping rule. Present only when the request set " + "shipping_extended=True" + ), + ) class V60SourcingRules(BaseModel): @@ -134,6 +176,38 @@ class V60TaxSummary(BaseModel): ) +class V60AddressComponents(BaseModel): + """Geocoded address broken into parts. + + Returned only when ``address_detail_extended=True`` is passed on the + request. + """ + + model_config = ConfigDict(populate_by_name=True) + + country_code: str = Field( + ..., + alias="countryCode", + description="ISO-3 country code of the geocoded location (e.g. USA, CAN)", + ) + country_name: str = Field(..., alias="countryName", description="Full country name") + state_code: str = Field( + ..., alias="stateCode", description="State or province code (e.g. CA, ON)" + ) + state: str = Field(..., description="Full state or province name") + county: str = Field(..., description="County name, when available") + city: str = Field(..., description="City name") + street: str = Field(..., description="Street name") + postal_code: str = Field( + ..., + alias="postalCode", + description="Full postal code (ZIP+4 for US addresses when available)", + ) + house_number: str = Field( + ..., alias="houseNumber", description="House/street number, when available" + ) + + class V60AddressDetail(BaseModel): """Address detail information for v6.0.""" @@ -145,6 +219,137 @@ class V60AddressDetail(BaseModel): incorporated: str = Field(..., description="Incorporation status") geo_lat: float = Field(..., alias="geoLat", description="Geocoded latitude") geo_lng: float = Field(..., alias="geoLng", description="Geocoded longitude") + address: Optional[V60AddressComponents] = Field( + None, + description=( + "Geocoded address broken into parts. Present only when the request " + "set address_detail_extended=True" + ), + ) + + +class V60RateRule(BaseModel): + """A product rate rule that applies to a TIC in the resolved jurisdiction.""" + + model_config = ConfigDict(populate_by_name=True) + + jur_tax_code: Optional[str] = Field( + None, + alias="jurTaxCode", + description="Code identifying the jurisdiction the rule applies to", + ) + effective_dt: Optional[int] = Field( + None, + alias="effectiveDt", + description="Date the rule takes effect, in YYYYMMDD format", + ) + expires_dt: Optional[int] = Field( + None, + alias="expiresDt", + description=( + "Date the rule expires, in YYYYMMDD format; null means still in effect" + ), + ) + effective_tax_rate: Optional[float] = Field( + None, + alias="effectiveTaxRate", + description=( + "Decimal tax rate that applies to this product in this jurisdiction" + ), + ) + percent_taxable: Optional[float] = Field( + None, + alias="percentTaxable", + description="Fraction of the sale that is taxable (0.5 means half-exempt)", + ) + exempt_over: Optional[float] = Field( + None, + alias="exemptOver", + description="Amounts above this dollar value are exempt", + ) + exempt_under: Optional[float] = Field( + None, + alias="exemptUnder", + description="Amounts below this dollar value are exempt", + ) + taxable_portion_over: Optional[float] = Field( + None, + alias="taxablePortionOver", + description="Only the amount over this threshold is taxed", + ) + rate_cap_per_unit: Optional[float] = Field( + None, + alias="rateCapPerUnit", + description="Maximum tax rate cap per unit (e.g. $0.30/cigar)", + ) + per_volume_tax_rate: Optional[float] = Field( + None, + alias="perVolumeTaxRate", + description="Per-volume tax rate for volume-based overrides (e.g. $0.05/mL)", + ) + per_volume_unit: Optional[str] = Field( + None, + alias="perVolumeUnit", + description="Unit of measurement for per_volume_tax_rate (e.g. mL)", + ) + is_destination_tax_type: Optional[bool] = Field( + None, + alias="isDestinationTaxType", + description="Whether the rule follows destination-based sourcing", + ) + is_food_drug: Optional[bool] = Field( + None, + alias="isFoodDrug", + description="Whether the product falls under food/drug classification", + ) + + +class V60TaxabilityCode(BaseModel): + """Resolved taxability code details and its applicable rate rules.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., description="Taxability Information Code supplied on request") + state_fips: str = Field( + ..., alias="stateFIPS", description="State FIPS code for the request location" + ) + county_fips: str = Field( + ..., + alias="countyFIPS", + description="County FIPS code for the request location", + ) + title: str = Field(..., description="Short title of the TIC") + label: str = Field(..., description="Longer description of the TIC") + rate_action_code: str = Field( + ..., + alias="rateActionCode", + description=( + "Outcome of the TIC lookup: T00 (valid, rules listed), T01 (valid, no " + "applicable rules), T02 (invalid TIC), T03 (invalid TIC format)" + ), + ) + rate_action_message: str = Field( + ..., + alias="rateActionMessage", + description="Human-readable explanation of the rate_action_code", + ) + rate_rules: Optional[List[V60RateRule]] = Field( + None, + alias="rateRules", + description="Product rate rules active on the current date", + ) + + +class V60ProductDetail(BaseModel): + """Product-specific tax rules for a requested taxability code.""" + + model_config = ConfigDict(populate_by_name=True) + + taxability_code: V60TaxabilityCode = Field( + ..., + alias="taxabilityCode", + description="Resolved taxability code details and applicable rate rules", + ) class V60Response(BaseModel): @@ -173,6 +378,15 @@ class V60Response(BaseModel): address_detail: V60AddressDetail = Field( ..., alias="addressDetail", description="Address details" ) + product_detail: Optional[V60ProductDetail] = Field( + None, + alias="productDetail", + description=( + "Product-specific tax rules. Present only when taxability_code is " + "supplied on the request and the account carries the product_rates " + "entitlement" + ), + ) class V60AccountMetrics(BaseModel): @@ -342,6 +556,33 @@ class V60PostalCodeAddressDetail(BaseModel): ) +class V60SingleArticleTax(BaseModel): + """Tennessee Single Article Tax breakdown. + + Present only when ``sat_item_total`` is supplied on a Tennessee lookup. + """ + + model_config = ConfigDict(populate_by_name=True) + + applied_total: str = Field( + ..., alias="appliedTotal", description="Single-article item total applied" + ) + county_tax_rate: str = Field( + ..., alias="countyTaxRate", description="County tax rate used" + ) + local_tax_limit: str = Field( + ..., alias="localTaxLimit", description="Local tax limit applied" + ) + local_tax_total: str = Field( + ..., alias="localTaxTotal", description="Total local tax calculated" + ) + state_additional_tax_total: str = Field( + ..., + alias="stateAdditionalTaxTotal", + description="Additional state tax calculated for the single article", + ) + + class V60PostalCodeResponse(BaseModel): """Response for postal code lookup. @@ -358,6 +599,14 @@ class V60PostalCodeResponse(BaseModel): address_detail: V60PostalCodeAddressDetail = Field( ..., alias="addressDetail", description="Address details for postal code lookup" ) + sat_tax_detail: Optional[V60SingleArticleTax] = Field( + None, + alias="satTaxDetail", + description=( + "Tennessee Single Article Tax breakdown. Present only when " + "sat_item_total is supplied on a Tennessee lookup" + ), + ) # ============================================================================= @@ -431,20 +680,36 @@ class ProductCodeSearchResponse(BaseModel): Attributes: query: The original query sent in the POST request. results: Array of matching product codes ranked by relevance. + next_cursor: Cursor for retrieving the next page of results. + schema_url: URL to the JSON Schema describing this response. """ model_config = ConfigDict(populate_by_name=True) query: str = Field(..., description="The original query sent in the POST request") results: List["ProductCodeSearchResult"] = Field( - ..., + default_factory=list, description="Array of matching product codes ranked and scored by relevance", ) + next_cursor: Optional[str] = Field( + None, + alias="nextCursor", + description="Cursor for retrieving the next page of results", + ) + schema_url: Optional[str] = Field( + None, + alias="$schema", + description="URL to the JSON Schema describing this response", + ) class ProductCodeRecommendation(BaseModel): """A single AI-powered product code recommendation. + Only ``status`` is guaranteed. When ``status`` is ``"fail"`` the API + returns ``error`` populated and every other field null, so all remaining + fields are optional. + Attributes: status: Status of the prediction result (success or fail). error: Non-null error message when the prediction fails. @@ -461,24 +726,30 @@ class ProductCodeRecommendation(BaseModel): ..., description="Status of the prediction result (success or fail)" ) error: Optional[str] = Field( - None, description="Non-null error message when the prediction fails" + None, + description=( + 'Error string formatted as " - " on failure; ' + "null on success" + ), ) - tic_id: int = Field( - ..., + tic_id: Optional[int] = Field( + None, alias="ticId", description="The recommended Taxability Information Code", ) - label: str = Field(..., description="The taxabilityCode label from the TIC data") - natural_label: str = Field( - ..., + label: Optional[str] = Field( + None, description="The taxabilityCode label from the TIC data" + ) + natural_label: Optional[str] = Field( + None, alias="naturalLabel", description="A natural label refactored to align with the description", ) - tic_description: str = Field( - ..., description="Full description of the recommended TIC code" + tic_description: Optional[str] = Field( + None, description="Full description of the recommended TIC code" ) - product_description: str = Field( - ..., description="The original product description sent in the query" + product_description: Optional[str] = Field( + None, description="The original product description sent in the query" ) diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index d30fa06..ba5e0dc 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -1,21 +1,46 @@ """API functions for the ZipTax SDK.""" import logging +import warnings from typing import Any, Dict, List, Optional, Union from ..config import Config from ..exceptions import ZipTaxCloudConfigError from ..models import ( + AccountMetrics, CalculateCartRequest, CalculateCartResponse, + CreateExemptionCertificateRequest, + CreateMerchantRequest, CreateOrderFromCartRequest, CreateOrderRequest, + DeleteExemptionCertificateRequest, + ExemptionCertificateListResponse, + ExemptionCertificateResponse, + GetExemptionCertificateRequest, + HealthResponse, + ListExemptionCertificatesRequest, + MerchantCalculateCartRequest, + MerchantCalculateCartResponse, + MerchantCreateOrderFromCartRequest, + MerchantCreateOrderRequest, + MerchantCreateRefundRequest, + MerchantGetOrderRequest, + MerchantMutationResponse, + MerchantOrderResponse, + MerchantRefundResponse, + MerchantResponse, + MerchantUpdateOrderRequest, OrderResponse, ProductCodeRecommendationResponse, ProductCodeSearchResponse, RefundTransactionRequest, RefundTransactionResponse, + SetMerchantCredentialsRequest, + SystemMetadataResponse, TaxCloudCalculateCartResponse, + TicDataResponse, + UpdateMerchantRequest, UpdateOrderRequest, V60AccountMetrics, V60PostalCodeResponse, @@ -27,16 +52,39 @@ parse_address_string, validate_address, validate_address_autocomplete, + validate_adjustment, validate_coordinates, validate_country_code, validate_format, validate_historical_date, + validate_merchant_id, validate_postal_code, validate_product_query, ) logger = logging.getLogger(__name__) +_TAXCLOUD_DIRECT_DEPRECATION = ( + "{name} calls the TaxCloud API directly and is deprecated. The documented " + "path is the Ziptax merchant layer: use {replacement} with a merchantId " + "instead. See https://docs.zip.tax/guides/merchant-compliance-solutions/" + "merchant-management" +) + + +def _warn_taxcloud_direct(name: str, replacement: str) -> None: + """Emit a DeprecationWarning for a direct-to-TaxCloud function. + + Args: + name: Name of the deprecated SDK function + replacement: Name of the merchant-layer function to use instead + """ + warnings.warn( + _TAXCLOUD_DIRECT_DEPRECATION.format(name=name, replacement=replacement), + DeprecationWarning, + stacklevel=3, + ) + class Functions: """Functions class for ZipTax API endpoints.""" @@ -71,15 +119,38 @@ def GetSalesTaxByAddress( country_code: str = "USA", historical: Optional[str] = None, format: str = "json", + city: Optional[str] = None, + state: Optional[str] = None, + adjustment: Optional[str] = None, + address_detail_extended: bool = False, + shipping_extended: bool = False, + sat_item_total: Optional[float] = None, ) -> V60Response: """Get sales tax rates by address. Args: address: Full or partial street address for geocoding - taxability_code: Optional taxability code - country_code: Country code (default: "USA") + taxability_code: Product Taxability Information Code (TIC). When + supplied, the response includes a ``product_detail`` object + describing the rate rules for that product category. Accepts + numeric standard TICs (e.g. "20010") or alphanumeric override + codes (e.g. "CIR00001"). Requires the product rates entitlement + country_code: Country code (default: "USA"). One of USA, CAN, PRI, + ASM, GUM, MNP, VIR. CAN requires the Canadian rates entitlement historical: Historical date for rates (YYYYMM format, e.g. "202401") format: Response format (default: "json") + city: City name, used with state to narrow the lookup + state: State name or two-letter abbreviation, used to disambiguate + adjustment: Sourcing/unincorporated-area handling. One of "auto" + (default), "origin", or "destination" + address_detail_extended: When True, ``address_detail.address`` + carries the full geocoding object broken into address parts + shipping_extended: When True, ``shipping.shipping_extended`` carries + the detailed state shipping-taxability rule + sat_item_total: Single-article item total in dollars, for Tennessee + Single Article Tax on TN address lookups. Note the v6.0 response + does not return a SAT breakdown object; that detail is only + available on the postal-code (legacy-shaped) response Returns: V60Response object with tax rate information @@ -93,6 +164,8 @@ def GetSalesTaxByAddress( validate_country_code(country_code) if historical: validate_historical_date(historical) + if adjustment is not None: + validate_adjustment(adjustment) validate_format(format) # Build query parameters @@ -108,6 +181,24 @@ def GetSalesTaxByAddress( if historical: params["historical"] = historical + if city: + params["city"] = city + + if state: + params["state"] = state + + if adjustment is not None: + params["adjustment"] = adjustment + + if address_detail_extended: + params["addressDetailExtended"] = "true" + + if shipping_extended: + params["shippingExtended"] = "true" + + if sat_item_total is not None: + params["sat_item_total"] = sat_item_total + # Make request with retry logic @retry_with_backoff( max_retries=self.max_retries, @@ -126,15 +217,31 @@ def GetSalesTaxByGeoLocation( country_code: str = "USA", historical: Optional[str] = None, format: str = "json", + taxability_code: Optional[str] = None, + adjustment: Optional[str] = None, + address_detail_extended: bool = False, + shipping_extended: bool = False, + sat_item_total: Optional[float] = None, ) -> V60Response: """Get sales tax rates by geolocation. Args: lat: Latitude for geolocation lng: Longitude for geolocation - country_code: Country code (default: "USA") + country_code: Country code (default: "USA"). One of USA, CAN, PRI, + ASM, GUM, MNP, VIR historical: Historical date for rates (YYYYMM format, e.g. "202401") format: Response format (default: "json") + taxability_code: Product Taxability Information Code (TIC). When + supplied, the response includes a ``product_detail`` object + adjustment: Sourcing/unincorporated-area handling. One of "auto" + (default), "origin", or "destination" + address_detail_extended: When True, ``address_detail.address`` + carries the full geocoding object broken into address parts + shipping_extended: When True, ``shipping.shipping_extended`` carries + the detailed state shipping-taxability rule + sat_item_total: Single-article item total in dollars, for Tennessee + Single Article Tax calculation Returns: V60Response object with tax rate information @@ -148,6 +255,8 @@ def GetSalesTaxByGeoLocation( validate_country_code(country_code) if historical: validate_historical_date(historical) + if adjustment is not None: + validate_adjustment(adjustment) validate_format(format) # Build query parameters @@ -161,6 +270,21 @@ def GetSalesTaxByGeoLocation( if historical: params["historical"] = historical + if taxability_code: + params["taxabilityCode"] = taxability_code + + if adjustment is not None: + params["adjustment"] = adjustment + + if address_detail_extended: + params["addressDetailExtended"] = "true" + + if shipping_extended: + params["shippingExtended"] = "true" + + if sat_item_total is not None: + params["sat_item_total"] = sat_item_total + # Make request with retry logic @retry_with_backoff( max_retries=self.max_retries, @@ -173,7 +297,11 @@ def _make_request() -> Dict[str, Any]: return V60Response(**response_data) def GetAccountMetrics(self, key: Optional[str] = None) -> V60AccountMetrics: - """Get account metrics. + """Get account metrics in the simplified v6.0 format. + + Calls ``GET /account/v60/metrics``. In v6.0 all keys are geo keys, so + the counters reflect geo usage. For the per-pool breakdown (core, geo, + and merchant) use ``GetAccountUsage`` instead. Args: key: Optional API key parameter @@ -204,12 +332,28 @@ def GetRatesByPostalCode( self, postal_code: str, format: str = "json", + state: Optional[str] = None, + city: Optional[str] = None, + county: Optional[str] = None, + historical: Optional[str] = None, + sat_item_total: Optional[float] = None, ) -> V60PostalCodeResponse: """Get sales tax rates by US postal code. + Postal-code-only lookups are served in the legacy (v5.0-shaped) flat + response, which can contain one row per jurisdiction overlapping the + ZIP. + Args: postal_code: US postal code (5-digit format, e.g., "92694") format: Response format (default: "json") + state: State name or two-letter abbreviation, to narrow the lookup + city: City name, to narrow the lookup + county: County name, to refine the lookup + historical: Historical date for rates (YYYYMM format, e.g. "202401") + sat_item_total: Single-article item total in dollars. When supplied + on a Tennessee lookup, the response carries a ``sat_tax_detail`` + breakdown Returns: V60PostalCodeResponse object with tax rate information for all locations @@ -221,6 +365,8 @@ def GetRatesByPostalCode( """ # Validate inputs validate_postal_code(postal_code) + if historical: + validate_historical_date(historical) validate_format(format) # Build query parameters @@ -229,6 +375,21 @@ def GetRatesByPostalCode( "format": format, } + if state: + params["state"] = state + + if city: + params["city"] = city + + if county: + params["county"] = county + + if historical: + params["historical"] = historical + + if sat_item_total is not None: + params["sat_item_total"] = sat_item_total + # Make request with retry logic @retry_with_backoff( max_retries=self.max_retries, @@ -358,6 +519,13 @@ def CalculateCart( is the same regardless of which backend is used. The response type differs based on the backend. + .. note:: + This 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 + :meth:`MerchantCalculateCart`, which takes a ``merchant_id`` and + serves both self-managed and TaxCloud-connected merchants. + Args: request: CalculateCartRequest object with cart details including customer ID, addresses, currency, and line items @@ -546,6 +714,11 @@ def CreateOrder( ) -> OrderResponse: """Create an order in TaxCloud. + .. deprecated:: + This calls the TaxCloud API directly, which is no longer the + documented integration path. Use :meth:`MerchantCreateOrder` with a + ``merchant_id`` instead. Emits a ``DeprecationWarning``. + Args: request: CreateOrderRequest object with order details address_autocomplete: Address autocomplete option (default: "none") @@ -595,6 +768,7 @@ def CreateOrder( >>> order = client.request.CreateOrder(request) """ self._check_taxcloud_config() + _warn_taxcloud_direct("CreateOrder", "MerchantCreateOrder") # Validate inputs validate_address_autocomplete(address_autocomplete) @@ -626,6 +800,11 @@ def _make_request() -> Dict[str, Any]: def GetOrder(self, order_id: str) -> OrderResponse: """Retrieve an order from TaxCloud by ID. + .. deprecated:: + This calls the TaxCloud API directly, which is no longer the + documented integration path. Use :meth:`MerchantGetOrder` with a + ``merchant_id`` instead. Emits a ``DeprecationWarning``. + Args: order_id: The ID of the order to retrieve @@ -640,6 +819,7 @@ def GetOrder(self, order_id: str) -> OrderResponse: >>> order = client.request.GetOrder("my-order-1") """ self._check_taxcloud_config() + _warn_taxcloud_direct("GetOrder", "MerchantGetOrder") # Build path with connection ID and order ID path = ( @@ -665,6 +845,11 @@ def UpdateOrder( ) -> OrderResponse: """Update an existing order's completedDate in TaxCloud. + .. deprecated:: + This calls the TaxCloud API directly, which is no longer the + documented integration path. Use :meth:`MerchantUpdateOrder` with a + ``merchant_id`` instead. Emits a ``DeprecationWarning``. + Args: order_id: The ID of the order to update request: UpdateOrderRequest object with updated completedDate @@ -684,6 +869,7 @@ def UpdateOrder( >>> order = client.request.UpdateOrder("my-order-1", request) """ self._check_taxcloud_config() + _warn_taxcloud_direct("UpdateOrder", "MerchantUpdateOrder") # Build path with connection ID and order ID path = ( @@ -711,6 +897,11 @@ def RefundOrder( ) -> List[RefundTransactionResponse]: """Create a refund against an order in TaxCloud. + .. deprecated:: + This calls the TaxCloud API directly, which is no longer the + documented integration path. Use :meth:`MerchantCreateRefund` with a + ``merchant_id`` instead. Emits a ``DeprecationWarning``. + An order can only be refunded once, regardless of whether the order is partially or fully refunded. @@ -745,6 +936,7 @@ def RefundOrder( >>> refunds = client.request.RefundOrder("my-order-1", request) """ self._check_taxcloud_config() + _warn_taxcloud_direct("RefundOrder", "MerchantCreateRefund") # Build path with connection ID and order ID conn_id = self.config.taxcloud_connection_id @@ -778,6 +970,11 @@ def CreateOrderFromCart( ) -> OrderResponse: """Create an order from a previously calculated cart in TaxCloud. + .. deprecated:: + This calls the TaxCloud API directly, which is no longer the + documented integration path. Use :meth:`MerchantCreateOrderFromCart` with a + ``merchant_id`` instead. Emits a ``DeprecationWarning``. + Converts an existing cart (created via CalculateCart with TaxCloud credentials) into a finalized order for tax filing. The user must have previously called CalculateCart with TaxCloud credentials and @@ -806,6 +1003,7 @@ def CreateOrderFromCart( >>> order = client.request.CreateOrderFromCart(request) """ self._check_taxcloud_config() + _warn_taxcloud_direct("CreateOrderFromCart", "MerchantCreateOrderFromCart") # Build path with connection ID conn_id = self.config.taxcloud_connection_id @@ -825,3 +1023,701 @@ def _make_request() -> Dict[str, Any]: response_data = _make_request() return OrderResponse(**response_data) + + # ========================================================================= + # Merchant Management + # ========================================================================= + + def _merchant_post(self, path: str, body: Dict[str, Any]) -> Any: + """POST to a merchant endpoint with retry logic. + + Args: + path: Merchant endpoint path + body: JSON request body + + Returns: + Decoded response body + """ + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Any: + return self.http_client.post(path, json=body) + + return _make_request() + + def CreateMerchant( + self, + request: CreateMerchantRequest, + ) -> MerchantMutationResponse: + """Create a merchant under the authenticated account. + + ``merchant_type`` selects the compliance model. ``"self-managed"`` + creates a merchant that is active immediately with no TaxCloud invite. + ``"taxcloud"`` (the default) starts the TaxCloud invite process and + requires an Enterprise plan. + + Args: + request: CreateMerchantRequest with the merchant's details + + Returns: + MerchantMutationResponse carrying the new merchant_id + + Raises: + ZipTaxValidationError: If input parameters are invalid + ZipTaxAPIError: If the API returns an error + + Example: + >>> from ziptax.models import CreateMerchantRequest, MerchantType + >>> merchant = client.request.CreateMerchant( + ... CreateMerchantRequest( + ... merchant_name="Acme Supply Co", + ... contact_email="ops@acme.example", + ... merchant_type=MerchantType.SELF_MANAGED, + ... ) + ... ) + >>> print(merchant.merchant_id) + """ + response_data = self._merchant_post( + "/merchant/create", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantMutationResponse(**response_data) + + def UpdateMerchant( + self, + request: UpdateMerchantRequest, + ) -> MerchantMutationResponse: + """Update an existing merchant. + + The caller must own the merchant. Cross-account modification and + soft-deleted merchants return 403. + + Args: + request: UpdateMerchantRequest with merchant_id and update fields + + Returns: + MerchantMutationResponse describing the result + + Raises: + ZipTaxValidationError: If input parameters are invalid + ZipTaxAPIError: If the API returns an error + + Example: + >>> from ziptax.models import ( + ... UpdateMerchantRequest, MerchantUpdateFields + ... ) + >>> result = client.request.UpdateMerchant( + ... UpdateMerchantRequest( + ... merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13", + ... update=MerchantUpdateFields( + ... merchant_name="Acme Supply Company" + ... ), + ... ) + ... ) + """ + response_data = self._merchant_post( + "/merchant/update", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantMutationResponse(**response_data) + + def DeleteMerchant(self, merchant_id: str) -> MerchantMutationResponse: + """Soft-delete a merchant. + + Args: + merchant_id: UUID of the merchant to delete + + Returns: + MerchantMutationResponse describing the result + + Raises: + ZipTaxValidationError: If merchant_id is empty + ZipTaxAPIError: If the API returns an error + + Example: + >>> result = client.request.DeleteMerchant( + ... "9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13" + ... ) + """ + validate_merchant_id(merchant_id) + + response_data = self._merchant_post( + "/merchant/delete", {"merchantId": merchant_id} + ) + return MerchantMutationResponse(**response_data) + + def GetMerchant(self, merchant_id: str) -> MerchantResponse: + """Retrieve a single merchant by UUID. + + Soft-deleted merchants are treated as non-existent and return 404. + + Args: + merchant_id: UUID of the merchant to retrieve + + Returns: + MerchantResponse with the merchant's details and TaxCloud status + + Raises: + ZipTaxValidationError: If merchant_id is empty + ZipTaxAPIError: If the API returns an error + + Example: + >>> merchant = client.request.GetMerchant( + ... "9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13" + ... ) + >>> print(merchant.status) + """ + validate_merchant_id(merchant_id) + + response_data = self._merchant_post( + "/merchant/get", {"merchantId": merchant_id} + ) + return MerchantResponse(**response_data) + + def ListMerchants(self) -> List[MerchantResponse]: + """List every active merchant owned by the calling account. + + Soft-deleted merchants are excluded. + + Returns: + List of MerchantResponse objects + + Raises: + ZipTaxAPIError: If the API returns an error + + Example: + >>> for merchant in client.request.ListMerchants(): + ... print(merchant.merchant_id, merchant.merchant_name) + """ + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Any: + return self.http_client.get("/merchant/list") + + response_data = _make_request() + + # The API returns a bare array; tolerate an empty/null body. + if not response_data: + return [] + + return [MerchantResponse(**item) for item in response_data] + + def SetMerchantCredentials( + self, + request: SetMerchantCredentialsRequest, + ) -> MerchantMutationResponse: + """Set or update a merchant's TaxCloud credentials. + + Use this to connect a merchant who already has a TaxCloud account. To + invite a merchant who does not, set ``send_taxcloud_invite`` on + ``CreateMerchant`` instead. Credentials are encrypted at rest by the + API. On success an asynchronous webhook notification is sent to the + configured endpoint. + + Args: + request: SetMerchantCredentialsRequest with merchant_id, + connection_id, and api_key + + Returns: + MerchantMutationResponse describing the result + + Raises: + ZipTaxValidationError: If input parameters are invalid + ZipTaxAPIError: If the API returns an error + """ + response_data = self._merchant_post( + "/merchant/credentials/set", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantMutationResponse(**response_data) + + def DeleteMerchantCredentials(self, merchant_id: str) -> MerchantMutationResponse: + """Delete a merchant's stored TaxCloud credentials. + + Args: + merchant_id: UUID of the merchant whose credentials are deleted + + Returns: + MerchantMutationResponse describing the result + + Raises: + ZipTaxValidationError: If merchant_id is empty + ZipTaxAPIError: If the API returns an error + """ + validate_merchant_id(merchant_id) + + response_data = self._merchant_post( + "/merchant/credentials/delete", {"merchantId": merchant_id} + ) + return MerchantMutationResponse(**response_data) + + # ========================================================================= + # Merchant Transactions + # ========================================================================= + + def MerchantCalculateCart( + self, + request: MerchantCalculateCartRequest, + ) -> MerchantCalculateCartResponse: + """Calculate sales tax per line item for one or more merchant carts. + + The request body is the same for every merchant; Ziptax routes on the + merchant's compliance model. TaxCloud-connected merchants have the cart + forwarded to TaxCloud and can convert the result into an order with + ``MerchantCreateOrderFromCart``. Self-managed merchants are calculated + in-process by the Ziptax rate engine; nothing is persisted, + ``connection_id`` is omitted, and discounts are not supported. + + This is the only transaction endpoint available to self-managed + merchants. + + Args: + request: MerchantCalculateCartRequest with merchant_id and carts + + Returns: + MerchantCalculateCartResponse with per-line-item tax + + Raises: + ZipTaxAPIError: If the API returns an error + + Example: + >>> from ziptax.models import ( + ... MerchantCalculateCartRequest, MerchantCart, + ... MerchantCartLineItem, MerchantAddress, MerchantCurrency, + ... ) + >>> request = 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, + ... ) + ... ], + ... ) + ... ], + ... ) + >>> result = client.request.MerchantCalculateCart(request) + >>> print(result.items[0].line_items[0].tax.amount) + """ + response_data = self._merchant_post( + "/merchant/cart/calculate", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantCalculateCartResponse(**response_data) + + def MerchantCreateOrder( + self, + request: MerchantCreateOrderRequest, + ) -> MerchantOrderResponse: + """Record a merchant order directly. + + Requires a TaxCloud-connected merchant with credentials on file. Returns + 403 for a self-managed merchant, whose only available transaction + endpoint is ``MerchantCalculateCart``. + + Args: + request: MerchantCreateOrderRequest with the order details + + Returns: + MerchantOrderResponse with the recorded order + + Raises: + ZipTaxAPIError: If the API returns an error + """ + response_data = self._merchant_post( + "/merchant/order/create", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantOrderResponse(**response_data) + + def MerchantCreateOrderFromCart( + self, + request: MerchantCreateOrderFromCartRequest, + ) -> MerchantOrderResponse: + """Capture a previously calculated cart as an order. + + Requires a TaxCloud-connected merchant. Self-managed cart calculations + are not persisted and cannot be converted into orders. + + Args: + request: MerchantCreateOrderFromCartRequest with merchant_id, + cart_id, and order_id + + Returns: + MerchantOrderResponse with the created order + + Raises: + ZipTaxAPIError: If the API returns an error + """ + response_data = self._merchant_post( + "/merchant/order/create-from-cart", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantOrderResponse(**response_data) + + def MerchantGetOrder( + self, + request: MerchantGetOrderRequest, + ) -> MerchantOrderResponse: + """Retrieve a merchant order. + + Args: + request: MerchantGetOrderRequest with merchant_id, order_id, and an + optional ``expand="refunds"`` + + Returns: + MerchantOrderResponse with the order details + + Raises: + ZipTaxAPIError: If the API returns an error + + Example: + >>> from ziptax.models import MerchantGetOrderRequest + >>> order = client.request.MerchantGetOrder( + ... MerchantGetOrderRequest( + ... merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13", + ... order_id="my-order-1", + ... expand="refunds", + ... ) + ... ) + """ + response_data = self._merchant_post( + "/merchant/order/get", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantOrderResponse(**response_data) + + def MerchantUpdateOrder( + self, + request: MerchantUpdateOrderRequest, + ) -> MerchantOrderResponse: + """Update a merchant order's completed date. + + Args: + request: MerchantUpdateOrderRequest with merchant_id, order_id, and + the new completed_date + + Returns: + MerchantOrderResponse with the updated order + + Raises: + ZipTaxAPIError: If the API returns an error + """ + response_data = self._merchant_post( + "/merchant/order/update", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantOrderResponse(**response_data) + + def MerchantCreateRefund( + self, + request: MerchantCreateRefundRequest, + ) -> MerchantRefundResponse: + """Refund a merchant order, in full or in part. + + Omit ``items`` to refund the entire order. + + Args: + request: MerchantCreateRefundRequest with merchant_id, order_id, and + optional items to refund + + Returns: + MerchantRefundResponse with the recorded refund + + Raises: + ZipTaxAPIError: If the API returns an error + + Example: + Full refund: + >>> from ziptax.models import MerchantCreateRefundRequest + >>> refund = client.request.MerchantCreateRefund( + ... MerchantCreateRefundRequest( + ... merchant_id="9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13", + ... order_id="my-order-1", + ... ) + ... ) + + Partial refund: + >>> from ziptax.models import MerchantRefundItem + >>> 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)], + ... ) + ... ) + """ + response_data = self._merchant_post( + "/merchant/refund/create", + request.model_dump(by_alias=True, exclude_none=True), + ) + return MerchantRefundResponse(**response_data) + + # ========================================================================= + # Exemption Certificates + # ========================================================================= + + def CreateExemptionCertificate( + self, + request: CreateExemptionCertificateRequest, + ) -> ExemptionCertificateResponse: + """Create an exemption certificate for a customer. + + Requires a TaxCloud-connected merchant. Carts and orders submitted with + the same ``customer_id`` can then reference the returned + ``certificate_id`` as ``exemption_id``. + + Args: + request: CreateExemptionCertificateRequest with the certificate + details + + Returns: + ExemptionCertificateResponse with the stored certificate + + Raises: + ZipTaxAPIError: If the API returns an error + + Example: + >>> from ziptax.models import ( + ... CreateExemptionCertificateRequest, ExemptState, + ... ExemptionCertificateReason, + ... ExemptionCertificateBusinessType, 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")], + ... ) + ... ) + """ + response_data = self._merchant_post( + "/merchant/cert/create", + request.model_dump(by_alias=True, exclude_none=True), + ) + return ExemptionCertificateResponse(**response_data) + + def GetExemptionCertificate( + self, + request: GetExemptionCertificateRequest, + ) -> ExemptionCertificateResponse: + """Retrieve an exemption certificate by ID. + + Args: + request: GetExemptionCertificateRequest with merchant_id and + certificate_id + + Returns: + ExemptionCertificateResponse with the certificate + + Raises: + ZipTaxAPIError: If the API returns an error + """ + response_data = self._merchant_post( + "/merchant/cert/get", + request.model_dump(by_alias=True, exclude_none=True), + ) + return ExemptionCertificateResponse(**response_data) + + def ListExemptionCertificates( + self, + request: ListExemptionCertificatesRequest, + ) -> ExemptionCertificateListResponse: + """List a merchant's exemption certificates. + + Results are paginated. Pass the ``next_cursor`` from a response as + ``cursor`` on the following call to fetch the next page. + + Args: + request: ListExemptionCertificatesRequest with merchant_id and + optional filter and pagination fields + + Returns: + ExemptionCertificateListResponse with a page of certificates + + Raises: + ZipTaxAPIError: If the API returns an error + """ + response_data = self._merchant_post( + "/merchant/cert/list", + request.model_dump(by_alias=True, exclude_none=True), + ) + return ExemptionCertificateListResponse(**response_data) + + def DeleteExemptionCertificate( + self, + request: DeleteExemptionCertificateRequest, + ) -> Dict[str, Any]: + """Delete an exemption certificate. + + Args: + request: DeleteExemptionCertificateRequest with merchant_id and + certificate_id + + Returns: + The raw response body from TaxCloud, which is unspecified + + Raises: + ZipTaxAPIError: If the API returns an error + """ + response_data = self._merchant_post( + "/merchant/cert/delete", + request.model_dump(by_alias=True, exclude_none=True), + ) + return response_data if isinstance(response_data, dict) else {} + + # ========================================================================= + # TIC Data and System + # ========================================================================= + + def GetTicData(self) -> TicDataResponse: + """Retrieve the full Taxability Information Code (TIC) list. + + Returns every TIC available to the account, including the hierarchy + (each entry carries its ``parent``). + + Returns: + TicDataResponse with the full TIC list + + Raises: + ZipTaxAPIError: If the API returns an error + + Example: + >>> data = client.request.GetTicData() + >>> for entry in data.tic_list[:5]: + ... print(entry.tic.id, entry.tic.title) + """ + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Dict[str, Any]: + return self.http_client.get("/data/tic") + + response_data = _make_request() + return TicDataResponse(**response_data) + + def GetTicSearchSchema(self) -> Dict[str, Any]: + """Retrieve the JSON Schema describing the TIC search response. + + This endpoint is public and requires no authentication. + + Returns: + The JSON Schema document as a dictionary + + Raises: + ZipTaxAPIError: If the API returns an error + """ + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Dict[str, Any]: + return self.http_client.get("/schemas/ticsearch") + + return _make_request() + + def GetAccountUsage(self, key: Optional[str] = None) -> AccountMetrics: + """Get account usage across the core, geo, and merchant request pools. + + Calls ``GET /account/metrics``, which breaks usage out per pool and + reports the ``geo_enabled`` entitlement. For the simplified v6.0 + counters, use ``GetAccountMetrics`` instead. + + Args: + key: Optional API key parameter. Defaults to the client's key + + Returns: + AccountMetrics with per-pool counts, limits, and usage percentages + + Raises: + ZipTaxAPIError: If the API returns an error + + Example: + >>> usage = client.request.GetAccountUsage() + >>> print(usage.core_usage_percent, usage.merchant_request_count) + """ + params: Dict[str, Any] = {} + if key: + params["key"] = key + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Dict[str, Any]: + return self.http_client.get("/account/metrics", params=params) + + response_data = _make_request() + return AccountMetrics(**response_data) + + def GetHealth(self) -> HealthResponse: + """Check API availability and component health. + + Returns: + HealthResponse with overall status and per-component detail + + Raises: + ZipTaxAPIError: If the API returns an error + """ + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Dict[str, Any]: + return self.http_client.get("/system/health") + + response_data = _make_request() + return HealthResponse(**response_data) + + def GetSystemMetadata(self) -> SystemMetadataResponse: + """Retrieve build and host information for the serving instance. + + Returns: + SystemMetadataResponse with the Go version and hostname + + Raises: + ZipTaxAPIError: If the API returns an error + """ + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Dict[str, Any]: + return self.http_client.get("/system/metadata") + + response_data = _make_request() + return SystemMetadataResponse(**response_data) diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 21c18d5..3a8ea88 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -59,13 +59,17 @@ def validate_coordinates(lat: str, lng: str) -> None: def validate_country_code(country_code: str) -> None: """Validate country code parameter. + Accepts USA, CAN, and the US territories (ASM, GUM, MNP, PRI, VIR). + CAN requires the Canadian rates entitlement on the account; territories + are looked up via the USA path and need no extra entitlement. + Args: country_code: Country code to validate Raises: ZipTaxValidationError: If country code is invalid """ - valid_codes = ["USA", "CAN"] + valid_codes = ["USA", "CAN", "PRI", "ASM", "GUM", "MNP", "VIR"] if country_code not in valid_codes: raise ZipTaxValidationError( @@ -73,6 +77,39 @@ def validate_country_code(country_code: str) -> None: ) +def validate_adjustment(adjustment: str) -> None: + """Validate the sourcing adjustment parameter. + + Args: + adjustment: Adjustment mode to validate + + Raises: + ZipTaxValidationError: If adjustment is invalid + """ + valid_adjustments = ["auto", "origin", "destination"] + + if adjustment not in valid_adjustments: + raise ZipTaxValidationError( + f"Adjustment must be one of {valid_adjustments}, got: {adjustment}" + ) + + +def validate_merchant_id(merchant_id: str) -> None: + """Validate a merchant UUID parameter. + + Args: + merchant_id: Merchant UUID to validate + + Raises: + ZipTaxValidationError: If merchant_id is missing or not a string + """ + if not isinstance(merchant_id, str): + raise ZipTaxValidationError("Merchant ID must be a string") + + if not merchant_id or not merchant_id.strip(): + raise ZipTaxValidationError("Merchant ID cannot be empty") + + def validate_historical_date(historical: str) -> None: """Validate historical date parameter. diff --git a/tests/test_functions.py b/tests/test_functions.py index a98f088..75c8bec 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -1,5 +1,7 @@ """Tests for API functions.""" +import warnings + import pytest from pydantic import ValidationError @@ -13,6 +15,7 @@ CartLineItem, CreateOrderFromCartRequest, CreateOrderRequest, + MerchantGetOrderRequest, OrderResponse, ProductCodeRecommendationResponse, ProductCodeSearchResponse, @@ -1879,3 +1882,434 @@ def test_camel_case_alias_accepted(self): ) assert request.cart_id == "ce4a1234-5678-90ab-cdef-1234567890ab" assert request.order_id == "my-order-1" + + +class TestTaxCloudDirectDeprecation: + """The direct-to-TaxCloud functions warn but keep working.""" + + @pytest.mark.parametrize( + "name,replacement", + [ + ("CreateOrder", "MerchantCreateOrder"), + ("GetOrder", "MerchantGetOrder"), + ("UpdateOrder", "MerchantUpdateOrder"), + ("RefundOrder", "MerchantCreateRefund"), + ("CreateOrderFromCart", "MerchantCreateOrderFromCart"), + ], + ) + def test_replacement_exists(self, mock_http_client, mock_config, name, replacement): + """Every deprecated function names a real merchant-layer replacement.""" + functions = Functions(mock_http_client, mock_config) + + assert hasattr(functions, name) + assert hasattr(functions, replacement) + + def test_get_order_warns( + self, + mock_http_client, + mock_taxcloud_http_client, + mock_taxcloud_config, + sample_order_response, + ): + """GetOrder emits a DeprecationWarning naming its replacement.""" + mock_taxcloud_http_client.get.return_value = sample_order_response + functions = Functions( + mock_http_client, mock_taxcloud_config, mock_taxcloud_http_client + ) + + with pytest.warns(DeprecationWarning, match="MerchantGetOrder"): + response = functions.GetOrder("test-order-1") + + assert isinstance(response, OrderResponse) + + def test_warning_does_not_fire_before_config_check( + self, mock_http_client, mock_config + ): + """A missing-credentials error still raises rather than only warning.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxCloudConfigError): + functions.GetOrder("test-order-1") + + def test_merchant_functions_do_not_warn(self, mock_http_client, mock_config): + """The merchant-layer replacements are not deprecated.""" + mock_http_client.post.return_value = { + "orderId": "o1", + "connectionId": "c1", + } + functions = Functions(mock_http_client, mock_config) + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + functions.MerchantGetOrder( + MerchantGetOrderRequest(merchant_id="m-1", order_id="o1") + ) + + +class TestV60RequestParameters: + """Test cases for the v6.0 query parameters added in 0.3.0.""" + + def test_extended_flags_sent( + self, mock_http_client, mock_config, sample_v60_response + ): + """The extended-detail flags are sent as string booleans.""" + mock_http_client.get.return_value = sample_v60_response + functions = Functions(mock_http_client, mock_config) + + functions.GetSalesTaxByAddress( + "200 Spectrum Center Drive, Irvine, CA 92618", + address_detail_extended=True, + shipping_extended=True, + adjustment="destination", + city="Irvine", + state="CA", + sat_item_total=1600.0, + ) + + params = mock_http_client.get.call_args[1]["params"] + assert params["addressDetailExtended"] == "true" + assert params["shippingExtended"] == "true" + assert params["adjustment"] == "destination" + assert params["city"] == "Irvine" + assert params["state"] == "CA" + assert params["sat_item_total"] == 1600.0 + + def test_flags_omitted_by_default( + self, mock_http_client, mock_config, sample_v60_response + ): + """Unset optional parameters are not sent.""" + mock_http_client.get.return_value = sample_v60_response + functions = Functions(mock_http_client, mock_config) + + functions.GetSalesTaxByAddress("200 Spectrum Center Drive, Irvine, CA 92618") + + params = mock_http_client.get.call_args[1]["params"] + for key in ( + "addressDetailExtended", + "shippingExtended", + "adjustment", + "city", + "state", + "sat_item_total", + ): + assert key not in params + + def test_invalid_adjustment_rejected(self, mock_http_client, mock_config): + """An unknown adjustment mode is rejected before the request.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="Adjustment must be one of"): + functions.GetSalesTaxByAddress("200 Spectrum Center Dr", adjustment="both") + + mock_http_client.get.assert_not_called() + + @pytest.mark.parametrize("code", ["USA", "CAN", "PRI", "ASM", "GUM", "MNP", "VIR"]) + def test_territory_country_codes_accepted( + self, mock_http_client, mock_config, sample_v60_response, code + ): + """US territories and Canada are accepted country codes.""" + mock_http_client.get.return_value = sample_v60_response + functions = Functions(mock_http_client, mock_config) + + functions.GetSalesTaxByAddress("1 Main St", country_code=code) + + assert mock_http_client.get.call_args[1]["params"]["countryCode"] == code + + def test_geolocation_accepts_new_parameters( + self, mock_http_client, mock_config, sample_v60_response + ): + """GetSalesTaxByGeoLocation accepts taxability_code and the new flags.""" + mock_http_client.get.return_value = sample_v60_response + functions = Functions(mock_http_client, mock_config) + + functions.GetSalesTaxByGeoLocation( + lat="33.6595", + lng="-117.7422", + taxability_code="20010", + shipping_extended=True, + ) + + params = mock_http_client.get.call_args[1]["params"] + assert params["taxabilityCode"] == "20010" + assert params["shippingExtended"] == "true" + + def test_postal_code_accepts_new_parameters( + self, mock_http_client, mock_config, sample_postal_code_response + ): + """GetRatesByPostalCode accepts the narrowing and SAT parameters.""" + mock_http_client.get.return_value = sample_postal_code_response + functions = Functions(mock_http_client, mock_config) + + functions.GetRatesByPostalCode( + "37201", + state="TN", + city="Nashville", + county="Davidson", + historical="202401", + sat_item_total=1600.0, + ) + + params = mock_http_client.get.call_args[1]["params"] + assert params["state"] == "TN" + assert params["city"] == "Nashville" + assert params["county"] == "Davidson" + assert params["historical"] == "202401" + assert params["sat_item_total"] == 1600.0 + + def test_geolocation_all_new_parameters( + self, mock_http_client, mock_config, sample_v60_response + ): + """Every new geolocation parameter reaches the query string.""" + mock_http_client.get.return_value = sample_v60_response + functions = Functions(mock_http_client, mock_config) + + functions.GetSalesTaxByGeoLocation( + lat="36.1627", + lng="-86.7816", + adjustment="origin", + address_detail_extended=True, + shipping_extended=True, + sat_item_total=1600.0, + ) + + params = mock_http_client.get.call_args[1]["params"] + assert params["adjustment"] == "origin" + assert params["addressDetailExtended"] == "true" + assert params["shippingExtended"] == "true" + assert params["sat_item_total"] == 1600.0 + + def test_geolocation_invalid_adjustment(self, mock_http_client, mock_config): + """An unknown adjustment mode is rejected on the geolocation path too.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="Adjustment must be one of"): + functions.GetSalesTaxByGeoLocation( + lat="36.1627", lng="-86.7816", adjustment="nowhere" + ) + + def test_postal_code_invalid_historical(self, mock_http_client, mock_config): + """An invalid historical value is rejected before the request.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError): + functions.GetRatesByPostalCode("37201", historical="2024") + + +class TestV60ResponseAdditions: + """Test cases for the v6.0 response objects added in 0.3.0.""" + + def test_product_detail_parsed( + self, mock_http_client, mock_config, sample_v60_response + ): + """productDetail parses into the typed model when present.""" + payload = dict(sample_v60_response) + payload["productDetail"] = { + "taxabilityCode": { + "id": "20010", + "stateFIPS": "6", + "countyFIPS": "059", + "title": "Prescription drugs", + "label": "Medications dispensed pursuant to a prescription", + "rateActionCode": "T00", + "rateActionMessage": "Valid tic, rules listed.", + "rateRules": [ + { + "jurTaxCode": "06", + "effectiveDt": 20200101, + "expiresDt": None, + "effectiveTaxRate": 0.0, + "percentTaxable": None, + "exemptOver": None, + "exemptUnder": None, + "taxablePortionOver": None, + "rateCapPerUnit": None, + "perVolumeTaxRate": None, + "perVolumeUnit": None, + "isDestinationTaxType": True, + "isFoodDrug": True, + } + ], + } + } + mock_http_client.get.return_value = payload + functions = Functions(mock_http_client, mock_config) + + response = functions.GetSalesTaxByAddress("1 Main St", taxability_code="20010") + + code = response.product_detail.taxability_code + assert code.id == "20010" + assert code.rate_action_code == "T00" + assert code.rate_rules[0].effective_dt == 20200101 + assert code.rate_rules[0].is_food_drug is True + assert code.rate_rules[0].expires_dt is None + + def test_product_detail_absent_by_default( + self, mock_http_client, mock_config, sample_v60_response + ): + """A response without productDetail leaves the field None.""" + mock_http_client.get.return_value = sample_v60_response + functions = Functions(mock_http_client, mock_config) + + response = functions.GetSalesTaxByAddress("1 Main St") + + assert response.product_detail is None + + def test_address_components_parsed( + self, mock_http_client, mock_config, sample_v60_response + ): + """addressDetail.address parses when extended detail is requested.""" + payload = dict(sample_v60_response) + payload["addressDetail"] = dict(payload["addressDetail"]) + payload["addressDetail"]["address"] = { + "countryCode": "USA", + "countryName": "United States", + "stateCode": "CA", + "state": "California", + "county": "Orange", + "city": "Irvine", + "street": "Spectrum Center Dr", + "postalCode": "92618-4966", + "houseNumber": "200", + } + mock_http_client.get.return_value = payload + functions = Functions(mock_http_client, mock_config) + + response = functions.GetSalesTaxByAddress( + "200 Spectrum Center Dr", address_detail_extended=True + ) + + assert response.address_detail.address.city == "Irvine" + assert response.address_detail.address.postal_code == "92618-4966" + assert response.address_detail.address.house_number == "200" + + def test_shipping_extended_parsed( + self, mock_http_client, mock_config, sample_v60_response + ): + """shipping.shippingExtended parses when requested.""" + payload = dict(sample_v60_response) + payload["shipping"] = dict(payload["shipping"]) + payload["shipping"]["shippingExtended"] = { + "rule": "EXEMPT_WHEN_SEPARATELY_STATED", + "exemptWhenSeparatelyStated": "true", + "description": "Shipping is exempt when separately stated.", + "stateCode": "CA", + "stateName": "California", + } + mock_http_client.get.return_value = payload + functions = Functions(mock_http_client, mock_config) + + response = functions.GetSalesTaxByAddress( + "200 Spectrum Center Dr", shipping_extended=True + ) + + extended = response.shipping.shipping_extended + assert extended.rule == "EXEMPT_WHEN_SEPARATELY_STATED" + assert extended.exempt_when_separately_stated == "true" + assert extended.state_code == "CA" + + def test_sat_tax_detail_parsed( + self, mock_http_client, mock_config, sample_postal_code_response + ): + """satTaxDetail parses on the legacy-shaped postal code response.""" + payload = dict(sample_postal_code_response) + payload["satTaxDetail"] = { + "appliedTotal": "1600.00", + "countyTaxRate": "0.0225", + "localTaxLimit": "1600.00", + "localTaxTotal": "36.00", + "stateAdditionalTaxTotal": "44.75", + } + mock_http_client.get.return_value = payload + functions = Functions(mock_http_client, mock_config) + + response = functions.GetRatesByPostalCode("37201", sat_item_total=1600.0) + + assert response.sat_tax_detail.applied_total == "1600.00" + assert response.sat_tax_detail.local_tax_total == "36.00" + + def test_sat_tax_detail_absent_by_default( + self, mock_http_client, mock_config, sample_postal_code_response + ): + """A response without satTaxDetail leaves the field None.""" + mock_http_client.get.return_value = sample_postal_code_response + functions = Functions(mock_http_client, mock_config) + + response = functions.GetRatesByPostalCode("92694") + + assert response.sat_tax_detail is None + + +class TestProductCodeResponseAdditions: + """Test cases for the TIC search and recommend response fixes.""" + + def test_search_pagination_fields(self, mock_http_client, mock_config): + """nextCursor and $schema parse off the search envelope.""" + mock_http_client.post.return_value = { + "$schema": "https://api.zip-tax.com/schemas/ticsearch", + "nextCursor": "eyJvZmZzZXQiOjEwfQ==", + "query": "baked goods", + "results": [ + { + "ticId": 41030, + "label": "Bakery Items", + "naturalLabel": "Bakery Items", + "description": "Bakery items sold without utensils", + "documentation": "Long form documentation", + "rank": 1, + "score": 0.94, + } + ], + } + functions = Functions(mock_http_client, mock_config) + + response = functions.SearchProductCodes("baked goods") + + assert response.next_cursor == "eyJvZmZzZXQiOjEwfQ==" + assert response.schema_url == "https://api.zip-tax.com/schemas/ticsearch" + assert response.results[0].tic_id == 41030 + + def test_search_without_pagination_fields(self, mock_http_client, mock_config): + """A response omitting nextCursor and $schema still parses.""" + mock_http_client.post.return_value = {"query": "q", "results": []} + functions = Functions(mock_http_client, mock_config) + + response = functions.SearchProductCodes("q") + + assert response.next_cursor is None + assert response.schema_url is None + assert response.results == [] + + def test_recommend_failed_prediction(self, mock_http_client, mock_config): + """A failed prediction returns nulls without raising.""" + mock_http_client.post.return_value = { + "predictions": [ + { + "status": "fail", + "error": "400 - could not classify", + "ticId": None, + "label": None, + "naturalLabel": None, + "tic_description": None, + "product_description": None, + } + ] + } + functions = Functions(mock_http_client, mock_config) + + response = functions.RecommendProductCode("unclassifiable widget") + + prediction = response.predictions[0] + assert prediction.status == "fail" + assert prediction.error == "400 - could not classify" + assert prediction.tic_id is None + assert prediction.label is None + + def test_recommend_status_only(self, mock_http_client, mock_config): + """Only status is required on a prediction.""" + mock_http_client.post.return_value = {"predictions": [{"status": "fail"}]} + functions = Functions(mock_http_client, mock_config) + + response = functions.RecommendProductCode("x") + + assert response.predictions[0].status == "fail" + assert response.predictions[0].error is None diff --git a/tests/test_merchant.py b/tests/test_merchant.py new file mode 100644 index 0000000..529495a --- /dev/null +++ b/tests/test_merchant.py @@ -0,0 +1,1219 @@ +"""Tests for the merchant, TIC data, and system API functions.""" + +import pytest +from pydantic import ValidationError + +from ziptax.exceptions import ZipTaxValidationError +from ziptax.models import ( + AccountMetrics, + CreateExemptionCertificateRequest, + CreateMerchantRequest, + DeleteExemptionCertificateRequest, + ExemptionCertificateBusinessType, + ExemptionCertificateListResponse, + ExemptionCertificateReason, + ExemptionCertificateResponse, + ExemptState, + GetExemptionCertificateRequest, + HealthResponse, + ListExemptionCertificatesRequest, + MerchantAddress, + MerchantCalculateCartRequest, + MerchantCalculateCartResponse, + MerchantCart, + MerchantCartLineItem, + MerchantCreateOrderFromCartRequest, + MerchantCreateOrderRequest, + MerchantCreateRefundRequest, + MerchantCurrency, + MerchantGetOrderRequest, + MerchantMutationResponse, + MerchantOrderLineItem, + MerchantOrderResponse, + MerchantRefundItem, + MerchantRefundResponse, + MerchantResponse, + MerchantTax, + MerchantType, + MerchantUpdateFields, + MerchantUpdateOrderRequest, + SetMerchantCredentialsRequest, + SystemMetadataResponse, + TicDataResponse, + UpdateMerchantRequest, +) +from ziptax.resources.functions import Functions + +MERCHANT_ID = "9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13" + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def merchant_mutation_response(): + """Standard merchant mutation response body.""" + return { + "merchantId": MERCHANT_ID, + "message": "Merchant created successfully", + "status": "success", + } + + +@pytest.fixture +def merchant_record(): + """A single merchant record.""" + return { + "merchantId": MERCHANT_ID, + "merchantName": "Acme Supply Co", + "status": "taxcloud_connected", + "contactFirst": "Dana", + "contactLast": "Reyes", + "contactEmail": "ops@acme.example", + "referenceId": "acct-1042", + } + + +@pytest.fixture +def sample_cart_request(): + """A one-cart MerchantCalculateCartRequest.""" + return MerchantCalculateCartRequest( + merchant_id=MERCHANT_ID, + 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 + ), + ], + ) + ], + ) + + +@pytest.fixture +def taxcloud_cart_response(): + """Cart response for a TaxCloud-connected merchant.""" + return { + "connectionId": "25eb9b97-0000-0000-0000-000000000000", + "transactionDate": "2026-08-04T14:00:00Z", + "items": [ + { + "cartId": "my-cart-1", + "customerId": "customer-453", + "currency": {"currencyCode": "USD"}, + "deliveredBySeller": False, + "exemption": {"isExempt": False}, + "origin": { + "line1": "1600 Amphitheatre Pkwy", + "city": "Mountain View", + "state": "CA", + "zip": "94043", + "countryCode": "US", + }, + "destination": { + "line1": "350 5th Ave", + "city": "New York", + "state": "NY", + "zip": "10118", + "countryCode": "US", + }, + "lineItems": [ + { + "index": 0, + "itemId": "sku-1", + "tic": 0, + "price": 10.75, + "originalPrice": 10.75, + "quantity": 1.5, + "tax": {"rate": 0.08875, "amount": 1.43109}, + } + ], + } + ], + } + + +@pytest.fixture +def self_managed_cart_response(): + """Cart response for a self-managed merchant. + + The self-managed path omits connectionId, transactionDate, + deliveredBySeller, and exemption. + """ + return { + "items": [ + { + "cartId": "my-cart-1", + "customerId": "customer-453", + "currency": {"currencyCode": "USD"}, + "origin": { + "line1": "1600 Amphitheatre Pkwy", + "city": "Mountain View", + "state": "CA", + "zip": "94043", + "countryCode": "US", + }, + "destination": { + "line1": "350 5th Ave", + "city": "New York", + "state": "NY", + "zip": "10118", + "countryCode": "US", + }, + "lineItems": [ + { + "index": 0, + "itemId": "sku-1", + "originalPrice": 10.75, + "price": 10.75, + "quantity": 1.5, + "tax": {"rate": 0.08875, "amount": 1.43109}, + } + ], + } + ] + } + + +@pytest.fixture +def merchant_order_response(): + """A merchant order response body.""" + return { + "orderId": "my-order-1", + "connectionId": "25eb9b97-0000-0000-0000-000000000000", + "customerId": "customer-453", + "channel": None, + "kind": "order", + "currency": {"currencyCode": "USD"}, + "deliveredBySeller": False, + "excludeFromFiling": False, + "exemption": {"isExempt": False}, + "transactionDate": "2026-08-04T14:00:00Z", + "completedDate": "2026-08-05T09:00:00Z", + "origin": { + "line1": "1600 Amphitheatre Pkwy", + "city": "Mountain View", + "state": "CA", + "zip": "94043", + "countryCode": "US", + }, + "destination": { + "line1": "350 5th Ave", + "city": "New York", + "state": "NY", + "zip": "10118", + "countryCode": "US", + }, + "lineItems": [ + { + "index": 0, + "itemId": "sku-1", + "originalPrice": 10.75, + "price": 10.75, + "quantity": 1.5, + "tax": {"rate": 0.08875, "amount": 1.43109}, + "tic": 0, + } + ], + } + + +@pytest.fixture +def exemption_certificate_response(): + """An exemption certificate response body.""" + return { + "accountId": 12345, + "certificateId": "cert-abc-123", + "connectionId": "25eb9b97-0000-0000-0000-000000000000", + "createdDate": "2026-08-04T14:00:00Z", + "customerId": "customer-453", + "customerName": "Acme Resale LLC", + "customerBusinessType": "RetailTrade", + "reason": "Resale", + "reasonDescription": "Resale", + "singlePurchase": False, + "disabledAt": None, + "address": { + "line1": "350 5th Ave", + "city": "New York", + "state": "NY", + "zip": "10118", + "countryCode": "US", + }, + "states": [{"abbreviation": "NY"}], + } + + +# ============================================================================= +# Merchant Management +# ============================================================================= + + +class TestCreateMerchant: + """Test cases for CreateMerchant.""" + + def test_basic_request( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """Creating a merchant returns the new merchant ID.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + result = functions.CreateMerchant( + CreateMerchantRequest(merchant_name="Acme Supply Co") + ) + + assert isinstance(result, MerchantMutationResponse) + assert result.merchant_id == MERCHANT_ID + assert result.status == "success" + + def test_uses_correct_path( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """CreateMerchant posts to /merchant/create.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + functions.CreateMerchant(CreateMerchantRequest(merchant_name="Acme")) + + assert mock_http_client.post.call_args[0][0] == "/merchant/create" + + def test_self_managed_serialization( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """merchant_type serializes to the snake_case API field.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + functions.CreateMerchant( + CreateMerchantRequest( + merchant_name="Acme Supply Co", + contact_email="ops@acme.example", + reference_id="acct-1042", + merchant_type=MerchantType.SELF_MANAGED, + ) + ) + + body = mock_http_client.post.call_args[1]["json"] + assert body["merchantName"] == "Acme Supply Co" + assert body["contactEmail"] == "ops@acme.example" + assert body["referenceId"] == "acct-1042" + assert body["merchant_type"] == "self-managed" + + def test_empty_name_rejected(self): + """An empty merchant name fails model validation.""" + with pytest.raises(ValidationError): + CreateMerchantRequest(merchant_name="") + + def test_optional_fields_omitted( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """Unset optional fields are excluded from the request body.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + functions.CreateMerchant(CreateMerchantRequest(merchant_name="Acme")) + + body = mock_http_client.post.call_args[1]["json"] + assert body == {"merchantName": "Acme"} + + +class TestUpdateMerchant: + """Test cases for UpdateMerchant.""" + + def test_basic_request( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """Updating a merchant nests the fields under 'update'.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + result = functions.UpdateMerchant( + UpdateMerchantRequest( + merchant_id=MERCHANT_ID, + update=MerchantUpdateFields(merchant_name="Acme Supply Company"), + ) + ) + + assert isinstance(result, MerchantMutationResponse) + assert mock_http_client.post.call_args[0][0] == "/merchant/update" + body = mock_http_client.post.call_args[1]["json"] + assert body["merchantId"] == MERCHANT_ID + assert body["update"]["merchantName"] == "Acme Supply Company" + + def test_empty_merchant_id_rejected(self): + """An empty merchant_id fails model validation.""" + with pytest.raises(ValidationError): + UpdateMerchantRequest( + merchant_id="", update=MerchantUpdateFields(merchant_name="Acme") + ) + + +class TestGetAndDeleteMerchant: + """Test cases for GetMerchant and DeleteMerchant.""" + + def test_get_merchant(self, mock_http_client, mock_config, merchant_record): + """GetMerchant posts the merchant ID and parses the record.""" + mock_http_client.post.return_value = merchant_record + functions = Functions(mock_http_client, mock_config) + + merchant = functions.GetMerchant(MERCHANT_ID) + + assert isinstance(merchant, MerchantResponse) + assert merchant.merchant_id == MERCHANT_ID + assert merchant.merchant_name == "Acme Supply Co" + assert merchant.status == "taxcloud_connected" + assert merchant.reference_id == "acct-1042" + assert mock_http_client.post.call_args[0][0] == "/merchant/get" + assert mock_http_client.post.call_args[1]["json"] == {"merchantId": MERCHANT_ID} + + def test_delete_merchant( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """DeleteMerchant posts to /merchant/delete.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + result = functions.DeleteMerchant(MERCHANT_ID) + + assert isinstance(result, MerchantMutationResponse) + assert mock_http_client.post.call_args[0][0] == "/merchant/delete" + + @pytest.mark.parametrize("bad_id", ["", " "]) + def test_empty_merchant_id_validation(self, mock_http_client, mock_config, bad_id): + """Blank merchant IDs are rejected before the request is made.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="Merchant ID cannot be empty"): + functions.GetMerchant(bad_id) + + mock_http_client.post.assert_not_called() + + def test_non_string_merchant_id_validation(self, mock_http_client, mock_config): + """A non-string merchant ID is rejected.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="must be a string"): + functions.DeleteMerchant(12345) + + +class TestListMerchants: + """Test cases for ListMerchants.""" + + def test_basic_request(self, mock_http_client, mock_config, merchant_record): + """ListMerchants parses the bare array response.""" + mock_http_client.get.return_value = [merchant_record, merchant_record] + functions = Functions(mock_http_client, mock_config) + + merchants = functions.ListMerchants() + + assert len(merchants) == 2 + assert all(isinstance(m, MerchantResponse) for m in merchants) + assert mock_http_client.get.call_args[0][0] == "/merchant/list" + + def test_empty_list(self, mock_http_client, mock_config): + """An empty response yields an empty list.""" + mock_http_client.get.return_value = [] + functions = Functions(mock_http_client, mock_config) + + assert functions.ListMerchants() == [] + + def test_null_body(self, mock_http_client, mock_config): + """A null response body yields an empty list rather than raising.""" + mock_http_client.get.return_value = None + functions = Functions(mock_http_client, mock_config) + + assert functions.ListMerchants() == [] + + def test_minimal_record(self, mock_http_client, mock_config): + """Only merchantId, merchantName, and status are required.""" + mock_http_client.get.return_value = [ + { + "merchantId": MERCHANT_ID, + "merchantName": "Acme", + "status": "external_compliance", + } + ] + functions = Functions(mock_http_client, mock_config) + + merchant = functions.ListMerchants()[0] + + assert merchant.contact_email is None + assert merchant.status == "external_compliance" + + +class TestMerchantCredentials: + """Test cases for the merchant credential functions.""" + + def test_set_credentials( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """SetMerchantCredentials posts the connection ID and API key.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + result = functions.SetMerchantCredentials( + SetMerchantCredentialsRequest( + merchant_id=MERCHANT_ID, + connection_id="conn-uuid", + api_key="taxcloud-key", + ) + ) + + assert isinstance(result, MerchantMutationResponse) + assert mock_http_client.post.call_args[0][0] == "/merchant/credentials/set" + body = mock_http_client.post.call_args[1]["json"] + assert body["connectionId"] == "conn-uuid" + assert body["apiKey"] == "taxcloud-key" + + def test_delete_credentials( + self, mock_http_client, mock_config, merchant_mutation_response + ): + """DeleteMerchantCredentials posts only the merchant ID.""" + mock_http_client.post.return_value = merchant_mutation_response + functions = Functions(mock_http_client, mock_config) + + functions.DeleteMerchantCredentials(MERCHANT_ID) + + assert mock_http_client.post.call_args[0][0] == "/merchant/credentials/delete" + assert mock_http_client.post.call_args[1]["json"] == {"merchantId": MERCHANT_ID} + + def test_no_get_credentials_function(self, mock_http_client, mock_config): + """The undocumented credentials/get endpoint is not exposed.""" + functions = Functions(mock_http_client, mock_config) + + assert not hasattr(functions, "GetMerchantCredentials") + + +# ============================================================================= +# Merchant Cart Calculation +# ============================================================================= + + +class TestMerchantCalculateCart: + """Test cases for MerchantCalculateCart.""" + + def test_uses_correct_path( + self, + mock_http_client, + mock_config, + sample_cart_request, + taxcloud_cart_response, + ): + """MerchantCalculateCart posts to /merchant/cart/calculate.""" + mock_http_client.post.return_value = taxcloud_cart_response + functions = Functions(mock_http_client, mock_config) + + functions.MerchantCalculateCart(sample_cart_request) + + assert mock_http_client.post.call_args[0][0] == "/merchant/cart/calculate" + + def test_request_serialization( + self, + mock_http_client, + mock_config, + sample_cart_request, + taxcloud_cart_response, + ): + """The request body uses camelCase aliases and nests line items.""" + mock_http_client.post.return_value = taxcloud_cart_response + functions = Functions(mock_http_client, mock_config) + + functions.MerchantCalculateCart(sample_cart_request) + + body = mock_http_client.post.call_args[1]["json"] + assert body["merchantId"] == MERCHANT_ID + cart = body["items"][0] + assert cart["customerId"] == "customer-453" + assert cart["cartId"] == "my-cart-1" + assert cart["currency"] == {"currencyCode": "USD"} + assert cart["origin"]["countryCode"] == "US" + assert cart["lineItems"][0] == { + "index": 0, + "itemId": "sku-1", + "price": 10.75, + "quantity": 1.5, + "tic": 0, + } + + def test_no_credentials_in_body( + self, + mock_http_client, + mock_config, + sample_cart_request, + taxcloud_cart_response, + ): + """Reserved credential keys are never sent; the API rejects them.""" + mock_http_client.post.return_value = taxcloud_cart_response + functions = Functions(mock_http_client, mock_config) + + functions.MerchantCalculateCart(sample_cart_request) + + body = mock_http_client.post.call_args[1]["json"] + lowered = {k.lower() for k in body} + assert "apikey" not in lowered + assert "connectionid" not in lowered + assert "xapikey" not in lowered + + def test_taxcloud_response_parsed( + self, + mock_http_client, + mock_config, + sample_cart_request, + taxcloud_cart_response, + ): + """The TaxCloud-connected response shape parses fully.""" + mock_http_client.post.return_value = taxcloud_cart_response + functions = Functions(mock_http_client, mock_config) + + result = functions.MerchantCalculateCart(sample_cart_request) + + assert isinstance(result, MerchantCalculateCartResponse) + assert result.connection_id == "25eb9b97-0000-0000-0000-000000000000" + assert result.transaction_date == "2026-08-04T14:00:00Z" + cart = result.items[0] + assert cart.cart_id == "my-cart-1" + assert cart.delivered_by_seller is False + assert cart.line_items[0].tax.rate == 0.08875 + assert cart.line_items[0].tax.amount == 1.43109 + assert cart.line_items[0].original_price == 10.75 + + def test_self_managed_response_parsed( + self, + mock_http_client, + mock_config, + sample_cart_request, + self_managed_cart_response, + ): + """The self-managed response omits connectionId and still parses.""" + mock_http_client.post.return_value = self_managed_cart_response + functions = Functions(mock_http_client, mock_config) + + result = functions.MerchantCalculateCart(sample_cart_request) + + assert isinstance(result, MerchantCalculateCartResponse) + assert result.connection_id is None + assert result.transaction_date is None + cart = result.items[0] + assert cart.delivered_by_seller is None + assert cart.exemption is None + assert cart.line_items[0].tax.amount == 1.43109 + + def test_transaction_date_forwarded( + self, mock_http_client, mock_config, taxcloud_cart_response + ): + """An explicit transaction date is forwarded.""" + mock_http_client.post.return_value = taxcloud_cart_response + functions = Functions(mock_http_client, mock_config) + + functions.MerchantCalculateCart( + MerchantCalculateCartRequest( + merchant_id=MERCHANT_ID, + transaction_date="2026-01-15T09:30:00Z", + items=[ + MerchantCart( + customer_id="c1", + currency=MerchantCurrency(currency_code="USD"), + origin=MerchantAddress( + line1="a", city="b", state="CA", zip="94043" + ), + destination=MerchantAddress( + line1="c", city="d", state="NY", zip="10118" + ), + line_items=[ + MerchantCartLineItem( + index=0, item_id="s", price=1.0, quantity=1.0 + ) + ], + ) + ], + ) + ) + + body = mock_http_client.post.call_args[1]["json"] + assert body["transactionDate"] == "2026-01-15T09:30:00Z" + + def test_empty_items_rejected(self): + """At least one cart is required.""" + with pytest.raises(ValidationError): + MerchantCalculateCartRequest(merchant_id=MERCHANT_ID, items=[]) + + def test_empty_line_items_rejected(self): + """At least one line item is required per cart.""" + with pytest.raises(ValidationError): + MerchantCart( + customer_id="c1", + currency=MerchantCurrency(currency_code="USD"), + origin=MerchantAddress(line1="a", city="b", state="CA", zip="94043"), + destination=MerchantAddress( + line1="c", city="d", state="NY", zip="10118" + ), + line_items=[], + ) + + def test_negative_index_rejected(self): + """Line item index must be zero-based and non-negative.""" + with pytest.raises(ValidationError): + MerchantCartLineItem(index=-1, item_id="s", price=1.0, quantity=1.0) + + def test_invalid_currency_rejected(self): + """Only USD and CAD are accepted.""" + with pytest.raises(ValidationError): + MerchantCurrency(currency_code="GBP") + + def test_invalid_country_code_rejected(self): + """Only US and CA are accepted on merchant addresses.""" + with pytest.raises(ValidationError): + MerchantAddress( + line1="a", city="b", state="CA", zip="94043", country_code="GB" + ) + + +# ============================================================================= +# Merchant Orders +# ============================================================================= + + +class TestMerchantOrders: + """Test cases for the merchant order functions.""" + + def test_create_order(self, mock_http_client, mock_config, merchant_order_response): + """MerchantCreateOrder posts to /merchant/order/create.""" + mock_http_client.post.return_value = merchant_order_response + functions = Functions(mock_http_client, mock_config) + + order = functions.MerchantCreateOrder( + MerchantCreateOrderRequest( + merchant_id=MERCHANT_ID, + order_id="my-order-1", + customer_id="customer-453", + transaction_date="2026-08-04T14:00:00Z", + completed_date="2026-08-05T09:00:00Z", + 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" + ), + currency=MerchantCurrency(currency_code="USD"), + line_items=[ + MerchantOrderLineItem( + index=0, + item_id="sku-1", + price=10.75, + quantity=1.5, + tax=MerchantTax(amount=1.43109, rate=0.08875), + ) + ], + ) + ) + + assert isinstance(order, MerchantOrderResponse) + assert order.order_id == "my-order-1" + assert mock_http_client.post.call_args[0][0] == "/merchant/order/create" + body = mock_http_client.post.call_args[1]["json"] + assert body["lineItems"][0]["tax"] == {"amount": 1.43109, "rate": 0.08875} + + def test_create_order_from_cart( + self, mock_http_client, mock_config, merchant_order_response + ): + """MerchantCreateOrderFromCart posts cartId and orderId.""" + mock_http_client.post.return_value = merchant_order_response + functions = Functions(mock_http_client, mock_config) + + order = functions.MerchantCreateOrderFromCart( + MerchantCreateOrderFromCartRequest( + merchant_id=MERCHANT_ID, + cart_id="my-cart-1", + order_id="my-order-1", + completed=True, + ) + ) + + assert isinstance(order, MerchantOrderResponse) + assert ( + mock_http_client.post.call_args[0][0] == "/merchant/order/create-from-cart" + ) + body = mock_http_client.post.call_args[1]["json"] + assert body["cartId"] == "my-cart-1" + assert body["orderId"] == "my-order-1" + assert body["completed"] is True + + def test_create_order_from_cart_requires_ids(self): + """Blank cart_id or order_id fails model validation.""" + with pytest.raises(ValidationError): + MerchantCreateOrderFromCartRequest( + merchant_id=MERCHANT_ID, cart_id="", order_id="my-order-1" + ) + with pytest.raises(ValidationError): + MerchantCreateOrderFromCartRequest( + merchant_id=MERCHANT_ID, cart_id="my-cart-1", order_id="" + ) + + def test_get_order(self, mock_http_client, mock_config, merchant_order_response): + """MerchantGetOrder posts to /merchant/order/get.""" + mock_http_client.post.return_value = merchant_order_response + functions = Functions(mock_http_client, mock_config) + + order = functions.MerchantGetOrder( + MerchantGetOrderRequest(merchant_id=MERCHANT_ID, order_id="my-order-1") + ) + + assert isinstance(order, MerchantOrderResponse) + assert order.line_items[0].tax.amount == 1.43109 + assert mock_http_client.post.call_args[0][0] == "/merchant/order/get" + + def test_get_order_with_expand( + self, mock_http_client, mock_config, merchant_order_response + ): + """expand='refunds' is forwarded and refunds parse.""" + with_refunds = dict(merchant_order_response) + with_refunds["refunds"] = [ + { + "connectionId": "25eb9b97-0000-0000-0000-000000000000", + "createdDate": "2026-08-06T09:00:00Z", + "items": [ + { + "index": 0, + "itemId": "sku-1", + "price": 10.75, + "quantity": 1.0, + "tax": {"amount": 0.954}, + "tic": 0, + } + ], + } + ] + mock_http_client.post.return_value = with_refunds + functions = Functions(mock_http_client, mock_config) + + order = functions.MerchantGetOrder( + MerchantGetOrderRequest( + merchant_id=MERCHANT_ID, order_id="my-order-1", expand="refunds" + ) + ) + + assert mock_http_client.post.call_args[1]["json"]["expand"] == "refunds" + assert order.refunds[0].items[0].tax.amount == 0.954 + + def test_invalid_expand_rejected(self): + """Only 'refunds' is a valid expand value.""" + with pytest.raises(ValidationError): + MerchantGetOrderRequest( + merchant_id=MERCHANT_ID, order_id="o", expand="everything" + ) + + def test_update_order(self, mock_http_client, mock_config, merchant_order_response): + """MerchantUpdateOrder posts the new completed date.""" + mock_http_client.post.return_value = merchant_order_response + functions = Functions(mock_http_client, mock_config) + + functions.MerchantUpdateOrder( + MerchantUpdateOrderRequest( + merchant_id=MERCHANT_ID, + order_id="my-order-1", + completed_date="2026-08-06T10:00:00Z", + ) + ) + + assert mock_http_client.post.call_args[0][0] == "/merchant/order/update" + body = mock_http_client.post.call_args[1]["json"] + assert body["completedDate"] == "2026-08-06T10:00:00Z" + + def test_order_response_minimal(self, mock_http_client, mock_config): + """Only orderId and connectionId are required on the response.""" + mock_http_client.post.return_value = { + "orderId": "o1", + "connectionId": "c1", + } + functions = Functions(mock_http_client, mock_config) + + order = functions.MerchantGetOrder( + MerchantGetOrderRequest(merchant_id=MERCHANT_ID, order_id="o1") + ) + + assert order.order_id == "o1" + assert order.line_items is None + + +class TestMerchantRefunds: + """Test cases for MerchantCreateRefund.""" + + def test_full_refund(self, mock_http_client, mock_config): + """Omitting items requests a full refund.""" + mock_http_client.post.return_value = { + "connectionId": "c1", + "createdDate": "2026-08-06T09:00:00Z", + "items": [], + } + functions = Functions(mock_http_client, mock_config) + + refund = functions.MerchantCreateRefund( + MerchantCreateRefundRequest(merchant_id=MERCHANT_ID, order_id="my-order-1") + ) + + assert isinstance(refund, MerchantRefundResponse) + assert mock_http_client.post.call_args[0][0] == "/merchant/refund/create" + body = mock_http_client.post.call_args[1]["json"] + assert "items" not in body + + def test_partial_refund(self, mock_http_client, mock_config): + """Supplying items scopes a partial refund.""" + mock_http_client.post.return_value = { + "connectionId": "c1", + "items": [ + { + "index": 0, + "itemId": "sku-1", + "price": 10.75, + "quantity": 1.0, + "tax": {"amount": 0.954}, + } + ], + } + functions = Functions(mock_http_client, mock_config) + + refund = functions.MerchantCreateRefund( + MerchantCreateRefundRequest( + merchant_id=MERCHANT_ID, + order_id="my-order-1", + items=[MerchantRefundItem(item_id="sku-1", quantity=1.0)], + ) + ) + + body = mock_http_client.post.call_args[1]["json"] + assert body["items"] == [{"itemId": "sku-1", "quantity": 1.0}] + assert refund.items[0].tax.amount == 0.954 + + def test_refund_without_tax(self, mock_http_client, mock_config): + """A refund item without a tax block still parses.""" + mock_http_client.post.return_value = { + "connectionId": "c1", + "items": [{"index": 0, "itemId": "sku-1", "price": 10.75, "quantity": 1.0}], + } + functions = Functions(mock_http_client, mock_config) + + refund = functions.MerchantCreateRefund( + MerchantCreateRefundRequest(merchant_id=MERCHANT_ID, order_id="o1") + ) + + assert refund.items[0].tax is None + + +# ============================================================================= +# Exemption Certificates +# ============================================================================= + + +class TestExemptionCertificates: + """Test cases for the exemption certificate functions.""" + + def _request(self): + return CreateExemptionCertificateRequest( + merchant_id=MERCHANT_ID, + 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")], + ) + + def test_create( + self, mock_http_client, mock_config, exemption_certificate_response + ): + """CreateExemptionCertificate posts enum values as strings.""" + mock_http_client.post.return_value = exemption_certificate_response + functions = Functions(mock_http_client, mock_config) + + cert = functions.CreateExemptionCertificate(self._request()) + + assert isinstance(cert, ExemptionCertificateResponse) + assert cert.certificate_id == "cert-abc-123" + assert mock_http_client.post.call_args[0][0] == "/merchant/cert/create" + body = mock_http_client.post.call_args[1]["json"] + assert body["customerBusinessType"] == "RetailTrade" + assert body["reason"] == "Resale" + assert body["states"] == [{"abbreviation": "NY"}] + + def test_reason_description_length_limit(self): + """reason_description is capped at 20 characters.""" + with pytest.raises(ValidationError): + CreateExemptionCertificateRequest( + merchant_id=MERCHANT_ID, + customer_id="c", + customer_name="n", + customer_business_type=(ExemptionCertificateBusinessType.RETAIL_TRADE), + reason=ExemptionCertificateReason.RESALE, + reason_description="x" * 21, + address=MerchantAddress(line1="a", city="b", state="NY", zip="10118"), + states=[ExemptState(abbreviation="NY")], + ) + + def test_invalid_reason_rejected(self): + """An unknown exemption reason fails model validation.""" + with pytest.raises(ValidationError): + CreateExemptionCertificateRequest( + merchant_id=MERCHANT_ID, + customer_id="c", + customer_name="n", + customer_business_type=(ExemptionCertificateBusinessType.RETAIL_TRADE), + reason="NotAReason", + reason_description="x", + address=MerchantAddress(line1="a", city="b", state="NY", zip="10118"), + states=[ExemptState(abbreviation="NY")], + ) + + def test_get(self, mock_http_client, mock_config, exemption_certificate_response): + """GetExemptionCertificate posts to /merchant/cert/get.""" + mock_http_client.post.return_value = exemption_certificate_response + functions = Functions(mock_http_client, mock_config) + + cert = functions.GetExemptionCertificate( + GetExemptionCertificateRequest( + merchant_id=MERCHANT_ID, certificate_id="cert-abc-123" + ) + ) + + assert cert.customer_name == "Acme Resale LLC" + assert cert.disabled_at is None + assert mock_http_client.post.call_args[0][0] == "/merchant/cert/get" + + def test_list(self, mock_http_client, mock_config, exemption_certificate_response): + """ListExemptionCertificates forwards pagination fields.""" + mock_http_client.post.return_value = { + "items": [exemption_certificate_response], + "limit": 20, + "nextCursor": "cursor-2", + } + functions = Functions(mock_http_client, mock_config) + + page = functions.ListExemptionCertificates( + ListExemptionCertificatesRequest( + merchant_id=MERCHANT_ID, + limit=20, + cursor="cursor-1", + ascending=True, + sort_by="createdDate", + customer_id="customer-453", + disabled=False, + ) + ) + + assert isinstance(page, ExemptionCertificateListResponse) + assert page.next_cursor == "cursor-2" + assert len(page.items) == 1 + assert mock_http_client.post.call_args[0][0] == "/merchant/cert/list" + body = mock_http_client.post.call_args[1]["json"] + assert body["limit"] == 20 + assert body["cursor"] == "cursor-1" + assert body["ascending"] is True + assert body["sortBy"] == "createdDate" + assert body["customerId"] == "customer-453" + assert body["disabled"] is False + + def test_list_limit_bounds(self): + """limit must be between 1 and 100.""" + with pytest.raises(ValidationError): + ListExemptionCertificatesRequest(merchant_id=MERCHANT_ID, limit=0) + with pytest.raises(ValidationError): + ListExemptionCertificatesRequest(merchant_id=MERCHANT_ID, limit=101) + + def test_delete(self, mock_http_client, mock_config): + """DeleteExemptionCertificate returns the raw passthrough body.""" + mock_http_client.post.return_value = {"deleted": True} + functions = Functions(mock_http_client, mock_config) + + result = functions.DeleteExemptionCertificate( + DeleteExemptionCertificateRequest( + merchant_id=MERCHANT_ID, certificate_id="cert-abc-123" + ) + ) + + assert result == {"deleted": True} + assert mock_http_client.post.call_args[0][0] == "/merchant/cert/delete" + + def test_delete_non_dict_body(self, mock_http_client, mock_config): + """A non-dict passthrough body is normalized to an empty dict.""" + mock_http_client.post.return_value = None + functions = Functions(mock_http_client, mock_config) + + result = functions.DeleteExemptionCertificate( + DeleteExemptionCertificateRequest( + merchant_id=MERCHANT_ID, certificate_id="cert-abc-123" + ) + ) + + assert result == {} + + +# ============================================================================= +# TIC Data and System +# ============================================================================= + + +class TestTicData: + """Test cases for GetTicData and GetTicSearchSchema.""" + + def test_get_tic_data(self, mock_http_client, mock_config): + """GetTicData parses the nested tic_list structure.""" + mock_http_client.get.return_value = { + "tic_list": [ + { + "tic": { + "id": "20010", + "title": "Computers", + "label": "Computers and peripherals", + "nl_title": "Computers", + "nl_label": "Computers and peripherals", + "parent": "20000", + } + } + ] + } + functions = Functions(mock_http_client, mock_config) + + data = functions.GetTicData() + + assert isinstance(data, TicDataResponse) + assert data.tic_list[0].tic.id == "20010" + assert data.tic_list[0].tic.parent == "20000" + assert mock_http_client.get.call_args[0][0] == "/data/tic" + + def test_get_tic_data_empty(self, mock_http_client, mock_config): + """A response with no tic_list yields an empty list.""" + mock_http_client.get.return_value = {} + functions = Functions(mock_http_client, mock_config) + + assert functions.GetTicData().tic_list == [] + + def test_get_tic_search_schema(self, mock_http_client, mock_config): + """GetTicSearchSchema returns the raw JSON Schema document.""" + schema = {"type": "object", "required": ["query", "results"]} + mock_http_client.get.return_value = schema + functions = Functions(mock_http_client, mock_config) + + assert functions.GetTicSearchSchema() == schema + assert mock_http_client.get.call_args[0][0] == "/schemas/ticsearch" + + +class TestSystemEndpoints: + """Test cases for the health, metadata, and account usage functions.""" + + def test_health(self, mock_http_client, mock_config): + """GetHealth parses status and component detail.""" + mock_http_client.get.return_value = { + "status": "ok", + "components": { + "dynamo": "ok", + "taxdata": "ok", + "taxdata_count": 42000, + }, + } + functions = Functions(mock_http_client, mock_config) + + health = functions.GetHealth() + + assert isinstance(health, HealthResponse) + assert health.status == "ok" + assert health.components.taxdata_count == 42000 + assert mock_http_client.get.call_args[0][0] == "/system/health" + + def test_system_metadata(self, mock_http_client, mock_config): + """GetSystemMetadata parses the build/host fields.""" + mock_http_client.get.return_value = { + "go_version": "go1.22.3", + "hostname": "api-7f9c", + } + functions = Functions(mock_http_client, mock_config) + + metadata = functions.GetSystemMetadata() + + assert isinstance(metadata, SystemMetadataResponse) + assert metadata.go_version == "go1.22.3" + assert mock_http_client.get.call_args[0][0] == "/system/metadata" + + def test_account_usage(self, mock_http_client, mock_config): + """GetAccountUsage parses the per-pool metrics.""" + mock_http_client.get.return_value = { + "core_request_count": 100, + "core_request_limit": 1000, + "core_usage_percent": 10.0, + "geo_enabled": True, + "geo_request_count": 50, + "geo_request_limit": 500, + "geo_usage_percent": 10.0, + "merchant_request_count": 5, + "merchant_request_limit": 100, + "merchant_usage_percent": 5.0, + "is_active": True, + "message": "Account active", + } + functions = Functions(mock_http_client, mock_config) + + usage = functions.GetAccountUsage() + + assert isinstance(usage, AccountMetrics) + assert usage.geo_enabled is True + assert usage.merchant_request_count == 5 + assert mock_http_client.get.call_args[0][0] == "/account/metrics" + + def test_account_usage_with_key(self, mock_http_client, mock_config): + """An explicit key is forwarded as a query parameter.""" + mock_http_client.get.return_value = { + "core_request_count": 1, + "core_request_limit": 10, + "core_usage_percent": 10.0, + "geo_enabled": False, + "geo_request_count": 0, + "geo_request_limit": 0, + "geo_usage_percent": 0.0, + "merchant_request_count": 0, + "merchant_request_limit": 0, + "merchant_usage_percent": 0.0, + "is_active": True, + "message": "ok", + } + functions = Functions(mock_http_client, mock_config) + + functions.GetAccountUsage(key="other-key") + + assert mock_http_client.get.call_args[1]["params"] == {"key": "other-key"} + + def test_account_usage_distinct_from_v60( + self, mock_http_client, mock_config, sample_account_metrics + ): + """GetAccountMetrics still targets the v60 path and shape.""" + mock_http_client.get.return_value = sample_account_metrics + functions = Functions(mock_http_client, mock_config) + + functions.GetAccountMetrics() + + assert mock_http_client.get.call_args[0][0] == "/account/v60/metrics" From 4b58d95c4bc00b282f4fd9291cf6dfe1335c7b83 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 7 Aug 2026 09:53:31 -0700 Subject: [PATCH 2/5] Address PR review: docstring fixes, README guard, snake_case comments - AccountMetrics docstring named the wrong functions. It is returned by GetAccountUsage (GET /account/metrics), not GetAccountMetrics, and the referenced GetAccountMetricsV60 does not exist. - README extended-options example dereferenced response.shipping without guarding it; the field is Optional and is None for some regions. - validate_merchant_id docstring claimed UUID validation but only checks for a non-empty string. Reworded to state that intentionally: the server is the authority on valid IDs and returns 403/404, so strict client-side format checking risks rejecting IDs the API would accept. - Documented the ProductCodeSearchResponse.results relaxation in the CHANGELOG. It defaults to [] because the published spec lists only $schema and query as required. Also added comments at the two places where the API is genuinely snake_case among camelCase siblings, both verified against the OpenAPI spec and the API source, so they are not "corrected" later: - merchant_type on POST /merchant/create - sat_item_total on GET /request/v60 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 +++++++++++- README.md | 5 +++-- src/ziptax/models/merchant.py | 14 +++++++++++--- src/ziptax/models/responses.py | 5 +++++ src/ziptax/resources/functions.py | 9 +++++++++ src/ziptax/utils/validation.py | 10 ++++++++-- 6 files changed, 47 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7668b6..acdcb4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,11 @@ endpoint, or return type. `V60SingleArticleTax`, plus the full merchant, certificate, TIC data, and system model set in the new `src/ziptax/models/merchant.py` module - **Pagination fields on TIC search**: `ProductCodeSearchResponse.next_cursor` - and `.schema_url` (`$schema`) + and `.schema_url` (`$schema`). `results` also relaxed from required to + defaulting to `[]`, matching the published spec, which lists only `$schema` + and `query` as required. A response omitting `results` now parses to an + empty list rather than raising. The API sends `results: []` explicitly when + a query genuinely has no matches - **Validators**: `validate_adjustment` and `validate_merchant_id` - **90 new tests** across `tests/test_merchant.py` (57) and `tests/test_functions.py` (33), covering both merchant compliance models, @@ -109,6 +113,12 @@ endpoint, or return type. - v6.0 postal-code-only lookups continue to return the legacy (v5.0-shaped) flat response, so `GetRatesByPostalCode` still returns `V60PostalCodeResponse`. +- Two API fields are genuinely snake_case among camelCase siblings, and the + SDK matches them deliberately: the `merchant_type` body field on + `POST /merchant/create`, and the `sat_item_total` query parameter on + `GET /request/v60`. Both are verified against the published OpenAPI spec + and the API source, and are commented in the code so they are not + "corrected" to camelCase later. ### Changed diff --git a/README.md b/README.md index 0194717..fe322b6 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,9 @@ 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 -if response.shipping.shipping_extended: +# 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}") ``` diff --git a/src/ziptax/models/merchant.py b/src/ziptax/models/merchant.py index 3795a97..2d9013c 100644 --- a/src/ziptax/models/merchant.py +++ b/src/ziptax/models/merchant.py @@ -291,12 +291,19 @@ class CreateMerchantRequest(BaseModel): "instead. Ignored when merchant_type is 'self-managed'" ), ) + # No alias: the API field really is snake_case here, unlike every sibling + # on this request. Confirmed against POST /merchant/create in + # https://docs.zip.tax/openapi/api-reference.json and against + # CreateMerchantRequest in the API source (controllers/merchant/models.go). + # Do not "fix" this to merchantType; the server would ignore it and + # silently fall back to the default 'taxcloud' compliance model. merchant_type: Optional[MerchantType] = Field( None, description=( "Compliance model. 'taxcloud' (the default) starts the TaxCloud " "invite process and requires Enterprise; 'self-managed' is active " - "immediately with no invite" + "immediately with no invite. Serialized as the snake_case API " + "field 'merchant_type'" ), ) reference_id: Optional[str] = Field( @@ -1375,8 +1382,9 @@ class SystemMetadataResponse(BaseModel): class AccountMetrics(BaseModel): """Account usage metrics across core, geo, and merchant request pools. - Returned by ``GetAccountMetrics``. For the simplified v6.0 shape, see - ``V60AccountMetrics`` returned by ``GetAccountMetricsV60``. + Returned by ``GetAccountUsage`` (``GET /account/metrics``). For the + simplified v6.0 shape, see ``V60AccountMetrics``, returned by + ``GetAccountMetrics`` (``GET /account/v60/metrics``). """ model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index f143f49..87b75ff 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -687,6 +687,11 @@ class ProductCodeSearchResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) query: str = Field(..., description="The original query sent in the POST request") + # Defaults to [] rather than being required: the published OpenAPI spec + # lists only $schema and query as required on this response, and the + # endpoint is a verbatim passthrough to TaxCloud. A body that omits + # results therefore parses to an empty list instead of raising. The API + # sends results: [] explicitly when a query genuinely has no matches. results: List["ProductCodeSearchResult"] = Field( default_factory=list, description="Array of matching product codes ranked and scored by relevance", diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index ba5e0dc..6dd806e 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -196,6 +196,9 @@ def GetSalesTaxByAddress( if shipping_extended: params["shippingExtended"] = "true" + # snake_case is correct here: the API query parameter really is + # sat_item_total, unlike its camelCase siblings. Confirmed against + # GET /request/v60 in https://docs.zip.tax/openapi/api-reference.json. if sat_item_total is not None: params["sat_item_total"] = sat_item_total @@ -282,6 +285,9 @@ def GetSalesTaxByGeoLocation( if shipping_extended: params["shippingExtended"] = "true" + # snake_case is correct here: the API query parameter really is + # sat_item_total, unlike its camelCase siblings. Confirmed against + # GET /request/v60 in https://docs.zip.tax/openapi/api-reference.json. if sat_item_total is not None: params["sat_item_total"] = sat_item_total @@ -387,6 +393,9 @@ def GetRatesByPostalCode( if historical: params["historical"] = historical + # snake_case is correct here: the API query parameter really is + # sat_item_total, unlike its camelCase siblings. Confirmed against + # GET /request/v60 in https://docs.zip.tax/openapi/api-reference.json. if sat_item_total is not None: params["sat_item_total"] = sat_item_total diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 3a8ea88..9a1c1d3 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -95,10 +95,16 @@ def validate_adjustment(adjustment: str) -> None: def validate_merchant_id(merchant_id: str) -> None: - """Validate a merchant UUID parameter. + """Check that a merchant ID is a non-empty string. + + The API issues merchant IDs as UUIDs, but this deliberately does not + enforce UUID format: the server is the authority on which IDs are valid + and returns 403/404 for unknown or unowned merchants. Validating only + the obvious client-side mistake avoids rejecting an ID the API would + have accepted. Args: - merchant_id: Merchant UUID to validate + merchant_id: Merchant ID to validate Raises: ZipTaxValidationError: If merchant_id is missing or not a string From 26b229d832fb547e718e4aa24252fd16e657a0f0 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 7 Aug 2026 10:19:29 -0700 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/ziptax/utils/validation.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 9a1c1d3..f1b95d9 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -107,14 +107,22 @@ def validate_merchant_id(merchant_id: str) -> None: merchant_id: Merchant ID to validate Raises: - ZipTaxValidationError: If merchant_id is missing or not a string + ZipTaxValidationError: If merchant_id is missing, not a string, or not a UUID """ if not isinstance(merchant_id, str): raise ZipTaxValidationError("Merchant ID must be a string") - if not merchant_id or not merchant_id.strip(): + merchant_id = merchant_id.strip() + if not merchant_id: raise ZipTaxValidationError("Merchant ID cannot be empty") + try: + import uuid + + uuid.UUID(merchant_id) + except (ValueError, TypeError): + raise ZipTaxValidationError(f"Merchant ID must be a valid UUID, got: {merchant_id}") + def validate_historical_date(historical: str) -> None: """Validate historical date parameter. From 1d31eda6a90293400375de8e70c9c006eede8552 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 7 Aug 2026 10:29:39 -0700 Subject: [PATCH 4/5] Pin merchant_type snake_case with a negative assertion Re-verified POST /merchant/create against both sources: - https://docs.zip.tax/openapi/api-reference.json lists the body property as "merchant_type". "merchantType" does not appear in the schema. - CreateMerchantRequest in the API source tags the field `json:"merchant_type"` (controllers/merchant/models.go:25). The only "merchantType" occurrences in the API are local Go identifiers, not JSON keys. So the SDK's snake_case is correct and no alias should be added. The inconsistency is real but it is the API's, not the SDK's. Getting this wrong fails silently rather than loudly, which is why it is worth pinning: an unrecognized key would leave the field empty, and normalizeMerchantType("") returns MerchantTypeTaxCloud with ok=true (controllers/merchant/handler.go:214-223). A caller asking for a self-managed merchant would get a TaxCloud merchant and an invite flow with no error surfaced anywhere. The test already asserted the snake_case key is present; it now also asserts "merchantType" is absent, and the docstring records why. Verified the guard fires: adding alias="merchantType" makes the test fail. Co-Authored-By: Claude Opus 5 --- tests/test_merchant.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_merchant.py b/tests/test_merchant.py index 529495a..2013331 100644 --- a/tests/test_merchant.py +++ b/tests/test_merchant.py @@ -302,7 +302,20 @@ def test_uses_correct_path( def test_self_managed_serialization( self, mock_http_client, mock_config, merchant_mutation_response ): - """merchant_type serializes to the snake_case API field.""" + """merchant_type serializes to the snake_case API field. + + This one field is snake_case while every sibling is camelCase, so it + reads like an oversight and invites a "fix" to merchantType. It is + not: POST /merchant/create declares merchant_type in the published + OpenAPI spec, and CreateMerchantRequest in the API source tags it + `json:"merchant_type"`. + + The consequence of getting this wrong is silent. An unrecognized key + leaves the field empty, and normalizeMerchantType("") returns + MerchantTypeTaxCloud with ok=true, so a caller asking for a + self-managed merchant would get a TaxCloud merchant plus an invite + flow, with no error anywhere. Hence the negative assertion below. + """ mock_http_client.post.return_value = merchant_mutation_response functions = Functions(mock_http_client, mock_config) @@ -320,6 +333,7 @@ def test_self_managed_serialization( assert body["contactEmail"] == "ops@acme.example" assert body["referenceId"] == "acct-1042" assert body["merchant_type"] == "self-managed" + assert "merchantType" not in body def test_empty_name_rejected(self): """An empty merchant name fails model validation.""" From bec342c6e6edbb228f2d715852472a8339ff5952 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 7 Aug 2026 10:31:45 -0700 Subject: [PATCH 5/5] Make the merchant ID UUID check pass CI and match its docstring Follow-up to 26b229d, which added UUID validation to validate_merchant_id. Keeping that behaviour; fixing three things it left behind: - The raise was 92 characters, so ruff E501 and black would both have failed the lint job. Wrapped. - `import uuid` was inside the function body. Moved to the module imports alongside `re`, matching the rest of the file. - The docstring body still said the function "deliberately does not enforce UUID format", contradicting the new code directly below it. Rewritten to describe what it now does, and to keep the ownership boundary clear: a well-formed UUID the account does not own is still a server-side 403. Also narrowed `except (ValueError, TypeError)` to `ValueError`. The isinstance guard above already rejects non-strings, so TypeError was unreachable. Added six tests, which the autofix shipped without: malformed UUIDs are rejected before any HTTP call, and the two forms uuid.UUID accepts (surrounding whitespace, unhyphenated) are confirmed to pass. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 ++++- src/ziptax/utils/validation.py | 32 +++++++++++++++++------------- tests/test_merchant.py | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acdcb4e..90fea74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,10 @@ endpoint, or return type. and `query` as required. A response omitting `results` now parses to an empty list rather than raising. The API sends `results: []` explicitly when a query genuinely has no matches -- **Validators**: `validate_adjustment` and `validate_merchant_id` +- **Validators**: `validate_adjustment`, and `validate_merchant_id`, which + checks UUID format client-side so a malformed merchant ID fails fast with a + clear message rather than returning a 403 from the ownership check. + Ownership itself stays server-side - **90 new tests** across `tests/test_merchant.py` (57) and `tests/test_functions.py` (33), covering both merchant compliance models, request serialization, response parsing, and validation. Suite is 249 tests diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index f1b95d9..d9b3ff9 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -1,6 +1,7 @@ """Validation utilities for the ZipTax SDK.""" import re +import uuid from typing import Dict from ..exceptions import ZipTaxValidationError @@ -95,33 +96,36 @@ def validate_adjustment(adjustment: str) -> None: def validate_merchant_id(merchant_id: str) -> None: - """Check that a merchant ID is a non-empty string. + """Validate a merchant ID. - The API issues merchant IDs as UUIDs, but this deliberately does not - enforce UUID format: the server is the authority on which IDs are valid - and returns 403/404 for unknown or unowned merchants. Validating only - the obvious client-side mistake avoids rejecting an ID the API would - have accepted. + Merchant IDs are UUIDs, assigned by the API and returned as ``merchant_id`` + on the ``CreateMerchant`` response. The format is checked client-side so an + obviously malformed ID fails fast with a clear message instead of coming + back as a 403 from the ownership check. + + Ownership remains a server-side concern: a well-formed UUID that the + account does not own still returns 403, and a soft-deleted merchant + returns 404. Args: merchant_id: Merchant ID to validate Raises: - ZipTaxValidationError: If merchant_id is missing, not a string, or not a UUID + ZipTaxValidationError: If merchant_id is empty, not a string, or not a + valid UUID """ if not isinstance(merchant_id, str): raise ZipTaxValidationError("Merchant ID must be a string") - merchant_id = merchant_id.strip() - if not merchant_id: + if not merchant_id.strip(): raise ZipTaxValidationError("Merchant ID cannot be empty") try: - import uuid - - uuid.UUID(merchant_id) - except (ValueError, TypeError): - raise ZipTaxValidationError(f"Merchant ID must be a valid UUID, got: {merchant_id}") + uuid.UUID(merchant_id.strip()) + except ValueError: + raise ZipTaxValidationError( + f"Merchant ID must be a valid UUID, got: {merchant_id!r}" + ) def validate_historical_date(historical: str) -> None: diff --git a/tests/test_merchant.py b/tests/test_merchant.py index 2013331..71624e9 100644 --- a/tests/test_merchant.py +++ b/tests/test_merchant.py @@ -431,6 +431,42 @@ def test_non_string_merchant_id_validation(self, mock_http_client, mock_config): with pytest.raises(ZipTaxValidationError, match="must be a string"): functions.DeleteMerchant(12345) + @pytest.mark.parametrize( + "bad_id", + [ + "not-a-uuid", + "9f4c1e2a-7b3d-4c5e-8a91", # truncated + "9f4c1e2a-7b3d-4c5e-8a91-2f6b0d4e7c13-extra", + "zzzzzzzz-7b3d-4c5e-8a91-2f6b0d4e7c13", # non-hex + ], + ) + def test_malformed_uuid_rejected(self, mock_http_client, mock_config, bad_id): + """A merchant ID that is not a UUID fails fast, before any request.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="must be a valid UUID"): + functions.GetMerchant(bad_id) + + mock_http_client.post.assert_not_called() + + def test_surrounding_whitespace_tolerated( + self, mock_http_client, mock_config, merchant_record + ): + """A UUID padded with whitespace passes validation.""" + mock_http_client.post.return_value = merchant_record + functions = Functions(mock_http_client, mock_config) + + functions.GetMerchant(f" {MERCHANT_ID} ") + + def test_uuid_without_hyphens_accepted( + self, mock_http_client, mock_config, merchant_record + ): + """uuid.UUID accepts the unhyphenated form, so the SDK does too.""" + mock_http_client.post.return_value = merchant_record + functions = Functions(mock_http_client, mock_config) + + functions.GetMerchant(MERCHANT_ID.replace("-", "")) + class TestListMerchants: """Test cases for ListMerchants."""