diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0565e..90fea74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,129 @@ 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`). `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`, 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 + 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`. +- 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 + +- 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..fe322b6 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,45 @@ if response.sourcing_rules: print(f"Sourcing: {response.sourcing_rules.value}") ``` +#### Extended v6.0 Options + +`GetSalesTaxByAddress` and `GetSalesTaxByGeoLocation` accept additional +parameters that enrich the response. + +```python +response = client.request.GetSalesTaxByAddress( + address="200 Spectrum Center Drive, Irvine, CA 92618", + taxability_code="20010", # Adds product_detail with rate rules + adjustment="auto", # "auto" (default), "origin", "destination" + address_detail_extended=True, # Adds address_detail.address components + shipping_extended=True, # Adds shipping.shipping_extended + city="Irvine", # Narrow the lookup + state="CA", +) + +# Product rate rules for the requested TIC +if response.product_detail: + code = response.product_detail.taxability_code + print(f"{code.title} ({code.rate_action_code}): {code.rate_action_message}") + for rule in code.rate_rules or []: + print(f" {rule.jur_tax_code}: rate={rule.effective_tax_rate}") + +# Geocoded address broken into parts +if response.address_detail.address: + parts = response.address_detail.address + print(f"{parts.house_number} {parts.street}, {parts.city} {parts.postal_code}") + +# Detailed shipping rule. +# response.shipping is itself Optional - it is None for some regions. +if response.shipping and response.shipping.shipping_extended: + rule = response.shipping.shipping_extended + print(f"{rule.state_code}: {rule.rule} - {rule.description}") +``` + +`rate_action_code` reports the outcome of the TIC lookup: `T00` (valid, rules +listed), `T01` (valid, no applicable rules), `T02` (invalid TIC), `T03` +(invalid TIC format). + ### Get Sales Tax by Geolocation ```python @@ -139,15 +189,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 +253,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 +363,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 +878,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 +927,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 +973,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 +1061,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..2d9013c --- /dev/null +++ b/src/ziptax/models/merchant.py @@ -0,0 +1,1431 @@ +"""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'" + ), + ) + # 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. Serialized as the snake_case API " + "field 'merchant_type'" + ), + ) + 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 ``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") + + 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..87b75ff 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,41 @@ 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") + # 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", ) + 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 +731,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..6dd806e 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,27 @@ 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" + + # 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 + # Make request with retry logic @retry_with_backoff( max_retries=self.max_retries, @@ -126,15 +220,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 +258,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 +273,24 @@ 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" + + # 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 + # Make request with retry logic @retry_with_backoff( max_retries=self.max_retries, @@ -173,7 +303,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 +338,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 +371,8 @@ def GetRatesByPostalCode( """ # Validate inputs validate_postal_code(postal_code) + if historical: + validate_historical_date(historical) validate_format(format) # Build query parameters @@ -229,6 +381,24 @@ def GetRatesByPostalCode( "format": format, } + if state: + params["state"] = state + + if city: + params["city"] = city + + if county: + params["county"] = county + + 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 + # Make request with retry logic @retry_with_backoff( max_retries=self.max_retries, @@ -358,6 +528,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 +723,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 +777,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 +809,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 +828,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 +854,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 +878,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 +906,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 +945,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 +979,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 +1012,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 +1032,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..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 @@ -59,13 +60,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 +78,56 @@ 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 ID. + + 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 empty, not a string, or not a + valid UUID + """ + if not isinstance(merchant_id, str): + raise ZipTaxValidationError("Merchant ID must be a string") + + if not merchant_id.strip(): + raise ZipTaxValidationError("Merchant ID cannot be empty") + + try: + 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: """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..71624e9 --- /dev/null +++ b/tests/test_merchant.py @@ -0,0 +1,1269 @@ +"""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. + + 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) + + 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" + assert "merchantType" not in body + + 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) + + @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.""" + + 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"