From 929eae955a7aec0b7fc0888205ab7d1857386915 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Wed, 18 Feb 2026 12:30:45 -0800 Subject: [PATCH 1/9] ZIP-568: adds calculate cart function --- docs/spec.yaml | 299 ++++++++++++++++++++++++++++++ src/ziptax/__init__.py | 19 ++ src/ziptax/models/__init__.py | 19 ++ src/ziptax/models/responses.py | 125 +++++++++++++ src/ziptax/resources/functions.py | 69 +++++++ tests/conftest.py | 33 ++++ tests/test_functions.py | 206 ++++++++++++++++++++ 7 files changed, 770 insertions(+) diff --git a/docs/spec.yaml b/docs/spec.yaml index a86689e..647dec6 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -250,6 +250,62 @@ 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 shopping 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" + validation: + cart: + - "Only one cart allowed per request (items array must contain exactly 1 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)" + tax_calculation: + formula: "taxAmount = (price x quantity) x taxRate" + rounding: "5 decimal places for both taxRate and taxAmount" + 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" + 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 +974,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 (stored but not used for tax calculation currently)" + - 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 the single-element request items array)" + + - 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 +2111,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/src/ziptax/__init__.py b/src/ziptax/__init__.py index 394515b..a5765d8 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, @@ -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..d169cc7 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -360,6 +360,131 @@ 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: str = 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(..., description="Unit price of the item") + quantity: float = Field(..., description="Quantity of the item") + 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", description="Array of line items in the cart" + ) + + +class CalculateCartRequest(BaseModel): + """Request payload for calculating sales tax on a shopping cart.""" + + model_config = ConfigDict(populate_by_name=True) + + items: List[CartItem] = Field( + ..., 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..e8e9a4b 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -6,6 +6,8 @@ from ..config import Config from ..exceptions import ZipTaxCloudConfigError from ..models import ( + CalculateCartRequest, + CalculateCartResponse, CreateOrderRequest, OrderResponse, RefundTransactionRequest, @@ -232,6 +234,73 @@ def _make_request() -> Dict[str, Any]: response_data = _make_request() return V60PostalCodeResponse(**response_data) + # ========================================================================= + # ZipTax Cart Tax Calculation + # ========================================================================= + + def CalculateCart( + self, + request: CalculateCartRequest, + ) -> CalculateCartResponse: + """Calculate sales tax for a shopping 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. + + 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 + + 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) + """ + + # 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.model_dump(by_alias=True, exclude_none=True), + ) + + 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..6203fd1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -190,6 +190,39 @@ 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_order_response(): """Sample TaxCloud OrderResponse data for testing.""" diff --git a/tests/test_functions.py b/tests/test_functions.py index b2b8551..b9c8453 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -4,6 +4,12 @@ from ziptax.exceptions import ZipTaxCloudConfigError, ZipTaxValidationError from ziptax.models import ( + CalculateCartRequest, + CalculateCartResponse, + CartAddress, + CartCurrency, + CartItem, + CartLineItem, CreateOrderRequest, OrderResponse, RefundTransactionRequest, @@ -332,6 +338,206 @@ 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="123 Main St"), + origin=CartAddress(address="456 Other St"), + 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" + + def test_uses_ziptax_http_client( + self, mock_http_client, mock_config, sample_calculate_cart_response + ): + """Test that CalculateCart uses the ZipTax HTTP client, not TaxCloud.""" + mock_http_client.post.return_value = sample_calculate_cart_response + functions = Functions(mock_http_client, mock_config) + + request = self._build_request() + functions.CalculateCart(request) + + # Should use the main ZipTax http_client, not taxcloud + mock_http_client.post.assert_called_once() + + class TestTaxCloudFunctions: """Test cases for TaxCloud order management functions.""" From f5abf2719a5869b79ca0bf6cdf8093004136cae1 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Wed, 18 Feb 2026 12:54:37 -0800 Subject: [PATCH 2/9] ZIP-568: resolves QA review findings --- docs/spec.yaml | 11 ++++---- src/ziptax/models/responses.py | 23 +++++++++++----- tests/test_functions.py | 48 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/docs/spec.yaml b/docs/spec.yaml index 647dec6..bc403a3 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -255,7 +255,7 @@ resources: api: "ziptax" http_method: "POST" path: "/calculate/cart" - description: "Calculate sales tax for a shopping 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." + description: "Calculate sales tax for one or more carts with multiple line items. Accepts carts 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: @@ -269,10 +269,10 @@ resources: type: "header" header: "X-API-Key" format: "{apiKey}" - note: "Uses ZipTax API Key configured during client initialization" + note: "Uses ZipTax API Key configured during client initialization. See api.ziptax.authentication for canonical auth configuration." validation: cart: - - "Only one cart allowed per request (items array must contain exactly 1 element)" + - "Request must include one or more cart entries in the items array" - "currency.currencyCode must be 'USD'" - "customerId is required (string)" - "destination.address is required (string)" @@ -985,10 +985,9 @@ models: type: "array" items_type: "CartItem" required: true - description: "Array of cart items (must contain exactly 1 element)" + description: "Array of cart items (must contain at least 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" @@ -1086,7 +1085,7 @@ models: type: "array" items_type: "CartItemResponse" required: true - description: "Array of cart results (mirrors the single-element request items array)" + description: "Array of cart results (mirrors request items array order)" - name: "CartItemResponse" description: "A single cart response with calculated tax information per line item" diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index d169cc7..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 @@ -378,7 +378,7 @@ class CartCurrency(BaseModel): model_config = ConfigDict(populate_by_name=True) - currency_code: str = Field( + currency_code: Literal["USD"] = Field( ..., alias="currencyCode", description="ISO currency code (must be USD)" ) @@ -391,8 +391,12 @@ class CartLineItem(BaseModel): 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") + 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", @@ -414,7 +418,11 @@ class CartItem(BaseModel): ) origin: CartAddress = Field(..., description="Origin address") line_items: List[CartLineItem] = Field( - ..., alias="lineItems", description="Array of line items in the cart" + ..., + alias="lineItems", + min_length=1, + max_length=250, + description="Array of line items in the cart (1-250 items)", ) @@ -424,7 +432,10 @@ class CalculateCartRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) items: List[CartItem] = Field( - ..., description="Array of cart items (must contain exactly 1 element)" + ..., + min_length=1, + max_length=1, + description="Array of cart items (must contain exactly 1 element)", ) diff --git a/tests/test_functions.py b/tests/test_functions.py index b9c8453..18c00a2 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -1,6 +1,7 @@ """Tests for API functions.""" import pytest +from pydantic import ValidationError from ziptax.exceptions import ZipTaxCloudConfigError, ZipTaxValidationError from ziptax.models import ( @@ -537,6 +538,53 @@ def test_uses_ziptax_http_client( # Should use the main ZipTax http_client, not taxcloud mock_http_client.post.assert_called_once() + 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.""" From 7bc302eb941532d38954fd657af4bd8a4174acd0 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Thu, 19 Feb 2026 09:48:11 -0800 Subject: [PATCH 3/9] ZIP-568: udpates beta version to 0.2.1 --- CLAUDE.md | 2 +- docs/spec.yaml | 2 +- pyproject.toml | 2 +- src/ziptax/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 65cf9a3..598b2e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -774,5 +774,5 @@ For API-specific questions: --- **Last Updated**: 2025-02-16 -**SDK Version**: 0.2.0-beta +**SDK Version**: 0.2.1-beta **Maintained By**: ZipTax Team diff --git a/docs/spec.yaml b/docs/spec.yaml index bc403a3..37303f5 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 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 a5765d8..fe7a2e2 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -73,7 +73,7 @@ V60TaxSummary, ) -__version__ = "0.2.0-beta" +__version__ = "0.2.1-beta" __all__ = [ "ZipTaxClient", From 08fcfd371e6c71b570f488fa1494bf83bbde912d Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Thu, 19 Feb 2026 10:12:48 -0800 Subject: [PATCH 4/9] ZIP-568: adds origin/destination calc and updates docs --- CHANGELOG.md | 44 +++++ CLAUDE.md | 16 +- README.md | 82 ++++++++ docs/spec.yaml | 93 ++++++++- src/ziptax/resources/functions.py | 124 +++++++++++- tests/conftest.py | 113 +++++++++++ tests/test_functions.py | 309 ++++++++++++++++++++++++++++-- 7 files changed, 752 insertions(+), 29 deletions(-) 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 598b2e4..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 +**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 37303f5..0459f9d 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -272,7 +272,7 @@ resources: note: "Uses ZipTax API Key configured during client initialization. See api.ziptax.authentication for canonical auth configuration." validation: cart: - - "Request must include one or more cart entries in the items array" + - "items array must contain exactly 1 cart element" - "currency.currencyCode must be 'USD'" - "customerId is required (string)" - "destination.address is required (string)" @@ -284,14 +284,100 @@ resources: - "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" @@ -985,9 +1071,10 @@ models: type: "array" items_type: "CartItem" required: true - description: "Array of cart items (must contain at least 1 element)" + 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" @@ -1010,7 +1097,7 @@ models: - name: "origin" type: "CartAddress" required: true - description: "Origin address (stored but not used for tax calculation currently)" + description: "Origin address (used for origin/destination sourcing resolution)" - name: "line_items" type: "array" items_type: "CartLineItem" diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index e8e9a4b..7268fb2 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -1,6 +1,7 @@ """API functions for the ZipTax SDK.""" import logging +import re from typing import Any, Dict, List, Optional from ..config import Config @@ -238,15 +239,116 @@ def _make_request() -> Dict[str, Any]: # 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. - 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. + 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 @@ -257,6 +359,7 @@ def CalculateCart( Raises: ZipTaxAPIError: If the API returns an error + ValueError: If state cannot be parsed from a normalized address Example: >>> from ziptax.models import ( @@ -286,6 +389,19 @@ def CalculateCart( ... ) >>> 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( @@ -295,7 +411,7 @@ def CalculateCart( def _make_request() -> Dict[str, Any]: return self.http_client.post( "/calculate/cart", - json=request.model_dump(by_alias=True, exclude_none=True), + json=request_data, ) response_data = _make_request() diff --git a/tests/conftest.py b/tests/conftest.py index 6203fd1..a91a423 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -223,6 +223,119 @@ def sample_calculate_cart_response(): } +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 18c00a2..0afbe2d 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -339,22 +339,139 @@ 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): + 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="200 Spectrum Center Dr, Irvine, CA 92618-1905" - ), - origin=CartAddress( - address="323 Washington Ave N, Minneapolis, MN 55401-2427" - ), + destination=CartAddress(address=dest_address), + origin=CartAddress(address=origin_address), line_items=[ CartLineItem( item_id="item-1", @@ -373,9 +490,15 @@ def _build_request(self): ) def test_basic_request( - self, mock_http_client, mock_config, sample_calculate_cart_response + 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) @@ -389,9 +512,15 @@ def test_basic_request( mock_http_client.post.assert_called_once() def test_request_uses_correct_path( - self, mock_http_client, mock_config, sample_calculate_cart_response + 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) @@ -402,9 +531,15 @@ def test_request_uses_correct_path( assert call_args[0][0] == "/calculate/cart" def test_request_body_serialization( - self, mock_http_client, mock_config, sample_calculate_cart_response + 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) @@ -433,9 +568,15 @@ def test_request_body_serialization( assert line_item["quantity"] == 1.5 def test_optional_taxability_code_excluded_when_none( - self, mock_http_client, mock_config, sample_calculate_cart_response + 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) @@ -444,8 +585,12 @@ def test_optional_taxability_code_excluded_when_none( CartItem( customer_id="cust-1", currency=CartCurrency(currency_code="USD"), - destination=CartAddress(address="123 Main St"), - origin=CartAddress(address="456 Other St"), + 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", @@ -464,9 +609,15 @@ def test_optional_taxability_code_excluded_when_none( assert "taxabilityCode" not in line_item def test_optional_taxability_code_included_when_set( - self, mock_http_client, mock_config, sample_calculate_cart_response + 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) @@ -481,9 +632,15 @@ def test_optional_taxability_code_included_when_set( assert line_item["taxabilityCode"] == 0 def test_response_line_items_parsed( - self, mock_http_client, mock_config, sample_calculate_cart_response + 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) @@ -510,9 +667,15 @@ def test_response_line_items_parsed( assert item2.tax.amount == 4.5125 def test_response_addresses_parsed( - self, mock_http_client, mock_config, sample_calculate_cart_response + 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) @@ -525,18 +688,124 @@ def test_response_addresses_parsed( ) assert cart.origin.address == "323 Washington Ave N, Minneapolis, MN 55401-2427" - def test_uses_ziptax_http_client( + # ----- 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 that CalculateCart uses the ZipTax HTTP client, not TaxCloud.""" + """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) - # Should use the main ZipTax http_client, not taxcloud - mock_http_client.post.assert_called_once() + # 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.""" From 3c848db7c494828fc49993f71d1a2febc2179927 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Thu, 19 Feb 2026 10:26:44 -0800 Subject: [PATCH 5/9] ZIP-568: documentation clarfication in spec.yaml --- docs/spec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec.yaml b/docs/spec.yaml index 0459f9d..b1f6698 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -255,7 +255,7 @@ resources: api: "ziptax" http_method: "POST" path: "/calculate/cart" - description: "Calculate sales tax for one or more carts with multiple line items. Accepts carts with destination and origin addresses, calculates per-item tax using the v60 tax engine, and returns tax rate and amount for each line item." + 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: From ecffce47a13cafb3de57ebc01dcbe137b28e27d3 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 20 Feb 2026 08:58:06 -0800 Subject: [PATCH 6/9] ZIP-725: origin/destination updates for SDK --- CHANGELOG.md | 18 +- CLAUDE.md | 13 +- README.md | 16 +- docs/spec.yaml | 90 +--------- src/ziptax/resources/functions.py | 123 +------------- tests/conftest.py | 113 ------------- tests/test_functions.py | 269 +----------------------------- 7 files changed, 26 insertions(+), 616 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2eb09..7f352a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 + - 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` @@ -28,16 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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 @@ -46,10 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 +- All quality checks pass: black, ruff, mypy, pytest ## [0.2.0-beta] - 2025-02-16 diff --git a/CLAUDE.md b/CLAUDE.md index 0971a21..032f632 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,14 +133,15 @@ def CreateOrder(self, request: CreateOrderRequest, ...) -> OrderResponse: self._check_taxcloud_config() # Guards against missing credentials # ... implementation -# CalculateCart resolves origin/destination sourcing before calling the API +# CalculateCart sends the cart directly to the API for tax calculation +# The API handles origin/destination sourcing internally 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, + # Serialize and POST to /calculate/cart + response_data = self.http_client.post( + "/calculate/cart", + json=request.model_dump(by_alias=True, exclude_none=True), ) - # Override both addresses with the resolved address, then POST to /calculate/cart + return CalculateCartResponse(**response_data) ``` #### 4. **HTTP Client (`utils/http.py`)** diff --git a/README.md b/README.md index 5584cd9..64ea085 100644 --- a/README.md +++ b/README.md @@ -9,7 +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 +- 🛒 Cart tax calculation with per-item tax rates - 🔄 Automatic retry logic with exponential backoff - ✅ Input validation - 🔍 Type hints for better IDE support @@ -151,7 +151,7 @@ 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. +Calculate sales tax for a shopping cart with multiple line items. The API handles origin/destination sourcing resolution internally. ```python from ziptax.models import ( @@ -201,16 +201,6 @@ 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: @@ -603,7 +593,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 +- `CalculateCart(request)` - Calculate sales tax for a shopping cart #### TaxCloud API Methods (Optional) diff --git a/docs/spec.yaml b/docs/spec.yaml index b1f6698..9ea9f6f 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -284,100 +284,18 @@ resources: - "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. + Tax calculation and origin/destination sourcing resolution are + performed by the API via POST /calculate/cart, not by the SDK. + The SDK sends the cart request as-is 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 - 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" @@ -1097,7 +1015,7 @@ models: - name: "origin" type: "CartAddress" required: true - description: "Origin address (used for origin/destination sourcing resolution)" + description: "Origin address of the seller/shipper" - name: "line_items" type: "array" items_type: "CartLineItem" diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index 7268fb2..aafbe30 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -1,7 +1,6 @@ """API functions for the ZipTax SDK.""" import logging -import re from typing import Any, Dict, List, Optional from ..config import Config @@ -239,116 +238,14 @@ def _make_request() -> Dict[str, Any]: # 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 + Sends the cart to the API for tax calculation. The API handles + origin/destination sourcing resolution internally. Args: request: CalculateCartRequest object with cart details including @@ -359,7 +256,6 @@ def CalculateCart( Raises: ZipTaxAPIError: If the API returns an error - ValueError: If state cannot be parsed from a normalized address Example: >>> from ziptax.models import ( @@ -389,19 +285,6 @@ def CalculateCart( ... ) >>> 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( @@ -411,7 +294,7 @@ def CalculateCart( def _make_request() -> Dict[str, Any]: return self.http_client.post( "/calculate/cart", - json=request_data, + json=request.model_dump(by_alias=True, exclude_none=True), ) response_data = _make_request() diff --git a/tests/conftest.py b/tests/conftest.py index a91a423..6203fd1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -223,119 +223,6 @@ def sample_calculate_cart_response(): } -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 0afbe2d..690d88d 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -339,139 +339,22 @@ 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", - ): + 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=dest_address), - origin=CartAddress(address=origin_address), + 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", @@ -494,11 +377,8 @@ def test_basic_request( 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) @@ -516,11 +396,8 @@ def test_request_uses_correct_path( 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) @@ -535,11 +412,8 @@ def test_request_body_serialization( 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) @@ -572,11 +446,8 @@ def test_optional_taxability_code_excluded_when_none( 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) @@ -613,11 +484,8 @@ def test_optional_taxability_code_included_when_set( 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) @@ -636,11 +504,8 @@ def test_response_line_items_parsed( 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) @@ -671,11 +536,8 @@ def test_response_addresses_parsed( 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) @@ -688,123 +550,6 @@ def test_response_addresses_parsed( ) 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): From 58ae0527f86efffe0d4b46f4c6767f1f13def459 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 20 Feb 2026 15:38:05 -0800 Subject: [PATCH 7/9] ZIP-596: initial code commit for txc support in calc cart --- docs/spec.yaml | 333 +++++++++++++++++++- src/ziptax/__init__.py | 7 + src/ziptax/models/__init__.py | 7 + src/ziptax/models/responses.py | 80 +++++ src/ziptax/resources/functions.py | 130 +++++++- src/ziptax/utils/validation.py | 65 ++++ tests/conftest.py | 53 ++++ tests/test_functions.py | 504 ++++++++++++++++++++++++++++++ 8 files changed, 1160 insertions(+), 19 deletions(-) diff --git a/docs/spec.yaml b/docs/spec.yaml index 9ea9f6f..7199707 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -9,7 +9,7 @@ project: name: "ziptax-sdk" language: "python" - version: "0.2.1-beta" + version: "0.2.2-beta" description: "Official Python SDK for the ZipTax API with optional TaxCloud order management support" # Repository information @@ -250,26 +250,121 @@ resources: type: "V60AccountMetrics" is_array: false - # ZipTax Cart Tax Calculation Endpoint + # ----------------------------------------------------------------------- + # 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 using the v60 tax engine, and returns tax rate and amount for each line item." + 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" + description: "Cart with line items, addresses, and currency for tax calculation. Same input contract regardless of backend routing." returns: - type: "CalculateCartResponse" + 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}" - note: "Uses ZipTax API Key configured during client initialization. See api.ziptax.authentication for canonical auth configuration." + 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" @@ -283,14 +378,14 @@ resources: - "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)" + - "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" + 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 API via POST /calculate/cart, not by the SDK. - The SDK sends the cart request as-is and returns the response. + 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 @@ -302,12 +397,19 @@ resources: - "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" + - "v60 API error (ZipTax only)" - "Internal server error" # TaxCloud API Orders Endpoints @@ -1168,6 +1270,128 @@ models: 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: "TaxCloudCartResponse" + 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: "TaxCloudCartResponse" + 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 # --------------------------------------------------------------------------- @@ -2168,6 +2392,91 @@ actual_api_responses: ] } + 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/src/ziptax/__init__.py b/src/ziptax/__init__.py index fe7a2e2..a288591 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -55,6 +55,9 @@ Tax, TaxCloudAddress, TaxCloudAddressResponse, + TaxCloudCalculateCartResponse, + TaxCloudCartItemResponse, + TaxCloudCartLineItemResponse, TaxType, UpdateOrderRequest, V60AccountMetrics, @@ -119,6 +122,10 @@ "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 5049e7e..9acca61 100644 --- a/src/ziptax/models/__init__.py +++ b/src/ziptax/models/__init__.py @@ -27,6 +27,9 @@ Tax, TaxCloudAddress, TaxCloudAddressResponse, + TaxCloudCalculateCartResponse, + TaxCloudCartItemResponse, + TaxCloudCartLineItemResponse, TaxType, UpdateOrderRequest, V60AccountMetrics, @@ -74,6 +77,10 @@ "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 b16586f..97d654a 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -496,6 +496,86 @@ class CalculateCartResponse(BaseModel): 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 aafbe30..22d28b9 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -1,7 +1,7 @@ """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 @@ -12,6 +12,7 @@ OrderResponse, RefundTransactionRequest, RefundTransactionResponse, + TaxCloudCalculateCartResponse, UpdateOrderRequest, V60AccountMetrics, V60PostalCodeResponse, @@ -20,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, @@ -235,27 +237,31 @@ def _make_request() -> Dict[str, Any]: return V60PostalCodeResponse(**response_data) # ========================================================================= - # ZipTax Cart Tax Calculation + # Cart Tax Calculation (Dual API Routing) # ========================================================================= def CalculateCart( self, request: CalculateCartRequest, - ) -> CalculateCartResponse: + ) -> Union[CalculateCartResponse, TaxCloudCalculateCartResponse]: """Calculate sales tax for a shopping cart with multiple line items. - Sends the cart to the API for tax calculation. The API handles - origin/destination sourcing resolution internally. + 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 object with per-item tax calculations + 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 ( @@ -285,8 +291,25 @@ def CalculateCart( ... ) >>> 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 + """ - # Make request with retry logic @retry_with_backoff( max_retries=self.max_retries, base_delay=self.retry_delay, @@ -300,6 +323,99 @@ def _make_request() -> Dict[str, Any]: 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..4e2feea 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,67 @@ 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 + + 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, + } diff --git a/tests/conftest.py b/tests/conftest.py index 6203fd1..2e1f9b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -223,6 +223,59 @@ def sample_calculate_cart_response(): } +@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 690d88d..6021aff 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -15,6 +15,7 @@ OrderResponse, RefundTransactionRequest, RefundTransactionResponse, + TaxCloudCalculateCartResponse, UpdateOrderRequest, V60AccountMetrics, V60PostalCodeResponse, @@ -600,6 +601,509 @@ def test_currency_code_must_be_usd(self): 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.""" From 0cc7e80a9afe6a7a8707db307af1fe6defda26ad Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 20 Feb 2026 15:43:40 -0800 Subject: [PATCH 8/9] ZIP-596: bumps beta version --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- docs/spec.yaml | 2 +- pyproject.toml | 2 +- src/ziptax/__init__.py | 2 +- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f352a0..d97eca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 032f632..e268233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -787,5 +787,5 @@ For API-specific questions: --- **Last Updated**: 2025-02-19 -**SDK Version**: 0.2.1-beta +**SDK Version**: 0.2.3-beta **Maintained By**: ZipTax Team diff --git a/docs/spec.yaml b/docs/spec.yaml index 7199707..8b27603 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -9,7 +9,7 @@ project: name: "ziptax-sdk" language: "python" - version: "0.2.2-beta" + version: "0.2.3-beta" description: "Official Python SDK for the ZipTax API with optional TaxCloud order management support" # Repository information diff --git a/pyproject.toml b/pyproject.toml index db712dc..9270a20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ziptax-sdk" -version = "0.2.1-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 a288591..a838302 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -76,7 +76,7 @@ V60TaxSummary, ) -__version__ = "0.2.1-beta" +__version__ = "0.2.3-beta" __all__ = [ "ZipTaxClient", From e9cc0afd4271c9e245849c7956d5a2c10f02577c Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Fri, 20 Feb 2026 15:53:27 -0800 Subject: [PATCH 9/9] ZIP-596: QA fixes --- README.md | 7 +++++-- docs/spec.yaml | 4 ++-- src/ziptax/utils/validation.py | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 64ea085..175b6c3 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,10 @@ print(f"Message: {metrics.message}") ### Calculate Cart Tax -Calculate sales tax for a shopping cart with multiple line items. The API handles origin/destination sourcing resolution internally. +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 ( @@ -191,7 +194,7 @@ request = CalculateCartRequest( ] ) -# Calculate tax +# Calculate tax (routes to ZipTax or TaxCloud based on client config) result = client.request.CalculateCart(request) # Access results diff --git a/docs/spec.yaml b/docs/spec.yaml index 8b27603..a837d05 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -1295,7 +1295,7 @@ models: example: "25eb9b97-5acb-492d-b720-c03e79cf715a" - name: "items" type: "array" - items_type: "TaxCloudCartResponse" + items_type: "TaxCloudCartItemResponse" required: true description: "Array of cart results with calculated tax information" - name: "transaction_date" @@ -1306,7 +1306,7 @@ models: description: "RFC3339 datetime string the cart was calculated for" example: "2024-01-15T09:30:00Z" - - name: "TaxCloudCartResponse" + - name: "TaxCloudCartItemResponse" description: "A single cart response from TaxCloud with calculated tax information per line item" api: "taxcloud" properties: diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 4e2feea..28d3de7 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -199,7 +199,7 @@ def parse_address_string(address: str) -> Dict[str, str]: address: Full address string to parse Returns: - Dictionary with keys: line1, city, state, zip + Dictionary with keys: line1, city, state, zip, countryCode Raises: ZipTaxValidationError: If the address cannot be parsed into @@ -246,4 +246,5 @@ def parse_address_string(address: str) -> Dict[str, str]: "city": city, "state": state, "zip": zip_code, + "countryCode": "US", }