diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d4c72..d97eca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.3-beta] - 2025-02-20 + +### Added +- **TaxCloud Cart Routing**: `CalculateCart()` now automatically routes to TaxCloud's `POST /tax/connections/{connectionId}/carts` when TaxCloud credentials are configured + - Same input contract (`CalculateCartRequest`) regardless of backend + - SDK transforms request internally: parses single-string addresses into structured components, maps `taxabilityCode` to `tic`, adds 0-based `index` to line items + - Returns `TaxCloudCalculateCartResponse` (with `connectionId`, `transactionDate`, structured addresses, `deliveredBySeller`, `exemption`) when routed to TaxCloud + - Returns `CalculateCartResponse` (existing behavior) when TaxCloud is not configured + - Return type: `Union[CalculateCartResponse, TaxCloudCalculateCartResponse]` +- **Address Parsing Utility**: `parse_address_string()` in `utils/validation.py` + - Parses `"street, city, ST zip"` format into structured `{line1, city, state, zip}` dict + - Supports 5-digit and 9-digit ZIP codes (e.g., `92618` or `92618-1905`) + - Raises `ZipTaxValidationError` with descriptive messages on parse failure +- **3 New Pydantic Models** for TaxCloud cart responses: + - `TaxCloudCalculateCartResponse`: top-level response with `connection_id`, `items`, `transaction_date` + - `TaxCloudCartItemResponse`: per-cart result with structured addresses, `currency`, `delivered_by_seller`, `exemption` + - `TaxCloudCartLineItemResponse`: per-item result with `index`, `item_id`, `price`, `quantity`, `tax`, `tic` +- **17 New Tests** in `TestCalculateCartTaxCloudRouting`: + - Routing logic (ZipTax vs TaxCloud based on config) + - Request transformation (address parsing, index, tic mapping, field passthrough) + - Response parsing (top-level, cart fields, addresses, line items) + - Error handling (unparseable addresses, invalid state/zip) + +### Changed +- Version bumped from `0.2.1-beta` to `0.2.3-beta` +- `CalculateCart()` return type changed from `CalculateCartResponse` to `Union[CalculateCartResponse, TaxCloudCalculateCartResponse]` +- `CalculateCart()` implementation refactored into routing method with two private helpers: `_calculate_cart_ziptax()` and `_calculate_cart_taxcloud()` + +### Technical Details +- Request transformation handled by static method `_transform_cart_for_taxcloud()` in `Functions` +- TaxCloud cart uses `taxcloud_http_client` (separate auth) with path `/tax/connections/{connectionId}/carts` +- Retry logic with exponential backoff applies to both ZipTax and TaxCloud routes +- All quality checks pass: black, ruff, mypy, pytest (125 tests, 96% coverage) + +## [0.2.1-beta] - 2025-02-19 + +### Added +- **Cart Tax Calculation**: `CalculateCart()` function for calculating sales tax on shopping carts + - Accepts a `CalculateCartRequest` with customer info, addresses, currency, and line items + - Sends cart to `POST /calculate/cart` on the ZipTax API + - Returns per-item tax rate and amount via `CalculateCartResponse` + - Origin/destination sourcing is handled by the API internally +- **9 New Pydantic Models** for cart tax calculation: + - Request models: `CalculateCartRequest`, `CartItem`, `CartAddress`, `CartCurrency`, `CartLineItem` + - Response models: `CalculateCartResponse`, `CartItemResponse`, `CartLineItemResponse`, `CartTax` +- **Pydantic Validation** on cart models: + - `CartLineItem.price` and `quantity`: must be greater than 0 (`gt=0`) + - `CalculateCartRequest.items`: exactly 1 element (`min_length=1, max_length=1`) + - `CartItem.line_items`: 1-250 elements (`min_length=1, max_length=250`) + - `CartCurrency.currency_code`: must be `"USD"` (`Literal["USD"]`) +- **Documentation**: + - CalculateCart usage guide with code examples in README.md + - Cart endpoint specification in `docs/spec.yaml` + - Actual API request/response examples in spec + +### Changed +- Version bumped from `0.2.0-beta` to `0.2.1-beta` + +### Technical Details +- Cart calculation uses the ZipTax HTTP client (not TaxCloud) with `X-API-Key` authentication +- Request bodies serialized with `model_dump(by_alias=True, exclude_none=True)` for camelCase API fields +- `taxabilityCode` is optional and excluded from the payload when not set +- All quality checks pass: black, ruff, mypy, pytest + ## [0.2.0-beta] - 2025-02-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 65cf9a3..e268233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,8 +117,9 @@ def has_taxcloud_config(self) -> bool: - Uses decorator pattern for retry logic - TaxCloud methods check credentials before executing -**Critical Pattern**: +**Critical Patterns**: ```python +# TaxCloud methods check credentials first def _check_taxcloud_config(self) -> None: """Check if TaxCloud credentials are configured.""" if not self.config.has_taxcloud_config or self.taxcloud_http_client is None: @@ -131,6 +132,16 @@ def _check_taxcloud_config(self) -> None: def CreateOrder(self, request: CreateOrderRequest, ...) -> OrderResponse: self._check_taxcloud_config() # Guards against missing credentials # ... implementation + +# CalculateCart sends the cart directly to the API for tax calculation +# The API handles origin/destination sourcing internally +def CalculateCart(self, request: CalculateCartRequest) -> CalculateCartResponse: + # Serialize and POST to /calculate/cart + response_data = self.http_client.post( + "/calculate/cart", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + return CalculateCartResponse(**response_data) ``` #### 4. **HTTP Client (`utils/http.py`)** @@ -155,6 +166,7 @@ self.session.headers.update({"X-API-Key": api_key}) - **Purpose**: Pydantic models for request/response validation - **Structure**: - V60 models for ZipTax API responses + - Cart models for cart tax calculation (with Pydantic field constraints) - TaxCloud models for order management - Uses `Field` with `alias` for camelCase ↔ snake_case mapping @@ -545,6 +557,7 @@ This file is used as a reference for code generation and documentation. **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 **Response Format**: JSON with nested structure @@ -773,6 +786,6 @@ For API-specific questions: --- -**Last Updated**: 2025-02-16 -**SDK Version**: 0.2.0-beta +**Last Updated**: 2025-02-19 +**SDK Version**: 0.2.3-beta **Maintained By**: ZipTax Team diff --git a/README.md b/README.md index 12491c1..175b6c3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Official Python SDK for the [Ziptax API](https://zip-tax.com) - Get accurate sal ### Core Features (ZipTax API) - 🚀 Simple and intuitive API +- 🛒 Cart tax calculation with per-item tax rates - 🔄 Automatic retry logic with exponential backoff - ✅ Input validation - 🔍 Type hints for better IDE support @@ -148,6 +149,79 @@ print(f"Account Active: {metrics.is_active}") print(f"Message: {metrics.message}") ``` +### Calculate Cart Tax + +Calculate sales tax for a shopping cart with multiple line items. `CalculateCart` uses **dual-routing**: when TaxCloud credentials are configured on the client, the request is automatically routed to the TaxCloud API; otherwise it is sent to the ZipTax API. The input is the same `CalculateCartRequest` in both cases, but the response type differs: + +- **Without TaxCloud credentials** -- returns a `CalculateCartResponse` (ZipTax API) +- **With TaxCloud credentials** -- returns a `TaxCloudCalculateCartResponse` (TaxCloud API) + +```python +from ziptax.models import ( + CalculateCartRequest, + CartItem, + CartAddress, + CartCurrency, + CartLineItem, +) + +# Build the cart request +request = CalculateCartRequest( + items=[ + CartItem( + customer_id="customer-453", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress( + address="200 Spectrum Center Dr, Irvine, CA 92618" + ), + origin=CartAddress( + address="323 Washington Ave N, Minneapolis, MN 55401" + ), + line_items=[ + CartLineItem( + item_id="item-1", + price=10.75, + quantity=1.5, + ), + CartLineItem( + item_id="item-2", + price=25.00, + quantity=2.0, + taxability_code=0, + ), + ], + ) + ] +) + +# Calculate tax (routes to ZipTax or TaxCloud based on client config) +result = client.request.CalculateCart(request) + +# Access results +cart = result.items[0] +print(f"Cart ID: {cart.cart_id}") +for item in cart.line_items: + print(f" {item.item_id}: rate={item.tax.rate}, amount=${item.tax.amount:.2f}") +``` + +#### Validation + +The cart models enforce constraints at construction time via Pydantic: + +- `items` must contain exactly 1 cart +- `line_items` must contain 1-250 items +- `price` and `quantity` must be greater than 0 +- `currency_code` must be `"USD"` + +```python +from pydantic import ValidationError + +try: + CartLineItem(item_id="item-1", price=-5.00, quantity=1.0) +except ValidationError as e: + print(e) # price must be greater than 0 +``` + ## TaxCloud Order Management 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). @@ -522,6 +596,7 @@ API endpoint functions accessible via `client.request`. - `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 +- `CalculateCart(request)` - Calculate sales tax for a shopping cart #### TaxCloud API Methods (Optional) diff --git a/docs/spec.yaml b/docs/spec.yaml index a86689e..a837d05 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -9,7 +9,7 @@ project: name: "ziptax-sdk" language: "python" - version: "0.2.0-beta" + version: "0.2.3-beta" description: "Official Python SDK for the ZipTax API with optional TaxCloud order management support" # Repository information @@ -250,6 +250,168 @@ resources: type: "V60AccountMetrics" is_array: false + # ----------------------------------------------------------------------- + # Cart Tax Calculation - Dual API Routing + # ----------------------------------------------------------------------- + # The CalculateCart function supports TWO backends: + # 1. ZipTax API (default) - POST /calculate/cart + # 2. TaxCloud API (when configured) - POST /tax/connections/{connectionId}/carts + # + # Routing logic in Functions.CalculateCart: + # - If TaxCloud credentials ARE configured (has_taxcloud_config == True), + # route to TaxCloud API and return TaxCloudCalculateCartResponse + # - If TaxCloud credentials are NOT configured, route to ZipTax API + # and return CalculateCartResponse (existing behavior) + # + # The INPUT contract (CalculateCartRequest) is IDENTICAL for both backends. + # The SDK handles request transformation internally when routing to TaxCloud. + # The OUTPUT contract differs: CalculateCartResponse vs TaxCloudCalculateCartResponse. + # The return type is Union[CalculateCartResponse, TaxCloudCalculateCartResponse]. + # ----------------------------------------------------------------------- + + # ZipTax Cart Tax Calculation Endpoint (default when TaxCloud not configured) + - name: "CalculateCart" + api: "ziptax" + http_method: "POST" + path: "/calculate/cart" + description: | + Calculate sales tax for a single cart with multiple line items. + Accepts a cart with destination and origin addresses, calculates + per-item tax, and returns tax rate and amount for each line item. + + ROUTING BEHAVIOR: When the SDK client is initialized with TaxCloud + credentials (taxcloud_connection_id and taxcloud_api_key), this + function automatically routes the request to the TaxCloud API + instead of the ZipTax API. The input contract (CalculateCartRequest) + remains the same regardless of which backend is used. The SDK + transforms the request internally. The response type changes based + on the backend used. + + IMPLEMENTATION NOTES for routing in Functions.CalculateCart: + 1. Check self.config.has_taxcloud_config at the START of the method + 2. If True: transform request and POST to TaxCloud, return TaxCloudCalculateCartResponse + 3. If False: POST to ZipTax as-is (existing behavior), return CalculateCartResponse + 4. Return type annotation: Union[CalculateCartResponse, TaxCloudCalculateCartResponse] + operation_id: "calculateCart" + routing: + condition: "config.has_taxcloud_config" + when_true: + api: "taxcloud" + path: "/tax/connections/{connectionId}/carts" + http_method: "POST" + http_client: "taxcloud_http_client" + returns: + type: "TaxCloudCalculateCartResponse" + status_code: 200 + request_transformation: + description: | + The SDK must transform the CalculateCartRequest into the + TaxCloud request format before sending. The transformation + maps the ZipTax-style request to TaxCloud's expected schema. + steps: + - field: "items[].destination" + from: "CartAddress (single string: address)" + to: "TaxCloudAddress (structured: line1, city, state, zip, countryCode)" + note: | + Parse the single address string into structured components. + Use a simple parsing strategy: + - Split the address string by comma + - line1 = first segment (street address) + - city = second segment + - Parse last segment for state and zip (e.g., "CA 92618" or "CA 92618-1905") + - countryCode defaults to "US" + If parsing fails, raise a ZipTaxValidationError with a + descriptive message advising the user to provide a properly + formatted address string. + - field: "items[].origin" + from: "CartAddress (single string: address)" + to: "TaxCloudAddress (structured: line1, city, state, zip, countryCode)" + note: "Same parsing logic as destination" + - field: "items[].line_items[].taxability_code" + from: "taxabilityCode (Optional[int])" + to: "tic (int, defaults to 0)" + note: "Map taxabilityCode to TaxCloud's tic field. If None, use 0." + - field: "items[].line_items[].index" + from: "(not present in CalculateCartRequest)" + to: "index (int, required by TaxCloud)" + note: "Auto-generate index from the line item's position in the array (0-based)" + - field: "items[].currency.currency_code" + from: "Literal['USD']" + to: "currencyCode (string, 'USD' or 'CAD')" + note: "Pass through as-is; both APIs accept USD" + - field: "transactionDate" + from: "(not present in CalculateCartRequest)" + to: "transactionDate (optional RFC3339 datetime)" + note: "Not included in the transformed request; TaxCloud defaults to now" + when_false: + api: "ziptax" + path: "/calculate/cart" + http_method: "POST" + http_client: "http_client" + returns: + type: "CalculateCartResponse" + status_code: 200 + parameters: [] + request_body: + type: "CalculateCartRequest" + description: "Cart with line items, addresses, and currency for tax calculation. Same input contract regardless of backend routing." + returns: + type: "Union[CalculateCartResponse, TaxCloudCalculateCartResponse]" + is_array: false + status_code: 200 + note: "Return type depends on whether TaxCloud is configured. See routing section." + authentication: + type: "header" + header: "X-API-Key" + format: "{apiKey} or {taxCloudAPIKey}" + note: "Authentication header is determined by which HTTP client is used. ZipTax http_client uses ZipTax API key; TaxCloud taxcloud_http_client uses TaxCloud API key. Both are already configured on their respective HTTP client instances." + validation: + cart: + - "items array must contain exactly 1 cart element" + - "currency.currencyCode must be 'USD'" + - "customerId is required (string)" + - "destination.address is required (string)" + - "origin.address is required (string)" + line_items: + - "Minimum: 1 line item" + - "Maximum: 250 line items per cart" + - "itemId: required (string)" + - "price: required (positive decimal, greater than 0)" + - "quantity: required (positive decimal, greater than 0, supports fractional values)" + - "taxabilityCode: optional (integer, mapped to tic for TaxCloud, passed to v60 for ZipTax)" + tax_calculation: + formula: "taxAmount = (price x quantity) x taxRate" + rounding: "5 decimal places for both taxRate and taxAmount (ZipTax); API-determined for TaxCloud" + note: | + Tax calculation and origin/destination sourcing resolution are + performed by the respective API, not by the SDK. + The SDK sends the cart request and returns the response. + example: | + price = 10.75 + quantity = 1.5 + taxRate = 0.09025 + taxAmount = (10.75 x 1.5) x 0.09025 = 1.45528 + errors: + 400: + - "Invalid request format" + - "Missing required fields" + - "Cart exceeds 250 line items" + - "More than one cart in request" + - "Address parsing failed (TaxCloud route only)" + 401: + - "Missing X-API-KEY header" + - "Invalid or inactive API key" + 403: + - "Forbidden (TaxCloud only)" + 422: + - "Unprocessable Entity (TaxCloud only)" + 429: + - "Too Many Requests (TaxCloud only)" + 500: + - "Failed to calculate tax rate" + - "v60 API error (ZipTax only)" + - "Internal server error" + # TaxCloud API Orders Endpoints - name: "CreateOrder" api: "taxcloud" # Indicates this uses the TaxCloud API @@ -918,6 +1080,318 @@ models: api_field: "geoLng" description: "Longitude (0 for postal code lookups)" + # --------------------------------------------------------------------------- + # ZipTax Cart Tax Calculation Models + # --------------------------------------------------------------------------- + - name: "CalculateCartRequest" + description: "Request payload for calculating sales tax on a shopping cart. Wraps a single cart item in an 'items' array." + api: "ziptax" + properties: + - name: "items" + type: "array" + items_type: "CartItem" + required: true + description: "Array of cart items (must contain exactly 1 element)" + validation: + min_items: 1 + max_items: 1 + + - name: "CartItem" + description: "A single cart containing customer info, addresses, currency, and line items for tax calculation" + api: "ziptax" + properties: + - name: "customer_id" + type: "string" + required: true + api_field: "customerId" + description: "Customer identifier" + example: "customer-453" + - name: "currency" + type: "CartCurrency" + required: true + description: "Currency information (must be USD)" + - name: "destination" + type: "CartAddress" + required: true + description: "Destination address used for tax calculation" + - name: "origin" + type: "CartAddress" + required: true + description: "Origin address of the seller/shipper" + - name: "line_items" + type: "array" + items_type: "CartLineItem" + required: true + api_field: "lineItems" + description: "Array of line items in the cart (1-250 items)" + validation: + min_items: 1 + max_items: 250 + + - name: "CartAddress" + description: "Simple address structure for cart tax calculation (single string format)" + api: "ziptax" + properties: + - name: "address" + type: "string" + required: true + description: "Full address string for geocoding" + example: "200 Spectrum Center Dr, Irvine, CA 92618-1905" + + - name: "CartCurrency" + description: "Currency information for cart request" + api: "ziptax" + properties: + - name: "currency_code" + type: "string" + required: true + api_field: "currencyCode" + description: "ISO currency code (must be USD)" + enum: ["USD"] + example: "USD" + + - name: "CartLineItem" + description: "A line item in the cart request with product details for tax calculation" + api: "ziptax" + properties: + - name: "item_id" + type: "string" + required: true + api_field: "itemId" + description: "Unique identifier for the line item" + example: "item-1" + - name: "price" + type: "number" + format: "float" + required: true + description: "Unit price of the item (must be positive, greater than 0)" + example: 10.75 + validation: + exclusive_minimum: 0 + - name: "quantity" + type: "number" + format: "float" + required: true + description: "Quantity of the item (must be positive, greater than 0, supports fractional values for items sold by weight)" + example: 1.5 + validation: + exclusive_minimum: 0 + - name: "taxability_code" + type: "integer" + format: "int64" + required: false + api_field: "taxabilityCode" + description: "Taxability code for product-specific tax rules (passed to v60 tax engine if provided)" + example: 0 + + - name: "CalculateCartResponse" + description: "Response from cart tax calculation containing per-item tax details" + api: "ziptax" + properties: + - name: "items" + type: "array" + items_type: "CartItemResponse" + required: true + description: "Array of cart results (mirrors request items array order)" + + - name: "CartItemResponse" + description: "A single cart response with calculated tax information per line item" + api: "ziptax" + properties: + - name: "cart_id" + type: "string" + format: "uuid" + required: true + api_field: "cartId" + description: "Server-generated UUID identifying this cart calculation" + example: "ce4a...ccefdd" + - name: "customer_id" + type: "string" + required: true + api_field: "customerId" + description: "Customer identifier (echoed from request)" + example: "customer-453" + - name: "destination" + type: "CartAddress" + required: true + description: "Destination address (echoed from request)" + - name: "origin" + type: "CartAddress" + required: true + description: "Origin address (echoed from request)" + - name: "line_items" + type: "array" + items_type: "CartLineItemResponse" + required: true + api_field: "lineItems" + description: "Array of line items with calculated tax information" + + - name: "CartLineItemResponse" + description: "A line item in the cart response with calculated tax rate and amount" + api: "ziptax" + properties: + - name: "item_id" + type: "string" + required: true + api_field: "itemId" + description: "Unique identifier for the line item (echoed from request)" + example: "item-1" + - name: "price" + type: "number" + format: "float" + required: true + description: "Unit price of the item (echoed from request)" + example: 10.75 + - name: "quantity" + type: "number" + format: "float" + required: true + description: "Quantity of the item (echoed from request)" + example: 1.5 + - name: "tax" + type: "CartTax" + required: true + description: "Calculated tax information for this line item" + + - name: "CartTax" + description: "Calculated tax details for a cart line item" + api: "ziptax" + properties: + - name: "rate" + type: "number" + format: "float" + required: true + description: "Calculated sales tax rate (rounded to 5 decimal places)" + example: 0.09025 + - name: "amount" + type: "number" + format: "float" + required: true + description: "Calculated tax amount: (price x quantity) x rate (rounded to 5 decimal places)" + example: 1.45528 + + # --------------------------------------------------------------------------- + # TaxCloud Cart Tax Calculation Models + # --------------------------------------------------------------------------- + # These models are used when CalculateCart routes to TaxCloud API. + # The SDK transforms the shared CalculateCartRequest into the TaxCloud + # request format internally, but these models define the TaxCloud + # response structure returned to the caller. + # --------------------------------------------------------------------------- + + - name: "TaxCloudCalculateCartResponse" + description: | + Response from TaxCloud cart tax calculation. Returned by CalculateCart + when the client is configured with TaxCloud credentials. Contains the + connectionId, transactionDate, and an array of cart results with + TaxCloud-style structured addresses and line items with tax details. + api: "taxcloud" + properties: + - name: "connection_id" + type: "string" + required: true + api_field: "connectionId" + description: "TaxCloud Connection ID used for this cart calculation" + example: "25eb9b97-5acb-492d-b720-c03e79cf715a" + - name: "items" + type: "array" + items_type: "TaxCloudCartItemResponse" + required: true + description: "Array of cart results with calculated tax information" + - name: "transaction_date" + type: "string" + format: "date-time" + required: true + api_field: "transactionDate" + description: "RFC3339 datetime string the cart was calculated for" + example: "2024-01-15T09:30:00Z" + + - name: "TaxCloudCartItemResponse" + description: "A single cart response from TaxCloud with calculated tax information per line item" + api: "taxcloud" + properties: + - name: "cart_id" + type: "string" + required: true + api_field: "cartId" + description: "ID representing this cart. Auto-generated if not provided in the request." + example: "ce4a1234-5678-90ab-cdef-1234567890ab" + - name: "customer_id" + type: "string" + required: true + api_field: "customerId" + description: "Customer identifier (echoed from request)" + example: "customer-453" + - name: "currency" + type: "CurrencyResponse" + required: true + description: "Currency information" + - name: "delivered_by_seller" + type: "boolean" + required: true + api_field: "deliveredBySeller" + description: "Whether the seller directly delivered the order" + - name: "destination" + type: "TaxCloudAddressResponse" + required: true + description: "Destination address (structured format from TaxCloud)" + - name: "origin" + type: "TaxCloudAddressResponse" + required: true + description: "Origin address (structured format from TaxCloud)" + - name: "exemption" + type: "Exemption" + required: true + description: "Exemption information" + - name: "line_items" + type: "array" + items_type: "TaxCloudCartLineItemResponse" + required: true + api_field: "lineItems" + description: "Array of line items with calculated tax information" + + - name: "TaxCloudCartLineItemResponse" + description: "A line item in the TaxCloud cart response with calculated tax rate and amount" + api: "taxcloud" + properties: + - name: "index" + type: "integer" + format: "int64" + required: true + description: "Position/index of item within the cart (0-based)" + example: 0 + - name: "item_id" + type: "string" + required: true + api_field: "itemId" + description: "Unique identifier for the line item (echoed from request)" + example: "item-1" + - name: "price" + type: "number" + format: "float" + required: true + description: "Unit price of the item (echoed from request)" + example: 10.8 + - name: "quantity" + type: "number" + format: "float" + required: true + description: "Quantity of the item (echoed from request)" + example: 1.5 + - name: "tax" + type: "Tax" + required: true + description: "Calculated tax information for this line item (uses existing TaxCloud Tax model)" + - name: "tic" + type: "integer" + format: "int64" + required: true + description: "Taxability Information Code (mapped from taxabilityCode, defaults to 0)" + example: 0 + notes: + - "This field is nullable in the API response (type: integer | null)" + - "Use Optional[int] in the Pydantic model" + # --------------------------------------------------------------------------- # TaxCloud API Models - Order Management # --------------------------------------------------------------------------- @@ -1865,6 +2339,144 @@ actual_api_responses: } } + calculate_cart: + description: "Actual ZipTax response for CalculateCart" + endpoint: "POST /calculate/cart" + request_example: | + { + "items": [ + { + "currency": { "currencyCode": "USD" }, + "customerId": "customer-453", + "destination": { + "address": "200 Spectrum Center Dr, Irvine, CA 92618-1905" + }, + "origin": { + "address": "323 Washington Ave N, Minneapolis, MN 55401-2427" + }, + "lineItems": [ + { + "itemId": "item-1", + "price": 10.75, + "quantity": 1.5, + "taxabilityCode": 0 + } + ] + } + ] + } + response_example: | + { + "items": [ + { + "cartId": "ce4a...ccefdd", + "customerId": "customer-453", + "destination": { + "address": "200 Spectrum Center Dr, Irvine, CA 92618-1905" + }, + "origin": { + "address": "323 Washington Ave N, Minneapolis, MN 55401-2427" + }, + "lineItems": [ + { + "itemId": "item-1", + "price": 10.75, + "quantity": 1.5, + "tax": { + "rate": 0.09025, + "amount": 1.46 + } + } + ] + } + ] + } + + taxcloud_calculate_cart: + description: "Actual TaxCloud response for CalculateCart (when routed to TaxCloud)" + endpoint: "POST /tax/connections/{connectionId}/carts" + request_example: | + { + "items": [ + { + "currency": { "currencyCode": "USD" }, + "customerId": "customer-453", + "destination": { + "city": "Irvine", + "line1": "200 Spectrum Center Dr", + "state": "CA", + "zip": "92618-1905" + }, + "origin": { + "city": "Minneapolis", + "line1": "323 Washington Ave N", + "state": "MN", + "zip": "55401-2427" + }, + "lineItems": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.75, + "quantity": 1.5, + "tic": 0 + } + ] + } + ] + } + response_example: | + { + "connectionId": "25eb9b97-5acb-492d-b720-c03e79cf715a", + "items": [ + { + "cartId": "ce4a1234-5678-90ab-cdef-1234567890ab", + "customerId": "customer-453", + "currency": { + "currencyCode": "USD" + }, + "deliveredBySeller": false, + "destination": { + "line1": "200 Spectrum Center Dr", + "city": "Irvine", + "state": "CA", + "zip": "92618-1905", + "countryCode": "US" + }, + "origin": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US" + }, + "exemption": { + "exemptionId": null, + "isExempt": null + }, + "lineItems": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.75, + "quantity": 1.5, + "tax": { + "amount": 1.46, + "rate": 0.0903 + }, + "tic": 0 + } + ] + } + ], + "transactionDate": "2024-01-15T09:30:00Z" + } + notes: + - "The request body shown above is the TRANSFORMED version (after SDK parsing)" + - "The SDK receives CalculateCartRequest with single-string addresses and transforms them" + - "TaxCloud returns structured addresses, connectionId, transactionDate, and per-item tic" + - "The response is wrapped in TaxCloudCalculateCartResponse, not CalculateCartResponse" + taxcloud_create_order: description: "Actual TaxCloud response for CreateOrder" endpoint: "POST /tax/connections/{connectionId}/orders" diff --git a/pyproject.toml b/pyproject.toml index 89707d6..9270a20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ziptax-sdk" -version = "0.2.0-beta" +version = "0.2.3-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 394515b..a838302 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -29,10 +29,19 @@ ZipTaxValidationError, ) from .models import ( + CalculateCartRequest, + CalculateCartResponse, + CartAddress, + CartCurrency, + CartItem, CartItemRefundWithTaxRequest, CartItemRefundWithTaxResponse, + CartItemResponse, CartItemWithTax, CartItemWithTaxResponse, + CartLineItem, + CartLineItemResponse, + CartTax, CreateOrderRequest, Currency, CurrencyResponse, @@ -46,6 +55,9 @@ Tax, TaxCloudAddress, TaxCloudAddressResponse, + TaxCloudCalculateCartResponse, + TaxCloudCartItemResponse, + TaxCloudCartLineItemResponse, TaxType, UpdateOrderRequest, V60AccountMetrics, @@ -64,7 +76,7 @@ V60TaxSummary, ) -__version__ = "0.2.0-beta" +__version__ = "0.2.3-beta" __all__ = [ "ZipTaxClient", @@ -100,6 +112,20 @@ "JurisdictionType", "JurisdictionName", "TaxType", + # Cart Tax Calculation Models + "CalculateCartRequest", + "CalculateCartResponse", + "CartAddress", + "CartCurrency", + "CartItem", + "CartItemResponse", + "CartLineItem", + "CartLineItemResponse", + "CartTax", + # TaxCloud Cart Models + "TaxCloudCalculateCartResponse", + "TaxCloudCartItemResponse", + "TaxCloudCartLineItemResponse", # TaxCloud Models "TaxCloudAddress", "TaxCloudAddressResponse", diff --git a/src/ziptax/models/__init__.py b/src/ziptax/models/__init__.py index 23cacb3..9acca61 100644 --- a/src/ziptax/models/__init__.py +++ b/src/ziptax/models/__init__.py @@ -1,10 +1,19 @@ """Models module for ZipTax SDK.""" from .responses import ( + CalculateCartRequest, + CalculateCartResponse, + CartAddress, + CartCurrency, + CartItem, CartItemRefundWithTaxRequest, CartItemRefundWithTaxResponse, + CartItemResponse, CartItemWithTax, CartItemWithTaxResponse, + CartLineItem, + CartLineItemResponse, + CartTax, CreateOrderRequest, Currency, CurrencyResponse, @@ -18,6 +27,9 @@ Tax, TaxCloudAddress, TaxCloudAddressResponse, + TaxCloudCalculateCartResponse, + TaxCloudCartItemResponse, + TaxCloudCartLineItemResponse, TaxType, UpdateOrderRequest, V60AccountMetrics, @@ -55,6 +67,20 @@ "JurisdictionType", "JurisdictionName", "TaxType", + # Cart Tax Calculation Models + "CalculateCartRequest", + "CalculateCartResponse", + "CartAddress", + "CartCurrency", + "CartItem", + "CartItemResponse", + "CartLineItem", + "CartLineItemResponse", + "CartTax", + # TaxCloud Cart Models + "TaxCloudCalculateCartResponse", + "TaxCloudCartItemResponse", + "TaxCloudCartLineItemResponse", # TaxCloud Models "TaxCloudAddress", "TaxCloudAddressResponse", diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index fc6900e..97d654a 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -1,7 +1,7 @@ """Response models for the ZipTax API.""" from enum import Enum -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import AliasChoices, BaseModel, ConfigDict, Field @@ -360,6 +360,222 @@ class V60PostalCodeResponse(BaseModel): ) +# ============================================================================= +# ZipTax Cart Tax Calculation Models +# ============================================================================= + + +class CartAddress(BaseModel): + """Simple address structure for cart tax calculation (single string format).""" + + model_config = ConfigDict(populate_by_name=True) + + address: str = Field(..., description="Full address string for geocoding") + + +class CartCurrency(BaseModel): + """Currency information for cart request.""" + + model_config = ConfigDict(populate_by_name=True) + + currency_code: Literal["USD"] = Field( + ..., alias="currencyCode", description="ISO currency code (must be USD)" + ) + + +class CartLineItem(BaseModel): + """A line item in the cart request with product details for tax calculation.""" + + model_config = ConfigDict(populate_by_name=True) + + item_id: str = Field( + ..., alias="itemId", description="Unique identifier for the line item" + ) + price: float = Field( + ..., gt=0, description="Unit price of the item (must be greater than 0)" + ) + quantity: float = Field( + ..., gt=0, description="Quantity of the item (must be greater than 0)" + ) + taxability_code: Optional[int] = Field( + None, + alias="taxabilityCode", + description="Taxability code for product-specific tax rules", + ) + + +class CartItem(BaseModel): + """A single cart containing customer info, addresses, currency, and line items.""" + + model_config = ConfigDict(populate_by_name=True) + + customer_id: str = Field(..., alias="customerId", description="Customer identifier") + currency: CartCurrency = Field( + ..., description="Currency information (must be USD)" + ) + destination: CartAddress = Field( + ..., description="Destination address used for tax calculation" + ) + origin: CartAddress = Field(..., description="Origin address") + line_items: List[CartLineItem] = Field( + ..., + alias="lineItems", + min_length=1, + max_length=250, + description="Array of line items in the cart (1-250 items)", + ) + + +class CalculateCartRequest(BaseModel): + """Request payload for calculating sales tax on a shopping cart.""" + + model_config = ConfigDict(populate_by_name=True) + + items: List[CartItem] = Field( + ..., + min_length=1, + max_length=1, + description="Array of cart items (must contain exactly 1 element)", + ) + + +class CartTax(BaseModel): + """Calculated tax details for a cart line item.""" + + model_config = ConfigDict(populate_by_name=True) + + rate: float = Field( + ..., description="Calculated sales tax rate (rounded to 5 decimal places)" + ) + amount: float = Field( + ..., + description="Calculated tax amount: (price x quantity) x rate", + ) + + +class CartLineItemResponse(BaseModel): + """A line item in the cart response with calculated tax rate and amount.""" + + model_config = ConfigDict(populate_by_name=True) + + item_id: str = Field( + ..., alias="itemId", description="Unique identifier for the line item" + ) + price: float = Field(..., description="Unit price of the item") + quantity: float = Field(..., description="Quantity of the item") + tax: CartTax = Field( + ..., description="Calculated tax information for this line item" + ) + + +class CartItemResponse(BaseModel): + """A single cart response with calculated tax information per line item.""" + + model_config = ConfigDict(populate_by_name=True) + + cart_id: str = Field( + ..., + alias="cartId", + description="Server-generated UUID identifying this cart calculation", + ) + customer_id: str = Field(..., alias="customerId", description="Customer identifier") + destination: CartAddress = Field(..., description="Destination address") + origin: CartAddress = Field(..., description="Origin address") + line_items: List[CartLineItemResponse] = Field( + ..., + alias="lineItems", + description="Array of line items with calculated tax information", + ) + + +class CalculateCartResponse(BaseModel): + """Response from cart tax calculation containing per-item tax details.""" + + model_config = ConfigDict(populate_by_name=True) + + items: List[CartItemResponse] = Field(..., description="Array of cart results") + + +# ============================================================================= +# TaxCloud Cart Tax Calculation Models +# ============================================================================= + + +class TaxCloudCartLineItemResponse(BaseModel): + """A line item in the TaxCloud cart response with calculated tax details.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field( + ..., description="Position/index of item within the cart (0-based)" + ) + item_id: str = Field( + ..., alias="itemId", description="Unique identifier for the line item" + ) + price: float = Field(..., description="Unit price of the item") + quantity: float = Field(..., description="Quantity of the item") + tax: "Tax" = Field(..., description="Calculated tax information for this line item") + tic: Optional[int] = Field( + None, description="Taxability Information Code (mapped from taxabilityCode)" + ) + + +class TaxCloudCartItemResponse(BaseModel): + """A single cart response from TaxCloud with calculated tax information.""" + + model_config = ConfigDict(populate_by_name=True) + + cart_id: str = Field( + ..., + alias="cartId", + description="ID representing this cart calculation", + ) + customer_id: str = Field(..., alias="customerId", description="Customer identifier") + currency: "CurrencyResponse" = Field(..., description="Currency information") + delivered_by_seller: bool = Field( + ..., + alias="deliveredBySeller", + description="Whether the seller directly delivered the order", + ) + destination: "TaxCloudAddressResponse" = Field( + ..., description="Destination address (structured format)" + ) + origin: "TaxCloudAddressResponse" = Field( + ..., description="Origin address (structured format)" + ) + exemption: "Exemption" = Field(..., description="Exemption information") + line_items: List["TaxCloudCartLineItemResponse"] = Field( + ..., + alias="lineItems", + description="Array of line items with calculated tax information", + ) + + +class TaxCloudCalculateCartResponse(BaseModel): + """Response from TaxCloud cart tax calculation. + + Returned by CalculateCart when the client is configured with TaxCloud + credentials. Contains the connectionId, transactionDate, and an array + of cart results with TaxCloud-style structured addresses and line items. + """ + + model_config = ConfigDict(populate_by_name=True) + + connection_id: str = Field( + ..., + alias="connectionId", + description="TaxCloud Connection ID used for this cart calculation", + ) + items: List[TaxCloudCartItemResponse] = Field( + ..., description="Array of cart results with calculated tax information" + ) + transaction_date: str = Field( + ..., + alias="transactionDate", + description="RFC3339 datetime string the cart was calculated for", + ) + + # ============================================================================= # TaxCloud API Models - Order Management # ============================================================================= diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index e9a4e1f..22d28b9 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -1,15 +1,18 @@ """API functions for the ZipTax SDK.""" import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from ..config import Config from ..exceptions import ZipTaxCloudConfigError from ..models import ( + CalculateCartRequest, + CalculateCartResponse, CreateOrderRequest, OrderResponse, RefundTransactionRequest, RefundTransactionResponse, + TaxCloudCalculateCartResponse, UpdateOrderRequest, V60AccountMetrics, V60PostalCodeResponse, @@ -18,6 +21,7 @@ from ..utils.http import HTTPClient from ..utils.retry import retry_with_backoff from ..utils.validation import ( + parse_address_string, validate_address, validate_address_autocomplete, validate_coordinates, @@ -232,6 +236,186 @@ def _make_request() -> Dict[str, Any]: response_data = _make_request() return V60PostalCodeResponse(**response_data) + # ========================================================================= + # Cart Tax Calculation (Dual API Routing) + # ========================================================================= + + def CalculateCart( + self, + request: CalculateCartRequest, + ) -> Union[CalculateCartResponse, TaxCloudCalculateCartResponse]: + """Calculate sales tax for a shopping cart with multiple line items. + + Routes to TaxCloud API when TaxCloud credentials are configured, + otherwise uses the ZipTax API. The input contract (CalculateCartRequest) + is the same regardless of which backend is used. The response type + differs based on the backend. + + Args: + request: CalculateCartRequest object with cart details including + customer ID, addresses, currency, and line items + + Returns: + CalculateCartResponse when using ZipTax API, or + TaxCloudCalculateCartResponse when using TaxCloud API + + Raises: + ZipTaxAPIError: If the API returns an error + ZipTaxValidationError: If address parsing fails (TaxCloud route) + + Example: + >>> from ziptax.models import ( + ... CalculateCartRequest, CartItem, CartAddress, + ... CartCurrency, CartLineItem + ... ) + >>> request = CalculateCartRequest( + ... items=[ + ... CartItem( + ... customer_id="customer-453", + ... currency=CartCurrency(currency_code="USD"), + ... destination=CartAddress( + ... address="200 Spectrum Center Dr, Irvine, CA 92618" + ... ), + ... origin=CartAddress( + ... address="323 Washington Ave N, Minneapolis, MN 55401" + ... ), + ... line_items=[ + ... CartLineItem( + ... item_id="item-1", + ... price=10.75, + ... quantity=1.5, + ... ) + ... ], + ... ) + ... ] + ... ) + >>> result = client.request.CalculateCart(request) + """ + # Route to TaxCloud if configured + if self.config.has_taxcloud_config and self.taxcloud_http_client is not None: + return self._calculate_cart_taxcloud(request) + + return self._calculate_cart_ziptax(request) + + def _calculate_cart_ziptax( + self, + request: CalculateCartRequest, + ) -> CalculateCartResponse: + """Send cart calculation request to ZipTax API. + + Args: + request: CalculateCartRequest object + + Returns: + CalculateCartResponse with per-item tax calculations + """ + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Dict[str, Any]: + return self.http_client.post( + "/calculate/cart", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + response_data = _make_request() + return CalculateCartResponse(**response_data) + + def _calculate_cart_taxcloud( + self, + request: CalculateCartRequest, + ) -> TaxCloudCalculateCartResponse: + """Transform and send cart calculation request to TaxCloud API. + + Transforms the CalculateCartRequest into TaxCloud's expected format: + - Parses single-string addresses into structured components + - Maps taxabilityCode to tic (defaults to 0) + - Adds index to each line item (0-based) + + Args: + request: CalculateCartRequest object + + Returns: + TaxCloudCalculateCartResponse with TaxCloud-style results + + Raises: + ZipTaxValidationError: If address parsing fails + """ + assert self.taxcloud_http_client is not None + + # Transform request to TaxCloud format + taxcloud_body = self._transform_cart_for_taxcloud(request) + + # Build path with connection ID + path = f"/tax/connections/{self.config.taxcloud_connection_id}/carts" + + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> Dict[str, Any]: + assert self.taxcloud_http_client is not None + return self.taxcloud_http_client.post( + path, + json=taxcloud_body, + ) + + response_data = _make_request() + return TaxCloudCalculateCartResponse(**response_data) + + @staticmethod + def _transform_cart_for_taxcloud( + request: CalculateCartRequest, + ) -> Dict[str, Any]: + """Transform a CalculateCartRequest into TaxCloud's request format. + + Args: + request: CalculateCartRequest with ZipTax-style addresses + + Returns: + Dictionary matching TaxCloud's expected request body schema + + Raises: + ZipTaxValidationError: If address string cannot be parsed + """ + items = [] + for cart_item in request.items: + # Parse addresses from single strings to structured components + destination = parse_address_string(cart_item.destination.address) + origin = parse_address_string(cart_item.origin.address) + + # Transform line items: add index, map taxabilityCode -> tic + line_items = [] + for idx, line_item in enumerate(cart_item.line_items): + tc_line_item: Dict[str, Any] = { + "index": idx, + "itemId": line_item.item_id, + "price": line_item.price, + "quantity": line_item.quantity, + "tic": ( + line_item.taxability_code + if line_item.taxability_code is not None + else 0 + ), + } + line_items.append(tc_line_item) + + items.append( + { + "customerId": cart_item.customer_id, + "currency": { + "currencyCode": cart_item.currency.currency_code, + }, + "destination": destination, + "origin": origin, + "lineItems": line_items, + } + ) + + return {"items": items} + # ========================================================================= # TaxCloud API - Order Management Functions # ========================================================================= diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index e04e98b..28d3de7 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 +from typing import Dict from ..exceptions import ZipTaxValidationError @@ -182,3 +183,68 @@ def validate_postal_code(postal_code: str) -> None: f"Postal code must be in 5-digit format (e.g., 92694), " f"got: {postal_code}" ) + + +def parse_address_string(address: str) -> Dict[str, str]: + """Parse a single address string into structured TaxCloud address components. + + Parses addresses in the format: + "line1, city, state zip" or "line1, city, state zip-plus4" + + Examples: + "200 Spectrum Center Dr, Irvine, CA 92618" + "323 Washington Ave N, Minneapolis, MN 55401-2427" + + Args: + address: Full address string to parse + + Returns: + Dictionary with keys: line1, city, state, zip, countryCode + + Raises: + ZipTaxValidationError: If the address cannot be parsed into + the required components. The address must contain at least + 3 comma-separated segments, and the last segment must contain + a valid state abbreviation and ZIP code. + """ + if not address or not address.strip(): + raise ZipTaxValidationError( + "Address string cannot be empty. " + "Expected format: 'street, city, state zip'" + ) + + # Split by comma and strip whitespace + parts = [p.strip() for p in address.split(",")] + + if len(parts) < 3: + raise ZipTaxValidationError( + f"Cannot parse address into structured components. " + f"Expected at least 3 comma-separated parts " + f"(street, city, state zip), got {len(parts)}: {address!r}" + ) + + # line1 is everything before the last two segments + # city is the second-to-last segment + # state + zip is the last segment + line1 = ", ".join(parts[:-2]) + city = parts[-2] + state_zip = parts[-1] + + # Parse state and zip from the last segment (e.g., "CA 92618" or "CA 92618-1905") + state_zip_match = re.match(r"^([A-Za-z]{2})\s+(\d{5}(?:-\d{4})?)$", state_zip) + if not state_zip_match: + raise ZipTaxValidationError( + f"Cannot parse state and ZIP from address segment: {state_zip!r}. " + f"Expected format: 'ST 12345' or 'ST 12345-6789'" + ) + + state = state_zip_match.group(1).upper() + zip_code = state_zip_match.group(2) + + return { + "line1": line1, + "city": city, + "state": state, + "zip": zip_code, + "countryCode": "US", + } diff --git a/tests/conftest.py b/tests/conftest.py index fde00a7..2e1f9b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -190,6 +190,92 @@ def sample_postal_code_response(): } +@pytest.fixture +def sample_calculate_cart_response(): + """Sample CalculateCartResponse data for testing (matches actual API format).""" + return { + "items": [ + { + "cartId": "ce4a1234-5678-90ab-cdef-1234567890ab", + "customerId": "customer-453", + "destination": { + "address": "200 Spectrum Center Dr, Irvine, CA 92618-1905" + }, + "origin": { + "address": "323 Washington Ave N, Minneapolis, MN 55401-2427" + }, + "lineItems": [ + { + "itemId": "item-1", + "price": 10.75, + "quantity": 1.5, + "tax": {"rate": 0.09025, "amount": 1.45528}, + }, + { + "itemId": "item-2", + "price": 25.00, + "quantity": 2.0, + "tax": {"rate": 0.09025, "amount": 4.5125}, + }, + ], + } + ] + } + + +@pytest.fixture +def sample_taxcloud_calculate_cart_response(): + """Sample TaxCloudCalculateCartResponse data for testing.""" + return { + "connectionId": "test-connection-id-uuid", + "items": [ + { + "cartId": "ce4a1234-5678-90ab-cdef-1234567890ab", + "customerId": "customer-453", + "currency": {"currencyCode": "USD"}, + "deliveredBySeller": False, + "destination": { + "line1": "200 Spectrum Center Dr", + "city": "Irvine", + "state": "CA", + "zip": "92618-1905", + "countryCode": "US", + }, + "origin": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US", + }, + "exemption": { + "exemptionId": None, + "isExempt": None, + }, + "lineItems": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.75, + "quantity": 1.5, + "tax": {"amount": 1.46, "rate": 0.0903}, + "tic": 0, + }, + { + "index": 1, + "itemId": "item-2", + "price": 25.00, + "quantity": 2.0, + "tax": {"amount": 4.52, "rate": 0.0903}, + "tic": 0, + }, + ], + } + ], + "transactionDate": "2024-01-15T09:30:00Z", + } + + @pytest.fixture def sample_order_response(): """Sample TaxCloud OrderResponse data for testing.""" diff --git a/tests/test_functions.py b/tests/test_functions.py index b2b8551..6021aff 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -1,13 +1,21 @@ """Tests for API functions.""" import pytest +from pydantic import ValidationError from ziptax.exceptions import ZipTaxCloudConfigError, ZipTaxValidationError from ziptax.models import ( + CalculateCartRequest, + CalculateCartResponse, + CartAddress, + CartCurrency, + CartItem, + CartLineItem, CreateOrderRequest, OrderResponse, RefundTransactionRequest, RefundTransactionResponse, + TaxCloudCalculateCartResponse, UpdateOrderRequest, V60AccountMetrics, V60PostalCodeResponse, @@ -332,6 +340,770 @@ def test_response_fields( assert result.tax_use == 0.0775 +class TestCalculateCart: + """Test cases for CalculateCart function.""" + + def _build_request(self): + """Build a sample CalculateCartRequest for testing.""" + return CalculateCartRequest( + items=[ + CartItem( + customer_id="customer-453", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress( + address="200 Spectrum Center Dr, Irvine, CA 92618-1905" + ), + origin=CartAddress( + address="323 Washington Ave N, Minneapolis, MN 55401-2427" + ), + line_items=[ + CartLineItem( + item_id="item-1", + price=10.75, + quantity=1.5, + ), + CartLineItem( + item_id="item-2", + price=25.00, + quantity=2.0, + taxability_code=0, + ), + ], + ) + ] + ) + + def test_basic_request( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test basic cart tax calculation request.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + response = functions.CalculateCart(request) + + assert isinstance(response, CalculateCartResponse) + assert len(response.items) == 1 + assert response.items[0].cart_id == "ce4a1234-5678-90ab-cdef-1234567890ab" + assert response.items[0].customer_id == "customer-453" + mock_http_client.post.assert_called_once() + + def test_request_uses_correct_path( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test that CalculateCart calls the correct API path.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_http_client.post.call_args + assert call_args[0][0] == "/calculate/cart" + + def test_request_body_serialization( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test that request body uses camelCase field names (by_alias).""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_http_client.post.call_args + json_body = call_args[1]["json"] + + # Verify top-level structure + assert "items" in json_body + assert len(json_body["items"]) == 1 + + cart = json_body["items"][0] + # Verify camelCase aliases are used + assert "customerId" in cart + assert cart["customerId"] == "customer-453" + assert "lineItems" in cart + assert len(cart["lineItems"]) == 2 + + # Verify line item serialization + line_item = cart["lineItems"][0] + assert "itemId" in line_item + assert line_item["itemId"] == "item-1" + assert line_item["price"] == 10.75 + assert line_item["quantity"] == 1.5 + + def test_optional_taxability_code_excluded_when_none( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test that taxabilityCode is excluded from JSON when not set.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = CalculateCartRequest( + items=[ + CartItem( + customer_id="cust-1", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress( + address="200 Spectrum Center Dr, Irvine, CA 92618" + ), + origin=CartAddress( + address="323 Washington Ave N, Minneapolis, MN 55401" + ), + line_items=[ + CartLineItem( + item_id="item-1", + price=10.00, + quantity=1.0, + ) + ], + ) + ] + ) + functions.CalculateCart(request) + + call_args = mock_http_client.post.call_args + json_body = call_args[1]["json"] + line_item = json_body["items"][0]["lineItems"][0] + assert "taxabilityCode" not in line_item + + def test_optional_taxability_code_included_when_set( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test that taxabilityCode is included in JSON when set.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_http_client.post.call_args + json_body = call_args[1]["json"] + # Second line item has taxability_code=0 + line_item = json_body["items"][0]["lineItems"][1] + assert "taxabilityCode" in line_item + assert line_item["taxabilityCode"] == 0 + + def test_response_line_items_parsed( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test that response line items and tax details are properly parsed.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + response = functions.CalculateCart(request) + + cart = response.items[0] + assert len(cart.line_items) == 2 + + # First line item + item1 = cart.line_items[0] + assert item1.item_id == "item-1" + assert item1.price == 10.75 + assert item1.quantity == 1.5 + assert item1.tax.rate == 0.09025 + assert item1.tax.amount == 1.45528 + + # Second line item + item2 = cart.line_items[1] + assert item2.item_id == "item-2" + assert item2.price == 25.00 + assert item2.quantity == 2.0 + assert item2.tax.rate == 0.09025 + assert item2.tax.amount == 4.5125 + + def test_response_addresses_parsed( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test that response addresses are properly parsed.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + response = functions.CalculateCart(request) + + cart = response.items[0] + assert ( + cart.destination.address == "200 Spectrum Center Dr, Irvine, CA 92618-1905" + ) + assert cart.origin.address == "323 Washington Ave N, Minneapolis, MN 55401-2427" + + # ----- Pydantic Validation Tests ----- + + def test_price_must_be_greater_than_zero(self): + """Test that CartLineItem rejects price <= 0.""" + with pytest.raises(ValidationError, match="greater than 0"): + CartLineItem(item_id="item-1", price=0, quantity=1.0) + + with pytest.raises(ValidationError, match="greater than 0"): + CartLineItem(item_id="item-1", price=-5.00, quantity=1.0) + + def test_quantity_must_be_greater_than_zero(self): + """Test that CartLineItem rejects quantity <= 0.""" + with pytest.raises(ValidationError, match="greater than 0"): + CartLineItem(item_id="item-1", price=10.00, quantity=0) + + with pytest.raises(ValidationError, match="greater than 0"): + CartLineItem(item_id="item-1", price=10.00, quantity=-1.0) + + def test_items_must_contain_exactly_one_cart(self): + """Test that CalculateCartRequest rejects empty or multi-cart arrays.""" + with pytest.raises(ValidationError, match="too_short"): + CalculateCartRequest(items=[]) + + cart = CartItem( + customer_id="cust-1", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress(address="123 Main St"), + origin=CartAddress(address="456 Other St"), + line_items=[CartLineItem(item_id="item-1", price=10.00, quantity=1.0)], + ) + with pytest.raises(ValidationError, match="too_long"): + CalculateCartRequest(items=[cart, cart]) + + def test_line_items_must_have_at_least_one(self): + """Test that CartItem rejects empty line_items.""" + with pytest.raises(ValidationError, match="too_short"): + CartItem( + customer_id="cust-1", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress(address="123 Main St"), + origin=CartAddress(address="456 Other St"), + line_items=[], + ) + + def test_currency_code_must_be_usd(self): + """Test that CartCurrency rejects non-USD currency codes.""" + with pytest.raises(ValidationError, match="literal_error"): + CartCurrency(currency_code="EUR") + + +class TestCalculateCartTaxCloudRouting: + """Test cases for CalculateCart TaxCloud routing.""" + + def _build_request(self): + """Build a sample CalculateCartRequest for testing.""" + return CalculateCartRequest( + items=[ + CartItem( + customer_id="customer-453", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress( + address="200 Spectrum Center Dr, Irvine, CA 92618-1905" + ), + origin=CartAddress( + address="323 Washington Ave N, Minneapolis, MN 55401-2427" + ), + line_items=[ + CartLineItem( + item_id="item-1", + price=10.75, + quantity=1.5, + ), + CartLineItem( + item_id="item-2", + price=25.00, + quantity=2.0, + taxability_code=0, + ), + ], + ) + ] + ) + + # ----- Routing Logic Tests ----- + + def test_routes_to_ziptax_without_taxcloud_config( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + ): + """Test that CalculateCart routes to ZipTax when TaxCloud is not configured.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + response = functions.CalculateCart(request) + + assert isinstance(response, CalculateCartResponse) + mock_http_client.post.assert_called_once() + call_args = mock_http_client.post.call_args + assert call_args[0][0] == "/calculate/cart" + + def test_routes_to_taxcloud_with_taxcloud_config( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that CalculateCart routes to TaxCloud when configured.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + response = functions.CalculateCart(request) + + assert isinstance(response, TaxCloudCalculateCartResponse) + mock_taxcloud_http_client.post.assert_called_once() + # ZipTax http_client should NOT be called + mock_http_client.post.assert_not_called() + + def test_taxcloud_uses_correct_path( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that TaxCloud route uses the correct API path with connectionId.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + assert call_args[0][0] == "/tax/connections/test-connection-id-uuid/carts" + + # ----- Request Transformation Tests ----- + + def test_address_parsing_destination( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that destination address is parsed into structured components.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + json_body = call_args[1]["json"] + dest = json_body["items"][0]["destination"] + assert dest["line1"] == "200 Spectrum Center Dr" + assert dest["city"] == "Irvine" + assert dest["state"] == "CA" + assert dest["zip"] == "92618-1905" + + def test_address_parsing_origin( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that origin address is parsed into structured components.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + json_body = call_args[1]["json"] + origin = json_body["items"][0]["origin"] + assert origin["line1"] == "323 Washington Ave N" + assert origin["city"] == "Minneapolis" + assert origin["state"] == "MN" + assert origin["zip"] == "55401-2427" + + def test_line_items_have_index( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that line items get 0-based index added.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + json_body = call_args[1]["json"] + line_items = json_body["items"][0]["lineItems"] + assert line_items[0]["index"] == 0 + assert line_items[1]["index"] == 1 + + def test_taxability_code_mapped_to_tic( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that taxabilityCode is mapped to tic field.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + json_body = call_args[1]["json"] + line_items = json_body["items"][0]["lineItems"] + # item-1 has no taxability_code -> tic defaults to 0 + assert line_items[0]["tic"] == 0 + # item-2 has taxability_code=0 -> tic is 0 + assert line_items[1]["tic"] == 0 + + def test_taxability_code_nonzero_mapped_to_tic( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that a non-zero taxabilityCode is passed through to tic.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = CalculateCartRequest( + items=[ + CartItem( + customer_id="cust-1", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress( + address="200 Spectrum Center Dr, Irvine, CA 92618" + ), + origin=CartAddress( + address="323 Washington Ave N, Minneapolis, MN 55401" + ), + line_items=[ + CartLineItem( + item_id="item-1", + price=10.00, + quantity=1.0, + taxability_code=31000, + ) + ], + ) + ] + ) + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + json_body = call_args[1]["json"] + assert json_body["items"][0]["lineItems"][0]["tic"] == 31000 + + def test_currency_code_passed_through( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that currency code is passed through to TaxCloud.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + json_body = call_args[1]["json"] + assert json_body["items"][0]["currency"]["currencyCode"] == "USD" + + def test_customer_id_passed_through( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that customerId is passed through to TaxCloud.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + functions.CalculateCart(request) + + call_args = mock_taxcloud_http_client.post.call_args + json_body = call_args[1]["json"] + assert json_body["items"][0]["customerId"] == "customer-453" + + # ----- Response Parsing Tests ----- + + def test_taxcloud_response_parsed( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test that TaxCloud response is properly parsed.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + response = functions.CalculateCart(request) + + assert isinstance(response, TaxCloudCalculateCartResponse) + assert response.connection_id == "test-connection-id-uuid" + assert response.transaction_date == "2024-01-15T09:30:00Z" + assert len(response.items) == 1 + + def test_taxcloud_response_cart_fields( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test TaxCloud response cart item fields are properly parsed.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + response = functions.CalculateCart(request) + + cart = response.items[0] + assert cart.cart_id == "ce4a1234-5678-90ab-cdef-1234567890ab" + assert cart.customer_id == "customer-453" + assert cart.currency.currency_code == "USD" + assert cart.delivered_by_seller is False + assert cart.exemption.exemption_id is None + assert cart.exemption.is_exempt is None + + def test_taxcloud_response_addresses( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test TaxCloud response structured addresses are properly parsed.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + response = functions.CalculateCart(request) + + cart = response.items[0] + assert cart.destination.line1 == "200 Spectrum Center Dr" + assert cart.destination.city == "Irvine" + assert cart.destination.state == "CA" + assert cart.destination.zip == "92618-1905" + assert cart.destination.country_code == "US" + assert cart.origin.line1 == "323 Washington Ave N" + assert cart.origin.city == "Minneapolis" + assert cart.origin.state == "MN" + assert cart.origin.zip == "55401-2427" + assert cart.origin.country_code == "US" + + def test_taxcloud_response_line_items( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_taxcloud_calculate_cart_response, + ): + """Test TaxCloud response line items with tax details are parsed.""" + mock_taxcloud_http_client.post.return_value = ( + sample_taxcloud_calculate_cart_response + ) + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = self._build_request() + response = functions.CalculateCart(request) + + cart = response.items[0] + assert len(cart.line_items) == 2 + + item1 = cart.line_items[0] + assert item1.index == 0 + assert item1.item_id == "item-1" + assert item1.price == 10.75 + assert item1.quantity == 1.5 + assert item1.tax.rate == 0.0903 + assert item1.tax.amount == 1.46 + assert item1.tic == 0 + + item2 = cart.line_items[1] + assert item2.index == 1 + assert item2.item_id == "item-2" + assert item2.price == 25.00 + assert item2.quantity == 2.0 + assert item2.tax.rate == 0.0903 + assert item2.tax.amount == 4.52 + assert item2.tic == 0 + + # ----- Address Parsing Error Tests ----- + + def test_invalid_address_raises_validation_error( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + ): + """Test that unparseable address raises ZipTaxValidationError.""" + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = CalculateCartRequest( + items=[ + CartItem( + customer_id="cust-1", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress(address="bad address"), + origin=CartAddress( + address="323 Washington Ave N, Minneapolis, MN 55401" + ), + line_items=[ + CartLineItem(item_id="item-1", price=10.00, quantity=1.0) + ], + ) + ] + ) + + with pytest.raises(ZipTaxValidationError, match="Cannot parse address"): + functions.CalculateCart(request) + + def test_address_missing_state_zip_raises_error( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + ): + """Test that address with invalid state/zip segment raises error.""" + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = CalculateCartRequest( + items=[ + CartItem( + customer_id="cust-1", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress( + address="200 Spectrum Center Dr, Irvine, California" + ), + origin=CartAddress( + address="323 Washington Ave N, Minneapolis, MN 55401" + ), + line_items=[ + CartLineItem(item_id="item-1", price=10.00, quantity=1.0) + ], + ) + ] + ) + + with pytest.raises(ZipTaxValidationError, match="Cannot parse state and ZIP"): + functions.CalculateCart(request) + + class TestTaxCloudFunctions: """Test cases for TaxCloud order management functions."""