diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d4c72..ad2eb09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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**: Automatic tax sourcing resolution in `CalculateCart()` + - Looks up both origin and destination addresses via `GetSalesTaxByAddress` + - Extracts state from V60 normalized addresses to determine intrastate vs interstate + - Interstate transactions: uses destination address (sourcing skipped) + - Intrastate transactions: checks `sourcingRules.value` — uses origin address for "O", destination for "D" + - Overrides both addresses in the cart payload with the resolved address before sending to the API +- **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"]`) +- **Internal Helpers**: + - `_extract_state_from_normalized_address()`: parses state from V60 normalized address format + - `_resolve_sourcing_address()`: resolves which address to use for tax calculation +- **Test Coverage**: 122 tests with 96% code coverage + - `TestExtractStateFromNormalizedAddress`: 5 tests for state parsing + - `TestResolveSourcingAddress`: 4 tests for sourcing resolution + - `TestCalculateCart`: 16 tests for cart calculation, sourcing integration, and validation +- **Documentation**: + - CalculateCart usage guide with code examples in README.md + - Origin/destination sourcing explanation 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 +- Sourcing resolution always calls the V60 API for both addresses (no heuristic parsing) +- 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 with 96% coverage + ## [0.2.0-beta] - 2025-02-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 65cf9a3..0971a21 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,15 @@ def _check_taxcloud_config(self) -> None: def CreateOrder(self, request: CreateOrderRequest, ...) -> OrderResponse: self._check_taxcloud_config() # Guards against missing credentials # ... implementation + +# CalculateCart resolves origin/destination sourcing before calling the API +def CalculateCart(self, request: CalculateCartRequest) -> CalculateCartResponse: + cart = request.items[0] + resolved_address = self._resolve_sourcing_address( + origin_address=cart.origin.address, + destination_address=cart.destination.address, + ) + # Override both addresses with the resolved address, then POST to /calculate/cart ``` #### 4. **HTTP Client (`utils/http.py`)** @@ -155,6 +165,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 +556,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 +785,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.1-beta **Maintained By**: ZipTax Team diff --git a/README.md b/README.md index 12491c1..5584cd9 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 automatic origin/destination sourcing - 🔄 Automatic retry logic with exponential backoff - ✅ Input validation - 🔍 Type hints for better IDE support @@ -148,6 +149,86 @@ 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. The SDK automatically resolves origin/destination sourcing rules before sending the request to the 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 +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}") +``` + +#### Origin/Destination Sourcing + +The SDK automatically determines whether to use origin-based or destination-based tax rates: + +- **Interstate** (different states): Uses the destination address +- **Intrastate, destination-based** (e.g., CA, NY): Uses the destination address +- **Intrastate, origin-based** (e.g., TX, OH): Uses the origin address + +This is handled transparently -- the SDK looks up both addresses via `GetSalesTaxByAddress`, checks the `sourcingRules.value` field, and sends the correct address to the cart API. + +#### 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 +603,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 with origin/destination sourcing #### TaxCloud API Methods (Optional) diff --git a/docs/spec.yaml b/docs/spec.yaml index a86689e..b1f6698 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.1-beta" description: "Official Python SDK for the ZipTax API with optional TaxCloud order management support" # Repository information @@ -250,6 +250,148 @@ resources: type: "V60AccountMetrics" is_array: false + # ZipTax Cart Tax Calculation Endpoint + - 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 using the v60 tax engine, and returns tax rate and amount for each line item." + operation_id: "calculateCart" + parameters: [] + request_body: + type: "CalculateCartRequest" + description: "Cart with line items, addresses, and currency for tax calculation" + returns: + type: "CalculateCartResponse" + is_array: false + status_code: 200 + authentication: + type: "header" + header: "X-API-Key" + format: "{apiKey}" + note: "Uses ZipTax API Key configured during client initialization. See api.ziptax.authentication for canonical auth configuration." + 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, passed to v60 tax engine if provided)" + origin_destination_sourcing: + description: | + Determines whether sales tax should be calculated using the + origin (shipper) address or the destination (recipient) address, + based on the state's sourcing rules. The SDK resolves the + correct address automatically, then sends it to the cart API + for tax calculation. + concept: | + US states follow either origin-based or destination-based + sourcing rules. Origin-based states (e.g., TX, OH, PA) collect + tax based on the seller's location. Destination-based states + (e.g., CA, NY, FL) collect tax based on the buyer's location. + The v60 API exposes this via sourcingRules.value ("O" or "D") + in its response. + sdk_approach: | + The SDK resolves the correct address, then delegates tax + calculation to the API. It does NOT calculate tax locally. + Flow: SDK calls GetSalesTaxByAddress to determine which + address to use → overrides both origin and destination in the + cart request with the resolved address → sends the modified + request to POST /calculate/cart for the API to calculate. + state_parsing: | + The SDK always calls GetSalesTaxByAddress for both the origin + and destination addresses to obtain normalized addresses from + the V60 API. It does NOT attempt heuristic/regex parsing of + the raw input strings. The state is extracted from the + V60Response.addressDetail.normalizedAddress field using a + regex pattern matching ", XX NNNNN" (two-letter state code + followed by a 5-digit ZIP code). + resolution_steps: + - step: 1 + action: "Look up both addresses via the V60 API" + detail: | + Always call GetSalesTaxByAddress for both the destination + and origin addresses. Extract the state abbreviation from + each V60Response.addressDetail.normalizedAddress field. + - step: 2 + action: "Compare states to determine intrastate vs interstate" + detail: | + If the origin and destination states differ (interstate + transaction), skip sourcing resolution entirely and use the + destination address for the cart API call. + - step: 3 + action: "For intrastate: check sourcingRules.value from the destination lookup" + detail: | + If sourcingRules.value == "D" (destination-based): + - Use the destination address for the cart API call. + If sourcingRules.value == "O" (origin-based): + - Use the origin address for the cart API call. + - step: 4 + action: "Send resolved address to POST /calculate/cart" + detail: | + Override both the origin and destination addresses in the + cart request payload with the resolved address, then POST + to /calculate/cart. The API performs the actual tax + calculation using the resolved address's rates. + interstate_behavior: | + When the origin and destination states differ (interstate + transaction), sourcing resolution is skipped. The SDK always + uses the destination address for interstate transactions, + regardless of sourcingRules.value. + v60_fields_used: + - field: "sourcingRules.value" + values: ["O", "D"] + description: "'O' = origin-based taxation, 'D' = destination-based taxation" + - field: "addressDetail.normalizedAddress" + description: "Always used to extract the state (no heuristic fallback)" + - field: "taxSummaries[].rate" + description: "The tax rate the API applies to line items (via POST /calculate/cart)" + tax_calculation: + formula: "taxAmount = (price x quantity) x taxRate" + rounding: "5 decimal places for both taxRate and taxAmount" + note: | + Tax calculation is performed by the API via POST /calculate/cart, + not by the SDK locally. The SDK's role is to resolve the correct + address and pass it to the API. + example: | + price = 10.75 + quantity = 1.5 + taxRate = 0.09025 + taxAmount = (10.75 x 1.5) x 0.09025 = 1.45528 + origin_destination_example: | + Scenario: Seller in TX (origin-based state), buyer in TX (intrastate) + 1. SDK calls GetSalesTaxByAddress for both addresses + 2. Both resolve to TX → intrastate + 3. Destination lookup returns sourcingRules.value = "O" + 4. SDK overrides both addresses with origin "123 Main St, Dallas, TX 75201" + 5. SDK POSTs to /calculate/cart → API calculates using origin rates + + Scenario: Seller in MN, buyer in CA (interstate) + 1. SDK calls GetSalesTaxByAddress for both addresses + 2. States differ (MN vs CA) → interstate, skip sourcing + 3. SDK overrides both addresses with destination "200 Spectrum Center Dr, Irvine, CA 92618" + 4. SDK POSTs to /calculate/cart → API calculates using destination rates + errors: + 400: + - "Invalid request format" + - "Missing required fields" + - "Cart exceeds 250 line items" + - "More than one cart in request" + 401: + - "Missing X-API-KEY header" + - "Invalid or inactive API key" + 500: + - "Failed to calculate tax rate" + - "v60 API error" + - "Internal server error" + # TaxCloud API Orders Endpoints - name: "CreateOrder" api: "taxcloud" # Indicates this uses the TaxCloud API @@ -918,6 +1060,196 @@ 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 (used for origin/destination sourcing resolution)" + - 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 API Models - Order Management # --------------------------------------------------------------------------- @@ -1865,6 +2197,59 @@ 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_create_order: description: "Actual TaxCloud response for CreateOrder" endpoint: "POST /tax/connections/{connectionId}/orders" diff --git a/pyproject.toml b/pyproject.toml index 89707d6..db712dc 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.1-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..fe7a2e2 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, @@ -64,7 +73,7 @@ V60TaxSummary, ) -__version__ = "0.2.0-beta" +__version__ = "0.2.1-beta" __all__ = [ "ZipTaxClient", @@ -100,6 +109,16 @@ "JurisdictionType", "JurisdictionName", "TaxType", + # Cart Tax Calculation Models + "CalculateCartRequest", + "CalculateCartResponse", + "CartAddress", + "CartCurrency", + "CartItem", + "CartItemResponse", + "CartLineItem", + "CartLineItemResponse", + "CartTax", # TaxCloud Models "TaxCloudAddress", "TaxCloudAddressResponse", diff --git a/src/ziptax/models/__init__.py b/src/ziptax/models/__init__.py index 23cacb3..5049e7e 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, @@ -55,6 +64,16 @@ "JurisdictionType", "JurisdictionName", "TaxType", + # Cart Tax Calculation Models + "CalculateCartRequest", + "CalculateCartResponse", + "CartAddress", + "CartCurrency", + "CartItem", + "CartItemResponse", + "CartLineItem", + "CartLineItemResponse", + "CartTax", # TaxCloud Models "TaxCloudAddress", "TaxCloudAddressResponse", diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index fc6900e..b16586f 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,142 @@ 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 API Models - Order Management # ============================================================================= diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index e9a4e1f..7268fb2 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -1,11 +1,14 @@ """API functions for the ZipTax SDK.""" import logging +import re from typing import Any, Dict, List, Optional from ..config import Config from ..exceptions import ZipTaxCloudConfigError from ..models import ( + CalculateCartRequest, + CalculateCartResponse, CreateOrderRequest, OrderResponse, RefundTransactionRequest, @@ -232,6 +235,188 @@ def _make_request() -> Dict[str, Any]: response_data = _make_request() return V60PostalCodeResponse(**response_data) + # ========================================================================= + # ZipTax Cart Tax Calculation + # ========================================================================= + + @staticmethod + def _extract_state_from_normalized_address(normalized_address: str) -> str: + """Extract the US state abbreviation from a V60 normalized address. + + The V60 API returns normalized addresses in the format: + "200 Spectrum Center Dr, Irvine, CA 92618-5003, United States" + + This method extracts the two-letter state abbreviation (e.g., "CA") + from that format. + + Args: + normalized_address: Normalized address string from + V60Response.addressDetail.normalizedAddress + + Returns: + Two-letter uppercase state abbreviation (e.g., "CA", "TX") + + Raises: + ValueError: If the state cannot be parsed from the address + """ + # Match a two-letter state code followed by a ZIP code pattern + # e.g., "Irvine, CA 92618-5003, United States" + match = re.search(r",\s+([A-Z]{2})\s+\d{5}", normalized_address) + if match: + return match.group(1) + + raise ValueError( + f"Could not extract state from normalized address: " + f"'{normalized_address}'" + ) + + def _resolve_sourcing_address( + self, + origin_address: str, + destination_address: str, + ) -> str: + """Resolve which address to use for tax calculation based on sourcing rules. + + Uses the V60 API to look up both addresses, extracts the state from + each normalized address, and applies origin/destination sourcing logic: + + - Interstate (different states): use destination address (skip sourcing) + - Intrastate (same state), destination-based ("D"): use destination address + - Intrastate (same state), origin-based ("O"): use origin address + + Args: + origin_address: The seller/shipper address string + destination_address: The buyer/recipient address string + + Returns: + The address string to use for tax calculation + + Raises: + ZipTaxAPIError: If the V60 API returns an error + ValueError: If state cannot be parsed from a normalized address + """ + # Step 1: Look up both addresses via the V60 API + destination_v60 = self.GetSalesTaxByAddress(destination_address) + origin_v60 = self.GetSalesTaxByAddress(origin_address) + + # Step 2: Extract states from normalized addresses + dest_state = self._extract_state_from_normalized_address( + destination_v60.address_detail.normalized_address + ) + origin_state = self._extract_state_from_normalized_address( + origin_v60.address_detail.normalized_address + ) + + logger.debug( + f"Sourcing resolution: origin_state={origin_state}, " + f"dest_state={dest_state}" + ) + + # Step 3: Interstate — always use destination + if dest_state != origin_state: + logger.debug("Interstate transaction — using destination address for rates") + return destination_address + + # Step 4: Intrastate — check sourcingRules.value from destination lookup + sourcing_value = None + if destination_v60.sourcing_rules: + sourcing_value = destination_v60.sourcing_rules.value + + if sourcing_value == "O": + logger.debug("Origin-based sourcing state — using origin address for rates") + return origin_address + + # Default to destination (covers "D" and any unexpected value) + logger.debug( + "Destination-based sourcing state — using destination address for rates" + ) + return destination_address + + def CalculateCart( + self, + request: CalculateCartRequest, + ) -> CalculateCartResponse: + """Calculate sales tax for a shopping cart with multiple line items. + + Resolves origin/destination sourcing rules automatically, then sends + the cart to the API for tax calculation using the correct address. + + The sourcing resolution logic: + 1. Looks up both origin and destination addresses via GetSalesTaxByAddress + 2. Extracts and compares states from normalized addresses + 3. Interstate (different states): uses destination address + 4. Intrastate (same state): checks sourcingRules.value from the + destination lookup — if "O", uses origin address; otherwise + uses destination address + 5. Sends the resolved address to POST /calculate/cart + + Args: + request: CalculateCartRequest object with cart details including + customer ID, addresses, currency, and line items + + Returns: + CalculateCartResponse object with per-item tax calculations + + Raises: + ZipTaxAPIError: If the API returns an error + ValueError: If state cannot be parsed from a normalized address + + 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) + """ + cart = request.items[0] + + # Resolve which address should be used for tax calculation + resolved_address = self._resolve_sourcing_address( + origin_address=cart.origin.address, + destination_address=cart.destination.address, + ) + + # Build the request payload, overriding both addresses with the + # resolved address so the API calculates using the correct rates + request_data = request.model_dump(by_alias=True, exclude_none=True) + request_data["items"][0]["destination"]["address"] = resolved_address + request_data["items"][0]["origin"]["address"] = resolved_address + + # Make request with retry logic + @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_data, + ) + + response_data = _make_request() + return CalculateCartResponse(**response_data) + # ========================================================================= # TaxCloud API - Order Management Functions # ========================================================================= diff --git a/tests/conftest.py b/tests/conftest.py index fde00a7..a91a423 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -190,6 +190,152 @@ 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}, + }, + ], + } + ] + } + + +def _build_v60_response( + normalized_address: str, + sourcing_value: str = "D", + rate: float = 0.0775, +) -> dict: + """Build a V60 response dict with configurable address, sourcing, and rate. + + This is a helper function (not a fixture) so tests can call it with + different parameters inline. + """ + return { + "metadata": { + "version": "v60", + "response": { + "code": 100, + "name": "RESPONSE_CODE_SUCCESS", + "message": "Successful API Request.", + "definition": "http://api.zip-tax.com/request/v60/schema", + }, + }, + "baseRates": [ + { + "rate": rate, + "jurType": "US_STATE_SALES_TAX", + "jurName": ( + normalized_address.split(",")[-3].strip().split()[0] + if "," in normalized_address + else "XX" + ), + "jurDescription": "US State Sales Tax", + "jurTaxCode": "06", + } + ], + "service": { + "adjustmentType": "SERVICE_TAXABLE", + "taxable": "N", + "description": "Services non-taxable", + }, + "shipping": { + "adjustmentType": "FREIGHT_TAXABLE", + "taxable": "N", + "description": "Freight non-taxable", + }, + "sourcingRules": { + "adjustmentType": "ORIGIN_DESTINATION", + "description": ( + "Origin Based Taxation" + if sourcing_value == "O" + else "Destination Based Taxation" + ), + "value": sourcing_value, + }, + "taxSummaries": [ + { + "rate": rate, + "taxType": "SALES_TAX", + "summaryName": "Total Base Sales Tax", + "displayRates": [{"name": "Total Rate", "rate": rate}], + } + ], + "addressDetail": { + "normalizedAddress": normalized_address, + "incorporated": "true", + "geoLat": 33.65253, + "geoLng": -117.74794, + }, + } + + +@pytest.fixture +def v60_ca_destination(): + """V60 response for a California destination-based address.""" + return _build_v60_response( + normalized_address=( + "200 Spectrum Center Dr, Irvine, CA 92618-5003, United States" + ), + sourcing_value="D", + rate=0.07750, + ) + + +@pytest.fixture +def v60_mn_destination(): + """V60 response for a Minnesota destination-based address.""" + return _build_v60_response( + normalized_address=( + "323 Washington Ave N, Minneapolis, MN 55401-2427, United States" + ), + sourcing_value="D", + rate=0.08025, + ) + + +@pytest.fixture +def v60_tx_origin_dallas(): + """V60 response for a Texas origin-based address (Dallas).""" + return _build_v60_response( + normalized_address=("123 Main St, Dallas, TX 75201-1234, United States"), + sourcing_value="O", + rate=0.08250, + ) + + +@pytest.fixture +def v60_tx_origin_austin(): + """V60 response for a Texas origin-based address (Austin).""" + return _build_v60_response( + normalized_address=("456 Elm St, Austin, TX 78701-5678, United States"), + sourcing_value="O", + rate=0.08250, + ) + + @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..0afbe2d 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -1,9 +1,16 @@ """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, @@ -332,6 +339,522 @@ def test_response_fields( assert result.tax_use == 0.0775 +class TestExtractStateFromNormalizedAddress: + """Test cases for _extract_state_from_normalized_address helper.""" + + def test_standard_us_address(self): + """Test extraction from a standard US normalized address.""" + result = Functions._extract_state_from_normalized_address( + "200 Spectrum Center Dr, Irvine, CA 92618-5003, United States" + ) + assert result == "CA" + + def test_various_states(self): + """Test extraction for multiple different states.""" + cases = { + "123 Main St, Dallas, TX 75201-1234, United States": "TX", + "323 Washington Ave N, Minneapolis, MN 55401-2427, United States": "MN", + "456 Elm St, Austin, TX 78701-5678, United States": "TX", + "789 Broadway, New York, NY 10003, United States": "NY", + "100 Peachtree St, Atlanta, GA 30303, United States": "GA", + } + for address, expected_state in cases.items(): + result = Functions._extract_state_from_normalized_address(address) + assert result == expected_state, f"Failed for address: {address}" + + def test_five_digit_zip_without_extension(self): + """Test extraction with a plain 5-digit ZIP code.""" + result = Functions._extract_state_from_normalized_address( + "100 Main St, Portland, OR 97201, United States" + ) + assert result == "OR" + + def test_raises_for_unparseable_address(self): + """Test that ValueError is raised when state cannot be parsed.""" + with pytest.raises(ValueError, match="Could not extract state"): + Functions._extract_state_from_normalized_address("not a real address") + + def test_raises_for_empty_string(self): + """Test that ValueError is raised for empty string.""" + with pytest.raises(ValueError, match="Could not extract state"): + Functions._extract_state_from_normalized_address("") + + +class TestResolveSourcingAddress: + """Test cases for _resolve_sourcing_address helper.""" + + def test_interstate_uses_destination( + self, mock_http_client, mock_config, v60_ca_destination, v60_mn_destination + ): + """Test that interstate transactions always use the destination address.""" + # Destination: CA, Origin: MN — different states + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + functions = Functions(mock_http_client, mock_config) + + result = functions._resolve_sourcing_address( + origin_address="323 Washington Ave N, Minneapolis, MN 55401", + destination_address="200 Spectrum Center Dr, Irvine, CA 92618", + ) + + assert result == "200 Spectrum Center Dr, Irvine, CA 92618" + + def test_intrastate_destination_based( + self, mock_http_client, mock_config, v60_ca_destination + ): + """Test that intrastate destination-based state uses destination address.""" + # Both in CA, sourcing_value="D" + ca_origin = v60_ca_destination.copy() + ca_origin["addressDetail"] = { + "normalizedAddress": ( + "500 Other St, Los Angeles, CA 90001-1234, United States" + ), + "incorporated": "true", + "geoLat": 34.0, + "geoLng": -118.0, + } + mock_http_client.get.side_effect = [v60_ca_destination, ca_origin] + functions = Functions(mock_http_client, mock_config) + + result = functions._resolve_sourcing_address( + origin_address="500 Other St, Los Angeles, CA 90001", + destination_address="200 Spectrum Center Dr, Irvine, CA 92618", + ) + + assert result == "200 Spectrum Center Dr, Irvine, CA 92618" + + def test_intrastate_origin_based( + self, mock_http_client, mock_config, v60_tx_origin_austin, v60_tx_origin_dallas + ): + """Test that intrastate origin-based state uses origin address.""" + # Both in TX, sourcing_value="O" on destination lookup + mock_http_client.get.side_effect = [ + v60_tx_origin_austin, # destination lookup + v60_tx_origin_dallas, # origin lookup + ] + functions = Functions(mock_http_client, mock_config) + + result = functions._resolve_sourcing_address( + origin_address="123 Main St, Dallas, TX 75201", + destination_address="456 Elm St, Austin, TX 78701", + ) + + assert result == "123 Main St, Dallas, TX 75201" + + def test_calls_get_sales_tax_for_both_addresses( + self, mock_http_client, mock_config, v60_ca_destination, v60_mn_destination + ): + """Test that both addresses are looked up via the V60 API.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + functions = Functions(mock_http_client, mock_config) + + functions._resolve_sourcing_address( + origin_address="323 Washington Ave N, Minneapolis, MN 55401", + destination_address="200 Spectrum Center Dr, Irvine, CA 92618", + ) + + # Two GET calls: one for destination, one for origin + assert mock_http_client.get.call_count == 2 + + +class TestCalculateCart: + """Test cases for CalculateCart function.""" + + def _build_request( + self, + dest_address="200 Spectrum Center Dr, Irvine, CA 92618-1905", + origin_address="323 Washington Ave N, Minneapolis, MN 55401-2427", + ): + """Build a sample CalculateCartRequest for testing.""" + return CalculateCartRequest( + items=[ + CartItem( + customer_id="customer-453", + currency=CartCurrency(currency_code="USD"), + destination=CartAddress(address=dest_address), + origin=CartAddress(address=origin_address), + 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, + v60_ca_destination, + v60_mn_destination, + ): + """Test basic cart tax calculation request.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + 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, + v60_ca_destination, + v60_mn_destination, + ): + """Test that CalculateCart calls the correct API path.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + 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, + v60_ca_destination, + v60_mn_destination, + ): + """Test that request body uses camelCase field names (by_alias).""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + 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, + v60_ca_destination, + v60_mn_destination, + ): + """Test that taxabilityCode is excluded from JSON when not set.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + 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, + v60_ca_destination, + v60_mn_destination, + ): + """Test that taxabilityCode is included in JSON when set.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + 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, + v60_ca_destination, + v60_mn_destination, + ): + """Test that response line items and tax details are properly parsed.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + 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, + v60_ca_destination, + v60_mn_destination, + ): + """Test that response addresses are properly parsed.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + 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" + + # ----- Origin/Destination Sourcing Tests ----- + + def test_interstate_sends_destination_address( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + v60_ca_destination, + v60_mn_destination, + ): + """Test that interstate transactions send the destination address to the API.""" + # CA destination, MN origin — different states → use destination + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request( + dest_address="200 Spectrum Center Dr, Irvine, CA 92618", + origin_address="323 Washington Ave N, Minneapolis, MN 55401", + ) + functions.CalculateCart(request) + + call_args = mock_http_client.post.call_args + json_body = call_args[1]["json"] + cart = json_body["items"][0] + # Both addresses should be overridden with the destination address + assert cart["destination"]["address"] == ( + "200 Spectrum Center Dr, Irvine, CA 92618" + ) + assert cart["origin"]["address"] == ("200 Spectrum Center Dr, Irvine, CA 92618") + + def test_intrastate_destination_based_sends_destination_address( + self, mock_http_client, mock_config, sample_calculate_cart_response + ): + """Test intrastate destination-based state sends destination to the API.""" + from tests.conftest import _build_v60_response + + ca_dest = _build_v60_response( + normalized_address=( + "200 Spectrum Center Dr, Irvine, CA 92618-5003, United States" + ), + sourcing_value="D", + ) + ca_origin = _build_v60_response( + normalized_address=( + "500 Other St, Los Angeles, CA 90001-1234, United States" + ), + sourcing_value="D", + ) + mock_http_client.get.side_effect = [ca_dest, ca_origin] + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request( + dest_address="200 Spectrum Center Dr, Irvine, CA 92618", + origin_address="500 Other St, Los Angeles, CA 90001", + ) + functions.CalculateCart(request) + + call_args = mock_http_client.post.call_args + json_body = call_args[1]["json"] + cart = json_body["items"][0] + assert cart["destination"]["address"] == ( + "200 Spectrum Center Dr, Irvine, CA 92618" + ) + assert cart["origin"]["address"] == ("200 Spectrum Center Dr, Irvine, CA 92618") + + def test_intrastate_origin_based_sends_origin_address( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + v60_tx_origin_austin, + v60_tx_origin_dallas, + ): + """Test intrastate origin-based state sends origin address to the API.""" + # Both in TX, sourcing_value="O" → use origin + mock_http_client.get.side_effect = [ + v60_tx_origin_austin, # destination lookup + v60_tx_origin_dallas, # origin lookup + ] + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request( + dest_address="456 Elm St, Austin, TX 78701", + origin_address="123 Main St, Dallas, TX 75201", + ) + functions.CalculateCart(request) + + call_args = mock_http_client.post.call_args + json_body = call_args[1]["json"] + cart = json_body["items"][0] + # Both addresses should be overridden with the origin address + assert cart["destination"]["address"] == "123 Main St, Dallas, TX 75201" + assert cart["origin"]["address"] == "123 Main St, Dallas, TX 75201" + + def test_sourcing_makes_two_v60_lookups( + self, + mock_http_client, + mock_config, + sample_calculate_cart_response, + v60_ca_destination, + v60_mn_destination, + ): + """Test that CalculateCart makes two V60 GET lookups before the POST.""" + mock_http_client.get.side_effect = [v60_ca_destination, v60_mn_destination] + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + functions.CalculateCart(request) + + # Two GET calls for V60 lookups + one POST for cart calculation + assert mock_http_client.get.call_count == 2 + assert mock_http_client.post.call_count == 1 + + # ----- 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 TestTaxCloudFunctions: """Test cases for TaxCloud order management functions."""