From f8596bfc4eb438d47b3e90aa13c43589075b7494 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Mon, 16 Feb 2026 09:38:00 -0800 Subject: [PATCH 1/9] ZIP-562: adds order management functions for TaxCloud integration --- .github/workflows/version-check.yml | 195 +++++++ CHANGELOG.md | 44 ++ CLAUDE.md | 778 +++++++++++++++++++++++++ README.md | 187 +++++- docs/VERSIONING.md | 467 +++++++++++++++ docs/spec.yaml | 855 +++++++++++++++++++++++++++- examples/taxcloud_orders.py | 96 ++++ pyproject.toml | 2 +- scripts/bump_version.py | 331 +++++++++++ src/ziptax/__init__.py | 2 +- src/ziptax/client.py | 35 +- src/ziptax/config.py | 31 +- src/ziptax/exceptions.py | 6 + src/ziptax/models/__init__.py | 34 ++ src/ziptax/models/responses.py | 274 +++++++++ src/ziptax/resources/functions.py | 270 ++++++++- src/ziptax/utils/http.py | 100 ++++ 17 files changed, 3670 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/version-check.yml create mode 100644 CLAUDE.md create mode 100644 docs/VERSIONING.md create mode 100644 examples/taxcloud_orders.py create mode 100755 scripts/bump_version.py diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 0000000..8d17a27 --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,195 @@ +name: Version Bump Check + +on: + pull_request: + branches: [ main, develop ] + types: [ opened, synchronize, reopened ] + +jobs: + check-version-bump: + runs-on: ubuntu-latest + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for comparison + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install packaging library + run: | + python -m pip install --upgrade pip + pip install packaging toml + + - name: Get current version from PR + id: pr-version + run: | + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "PR Version: $VERSION" + + - name: Get base branch version + id: base-version + run: | + git fetch origin ${{ github.base_ref }} + git checkout origin/${{ github.base_ref }} -- pyproject.toml + BASE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + echo "version=$BASE_VERSION" >> $GITHUB_OUTPUT + echo "Base Version: $BASE_VERSION" + git checkout ${{ github.head_ref }} -- pyproject.toml + + - name: Compare versions + id: compare + run: | + python - <<'EOF' + import sys + from packaging import version + + pr_version = "${{ steps.pr-version.outputs.version }}" + base_version = "${{ steps.base-version.outputs.version }}" + + print(f"Base version: {base_version}") + print(f"PR version: {pr_version}") + + pr_ver = version.parse(pr_version) + base_ver = version.parse(base_version) + + if pr_ver <= base_ver: + print(f"❌ ERROR: Version not bumped!") + print(f"Current version ({pr_version}) must be greater than base version ({base_version})") + sys.exit(1) + else: + print(f"✅ Version properly bumped: {base_version} → {pr_version}") + EOF + + - name: Check version consistency + run: | + python - <<'EOF' + import tomllib + import sys + + # Read pyproject.toml version + with open('pyproject.toml', 'rb') as f: + pyproject_version = tomllib.load(f)['project']['version'] + + # Read __init__.py version + with open('src/ziptax/__init__.py', 'r') as f: + for line in f: + if line.strip().startswith('__version__'): + init_version = line.split('=')[1].strip().strip('"\'') + break + + print(f"pyproject.toml version: {pyproject_version}") + print(f"__init__.py version: {init_version}") + + if pyproject_version != init_version: + print(f"❌ ERROR: Version mismatch!") + print(f"pyproject.toml has {pyproject_version}") + print(f"__init__.py has {init_version}") + print(f"Both files must have the same version.") + sys.exit(1) + else: + print(f"✅ Version consistent across files: {pyproject_version}") + EOF + + - name: Validate semantic versioning format + run: | + python - <<'EOF' + import re + import sys + from packaging import version + + pr_version = "${{ steps.pr-version.outputs.version }}" + + # Check if version is valid according to PEP 440 + try: + version.parse(pr_version) + print(f"✅ Version {pr_version} is valid PEP 440 format") + except Exception as e: + print(f"❌ ERROR: Invalid version format: {pr_version}") + print(f"Error: {e}") + sys.exit(1) + + # Additional check for semantic versioning pattern + semver_pattern = r'^\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?$' + if re.match(semver_pattern, pr_version): + print(f"✅ Version follows semantic versioning: {pr_version}") + else: + print(f"⚠️ Warning: Version {pr_version} doesn't follow strict semantic versioning (X.Y.Z[-label])") + print(f" This is allowed but not recommended") + EOF + + - name: Check CHANGELOG update + id: changelog + run: | + # Check if CHANGELOG.md has been modified in this PR + git fetch origin ${{ github.base_ref }} + CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -c "CHANGELOG.md" || echo "0") + + if [ "$CHANGED" -eq "0" ]; then + echo "changed=false" >> $GITHUB_OUTPUT + echo "⚠️ Warning: CHANGELOG.md has not been updated" + else + echo "changed=true" >> $GITHUB_OUTPUT + echo "✅ CHANGELOG.md has been updated" + fi + + - name: Add PR comment with version info + if: always() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prVersion = '${{ steps.pr-version.outputs.version }}'; + const baseVersion = '${{ steps.base-version.outputs.version }}'; + const changelogUpdated = '${{ steps.changelog.outputs.changed }}' === 'true'; + + let body = `## Version Bump Check\n\n`; + body += `| Item | Status |\n`; + body += `|------|--------|\n`; + body += `| Base version | \`${baseVersion}\` |\n`; + body += `| PR version | \`${prVersion}\` |\n`; + body += `| Version bumped | ✅ Yes |\n`; + body += `| Version consistent | ✅ Yes |\n`; + body += `| CHANGELOG updated | ${changelogUpdated ? '✅ Yes' : '⚠️ No'} |\n\n`; + + if (!changelogUpdated) { + body += `> ⚠️ **Reminder**: Please update CHANGELOG.md with your changes.\n`; + } + + body += `\n---\n`; + body += `Version bump: \`${baseVersion}\` → \`${prVersion}\`\n`; + + // Find existing comment + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Version Bump Check') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ec8f0c..29c9237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **TaxCloud Integration**: Optional support for TaxCloud order management API + - `CreateOrder()` - Create orders in TaxCloud with line items and tax calculations + - `GetOrder()` - Retrieve existing orders by ID + - `UpdateOrder()` - Update order completed dates + - `RefundOrder()` - Create full or partial refunds against orders +- **18 New Pydantic Models** for TaxCloud data structures: + - Address models: `TaxCloudAddress`, `TaxCloudAddressResponse` + - Order models: `CreateOrderRequest`, `OrderResponse`, `UpdateOrderRequest` + - Line item models: `CartItemWithTax`, `CartItemWithTaxResponse` + - Refund models: `RefundTransactionRequest`, `RefundTransactionResponse`, `CartItemRefundWithTaxRequest`, `CartItemRefundWithTaxResponse` + - Supporting models: `Tax`, `RefundTax`, `Currency`, `CurrencyResponse`, `Exemption` +- **HTTP Client Enhancements**: + - Added `post()` method for POST requests + - Added `patch()` method for PATCH requests + - Both methods support JSON payloads, query parameters, and headers +- **Dual API Architecture**: + - Seamlessly manage two separate HTTP clients (ZipTax + TaxCloud) + - TaxCloud features are completely optional - enabled only when credentials provided + - Automatic credential validation with helpful error messages +- **New Exception**: `ZipTaxCloudConfigError` for TaxCloud configuration issues +- **Configuration Enhancements**: + - Added `taxcloud_connection_id` parameter for TaxCloud Connection ID + - Added `taxcloud_api_key` parameter for TaxCloud API authentication + - Added `taxcloud_base_url` parameter (default: `https://api.v3.taxcloud.com`) + - Added `has_taxcloud_config` property to check TaxCloud configuration +- **Documentation Updates**: + - Comprehensive TaxCloud usage examples in README.md + - New example file: `examples/taxcloud_orders.py` + - Created `CLAUDE.md` - AI development guide for the project + - Updated API reference with TaxCloud endpoints + - Updated exception hierarchy documentation +- **Postal Code Lookup**: Added `GetRatesByPostalCode()` function to README examples + +### Technical Details +- TaxCloud API uses separate authentication with X-API-KEY header +- Connection ID is automatically injected into TaxCloud API paths +- All TaxCloud methods validate credentials before execution +- Type-safe implementation with assertions for optional HTTP clients +- Maintains backward compatibility - all existing ZipTax functionality unchanged +- Passes all linting (ruff) and type checking (mypy) with no errors + ## [1.0.0] - 2024-01-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..938b7dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,778 @@ +# CLAUDE.md - AI Development Guide + +This document provides comprehensive guidance for AI assistants (like Claude) working on the ZipTax Python SDK project. It covers the codebase architecture, development patterns, testing requirements, and contribution guidelines. + +## Table of Contents + +- [Project Overview](#project-overview) +- [Architecture](#architecture) +- [Development Environment](#development-environment) +- [Code Standards](#code-standards) +- [Testing Requirements](#testing-requirements) +- [Common Tasks](#common-tasks) +- [API Integration](#api-integration) +- [Troubleshooting](#troubleshooting) + +--- + +## Project Overview + +### What is This Project? + +The ZipTax Python SDK is an official client library that provides programmatic access to: + +1. **ZipTax API** (Primary) - Sales and use tax rate lookups for US and Canadian addresses +2. **TaxCloud API** (Optional) - Order management and refund processing capabilities + +### Key Design Principles + +- **Dual API Support**: The SDK seamlessly integrates two separate APIs with a unified interface +- **Optional Features**: TaxCloud functionality is completely optional - users can use ZipTax features without TaxCloud credentials +- **Type Safety**: Full type hints using Python type annotations and Pydantic models +- **Error Handling**: Comprehensive exception hierarchy for different error scenarios +- **Retry Logic**: Automatic retry with exponential backoff for transient failures +- **Backward Compatibility**: All existing ZipTax functionality remains unchanged + +--- + +## Architecture + +### Directory Structure + +``` +ziptax-python/ +├── src/ziptax/ # Main source code +│ ├── __init__.py # Package exports +│ ├── client.py # Main ZipTaxClient class +│ ├── config.py # Configuration management +│ ├── exceptions.py # Custom exceptions +│ ├── models/ # Pydantic data models +│ │ ├── __init__.py +│ │ └── responses.py # API response models +│ ├── resources/ # API endpoint functions +│ │ ├── __init__.py +│ │ └── functions.py # ZipTax and TaxCloud functions +│ └── utils/ # Utility modules +│ ├── __init__.py +│ ├── http.py # HTTP client wrapper +│ ├── retry.py # Retry logic +│ └── validation.py # Input validation +├── tests/ # Test suite +├── examples/ # Usage examples +├── docs/ # Documentation +│ └── spec.yaml # OpenAPI-style specification +├── pyproject.toml # Project configuration +└── README.md # User documentation +``` + +### Core Components + +#### 1. **Client (`client.py`)** + +- **Purpose**: Main entry point for SDK users +- **Key Features**: + - Factory method `api_key()` for initialization + - Manages two HTTP clients (ZipTax + optional TaxCloud) + - Context manager support for resource cleanup + - Passes configuration to Functions class + +**Important Implementation Details**: +```python +# Client initialization pattern +client = ZipTaxClient.api_key( + api_key="ziptax-key", # Required + taxcloud_connection_id="uuid", # Optional + taxcloud_api_key="taxcloud-key", # Optional +) + +# Two separate HTTP clients are created: +# 1. self._http_client (ZipTax API) +# 2. self._taxcloud_http_client (TaxCloud API, if configured) +``` + +#### 2. **Configuration (`config.py`)** + +- **Purpose**: Centralized configuration management +- **Key Properties**: + - `api_key`: ZipTax API key (required) + - `base_url`: ZipTax API base URL + - `taxcloud_connection_id`: TaxCloud Connection ID (optional) + - `taxcloud_api_key`: TaxCloud API key (optional) + - `taxcloud_base_url`: TaxCloud API base URL + - `has_taxcloud_config`: Boolean property to check if TaxCloud is configured + +**Implementation Pattern**: +```python +# Config uses properties with private attributes +@property +def has_taxcloud_config(self) -> bool: + return bool(self._taxcloud_connection_id and self._taxcloud_api_key) +``` + +#### 3. **Functions (`resources/functions.py`)** + +- **Purpose**: Implements all API endpoint methods +- **Architecture**: + - Takes both `http_client` and optional `taxcloud_http_client` + - Uses decorator pattern for retry logic + - TaxCloud methods check credentials before executing + +**Critical Pattern**: +```python +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: + raise ZipTaxCloudConfigError( + "TaxCloud credentials not configured. Please provide " + "taxcloud_connection_id and taxcloud_api_key when creating the client." + ) + +# All TaxCloud methods call this first +def CreateOrder(self, request: CreateOrderRequest, ...) -> OrderResponse: + self._check_taxcloud_config() # Guards against missing credentials + # ... implementation +``` + +#### 4. **HTTP Client (`utils/http.py`)** + +- **Purpose**: Wraps `requests` library with error handling +- **Methods**: `get()`, `post()`, `patch()` +- **Features**: + - Automatic error response handling + - Status code to exception mapping + - Timeout and retry support + - Session management for connection pooling + +**Key Implementation**: +```python +# HTTP client is API-agnostic +# Uses X-API-Key header for authentication +self.session.headers.update({"X-API-Key": api_key}) +``` + +#### 5. **Models (`models/responses.py`)** + +- **Purpose**: Pydantic models for request/response validation +- **Structure**: + - V60 models for ZipTax API responses + - TaxCloud models for order management + - Uses `Field` with `alias` for camelCase ↔ snake_case mapping + +**Pydantic Configuration**: +```python +# All models use this configuration +model_config = ConfigDict(populate_by_name=True) + +# Field aliases map API fields to Python conventions +order_id: str = Field(..., alias="orderId", description="...") +``` + +--- + +## Development Environment + +### Prerequisites + +- Python 3.8 or higher +- pip (Python package installer) +- Git + +### Setup + +```bash +# Clone repository +git clone https://github.com/ziptax/ziptax-python.git +cd ziptax-python + +# Install in development mode with dev dependencies +pip install -e ".[dev]" + +# Verify installation +python -c "import ziptax; print(ziptax.__version__)" +``` + +### Development Dependencies + +```python +# Defined in pyproject.toml [project.optional-dependencies] +dev = [ + "pytest>=7.0.0", # Testing framework + "pytest-cov>=4.0.0", # Coverage reporting + "black>=23.0.0", # Code formatting + "mypy>=1.0.0", # Type checking + "ruff>=0.1.0", # Linting +] +``` + +--- + +## Code Standards + +### Type Hints + +**Required**: All functions must have complete type hints + +```python +# Good ✅ +def GetOrder(self, order_id: str) -> OrderResponse: + pass + +# Bad ❌ +def GetOrder(self, order_id): + pass +``` + +### Pydantic Models + +**Pattern**: Use Pydantic v2 with `ConfigDict` + +```python +# Good ✅ +class OrderResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + order_id: str = Field(..., alias="orderId", description="...") + +# Bad ❌ (Pydantic v1 pattern) +class OrderResponse(BaseModel): + class Config: + allow_population_by_field_name = True +``` + +### Error Handling + +**Pattern**: Always check for TaxCloud configuration before using TaxCloud features + +```python +# Good ✅ +def CreateOrder(self, request: CreateOrderRequest) -> OrderResponse: + self._check_taxcloud_config() # Raises ZipTaxCloudConfigError if not configured + + # Type checker needs assertion + assert self.taxcloud_http_client is not None + + # ... implementation + +# Bad ❌ +def CreateOrder(self, request: CreateOrderRequest) -> OrderResponse: + # Missing config check + return self.taxcloud_http_client.post(...) # Type error + runtime error +``` + +### Naming Conventions + +- **Classes**: PascalCase (`ZipTaxClient`, `OrderResponse`) +- **Functions**: PascalCase for API methods (`GetSalesTaxByAddress`), snake_case for internal (`_check_taxcloud_config`) +- **Variables**: snake_case (`order_id`, `tax_amount`) +- **Constants**: UPPER_SNAKE_CASE (`DEFAULT_TIMEOUT`) + +### Documentation + +**Required**: All public methods must have docstrings with: +- Purpose description +- Args section with types +- Returns section with type +- Raises section for exceptions +- Example usage (for complex methods) + +```python +def CreateOrder( + self, + request: CreateOrderRequest, + address_autocomplete: str = "none", +) -> OrderResponse: + """Create an order in TaxCloud. + + Args: + request: CreateOrderRequest object with order details + address_autocomplete: Address autocomplete option (default: "none") + Options: "none", "origin", "destination", "all" + + Returns: + OrderResponse object with created order details + + Raises: + ZipTaxCloudConfigError: If TaxCloud credentials not configured + ZipTaxAPIError: If the API returns an error + + Example: + >>> request = CreateOrderRequest(...) + >>> order = client.request.CreateOrder(request) + """ +``` + +--- + +## Testing Requirements + +### Code Coverage + +**Minimum Requirement**: 80% code coverage + +```bash +# Run tests with coverage +pytest --cov=src/ziptax --cov-report=term-missing + +# Enforce minimum coverage +coverage report --fail-under=80 +``` + +### Test Structure + +``` +tests/ +├── test_client.py # Client initialization and lifecycle +├── test_functions.py # API endpoint functions +├── test_http.py # HTTP client functionality +├── test_retry.py # Retry logic +└── conftest.py # Shared fixtures +``` + +### Testing Patterns + +#### 1. **Mocking HTTP Requests** + +```python +import pytest +from unittest.mock import Mock, patch + +def test_create_order(mock_http_client): + """Test order creation.""" + # Mock response + mock_response = { + "orderId": "test-order-1", + "customerId": "customer-1", + # ... full response + } + + mock_http_client.post.return_value = mock_response + + # Test the function + result = client.request.CreateOrder(request) + + assert result.order_id == "test-order-1" +``` + +#### 2. **Testing Error Handling** + +```python +def test_taxcloud_without_credentials(): + """Test TaxCloud method without credentials raises error.""" + client = ZipTaxClient.api_key("test-key") # No TaxCloud credentials + + with pytest.raises(ZipTaxCloudConfigError) as exc_info: + client.request.GetOrder("order-1") + + assert "TaxCloud credentials not configured" in str(exc_info.value) +``` + +#### 3. **Testing Retry Logic** + +```python +@patch('time.sleep') # Mock sleep to speed up tests +def test_retry_on_server_error(mock_sleep, mock_http_client): + """Test automatic retry on server errors.""" + # Fail twice, then succeed + mock_http_client.get.side_effect = [ + ZipTaxServerError("Server error", 500), + ZipTaxServerError("Server error", 500), + {"data": "success"} + ] + + result = client.request.GetOrder("order-1") + + assert result is not None + assert mock_http_client.get.call_count == 3 +``` + +### Running Tests + +```bash +# Run all tests +pytest + +# Run specific test file +pytest tests/test_functions.py + +# Run specific test +pytest tests/test_functions.py::test_create_order + +# Run with verbose output +pytest -v + +# Run with coverage +pytest --cov=src/ziptax --cov-report=html +``` + +--- + +## Common Tasks + +### Adding a New API Endpoint + +**Step 1**: Add the data models to `models/responses.py` + +```python +class NewFeatureRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + field_name: str = Field(..., alias="fieldName", description="...") + +class NewFeatureResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + result: str = Field(..., description="...") +``` + +**Step 2**: Export models in `models/__init__.py` + +```python +from .responses import ( + # ... existing exports + NewFeatureRequest, + NewFeatureResponse, +) + +__all__ = [ + # ... existing exports + "NewFeatureRequest", + "NewFeatureResponse", +] +``` + +**Step 3**: Add the function to `resources/functions.py` + +```python +def NewFeature(self, request: NewFeatureRequest) -> NewFeatureResponse: + """Description of the new feature. + + Args: + request: NewFeatureRequest with input data + + Returns: + NewFeatureResponse with results + + Raises: + ZipTaxAPIError: If the API returns an error + """ + # Validation + # ... + + # 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("/new/endpoint", json=request.model_dump()) + + response_data = _make_request() + return NewFeatureResponse(**response_data) +``` + +**Step 4**: Add tests in `tests/test_functions.py` + +**Step 5**: Update documentation in `README.md` and examples + +**Step 6**: Run quality checks + +```bash +black src/ tests/ +ruff check src/ tests/ +mypy src/ziptax/ +pytest --cov=src/ziptax --cov-report=term +``` + +### Adding HTTP Methods + +The HTTP client (`utils/http.py`) currently supports GET, POST, and PATCH. To add more: + +```python +def delete( + self, + path: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, +) -> Dict[str, Any]: + """Make a DELETE request to the API.""" + url = f"{self.base_url}{path}" + logger.debug(f"DELETE {url}") + + try: + response = self.session.delete( + url, + params=params, + headers=headers, + timeout=self.timeout, + ) + + if not response.ok: + self._handle_error_response(response) + + return cast(Dict[str, Any], response.json()) + + except requests.exceptions.Timeout as e: + raise ZipTaxTimeoutError(f"Request timed out after {self.timeout}s: {e}") + except requests.exceptions.ConnectionError as e: + raise ZipTaxConnectionError(f"Connection error: {e}") + except (ZipTaxAPIError, ZipTaxTimeoutError, ZipTaxConnectionError): + raise + except Exception as e: + raise ZipTaxAPIError(f"Unexpected error: {e}") +``` + +### Updating the Spec File + +The `docs/spec.yaml` file serves as the single source of truth for the SDK structure. When adding features: + +1. Update the `api` section for new APIs or configuration +2. Update the `resources` section for new endpoints +3. Update the `models` section for new data structures +4. Update the `actual_api_responses` section with real API examples + +This file is used as a reference for code generation and documentation. + +--- + +## API Integration + +### ZipTax API + +**Base URL**: `https://api.zip-tax.com/` + +**Authentication**: X-API-Key header + +**Endpoints**: +- `GET /request/v60/` - Tax rate lookup by address or geolocation +- `GET /account/v60/metrics` - Account usage metrics + +**Response Format**: JSON with nested structure + +```json +{ + "metadata": { + "version": "v60", + "response": {"code": 100, "message": "..."} + }, + "baseRates": [...], + "taxSummaries": [...], + "addressDetail": {...} +} +``` + +### TaxCloud API + +**Base URL**: `https://api.v3.taxcloud.com/` + +**Authentication**: X-API-KEY header + Connection ID in path + +**Endpoints**: +- `POST /tax/connections/{connectionId}/orders` - Create order +- `GET /tax/connections/{connectionId}/orders/{orderId}` - Get order +- `PATCH /tax/connections/{connectionId}/orders/{orderId}` - Update order +- `POST /tax/connections/{connectionId}/orders/refunds/{orderId}` - Create refund + +**Response Format**: JSON with camelCase fields + +```json +{ + "orderId": "my-order-1", + "customerId": "customer-453", + "connectionId": "25eb9b97-...", + "lineItems": [...] +} +``` + +### Field Name Mapping + +The SDK uses snake_case for Python conventions, but APIs use camelCase. Pydantic handles this: + +```python +# API sends: {"orderId": "123", "customerId": "456"} +# Python receives: +order.order_id # "123" +order.customer_id # "456" + +# Python sends: +request = CreateOrderRequest(order_id="123", customer_id="456") +# API receives: {"orderId": "123", "customerId": "456"} +``` + +--- + +## Troubleshooting + +### Common Issues + +#### 1. **Type Checking Errors with Optional HTTP Client** + +**Problem**: +```python +# mypy error: Item "None" of "Optional[HTTPClient]" has no attribute "post" +return self.taxcloud_http_client.post(...) +``` + +**Solution**: +```python +# Add assertion after config check +self._check_taxcloud_config() # Raises if not configured +assert self.taxcloud_http_client is not None # Satisfies type checker +return self.taxcloud_http_client.post(...) +``` + +#### 2. **Pydantic Validation Errors** + +**Problem**: API returns data that doesn't match model + +**Debug Steps**: +1. Check `actual_api_responses` in `docs/spec.yaml` for real API response format +2. Verify field aliases match API field names +3. Check if fields should be Optional +4. Use `model_dump(by_alias=True)` when sending to API + +#### 3. **Import Errors After Adding Models** + +**Problem**: New models not accessible + +**Solution**: Update `models/__init__.py` exports: +```python +from .responses import ( + NewModel, # Add this +) + +__all__ = [ + "NewModel", # Add this +] +``` + +#### 4. **Line Length Violations (Ruff E501)** + +**Problem**: Lines longer than 88 characters + +**Solution**: +```python +# Use black formatter +black src/ziptax/file.py + +# Or break long strings +description=( + "This is a very long description that would exceed " + "the 88 character limit if written on one line" +) +``` + +### Debugging Tips + +1. **Enable Debug Logging**: +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +2. **Inspect HTTP Requests**: +```python +# HTTP client logs all requests at DEBUG level +# Check logs for: "POST https://... with json: {...}" +``` + +3. **Validate Pydantic Models**: +```python +# Test model with sample data +from ziptax.models import OrderResponse +data = {...} # Copy from API response +model = OrderResponse(**data) # Will raise ValidationError if invalid +``` + +4. **Test TaxCloud Config**: +```python +client = ZipTaxClient.api_key(...) +print(f"Has TaxCloud: {client.config.has_taxcloud_config}") +print(f"Connection ID: {client.config.taxcloud_connection_id}") +``` + +--- + +## Development Workflow + +### Before Committing + +1. **Format Code**: +```bash +black src/ tests/ +``` + +2. **Run Linter**: +```bash +ruff check src/ tests/ +``` + +3. **Type Check**: +```bash +mypy src/ziptax/ +``` + +4. **Run Tests**: +```bash +pytest --cov=src/ziptax --cov-report=term +``` + +5. **Check Coverage**: +```bash +coverage report --fail-under=80 +``` + +### CI/CD Pipeline + +The project uses GitHub Actions (when configured) to: +- Run tests on Python 3.8, 3.9, 3.10, 3.11 +- Check code formatting (black) +- Run linter (ruff) +- Run type checker (mypy) +- Generate coverage report +- Fail if coverage < 80% + +--- + +## Additional Resources + +### Documentation Links + +- **ZipTax API Docs**: https://zip-tax.com/api-documentation +- **TaxCloud API Docs**: https://docs.taxcloud.com/ +- **Pydantic V2 Docs**: https://docs.pydantic.dev/latest/ +- **Requests Library**: https://requests.readthedocs.io/ + +### Project Files + +- `docs/spec.yaml` - Comprehensive API specification +- `pyproject.toml` - Project metadata and dependencies +- `README.md` - User-facing documentation +- `CHANGELOG.md` - Version history + +### Code Examples + +- `examples/basic_usage.py` - Basic ZipTax usage +- `examples/taxcloud_orders.py` - TaxCloud order management +- `examples/async_usage.py` - Concurrent operations +- `examples/error_handling.py` - Error handling patterns + +--- + +## Questions & Support + +For questions about the codebase or implementation details: + +1. Check this CLAUDE.md file first +2. Review the `docs/spec.yaml` specification +3. Look at existing implementations for similar patterns +4. Check tests for usage examples + +For API-specific questions: +- ZipTax API: support@zip.tax +- TaxCloud API: TaxCloud documentation + +--- + +**Last Updated**: 2024-02-16 +**SDK Version**: 0.1.4-beta +**Maintained By**: ZipTax Team diff --git a/README.md b/README.md index 2e01ec2..2b2004d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Ziptax Python SDK -Official Python SDK for the [Ziptax API](https://zip-tax.com) - Get accurate sales and use tax rates for any US or Canadian address. +Official Python SDK for the [Ziptax API](https://zip-tax.com) - Get accurate sales and use tax rates for any US or Canadian address, with optional TaxCloud order management support. [![Python Version](https://img.shields.io/pypi/pyversions/ziptax-sdk)](https://pypi.org/project/ziptax-sdk/) [![License](https://img.shields.io/github/license/ziptax/ziptax-python)](LICENSE) ## Features +### Core Features (ZipTax API) - 🚀 Simple and intuitive API - 🔄 Automatic retry logic with exponential backoff - ✅ Input validation @@ -16,6 +17,12 @@ Official Python SDK for the [Ziptax API](https://zip-tax.com) - Get accurate sal - ⚡ Support for concurrent operations - 🧪 Well-tested with high code coverage +### TaxCloud Integration (Optional) +- 📋 **Order Management**: Create, retrieve, and update orders +- 💰 **Refund Processing**: Full and partial refund support +- 🔗 **Dual API Support**: Seamlessly integrate both ZipTax and TaxCloud +- 🔐 **Optional Configuration**: TaxCloud features only enabled when credentials provided + ## Installation ```bash @@ -115,6 +122,21 @@ response = client.request.GetSalesTaxByGeoLocation( print(response.addressDetail.normalizedAddress) ``` +### Get Rates by Postal Code + +```python +response = client.request.GetRatesByPostalCode( + postal_code="92694", + format="json", +) + +# Response includes all tax jurisdictions for the postal code +for result in response.results: + print(f"{result.geo_city}, {result.geo_state}") + print(f"Sales Tax: {result.tax_sales * 100:.2f}%") + print(f"Use Tax: {result.tax_use * 100:.2f}%") +``` + ### Get Account Metrics ```python @@ -127,6 +149,142 @@ print(f"Geo Usage: {metrics.geo_usage_percent:.2f}%") print(f"Account Active: {metrics.is_active}") ``` +## 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). + +### Initialize Client with TaxCloud Support + +```python +from ziptax import ZipTaxClient + +# Initialize with TaxCloud credentials +client = ZipTaxClient.api_key( + api_key="your-ziptax-api-key", + taxcloud_connection_id="25eb9b97-5acb-492d-b720-c03e79cf715a", + taxcloud_api_key="your-taxcloud-api-key", +) + +# TaxCloud features are now available via client.request +``` + +### Create an Order + +```python +from ziptax.models import ( + CreateOrderRequest, + TaxCloudAddress, + CartItemWithTax, + Tax, + Currency, +) + +# Prepare order request +order_request = CreateOrderRequest( + order_id="my-order-1", + customer_id="customer-453", + transaction_date="2024-01-15T09:30:00Z", + completed_date="2024-01-15T09:30:00Z", + origin=TaxCloudAddress( + line1="323 Washington Ave N", + city="Minneapolis", + state="MN", + zip="55401-2427", + ), + destination=TaxCloudAddress( + line1="323 Washington Ave N", + city="Minneapolis", + state="MN", + zip="55401-2427", + ), + line_items=[ + CartItemWithTax( + index=0, + item_id="item-1", + price=10.8, + quantity=1.5, + tax=Tax(amount=1.31, rate=0.0813), + ) + ], + currency=Currency(currency_code="USD"), +) + +# Create the order +order = client.request.CreateOrder(order_request) +print(f"Created order: {order.order_id}") +print(f"Tax amount: ${order.line_items[0].tax.amount}") +``` + +### Retrieve an Order + +```python +# Get an existing order by ID +order = client.request.GetOrder("my-order-1") + +print(f"Order ID: {order.order_id}") +print(f"Customer ID: {order.customer_id}") +print(f"Completed Date: {order.completed_date}") +print(f"Total Tax: ${sum(item.tax.amount for item in order.line_items)}") +``` + +### Update an Order + +```python +from ziptax.models import UpdateOrderRequest + +# Update the order's completed date +update_request = UpdateOrderRequest( + completed_date="2024-01-16T10:00:00Z" +) + +updated_order = client.request.UpdateOrder("my-order-1", update_request) +print(f"Updated completed date: {updated_order.completed_date}") +``` + +### Create a Refund + +```python +from ziptax.models import ( + RefundTransactionRequest, + CartItemRefundWithTaxRequest, +) + +# Partial refund - specify items and quantities +refund_request = RefundTransactionRequest( + items=[ + CartItemRefundWithTaxRequest( + item_id="item-1", + quantity=1.0, + ) + ] +) +refunds = client.request.RefundOrder("my-order-1", refund_request) +print(f"Refunded tax: ${refunds[0].items[0].tax.amount}") + +# Full refund - omit items parameter +full_refunds = client.request.RefundOrder("my-order-2") +print("Full refund created") +``` + +### TaxCloud Error Handling + +```python +from ziptax import ZipTaxCloudConfigError + +try: + # Attempt to use TaxCloud feature without credentials + order = client.request.GetOrder("my-order-1") + +except ZipTaxCloudConfigError as e: + # TaxCloud credentials not configured + print(f"TaxCloud error: {e.message}") + print("Please provide taxcloud_connection_id and taxcloud_api_key") + +except ZipTaxNotFoundError as e: + # Order not found + print(f"Order not found: {e.message}") +``` + ### Configuration You can configure the client using dict-style access: @@ -197,7 +355,8 @@ ZipTaxError ├── ZipTaxValidationError ├── ZipTaxConnectionError ├── ZipTaxTimeoutError -└── ZipTaxRetryError +├── ZipTaxRetryError +└── ZipTaxCloudConfigError (TaxCloud credentials not configured) ``` ## Async Operations @@ -338,6 +497,7 @@ See the [examples/](examples/) directory for complete examples: - [basic_usage.py](examples/basic_usage.py) - Basic SDK usage - [async_usage.py](examples/async_usage.py) - Concurrent operations - [error_handling.py](examples/error_handling.py) - Error handling patterns +- [taxcloud_orders.py](examples/taxcloud_orders.py) - TaxCloud order management ## API Reference @@ -359,12 +519,22 @@ Main client for interacting with the Ziptax API. API endpoint functions accessible via `client.request`. -#### Methods +#### ZipTax API Methods - `GetSalesTaxByAddress(address, **kwargs)` - Get tax rates by address - `GetSalesTaxByGeoLocation(lat, lng, **kwargs)` - Get tax rates by coordinates +- `GetRatesByPostalCode(postal_code, **kwargs)` - Get tax rates by US postal code - `GetAccountMetrics(**kwargs)` - Get account usage metrics +#### TaxCloud API Methods (Optional) + +Requires `taxcloud_connection_id` and `taxcloud_api_key` in client initialization. + +- `CreateOrder(request, **kwargs)` - Create an order in TaxCloud +- `GetOrder(order_id)` - Retrieve an order by ID +- `UpdateOrder(order_id, request)` - Update an order's completed date +- `RefundOrder(order_id, request)` - Create a full or partial refund + ## Requirements - Python 3.8+ @@ -387,9 +557,14 @@ Contributions are welcome! Please feel free to submit a Pull Request. 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +3. Make your changes and write tests +4. **Bump the version** using `python scripts/bump_version.py patch` (or `minor`/`major`) +5. Update CHANGELOG.md with your changes +6. Commit your changes (`git commit -m 'Add some amazing feature'`) +7. Push to the branch (`git push origin feature/amazing-feature`) +8. Open a Pull Request + +**Note**: All PRs require a version bump. See [docs/VERSIONING.md](docs/VERSIONING.md) for details on our versioning strategy. ## Changelog diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md new file mode 100644 index 0000000..8248d01 --- /dev/null +++ b/docs/VERSIONING.md @@ -0,0 +1,467 @@ +# Versioning Guide + +This document describes the versioning strategy and processes for the ZipTax Python SDK. + +## Table of Contents + +- [Versioning Strategy](#versioning-strategy) +- [Semantic Versioning](#semantic-versioning) +- [Version Bump Process](#version-bump-process) +- [Automated Checks](#automated-checks) +- [Release Process](#release-process) + +--- + +## Versioning Strategy + +The ZipTax Python SDK follows [Semantic Versioning 2.0.0](https://semver.org/) with pre-release labels for beta versions. + +### Version Format + +``` +MAJOR.MINOR.PATCH[-PRERELEASE] + +Examples: + 1.0.0 # Stable release + 0.1.4-beta # Beta pre-release + 2.1.0-rc1 # Release candidate +``` + +### Version Components + +- **MAJOR**: Incremented for breaking changes +- **MINOR**: Incremented for new features (backward compatible) +- **PATCH**: Incremented for bug fixes (backward compatible) +- **PRERELEASE**: Optional label like `beta`, `alpha`, `rc1` + +--- + +## Semantic Versioning + +### When to Bump Each Component + +#### Major Version (X.0.0) + +Bump the major version when making **breaking changes**: + +- Removing public APIs or functions +- Changing function signatures in non-backward-compatible ways +- Changing response model structures +- Removing or renaming model fields +- Changing default behavior that breaks existing code + +**Example**: Removing `GetSalesTaxByAddress()` or changing its required parameters. + +#### Minor Version (0.X.0) + +Bump the minor version when adding **new features**: + +- Adding new API endpoints +- Adding new optional parameters +- Adding new models or fields (backward compatible) +- Adding new exception types +- Enhancing existing features without breaking changes + +**Example**: Adding TaxCloud order management features (0.1.3 → 0.2.0). + +#### Patch Version (0.0.X) + +Bump the patch version for **bug fixes and minor improvements**: + +- Fixing bugs in existing functionality +- Improving error messages +- Documentation updates +- Performance improvements +- Dependency updates (non-breaking) + +**Example**: Fixing a validation bug (0.1.4 → 0.1.5). + +### Pre-release Labels + +Use pre-release labels for unstable versions: + +- **alpha**: Early testing, features incomplete or unstable +- **beta**: Feature complete, but may have bugs +- **rc1, rc2, ...**: Release candidates, nearly ready for production + +**Examples**: +- `0.2.0-alpha` → `0.2.0-beta` → `0.2.0-rc1` → `0.2.0` + +--- + +## Version Bump Process + +### Using the Version Bump Script + +We provide a helper script to maintain version consistency across all files. + +#### Check Current Version + +```bash +python scripts/bump_version.py --check +``` + +Output: +``` +📋 Version Check: + pyproject.toml: 0.1.4-beta + __init__.py: 0.1.4-beta + CLAUDE.md: 0.1.4-beta +✅ All versions match! +``` + +#### Bump Patch Version + +For bug fixes and minor improvements: + +```bash +python scripts/bump_version.py patch +``` + +Output: +``` +🔄 Version Bump: 0.1.4-beta → 0.1.5-beta + +✅ Updated pyproject.toml +✅ Updated src/ziptax/__init__.py +✅ Updated CLAUDE.md + +📋 Version Check: + pyproject.toml: 0.1.5-beta + __init__.py: 0.1.5-beta + CLAUDE.md: 0.1.5-beta +✅ All versions match! + +✨ Version bump complete! + +📝 Next steps: + 1. Update CHANGELOG.md with your changes + 2. Commit changes: git add -A && git commit -m 'Bump version to 0.1.5-beta' + 3. Create PR with version bump +``` + +#### Bump Minor Version + +For new features: + +```bash +python scripts/bump_version.py minor +``` + +This will bump `0.1.4-beta` → `0.2.0-beta` + +#### Bump Major Version + +For breaking changes: + +```bash +python scripts/bump_version.py major +``` + +This will bump `0.1.4-beta` → `1.0.0-beta` + +#### Set Specific Version + +```bash +python scripts/bump_version.py 1.0.0 +python scripts/bump_version.py 0.2.0-beta +``` + +#### Dry Run + +Preview changes without modifying files: + +```bash +python scripts/bump_version.py patch --dry-run +``` + +### Manual Version Update + +If you prefer to update versions manually, ensure consistency across these files: + +1. **pyproject.toml** (line 7): + ```toml + version = "0.1.5-beta" + ``` + +2. **src/ziptax/__init__.py** (line 47): + ```python + __version__ = "0.1.5-beta" + ``` + +3. **CLAUDE.md** (near end): + ```markdown + **SDK Version**: 0.1.5-beta + ``` + +4. **CHANGELOG.md**: + Update the `[Unreleased]` section with your changes. + +--- + +## Automated Checks + +### GitHub Actions Workflow + +Every pull request triggers the **Version Bump Check** workflow (`.github/workflows/version-check.yml`) which: + +1. ✅ **Verifies version was bumped** compared to base branch +2. ✅ **Checks version consistency** across all files +3. ✅ **Validates semantic versioning** format (PEP 440) +4. ⚠️ **Warns if CHANGELOG.md** wasn't updated +5. 💬 **Posts a comment** on the PR with version info + +### What the Workflow Checks + +``` +┌─────────────────────────────────────────┐ +│ Pull Request #123 │ +├─────────────────────────────────────────┤ +│ Base: main (0.1.4-beta) │ +│ PR: feature-branch (0.1.5-beta) │ +└─────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Version Checks │ + └──────────────────┘ + │ + ┌─────────┴─────────┐ + ▼ ▼ + ┌─────────┐ ┌──────────┐ + │ Bumped? │ │Consistent?│ + │ 0.1.4 │ │pyproject │ + │ → │ │__init__ │ + │ 0.1.5 │ │CLAUDE.md │ + └─────────┘ └──────────┘ + │ │ + └─────────┬─────────┘ + ▼ + ┌──────────────┐ + │ Valid Format?│ + │ PEP 440 │ + └──────────────┘ + │ + ▼ + ┌──────────────┐ + │CHANGELOG.md? │ + │ (warning) │ + └──────────────┘ + │ + ▼ + ┌──────────────┐ + │ PR Comment │ + │ with info │ + └──────────────┘ +``` + +### Example PR Comment + +The workflow posts this comment on your PR: + +```markdown +## Version Bump Check + +| Item | Status | +|------|--------| +| Base version | `0.1.4-beta` | +| PR version | `0.1.5-beta` | +| Version bumped | ✅ Yes | +| Version consistent | ✅ Yes | +| CHANGELOG updated | ⚠️ No | + +> ⚠️ **Reminder**: Please update CHANGELOG.md with your changes. + +--- +Version bump: `0.1.4-beta` → `0.1.5-beta` +``` + +### What Happens on Failure + +If the version check fails, the workflow will: + +- ❌ Fail the PR check +- 💬 Comment explaining the issue +- 🚫 Block merging (if branch protection is enabled) + +**Common failure scenarios**: + +1. **Version not bumped**: + ``` + ❌ ERROR: Version not bumped! + Current version (0.1.4-beta) must be greater than base version (0.1.4-beta) + ``` + +2. **Version mismatch**: + ``` + ❌ ERROR: Version mismatch! + pyproject.toml has 0.1.5-beta + __init__.py has 0.1.4-beta + Both files must have the same version. + ``` + +3. **Invalid format**: + ``` + ❌ ERROR: Invalid version format: 0.1.beta + Error: Invalid version: '0.1.beta' + ``` + +--- + +## Release Process + +### Pre-release (Beta) + +1. **Create feature branch**: + ```bash + git checkout -b feature/new-feature + ``` + +2. **Implement changes** and write tests + +3. **Bump version**: + ```bash + python scripts/bump_version.py minor # or patch/major + ``` + +4. **Update CHANGELOG.md**: + ```markdown + ## [Unreleased] + + ### Added + - New feature description + ``` + +5. **Commit and push**: + ```bash + git add -A + git commit -m "feat: Add new feature + + Bump version to 0.2.0-beta" + git push origin feature/new-feature + ``` + +6. **Create Pull Request** + - Automated checks will validate version bump + - Review and merge when approved + +7. **Tag the release** (after merge): + ```bash + git checkout main + git pull origin main + git tag v0.2.0-beta + git push origin v0.2.0-beta + ``` + +### Stable Release + +1. **Remove pre-release label**: + ```bash + python scripts/bump_version.py 1.0.0 + ``` + +2. **Update CHANGELOG.md**: + ```markdown + ## [1.0.0] - 2024-03-01 + + ### Added + - Feature A + - Feature B + + ### Changed + - Improvement X + ``` + +3. **Create release PR**: + ```bash + git checkout -b release/v1.0.0 + git add -A + git commit -m "chore: Release v1.0.0" + git push origin release/v1.0.0 + ``` + +4. **Merge and tag**: + ```bash + git checkout main + git pull origin main + git tag v1.0.0 + git push origin v1.0.0 + ``` + +5. **Publish to PyPI**: + ```bash + python -m build + python -m twine upload dist/* + ``` + +--- + +## Best Practices + +### ✅ DO + +- **Always bump version** for every PR that changes code +- **Use the helper script** to maintain consistency +- **Update CHANGELOG.md** with clear descriptions +- **Follow semantic versioning** rules strictly +- **Test thoroughly** before releasing +- **Tag releases** in git with `vX.Y.Z` format + +### ❌ DON'T + +- Don't merge PRs without version bumps +- Don't update versions manually without checking consistency +- Don't skip CHANGELOG updates +- Don't use arbitrary version numbers +- Don't make breaking changes in patch versions +- Don't release without testing + +--- + +## FAQ + +### Q: What if I forget to bump the version? + +**A**: The GitHub Actions workflow will fail and block the merge. Simply run the bump script and push the changes. + +### Q: Can I skip the version bump for documentation changes? + +**A**: No. All PRs require version bumps to maintain a clear history. Use patch bumps for documentation-only changes. + +### Q: How do I handle multiple PRs with version conflicts? + +**A**: +1. Rebase your branch on latest main +2. Run `python scripts/bump_version.py --check` to see current version +3. Bump to the next version +4. Commit and push + +### Q: When should I remove the `-beta` label? + +**A**: Remove pre-release labels when: +- All planned features are complete +- Test coverage is ≥80% +- No critical bugs exist +- Documentation is complete +- The release is stable enough for production use + +### Q: What if the automated check fails incorrectly? + +**A**: This is rare, but if it happens: +1. Check that all three files have the same version +2. Verify the version format is valid (X.Y.Z or X.Y.Z-label) +3. Ensure the PR version is greater than the base branch version +4. If still failing, check the workflow logs for details + +--- + +## Additional Resources + +- [Semantic Versioning 2.0.0](https://semver.org/) +- [PEP 440 - Version Identification](https://peps.python.org/pep-0440/) +- [Keep a Changelog](https://keepachangelog.com/) +- [Git Tagging](https://git-scm.com/book/en/v2/Git-Basics-Tagging) + +--- + +**Questions?** Open an issue or contact the maintainers. diff --git a/docs/spec.yaml b/docs/spec.yaml index 95125e1..1641405 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -10,7 +10,7 @@ project: name: "ziptax-sdk" language: "python" version: "1.0.0" - description: "Official Python SDK for the Ziptax API" + description: "Official Python SDK for the ZipTax API with optional TaxCloud order management support" # Repository information repository: @@ -28,27 +28,56 @@ project: # ----------------------------------------------------------------------------- # API Configuration # ----------------------------------------------------------------------------- +# The SDK now supports TWO APIs: +# 1. ZipTax API - For tax rate lookups and account metrics (REQUIRED) +# 2. TaxCloud API - For order management operations (OPTIONAL) + api: - # Base API details - name: "Ziptax API" - version: "v60" - base_url: "https://api.zip-tax.com/" - - # API specification source - spec: - type: "openapi" - version: "3.0.0" - source: "https://api.zip-tax.com/openapi.json" - - # Authentication methods - authentication: - type: "api_key" - location: "header" - parameter_name: "X-API-Key" - - # Additional auth configurations - supports_multiple_methods: false - methods: [] + # Primary API (ZipTax) - REQUIRED for all SDK functionality + ziptax: + name: "ZipTax API" + version: "v60" + base_url: "https://api.zip-tax.com/" + + # API specification source + spec: + type: "openapi" + version: "3.0.0" + source: "https://api.zip-tax.com/openapi.json" + + # Authentication methods + authentication: + type: "api_key" + location: "header" + parameter_name: "X-API-Key" + required: true + + # Secondary API (TaxCloud) - OPTIONAL for order management + taxcloud: + name: "TaxCloud API" + version: "v3" + base_url: "https://api.v3.taxcloud.com/" + documentation: "https://docs.taxcloud.com/api-reference/api-reference/sales-tax-api/orders/create-order" + + # Authentication methods + authentication: + type: "api_key" + location: "header" + parameter_name: "X-API-Key" + required: false # Only required if using TaxCloud features + + # Additional authentication requirements + required_parameters: + - name: "connectionId" + description: "TaxCloud Connection ID (UUID format) - used in API paths" + format: "uuid" + example: "25eb9b97-5acb-492d-b720-c03e79cf715a" + + # Feature availability notes + notes: + - "TaxCloud features are OPTIONAL and only available when both Connection ID and API Key are provided during client initialization" + - "Order management functions will return error if TaxCloud credentials not configured" + - "Uses Header authentication with the X-API-KEY header for both APIs, but TaxCloud also requires the connectionId in the path for order endpoints" # ----------------------------------------------------------------------------- # SDK Configuration @@ -220,7 +249,144 @@ resources: returns: type: "V60AccountMetrics" is_array: false - + + # TaxCloud API Orders Endpoints + - name: "CreateOrder" + api: "taxcloud" # Indicates this uses the TaxCloud API + http_method: "POST" + path: "/tax/connections/{connectionId}/orders" + description: "Create orders from marketplace transactions, pre-existing systems, or bulk uploads. Requires TaxCloud Connection ID and API Key configured during client initialization." + operation_id: "createOrder" + requires_taxcloud: true # This endpoint requires TaxCloud credentials + parameters: + - name: "connectionId" + type: "string" + format: "uuid" + required: true + location: "path" + description: "TaxCloud Connection ID (automatically populated from client configuration)" + example: "25eb9b97-5acb-492d-b720-c03e79cf715a" + - name: "addressAutocomplete" + type: "string" + required: false + location: "query" + description: "The specified addresses will be overridden with first result from address validation search" + default: "none" + enum: ["none", "origin", "destination", "all"] + request_body: + type: "CreateOrderRequest" + description: "Order details including line items, addresses, and tax information" + returns: + type: "OrderResponse" + is_array: false + status_code: 201 + authentication: + type: "header" + header: "X-API-KEY" + format: "{taxCloudAPIKey}" + note: "Uses TaxCloud API Key configured during client initialization" + + - name: "GetOrder" + api: "taxcloud" # Indicates this uses the TaxCloud API + http_method: "GET" + path: "/tax/connections/{connectionId}/orders/{orderId}" + description: "Retrieve a specific order by its ID from TaxCloud. Requires TaxCloud Connection ID and API Key configured during client initialization." + operation_id: "getOrder" + requires_taxcloud: true # This endpoint requires TaxCloud credentials + parameters: + - name: "connectionId" + type: "string" + format: "uuid" + required: true + location: "path" + description: "TaxCloud Connection ID (automatically populated from client configuration)" + example: "25eb9b97-5acb-492d-b720-c03e79cf715a" + - name: "orderId" + type: "string" + required: true + location: "path" + description: "The ID of the order to retrieve (order ID from external system)" + example: "my-order-1" + returns: + type: "OrderResponse" + is_array: false + status_code: 200 + authentication: + type: "header" + header: "X-API-KEY" + format: "{taxCloudAPIKey}" + note: "Uses TaxCloud API Key configured during client initialization" + + - name: "UpdateOrder" + api: "taxcloud" # Indicates this uses the TaxCloud API + http_method: "PATCH" + path: "/tax/connections/{connectionId}/orders/{orderId}" + description: "Update an existing order's completedDate in TaxCloud. Use this endpoint to change when an order was shipped/completed. Requires TaxCloud Connection ID and API Key configured during client initialization." + operation_id: "updateOrder" + requires_taxcloud: true # This endpoint requires TaxCloud credentials + parameters: + - name: "connectionId" + type: "string" + format: "uuid" + required: true + location: "path" + description: "TaxCloud Connection ID (automatically populated from client configuration)" + example: "25eb9b97-5acb-492d-b720-c03e79cf715a" + - name: "orderId" + type: "string" + required: true + location: "path" + description: "The ID of the order to update (order ID from external system)" + example: "my-order-1" + request_body: + type: "UpdateOrderRequest" + description: "Update order details (currently only completedDate can be updated)" + returns: + type: "OrderResponse" + is_array: false + status_code: 200 + authentication: + type: "header" + header: "X-API-KEY" + format: "{taxCloudAPIKey}" + note: "Uses TaxCloud API Key configured during client initialization" + + # TaxCloud API Refunds Endpoint + - name: "RefundOrder" + api: "taxcloud" # Indicates this uses the TaxCloud API + http_method: "POST" + path: "/tax/connections/{connectionId}/orders/refunds/{orderId}" + description: "Create a refund against an order in TaxCloud. An order can only be refunded once, regardless of whether the order is partially or fully refunded. Requires TaxCloud Connection ID and API Key configured during client initialization." + operation_id: "refundOrder" + requires_taxcloud: true # This endpoint requires TaxCloud credentials + parameters: + - name: "connectionId" + type: "string" + format: "uuid" + required: true + location: "path" + description: "TaxCloud Connection ID (automatically populated from client configuration)" + example: "25eb9b97-5acb-492d-b720-c03e79cf715a" + - name: "orderId" + type: "string" + required: true + location: "path" + description: "The ID of the order to refund against" + example: "my-order-1" + request_body: + type: "RefundTransactionRequest" + description: "Refund details including items to refund (empty or omitted items means full refund)" + returns: + type: "array" + items_type: "RefundTransactionResponse" + is_array: true + status_code: 201 + authentication: + type: "header" + header: "X-API-KEY" + format: "{taxCloudAPIKey}" + note: "Uses TaxCloud API Key configured during client initialization" + # ----------------------------------------------------------------------------- # Data Models # ----------------------------------------------------------------------------- @@ -770,6 +936,467 @@ models: api_field: "geoLng" description: "Longitude (0 for postal code lookups)" + # --------------------------------------------------------------------------- + # TaxCloud API Models - Order Management + # --------------------------------------------------------------------------- + - name: "CreateOrderRequest" + description: "Request payload for creating an order in TaxCloud" + api: "taxcloud" + properties: + - name: "order_id" + type: "string" + required: true + api_field: "orderId" + description: "Order ID in external system" + example: "my-order-1" + - name: "customer_id" + type: "string" + required: true + api_field: "customerId" + description: "Customer ID in external system" + example: "customer-453" + - name: "transaction_date" + type: "string" + format: "date-time" + required: true + api_field: "transactionDate" + description: "RFC3339 datetime string when order was purchased" + example: "2024-01-15T09:30:00Z" + - name: "completed_date" + type: "string" + format: "date-time" + required: true + api_field: "completedDate" + description: "RFC3339 datetime string when order was shipped/completed (created tax liability)" + example: "2024-01-15T09:30:00Z" + - name: "origin" + type: "TaxCloudAddress" + required: true + description: "Origin address of the order" + - name: "destination" + type: "TaxCloudAddress" + required: true + description: "Destination address of the order" + - name: "line_items" + type: "array" + items_type: "CartItemWithTax" + required: true + api_field: "lineItems" + description: "Array of line items in the order with tax calculations" + - name: "currency" + type: "Currency" + required: true + description: "Currency information for the order" + - name: "channel" + type: "string" + required: false + description: "Sales channel (e.g., amazon, ebay, walmart) for tax exclusion rules" + example: "amazon" + - name: "delivered_by_seller" + type: "boolean" + required: false + api_field: "deliveredBySeller" + description: "Whether the seller directly delivered the order" + - name: "exclude_from_filing" + type: "boolean" + required: false + api_field: "excludeFromFiling" + description: "Whether to exclude this order from tax filing" + default: false + - name: "exemption" + type: "Exemption" + required: false + description: "Exemption certificate information for the order" + + - name: "OrderResponse" + description: "Response after successfully creating an order in TaxCloud" + api: "taxcloud" + properties: + - name: "order_id" + type: "string" + required: true + api_field: "orderId" + description: "Order ID in external system" + - name: "customer_id" + type: "string" + required: true + api_field: "customerId" + description: "Customer ID in external system" + - name: "connection_id" + type: "string" + required: true + api_field: "connectionId" + description: "TaxCloud connection ID used for this order" + - name: "transaction_date" + type: "string" + format: "date-time" + required: true + api_field: "transactionDate" + description: "RFC3339 datetime string when order was purchased" + - name: "completed_date" + type: "string" + format: "date-time" + required: true + api_field: "completedDate" + description: "RFC3339 datetime string when order was shipped/completed" + - name: "origin" + type: "TaxCloudAddressResponse" + required: true + description: "Origin address of the order" + - name: "destination" + type: "TaxCloudAddressResponse" + required: true + description: "Destination address of the order" + - name: "line_items" + type: "array" + items_type: "CartItemWithTaxResponse" + required: true + api_field: "lineItems" + description: "Array of line items with tax calculations" + - name: "currency" + type: "CurrencyResponse" + required: true + description: "Currency information" + - name: "channel" + type: "string" + required: true + description: "Sales channel for the order" + - name: "delivered_by_seller" + type: "boolean" + required: true + api_field: "deliveredBySeller" + description: "Whether seller directly delivered the order" + - name: "exclude_from_filing" + type: "boolean" + required: true + default: false + api_field: "excludeFromFiling" + description: "Whether order is excluded from tax filing" + - name: "exemption" + type: "Exemption" + required: true + description: "Exemption information" + + - name: "TaxCloudAddress" + description: "Address structure for TaxCloud orders" + api: "taxcloud" + properties: + - name: "line1" + type: "string" + required: true + description: "First line of address (street, PO Box, or building)" + example: "323 Washington Ave N" + - name: "line2" + type: "string" + required: false + description: "Second line of address (apartment or suite number)" + - name: "city" + type: "string" + required: true + description: "City or post-town" + example: "Minneapolis" + - name: "state" + type: "string" + required: true + description: "State, province, county or large territorial division" + example: "MN" + - name: "zip" + type: "string" + required: true + description: "Postal or ZIP code" + example: "55401-2427" + - name: "country_code" + type: "string" + required: false + api_field: "countryCode" + description: "ISO 3166-1 alpha-2 country code" + default: "US" + enum: ["US", "CA"] + + - name: "TaxCloudAddressResponse" + description: "Address response structure from TaxCloud" + api: "taxcloud" + properties: + - name: "line1" + type: "string" + required: true + description: "First line of address" + - name: "line2" + type: "string" + required: false + description: "Second line of address" + - name: "city" + type: "string" + required: true + description: "City or post-town" + - name: "state" + type: "string" + required: true + description: "State abbreviation" + - name: "zip" + type: "string" + required: true + description: "Postal or ZIP code" + - name: "country_code" + type: "string" + required: true + default: "US" + api_field: "countryCode" + description: "ISO 3166-1 alpha-2 country code" + + - name: "CartItemWithTax" + description: "Cart line item with tax calculation for order creation" + api: "taxcloud" + properties: + - name: "index" + type: "integer" + format: "int64" + required: true + description: "Position/index of item within the cart" + example: 0 + - name: "item_id" + type: "string" + required: true + api_field: "itemId" + description: "Unique identifier for the cart item" + example: "item-1" + - name: "price" + type: "number" + format: "float" + required: true + description: "Unit price of the item" + example: 10.8 + - name: "quantity" + type: "number" + format: "float" + required: true + description: "Quantity of the item" + example: 1.5 + - name: "tax" + type: "Tax" + required: true + description: "Tax information for the item" + - name: "product_id" + type: "string" + required: false + api_field: "productId" + description: "Product ID from product catalog (must match existing product)" + - name: "tic" + type: "integer" + format: "int64" + required: false + description: "Taxability Information Code (defaults to 0 if not provided)" + default: 0 + + - name: "CartItemWithTaxResponse" + description: "Cart line item response from TaxCloud" + api: "taxcloud" + properties: + - name: "index" + type: "integer" + format: "int64" + required: true + description: "Position/index of item within the cart" + - name: "item_id" + type: "string" + required: true + api_field: "itemId" + description: "Unique identifier for the cart item" + - name: "price" + type: "number" + format: "float" + required: true + description: "Unit price of the item" + - name: "quantity" + type: "number" + format: "float" + required: true + description: "Quantity of the item" + - name: "tax" + type: "Tax" + required: true + description: "Tax information for the item" + - name: "tic" + type: "integer" + format: "int64" + required: true + description: "Taxability Information Code" + + - name: "Tax" + description: "Tax calculation details for a cart item" + api: "taxcloud" + properties: + - name: "amount" + type: "number" + format: "float" + required: true + description: "Tax amount calculated for the item" + example: 1.31 + - name: "rate" + type: "number" + format: "float" + required: true + description: "Tax rate applied (decimal format)" + example: 0.0813 + + - name: "Currency" + description: "Currency information for order" + api: "taxcloud" + properties: + - name: "currency_code" + type: "string" + required: false + api_field: "currencyCode" + description: "ISO currency code" + default: "USD" + enum: ["USD", "CAD"] + + - name: "CurrencyResponse" + description: "Currency response from TaxCloud" + api: "taxcloud" + properties: + - name: "currency_code" + type: "string" + required: true + api_field: "currencyCode" + description: "ISO currency code" + + - name: "Exemption" + description: "Tax exemption certificate information" + api: "taxcloud" + properties: + - name: "exemption_id" + type: "string" + required: false + api_field: "exemptionId" + description: "ID of exemption certificate used for customer (if provided, is_exempt assumed true)" + - name: "is_exempt" + type: "boolean" + required: false + api_field: "isExempt" + description: "Whether customer is exempt from tax" + + - name: "UpdateOrderRequest" + description: "Request payload for updating an order in TaxCloud (currently only completedDate can be updated)" + api: "taxcloud" + properties: + - name: "completed_date" + type: "string" + format: "date-time" + required: true + api_field: "completedDate" + description: "RFC3339 datetime string when order was shipped/completed (creates tax liability)" + example: "2024-01-16T10:00:00Z" + + # --------------------------------------------------------------------------- + # TaxCloud API Models - Refunds + # --------------------------------------------------------------------------- + - name: "RefundTransactionRequest" + description: "Request payload for creating a refund against an order in TaxCloud" + api: "taxcloud" + properties: + - name: "items" + type: "array" + items_type: "CartItemRefundWithTaxRequest" + required: false + description: "Items to refund. If empty list or omitted, entire order will be refunded" + - name: "returned_date" + type: "string" + format: "date-time" + required: false + api_field: "returnedDate" + description: "Include only if this return is a change to a previously filed sales tax return (triggers Amended Sales Tax Return - not typically recommended)" + + - name: "CartItemRefundWithTaxRequest" + description: "Cart line item to be refunded" + api: "taxcloud" + properties: + - name: "item_id" + type: "string" + required: true + api_field: "itemId" + description: "Unique identifier for the cart item to refund" + example: "item-1" + - name: "quantity" + type: "number" + format: "float" + required: true + description: "Quantity of the item to refund" + example: 1.0 + + - name: "RefundTransactionResponse" + description: "Response after successfully creating a refund in TaxCloud" + api: "taxcloud" + properties: + - name: "connection_id" + type: "string" + required: true + api_field: "connectionId" + description: "TaxCloud connection ID used for this refund" + - name: "created_date" + type: "string" + format: "date-time" + required: true + api_field: "createdDate" + description: "RFC3339 datetime string when the refund was created" + - name: "items" + type: "array" + items_type: "CartItemRefundWithTaxResponse" + required: true + description: "Array of refunded line items with tax calculations" + - name: "returned_date" + type: "string" + format: "date-time" + required: false + api_field: "returnedDate" + description: "RFC3339 datetime string when the refund took effect" + + - name: "CartItemRefundWithTaxResponse" + description: "Refunded cart line item response from TaxCloud" + api: "taxcloud" + properties: + - name: "index" + type: "integer" + format: "int64" + required: true + description: "Position/index of item within the cart" + - name: "item_id" + type: "string" + required: true + api_field: "itemId" + description: "Unique identifier for the cart item" + - name: "price" + type: "number" + format: "float" + required: true + description: "Price of the refunded item" + - name: "quantity" + type: "number" + format: "float" + required: true + description: "Quantity of the item refunded" + - name: "tax" + type: "RefundTax" + required: true + description: "Tax information for the refunded item" + - name: "tic" + type: "integer" + format: "int64" + required: false + default: 0 + description: "Taxability Information Code" + + - name: "RefundTax" + description: "Tax details for a refunded item" + api: "taxcloud" + properties: + - name: "amount" + type: "number" + format: "float" + required: true + description: "Tax amount refunded for the item" + example: 1.31 + # ----------------------------------------------------------------------------- # Dependencies @@ -1260,6 +1887,190 @@ actual_api_responses: } } + taxcloud_create_order: + description: "Actual TaxCloud response for CreateOrder" + endpoint: "POST /tax/connections/{connectionId}/orders" + example: | + { + "orderId": "my-order-1", + "customerId": "customer-453", + "connectionId": "25eb9b97-5acb-492d-b720-c03e79cf715a", + "transactionDate": "2024-01-15T09:30:00Z", + "completedDate": "2024-01-15T09:30:00Z", + "origin": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US" + }, + "destination": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US" + }, + "lineItems": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.8, + "quantity": 1.5, + "tax": { + "amount": 1.31, + "rate": 0.0813 + }, + "tic": 0 + } + ], + "currency": { + "currencyCode": "USD" + }, + "channel": null, + "deliveredBySeller": false, + "excludeFromFiling": false, + "exemption": { + "exemptionId": null, + "isExempt": null + } + } + + taxcloud_get_order: + description: "Actual TaxCloud response for GetOrder by ID" + endpoint: "GET /tax/connections/{connectionId}/orders/{orderId}" + example: | + { + "orderId": "my-order-1", + "customerId": "customer-453", + "connectionId": "25eb9b97-5acb-492d-b720-c03e79cf715a", + "transactionDate": "2024-01-15T09:30:00Z", + "completedDate": "2024-01-15T09:30:00Z", + "origin": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US" + }, + "destination": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US" + }, + "lineItems": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.8, + "quantity": 1.5, + "tax": { + "amount": 1.31, + "rate": 0.0813 + }, + "tic": 0 + } + ], + "currency": { + "currencyCode": "USD" + }, + "channel": null, + "deliveredBySeller": false, + "excludeFromFiling": false, + "exemption": { + "exemptionId": null, + "isExempt": null + } + } + + taxcloud_update_order: + description: "Actual TaxCloud response for UpdateOrder (updating completedDate)" + endpoint: "PATCH /tax/connections/{connectionId}/orders/{orderId}" + request_example: | + { + "completedDate": "2024-01-16T10:00:00Z" + } + response_example: | + { + "orderId": "my-order-1", + "customerId": "customer-453", + "connectionId": "25eb9b97-5acb-492d-b720-c03e79cf715a", + "transactionDate": "2024-01-15T09:30:00Z", + "completedDate": "2024-01-16T10:00:00Z", + "origin": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US" + }, + "destination": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401-2427", + "countryCode": "US" + }, + "lineItems": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.8, + "quantity": 1.5, + "tax": { + "amount": 1.31, + "rate": 0.0813 + }, + "tic": 0 + } + ], + "currency": { + "currencyCode": "USD" + }, + "channel": null, + "deliveredBySeller": false, + "excludeFromFiling": false, + "exemption": { + "exemptionId": null, + "isExempt": null + } + } + + taxcloud_refund_order: + description: "Actual TaxCloud response for RefundOrder (array of refund transactions)" + endpoint: "POST /tax/connections/{connectionId}/orders/refunds/{orderId}" + request_example: | + { + "items": [ + { + "itemId": "item-1", + "quantity": 1.0 + } + ] + } + response_example: | + [ + { + "connectionId": "25eb9b97-5acb-492d-b720-c03e79cf715a", + "createdDate": "2024-01-17T14:30:00Z", + "items": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.8, + "quantity": 1.0, + "tax": { + "amount": 0.87 + }, + "tic": 0 + } + ], + "returnedDate": "2024-01-17T14:30:00Z" + } + ] + # ----------------------------------------------------------------------------- # CI/CD Quality Requirements # ----------------------------------------------------------------------------- diff --git a/examples/taxcloud_orders.py b/examples/taxcloud_orders.py new file mode 100644 index 0000000..a3e22ba --- /dev/null +++ b/examples/taxcloud_orders.py @@ -0,0 +1,96 @@ +"""Example usage of TaxCloud order management features.""" + +from ziptax import ZipTaxClient +from ziptax.models import ( + CartItemRefundWithTaxRequest, + CartItemWithTax, + CreateOrderRequest, + Currency, + RefundTransactionRequest, + Tax, + TaxCloudAddress, + UpdateOrderRequest, +) + + +def main(): + """Demonstrate TaxCloud order management functionality.""" + # Initialize client with TaxCloud credentials + client = ZipTaxClient.api_key( + api_key="your-ziptax-api-key", + taxcloud_connection_id="25eb9b97-5acb-492d-b720-c03e79cf715a", + taxcloud_api_key="your-taxcloud-api-key", + ) + + # Example 1: Create an order + print("Creating an order...") + create_request = CreateOrderRequest( + order_id="my-order-1", + customer_id="customer-453", + transaction_date="2024-01-15T09:30:00Z", + completed_date="2024-01-15T09:30:00Z", + origin=TaxCloudAddress( + line1="323 Washington Ave N", + city="Minneapolis", + state="MN", + zip="55401-2427", + ), + destination=TaxCloudAddress( + line1="323 Washington Ave N", + city="Minneapolis", + state="MN", + zip="55401-2427", + ), + line_items=[ + CartItemWithTax( + index=0, + item_id="item-1", + price=10.8, + quantity=1.5, + tax=Tax(amount=1.31, rate=0.0813), + ) + ], + currency=Currency(currency_code="USD"), + ) + + order = client.request.CreateOrder(create_request) + print(f"Created order: {order.order_id}") + print(f"Tax amount: ${order.line_items[0].tax.amount}") + + # Example 2: Retrieve an order + print("\nRetrieving the order...") + retrieved_order = client.request.GetOrder("my-order-1") + print(f"Retrieved order: {retrieved_order.order_id}") + print(f"Completed date: {retrieved_order.completed_date}") + + # Example 3: Update an order's completed date + print("\nUpdating order completed date...") + update_request = UpdateOrderRequest(completed_date="2024-01-16T10:00:00Z") + updated_order = client.request.UpdateOrder("my-order-1", update_request) + print(f"Updated completed date: {updated_order.completed_date}") + + # Example 4: Create a partial refund + print("\nCreating a partial refund...") + refund_request = RefundTransactionRequest( + items=[ + CartItemRefundWithTaxRequest( + item_id="item-1", + quantity=1.0, + ) + ] + ) + refunds = client.request.RefundOrder("my-order-1", refund_request) + print(f"Created {len(refunds)} refund(s)") + print(f"Refunded tax amount: ${refunds[0].items[0].tax.amount}") + + # Example 5: Create a full refund (without specifying items) + print("\nCreating a full refund...") + full_refunds = client.request.RefundOrder("my-order-2") + print(f"Created full refund for order: my-order-2") + + # Close the client + client.close() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 54a515e..0502e5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ziptax-sdk" -version = "0.1.3-beta" +version = "0.1.4-beta" description = "Official Python SDK for the Ziptax API" readme = "README.md" requires-python = ">=3.8" diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100755 index 0000000..e5ac631 --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Version bump utility for ziptax-python SDK. + +This script helps maintain consistent versioning across the project by updating +version numbers in all required files. + +Usage: + python scripts/bump_version.py patch # 0.1.4-beta -> 0.1.5-beta + python scripts/bump_version.py minor # 0.1.4-beta -> 0.2.0-beta + python scripts/bump_version.py major # 0.1.4-beta -> 1.0.0-beta + python scripts/bump_version.py 0.2.0 # Set specific version +""" + +import argparse +import re +import sys +from pathlib import Path +from typing import Tuple + + +def parse_version(version_str: str) -> Tuple[int, int, int, str]: + """Parse version string into components. + + Args: + version_str: Version string like "0.1.4-beta" + + Returns: + Tuple of (major, minor, patch, suffix) + + Raises: + ValueError: If version format is invalid + """ + match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", version_str) + if not match: + raise ValueError(f"Invalid version format: {version_str}") + + major, minor, patch, suffix = match.groups() + return int(major), int(minor), int(patch), suffix or "" + + +def format_version(major: int, minor: int, patch: int, suffix: str = "") -> str: + """Format version components into string. + + Args: + major: Major version number + minor: Minor version number + patch: Patch version number + suffix: Optional suffix like "beta", "alpha", "rc1" + + Returns: + Formatted version string + """ + version = f"{major}.{minor}.{patch}" + if suffix: + version += f"-{suffix}" + return version + + +def get_current_version() -> str: + """Get current version from pyproject.toml. + + Returns: + Current version string + + Raises: + FileNotFoundError: If pyproject.toml not found + ValueError: If version not found in file + """ + pyproject_path = Path("pyproject.toml") + if not pyproject_path.exists(): + raise FileNotFoundError("pyproject.toml not found") + + content = pyproject_path.read_text() + match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + + if not match: + raise ValueError("Version not found in pyproject.toml") + + return match.group(1) + + +def bump_version(current: str, bump_type: str) -> str: + """Bump version according to type. + + Args: + current: Current version string + bump_type: One of "major", "minor", "patch", or explicit version + + Returns: + New version string + + Raises: + ValueError: If bump_type is invalid + """ + # If bump_type looks like a version, use it directly + if re.match(r"^\d+\.\d+\.\d+", bump_type): + # Validate the version format + parse_version(bump_type) + return bump_type + + major, minor, patch, suffix = parse_version(current) + + if bump_type == "major": + major += 1 + minor = 0 + patch = 0 + elif bump_type == "minor": + minor += 1 + patch = 0 + elif bump_type == "patch": + patch += 1 + else: + raise ValueError( + f"Invalid bump type: {bump_type}. " + f"Use 'major', 'minor', 'patch', or explicit version." + ) + + return format_version(major, minor, patch, suffix) + + +def update_pyproject_toml(new_version: str) -> None: + """Update version in pyproject.toml. + + Args: + new_version: New version string + """ + path = Path("pyproject.toml") + content = path.read_text() + + # Replace version line + new_content = re.sub( + r'^version\s*=\s*["\'][^"\']+["\']', + f'version = "{new_version}"', + content, + count=1, + flags=re.MULTILINE, + ) + + path.write_text(new_content) + print(f"✅ Updated pyproject.toml") + + +def update_init_py(new_version: str) -> None: + """Update __version__ in src/ziptax/__init__.py. + + Args: + new_version: New version string + """ + path = Path("src/ziptax/__init__.py") + content = path.read_text() + + # Replace __version__ line + new_content = re.sub( + r'^__version__\s*=\s*["\'][^"\']+["\']', + f'__version__ = "{new_version}"', + content, + count=1, + flags=re.MULTILINE, + ) + + path.write_text(new_content) + print(f"✅ Updated src/ziptax/__init__.py") + + +def update_claude_md(new_version: str) -> None: + """Update version reference in CLAUDE.md. + + Args: + new_version: New version string + """ + path = Path("CLAUDE.md") + if not path.exists(): + print("⚠️ CLAUDE.md not found, skipping") + return + + content = path.read_text() + + # Replace SDK Version line + new_content = re.sub( + r'\*\*SDK Version\*\*:\s*[^\n]+', + f'**SDK Version**: {new_version}', + content, + ) + + path.write_text(new_content) + print(f"✅ Updated CLAUDE.md") + + +def verify_consistency() -> bool: + """Verify version consistency across all files. + + Returns: + True if all versions match, False otherwise + """ + # Get version from pyproject.toml + pyproject_path = Path("pyproject.toml") + pyproject_content = pyproject_path.read_text() + pyproject_match = re.search( + r'^version\s*=\s*["\']([^"\']+)["\']', pyproject_content, re.MULTILINE + ) + pyproject_version = pyproject_match.group(1) if pyproject_match else None + + # Get version from __init__.py + init_path = Path("src/ziptax/__init__.py") + init_content = init_path.read_text() + init_match = re.search( + r'^__version__\s*=\s*["\']([^"\']+)["\']', init_content, re.MULTILINE + ) + init_version = init_match.group(1) if init_match else None + + # Get version from CLAUDE.md + claude_path = Path("CLAUDE.md") + claude_version = None + if claude_path.exists(): + claude_content = claude_path.read_text() + claude_match = re.search(r'\*\*SDK Version\*\*:\s*([^\n]+)', claude_content) + claude_version = claude_match.group(1).strip() if claude_match else None + + print("\n📋 Version Check:") + print(f" pyproject.toml: {pyproject_version}") + print(f" __init__.py: {init_version}") + print(f" CLAUDE.md: {claude_version or 'N/A'}") + + all_match = ( + pyproject_version == init_version + and (not claude_version or pyproject_version == claude_version) + ) + + if all_match: + print("✅ All versions match!") + else: + print("❌ Version mismatch detected!") + + return all_match + + +def main() -> int: + """Main entry point. + + Returns: + Exit code (0 for success, 1 for error) + """ + parser = argparse.ArgumentParser( + description="Bump version numbers across the project", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Bump patch version (0.1.4 -> 0.1.5) + python scripts/bump_version.py patch + + # Bump minor version (0.1.4 -> 0.2.0) + python scripts/bump_version.py minor + + # Bump major version (0.1.4 -> 1.0.0) + python scripts/bump_version.py major + + # Set specific version + python scripts/bump_version.py 0.2.0-beta + + # Just check version consistency + python scripts/bump_version.py --check + """, + ) + parser.add_argument( + "bump_type", + nargs="?", + choices=["major", "minor", "patch"], + help="Type of version bump or explicit version number", + ) + parser.add_argument( + "--check", + action="store_true", + help="Only check version consistency without bumping", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be changed without making changes", + ) + + args = parser.parse_args() + + # Check mode + if args.check: + return 0 if verify_consistency() else 1 + + # Require bump_type if not in check mode + if not args.bump_type: + parser.error("bump_type is required when not using --check") + + try: + # Get current and new versions + current_version = get_current_version() + new_version = bump_version(current_version, args.bump_type) + + print(f"\n🔄 Version Bump: {current_version} → {new_version}\n") + + if args.dry_run: + print("🔍 Dry run mode - no files will be modified\n") + print("Would update:") + print(" - pyproject.toml") + print(" - src/ziptax/__init__.py") + print(" - CLAUDE.md") + return 0 + + # Update all files + update_pyproject_toml(new_version) + update_init_py(new_version) + update_claude_md(new_version) + + print() + + # Verify consistency + if verify_consistency(): + print("\n✨ Version bump complete!") + print("\n📝 Next steps:") + print(" 1. Update CHANGELOG.md with your changes") + print(" 2. Commit changes: git add -A && git commit -m 'Bump version to {}'".format(new_version)) + print(" 3. Create PR with version bump") + return 0 + else: + print("\n⚠️ Version bump completed but consistency check failed!") + return 1 + + except Exception as e: + print(f"\n❌ Error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ziptax/__init__.py b/src/ziptax/__init__.py index e15c8ce..017a1f2 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -44,7 +44,7 @@ V60TaxSummary, ) -__version__ = "1.0.0" +__version__ = "0.1.4-beta" __all__ = [ "ZipTaxClient", diff --git a/src/ziptax/client.py b/src/ziptax/client.py index 2c49614..a2b854a 100644 --- a/src/ziptax/client.py +++ b/src/ziptax/client.py @@ -1,6 +1,7 @@ """Main client for the ZipTax SDK.""" import logging +from typing import Optional from .config import Config from .resources.functions import Functions @@ -46,8 +47,21 @@ def __init__(self, config: Config): base_url=config.base_url, timeout=config.timeout, ) + + # Create TaxCloud HTTP client if configured + self._taxcloud_http_client = None + if config.has_taxcloud_config: + assert config.taxcloud_api_key is not None + self._taxcloud_http_client = HTTPClient( + api_key=config.taxcloud_api_key, + base_url=config.taxcloud_base_url, + timeout=config.timeout, + ) + self.request = Functions( http_client=self._http_client, + taxcloud_http_client=self._taxcloud_http_client, + config=config, max_retries=config.max_retries, retry_delay=config.retry_delay, ) @@ -60,16 +74,22 @@ def api_key( timeout: int = 30, max_retries: int = 3, retry_delay: float = 1.0, + taxcloud_connection_id: Optional[str] = None, + taxcloud_api_key: Optional[str] = None, + taxcloud_base_url: str = "https://api.v3.taxcloud.com", **kwargs, ) -> "ZipTaxClient": """Create a ZipTaxClient instance with an API key. Args: api_key: ZipTax API key - base_url: Base URL for the API (default: https://api.zip-tax.com) + base_url: Base URL for the ZipTax API (default: https://api.zip-tax.com) timeout: Request timeout in seconds (default: 30) max_retries: Maximum number of retry attempts (default: 3) retry_delay: Delay between retries in seconds (default: 1.0) + taxcloud_connection_id: Optional TaxCloud Connection ID for order management + taxcloud_api_key: Optional TaxCloud API key for order management + taxcloud_base_url: Base URL for the TaxCloud API (default: https://api.v3.taxcloud.com) **kwargs: Additional configuration options Returns: @@ -79,7 +99,15 @@ def api_key( ZipTaxValidationError: If API key is invalid Example: + Basic usage: >>> client = ZipTaxClient.api_key('your-api-key') + + With TaxCloud support: + >>> client = ZipTaxClient.api_key( + ... 'your-api-key', + ... taxcloud_connection_id='25eb9b97-5acb-492d-b720-c03e79cf715a', + ... taxcloud_api_key='your-taxcloud-key' + ... ) """ validate_api_key(api_key) @@ -89,6 +117,9 @@ def api_key( timeout=timeout, max_retries=max_retries, retry_delay=retry_delay, + taxcloud_connection_id=taxcloud_connection_id, + taxcloud_api_key=taxcloud_api_key, + taxcloud_base_url=taxcloud_base_url, **kwargs, ) @@ -101,6 +132,8 @@ def close(self) -> None: calling this method directly. """ self._http_client.close() + if self._taxcloud_http_client: + self._taxcloud_http_client.close() def __enter__(self) -> "ZipTaxClient": """Context manager entry.""" diff --git a/src/ziptax/config.py b/src/ziptax/config.py index 78434db..291ee34 100644 --- a/src/ziptax/config.py +++ b/src/ziptax/config.py @@ -13,16 +13,22 @@ def __init__( timeout: int = 30, max_retries: int = 3, retry_delay: float = 1.0, + taxcloud_connection_id: Optional[str] = None, + taxcloud_api_key: Optional[str] = None, + taxcloud_base_url: str = "https://api.v3.taxcloud.com", **kwargs: Any, ): """Initialize Config. Args: api_key: ZipTax API key - base_url: Base URL for the API + base_url: Base URL for the ZipTax API timeout: Request timeout in seconds max_retries: Maximum number of retry attempts retry_delay: Delay between retries in seconds + taxcloud_connection_id: Optional TaxCloud Connection ID (UUID format) + taxcloud_api_key: Optional TaxCloud API key for order management + taxcloud_base_url: Base URL for the TaxCloud API **kwargs: Additional configuration options """ self._api_key = api_key @@ -30,6 +36,9 @@ def __init__( self._timeout = timeout self._max_retries = max_retries self._retry_delay = retry_delay + self._taxcloud_connection_id = taxcloud_connection_id + self._taxcloud_api_key = taxcloud_api_key + self._taxcloud_base_url = taxcloud_base_url.rstrip("/") self._extra: Dict[str, Any] = kwargs @property @@ -72,6 +81,26 @@ def retry_delay(self, value: float) -> None: """Set retry delay.""" self._retry_delay = value + @property + def taxcloud_connection_id(self) -> Optional[str]: + """Get TaxCloud connection ID.""" + return self._taxcloud_connection_id + + @property + def taxcloud_api_key(self) -> Optional[str]: + """Get TaxCloud API key.""" + return self._taxcloud_api_key + + @property + def taxcloud_base_url(self) -> str: + """Get TaxCloud base URL.""" + return self._taxcloud_base_url + + @property + def has_taxcloud_config(self) -> bool: + """Check if TaxCloud credentials are configured.""" + return bool(self._taxcloud_connection_id and self._taxcloud_api_key) + def __getitem__(self, key: str) -> Any: """Get configuration value by key. diff --git a/src/ziptax/exceptions.py b/src/ziptax/exceptions.py index e1b4af2..d545c61 100644 --- a/src/ziptax/exceptions.py +++ b/src/ziptax/exceptions.py @@ -118,3 +118,9 @@ def __init__( super().__init__(message) self.attempts = attempts self.last_exception = last_exception + + +class ZipTaxCloudConfigError(ZipTaxError): + """Exception raised when TaxCloud credentials are not configured.""" + + pass diff --git a/src/ziptax/models/__init__.py b/src/ziptax/models/__init__.py index 758f805..23cacb3 100644 --- a/src/ziptax/models/__init__.py +++ b/src/ziptax/models/__init__.py @@ -1,9 +1,25 @@ """Models module for ZipTax SDK.""" from .responses import ( + CartItemRefundWithTaxRequest, + CartItemRefundWithTaxResponse, + CartItemWithTax, + CartItemWithTaxResponse, + CreateOrderRequest, + Currency, + CurrencyResponse, + Exemption, JurisdictionName, JurisdictionType, + OrderResponse, + RefundTax, + RefundTransactionRequest, + RefundTransactionResponse, + Tax, + TaxCloudAddress, + TaxCloudAddressResponse, TaxType, + UpdateOrderRequest, V60AccountMetrics, V60AddressDetail, V60BaseRate, @@ -21,6 +37,7 @@ ) __all__ = [ + # V60 Models "V60Response", "V60ResponseInfo", "V60Metadata", @@ -38,4 +55,21 @@ "JurisdictionType", "JurisdictionName", "TaxType", + # TaxCloud Models + "TaxCloudAddress", + "TaxCloudAddressResponse", + "Tax", + "RefundTax", + "Currency", + "CurrencyResponse", + "Exemption", + "CartItemWithTax", + "CartItemWithTaxResponse", + "CreateOrderRequest", + "OrderResponse", + "UpdateOrderRequest", + "CartItemRefundWithTaxRequest", + "CartItemRefundWithTaxResponse", + "RefundTransactionRequest", + "RefundTransactionResponse", ] diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index c6e00ad..467ce68 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -332,3 +332,277 @@ class V60PostalCodeResponse(BaseModel): address_detail: V60PostalCodeAddressDetail = Field( ..., alias="addressDetail", description="Address details for postal code lookup" ) + + +# ============================================================================= +# TaxCloud API Models - Order Management +# ============================================================================= + + +class TaxCloudAddress(BaseModel): + """Address structure for TaxCloud orders.""" + + model_config = ConfigDict(populate_by_name=True) + + line1: str = Field(..., description="First line of address") + line2: Optional[str] = Field(None, description="Second line of address") + city: str = Field(..., description="City or post-town") + state: str = Field(..., description="State abbreviation") + zip: str = Field(..., description="Postal or ZIP code") + country_code: Optional[str] = Field( + "US", alias="countryCode", description="ISO 3166-1 alpha-2 country code" + ) + + +class TaxCloudAddressResponse(BaseModel): + """Address response structure from TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + line1: str = Field(..., description="First line of address") + line2: Optional[str] = Field(None, description="Second line of address") + city: str = Field(..., description="City or post-town") + state: str = Field(..., description="State abbreviation") + zip: str = Field(..., description="Postal or ZIP code") + country_code: str = Field( + ..., alias="countryCode", description="ISO 3166-1 alpha-2 country code" + ) + + +class Tax(BaseModel): + """Tax calculation details for a cart item.""" + + model_config = ConfigDict(populate_by_name=True) + + amount: float = Field(..., description="Tax amount calculated for the item") + rate: float = Field(..., description="Tax rate applied (decimal format)") + + +class RefundTax(BaseModel): + """Tax details for a refunded item.""" + + model_config = ConfigDict(populate_by_name=True) + + amount: float = Field(..., description="Tax amount refunded for the item") + + +class Currency(BaseModel): + """Currency information for order.""" + + model_config = ConfigDict(populate_by_name=True) + + currency_code: Optional[str] = Field( + "USD", alias="currencyCode", description="ISO currency code" + ) + + +class CurrencyResponse(BaseModel): + """Currency response from TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + currency_code: str = Field( + ..., alias="currencyCode", description="ISO currency code" + ) + + +class Exemption(BaseModel): + """Tax exemption certificate information.""" + + model_config = ConfigDict(populate_by_name=True) + + exemption_id: Optional[str] = Field( + None, alias="exemptionId", description="ID of exemption certificate" + ) + is_exempt: Optional[bool] = Field( + None, alias="isExempt", description="Whether customer is exempt from tax" + ) + + +class CartItemWithTax(BaseModel): + """Cart line item with tax calculation for order creation.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field(..., description="Position/index of item within the cart") + item_id: str = Field( + ..., alias="itemId", description="Unique identifier for the cart item" + ) + price: float = Field(..., description="Unit price of the item") + quantity: float = Field(..., description="Quantity of the item") + tax: Tax = Field(..., description="Tax information for the item") + product_id: Optional[str] = Field( + None, alias="productId", description="Product ID from product catalog" + ) + tic: Optional[int] = Field(0, description="Taxability Information Code") + + +class CartItemWithTaxResponse(BaseModel): + """Cart line item response from TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field(..., description="Position/index of item within the cart") + item_id: str = Field( + ..., alias="itemId", description="Unique identifier for the cart item" + ) + price: float = Field(..., description="Unit price of the item") + quantity: float = Field(..., description="Quantity of the item") + tax: Tax = Field(..., description="Tax information for the item") + tic: int = Field(..., description="Taxability Information Code") + + +class CreateOrderRequest(BaseModel): + """Request payload for creating an order in TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + order_id: str = Field( + ..., alias="orderId", description="Order ID in external system" + ) + customer_id: str = Field( + ..., alias="customerId", description="Customer ID in external system" + ) + transaction_date: str = Field( + ..., + alias="transactionDate", + description="RFC3339 datetime string when order was purchased", + ) + completed_date: str = Field( + ..., + alias="completedDate", + description="RFC3339 datetime string when order was shipped/completed", + ) + origin: TaxCloudAddress = Field(..., description="Origin address of the order") + destination: TaxCloudAddress = Field( + ..., description="Destination address of the order" + ) + line_items: List[CartItemWithTax] = Field( + ..., alias="lineItems", description="Array of line items in the order" + ) + currency: Currency = Field(..., description="Currency information for the order") + channel: Optional[str] = Field(None, description="Sales channel") + delivered_by_seller: Optional[bool] = Field( + None, alias="deliveredBySeller", description="Whether seller directly delivered" + ) + exclude_from_filing: Optional[bool] = Field( + False, + alias="excludeFromFiling", + description="Whether to exclude from tax filing", + ) + exemption: Optional[Exemption] = Field( + None, description="Exemption certificate information" + ) + + +class OrderResponse(BaseModel): + """Response after successfully creating an order in TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + order_id: str = Field( + ..., alias="orderId", description="Order ID in external system" + ) + customer_id: str = Field( + ..., alias="customerId", description="Customer ID in external system" + ) + connection_id: str = Field( + ..., alias="connectionId", description="TaxCloud connection ID" + ) + transaction_date: str = Field( + ..., alias="transactionDate", description="RFC3339 datetime string" + ) + completed_date: str = Field( + ..., alias="completedDate", description="RFC3339 datetime string" + ) + origin: TaxCloudAddressResponse = Field(..., description="Origin address") + destination: TaxCloudAddressResponse = Field(..., description="Destination address") + line_items: List[CartItemWithTaxResponse] = Field( + ..., alias="lineItems", description="Array of line items" + ) + currency: CurrencyResponse = Field(..., description="Currency information") + channel: Optional[str] = Field(None, description="Sales channel") + delivered_by_seller: bool = Field( + ..., alias="deliveredBySeller", description="Whether seller directly delivered" + ) + exclude_from_filing: bool = Field( + ..., alias="excludeFromFiling", description="Whether excluded from tax filing" + ) + exemption: Optional[Exemption] = Field(None, description="Exemption information") + + +class UpdateOrderRequest(BaseModel): + """Request payload for updating an order in TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + completed_date: str = Field( + ..., + alias="completedDate", + description="RFC3339 datetime string when order was shipped/completed", + ) + + +class CartItemRefundWithTaxRequest(BaseModel): + """Cart line item to be refunded.""" + + model_config = ConfigDict(populate_by_name=True) + + item_id: str = Field( + ..., alias="itemId", description="Unique identifier for the cart item to refund" + ) + quantity: float = Field(..., description="Quantity of the item to refund") + + +class CartItemRefundWithTaxResponse(BaseModel): + """Refunded cart line item response from TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + index: int = Field(..., description="Position/index of item within the cart") + item_id: str = Field( + ..., alias="itemId", description="Unique identifier for the cart item" + ) + price: float = Field(..., description="Price of the refunded item") + quantity: float = Field(..., description="Quantity of the item refunded") + tax: RefundTax = Field(..., description="Tax information for the refunded item") + tic: Optional[int] = Field(0, description="Taxability Information Code") + + +class RefundTransactionRequest(BaseModel): + """Request payload for creating a refund against an order in TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + items: Optional[List[CartItemRefundWithTaxRequest]] = Field( + None, + description="Items to refund. If empty/omitted, entire order will be refunded", + ) + returned_date: Optional[str] = Field( + None, + alias="returnedDate", + description=( + "RFC3339 datetime - only include if amending previously filed return" + ), + ) + + +class RefundTransactionResponse(BaseModel): + """Response after successfully creating a refund in TaxCloud.""" + + model_config = ConfigDict(populate_by_name=True) + + connection_id: str = Field( + ..., alias="connectionId", description="TaxCloud connection ID" + ) + created_date: str = Field( + ..., alias="createdDate", description="RFC3339 datetime when refund was created" + ) + items: List[CartItemRefundWithTaxResponse] = Field( + ..., description="Array of refunded line items" + ) + returned_date: Optional[str] = Field( + None, + alias="returnedDate", + description="RFC3339 datetime when refund took effect", + ) diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index 8aef24f..7407b84 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -1,9 +1,20 @@ """API functions for the ZipTax SDK.""" import logging -from typing import Any, Dict, Optional - -from ..models import V60AccountMetrics, V60PostalCodeResponse, V60Response +from typing import Any, Dict, List, Optional + +from ..config import Config +from ..exceptions import ZipTaxCloudConfigError +from ..models import ( + CreateOrderRequest, + OrderResponse, + RefundTransactionRequest, + RefundTransactionResponse, + UpdateOrderRequest, + V60AccountMetrics, + V60PostalCodeResponse, + V60Response, +) from ..utils.http import HTTPClient from ..utils.retry import retry_with_backoff from ..utils.validation import ( @@ -22,16 +33,25 @@ class Functions: """Functions class for ZipTax API endpoints.""" def __init__( - self, http_client: HTTPClient, max_retries: int = 3, retry_delay: float = 1.0 + self, + http_client: HTTPClient, + config: Config, + taxcloud_http_client: Optional[HTTPClient] = None, + max_retries: int = 3, + retry_delay: float = 1.0, ): """Initialize Functions. Args: - http_client: HTTP client for making requests + http_client: HTTP client for making ZipTax requests + config: Configuration object + taxcloud_http_client: Optional HTTP client for TaxCloud requests max_retries: Maximum number of retry attempts retry_delay: Delay between retries in seconds """ self.http_client = http_client + self.taxcloud_http_client = taxcloud_http_client + self.config = config self.max_retries = max_retries self.retry_delay = retry_delay @@ -211,3 +231,243 @@ def _make_request() -> Dict[str, Any]: response_data = _make_request() return V60PostalCodeResponse(**response_data) + + # ========================================================================= + # TaxCloud API - Order Management Functions + # ========================================================================= + + def _check_taxcloud_config(self) -> None: + """Check if TaxCloud credentials are configured. + + Raises: + ZipTaxCloudConfigError: If TaxCloud credentials are not configured + """ + if not self.config.has_taxcloud_config or self.taxcloud_http_client is None: + raise ZipTaxCloudConfigError( + "TaxCloud credentials not configured. Please provide " + "taxcloud_connection_id and taxcloud_api_key when creating the client." + ) + + def CreateOrder( + self, + request: CreateOrderRequest, + address_autocomplete: str = "none", + ) -> OrderResponse: + """Create an order in TaxCloud. + + Args: + request: CreateOrderRequest object with order details + address_autocomplete: Address autocomplete option (default: "none") + Options: "none", "origin", "destination", "all" + + Returns: + OrderResponse object with created order details + + Raises: + ZipTaxCloudConfigError: If TaxCloud credentials not configured + ZipTaxAPIError: If the API returns an error + + Example: + >>> from ziptax.models import ( + ... CreateOrderRequest, TaxCloudAddress, CartItemWithTax, + ... Tax, Currency + ... ) + >>> request = CreateOrderRequest( + ... order_id="my-order-1", + ... customer_id="customer-453", + ... transaction_date="2024-01-15T09:30:00Z", + ... completed_date="2024-01-15T09:30:00Z", + ... origin=TaxCloudAddress( + ... line1="323 Washington Ave N", + ... city="Minneapolis", + ... state="MN", + ... zip="55401-2427" + ... ), + ... destination=TaxCloudAddress( + ... line1="323 Washington Ave N", + ... city="Minneapolis", + ... state="MN", + ... zip="55401-2427" + ... ), + ... line_items=[ + ... CartItemWithTax( + ... index=0, + ... item_id="item-1", + ... price=10.8, + ... quantity=1.5, + ... tax=Tax(amount=1.31, rate=0.0813) + ... ) + ... ], + ... currency=Currency(currency_code="USD") + ... ) + >>> order = client.request.CreateOrder(request) + """ + self._check_taxcloud_config() + + # Build query parameters + params: Dict[str, Any] = {} + if address_autocomplete != "none": + params["addressAutocomplete"] = address_autocomplete + + # Build path with connection ID + path = f"/tax/connections/{self.config.taxcloud_connection_id}/orders" + + # Make request with retry logic + @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=request.model_dump(by_alias=True, exclude_none=True), + params=params, + ) + + response_data = _make_request() + return OrderResponse(**response_data) + + def GetOrder(self, order_id: str) -> OrderResponse: + """Retrieve an order from TaxCloud by ID. + + Args: + order_id: The ID of the order to retrieve + + Returns: + OrderResponse object with order details + + Raises: + ZipTaxCloudConfigError: If TaxCloud credentials not configured + ZipTaxAPIError: If the API returns an error + + Example: + >>> order = client.request.GetOrder("my-order-1") + """ + self._check_taxcloud_config() + + # Build path with connection ID and order ID + path = ( + f"/tax/connections/{self.config.taxcloud_connection_id}/orders/{order_id}" + ) + + # Make request with retry logic + @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.get(path) + + response_data = _make_request() + return OrderResponse(**response_data) + + def UpdateOrder( + self, + order_id: str, + request: UpdateOrderRequest, + ) -> OrderResponse: + """Update an existing order's completedDate in TaxCloud. + + Args: + order_id: The ID of the order to update + request: UpdateOrderRequest object with updated completedDate + + Returns: + OrderResponse object with updated order details + + Raises: + ZipTaxCloudConfigError: If TaxCloud credentials not configured + ZipTaxAPIError: If the API returns an error + + Example: + >>> from ziptax.models import UpdateOrderRequest + >>> request = UpdateOrderRequest( + ... completed_date="2024-01-16T10:00:00Z" + ... ) + >>> order = client.request.UpdateOrder("my-order-1", request) + """ + self._check_taxcloud_config() + + # Build path with connection ID and order ID + path = ( + f"/tax/connections/{self.config.taxcloud_connection_id}/orders/{order_id}" + ) + + # Make request with retry logic + @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.patch( + path, json=request.model_dump(by_alias=True, exclude_none=True) + ) + + response_data = _make_request() + return OrderResponse(**response_data) + + def RefundOrder( + self, + order_id: str, + request: Optional[RefundTransactionRequest] = None, + ) -> List[RefundTransactionResponse]: + """Create a refund against an order in TaxCloud. + + An order can only be refunded once, regardless of whether the order is + partially or fully refunded. + + Args: + order_id: The ID of the order to refund + request: Optional RefundTransactionRequest with items to refund. + If None or items is empty, entire order will be refunded. + + Returns: + List of RefundTransactionResponse objects + + Raises: + ZipTaxCloudConfigError: If TaxCloud credentials not configured + ZipTaxAPIError: If the API returns an error + + Example: + Full refund: + >>> refunds = client.request.RefundOrder("my-order-1") + + Partial refund: + >>> from ziptax.models import ( + ... RefundTransactionRequest, CartItemRefundWithTaxRequest + ... ) + >>> request = RefundTransactionRequest( + ... items=[ + ... CartItemRefundWithTaxRequest( + ... item_id="item-1", + ... quantity=1.0 + ... ) + ... ] + ... ) + >>> refunds = client.request.RefundOrder("my-order-1", request) + """ + self._check_taxcloud_config() + + # Build path with connection ID and order ID + conn_id = self.config.taxcloud_connection_id + path = f"/tax/connections/{conn_id}/orders/refunds/{order_id}" + + # Prepare request body + request_body = {} + if request: + request_body = request.model_dump(by_alias=True, exclude_none=True) + + # Make request with retry logic + @retry_with_backoff( + max_retries=self.max_retries, + base_delay=self.retry_delay, + ) + def _make_request() -> List[Dict[str, Any]]: + assert self.taxcloud_http_client is not None + return self.taxcloud_http_client.post(path, json=request_body) + + response_data = _make_request() + return [RefundTransactionResponse(**item) for item in response_data] diff --git a/src/ziptax/utils/http.py b/src/ziptax/utils/http.py index 2549ec8..f375071 100644 --- a/src/ziptax/utils/http.py +++ b/src/ziptax/utils/http.py @@ -143,6 +143,106 @@ def get( except Exception as e: raise ZipTaxAPIError(f"Unexpected error: {e}") + def post( + self, + path: str, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Any: + """Make a POST request to the API. + + Args: + path: API endpoint path + json: JSON request body + params: Query parameters + headers: Additional headers + + Returns: + Response data (dict or list) + + Raises: + ZipTaxConnectionError: For connection errors + ZipTaxTimeoutError: For timeout errors + ZipTaxAPIError: For API errors + """ + url = f"{self.base_url}{path}" + logger.debug(f"POST {url} with json: {json}, params: {params}") + + try: + response = self.session.post( + url, + json=json, + params=params, + headers=headers, + timeout=self.timeout, + ) + logger.debug(f"Response status: {response.status_code}") + + if not response.ok: + self._handle_error_response(response) + + return response.json() + + except requests.exceptions.Timeout as e: + raise ZipTaxTimeoutError(f"Request timed out after {self.timeout}s: {e}") + except requests.exceptions.ConnectionError as e: + raise ZipTaxConnectionError(f"Connection error: {e}") + except (ZipTaxAPIError, ZipTaxTimeoutError, ZipTaxConnectionError): + raise + except Exception as e: + raise ZipTaxAPIError(f"Unexpected error: {e}") + + def patch( + self, + path: str, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, Any]: + """Make a PATCH request to the API. + + Args: + path: API endpoint path + json: JSON request body + params: Query parameters + headers: Additional headers + + Returns: + Response data as dictionary + + Raises: + ZipTaxConnectionError: For connection errors + ZipTaxTimeoutError: For timeout errors + ZipTaxAPIError: For API errors + """ + url = f"{self.base_url}{path}" + logger.debug(f"PATCH {url} with json: {json}, params: {params}") + + try: + response = self.session.patch( + url, + json=json, + params=params, + headers=headers, + timeout=self.timeout, + ) + logger.debug(f"Response status: {response.status_code}") + + if not response.ok: + self._handle_error_response(response) + + return cast(Dict[str, Any], response.json()) + + except requests.exceptions.Timeout as e: + raise ZipTaxTimeoutError(f"Request timed out after {self.timeout}s: {e}") + except requests.exceptions.ConnectionError as e: + raise ZipTaxConnectionError(f"Connection error: {e}") + except (ZipTaxAPIError, ZipTaxTimeoutError, ZipTaxConnectionError): + raise + except Exception as e: + raise ZipTaxAPIError(f"Unexpected error: {e}") + def close(self) -> None: """Close the HTTP session.""" self.session.close() From b2d1028857748d573400d81ea5ca357936a8bce3 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Mon, 16 Feb 2026 09:43:34 -0800 Subject: [PATCH 2/9] ZIP-562: version bump --- CLAUDE.md | 2 +- pyproject.toml | 2 +- scripts/bump_version.py | 3 +-- src/ziptax/__init__.py | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 938b7dc..d0fad20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -774,5 +774,5 @@ For API-specific questions: --- **Last Updated**: 2024-02-16 -**SDK Version**: 0.1.4-beta +**SDK Version**: 0.2.0-beta **Maintained By**: ZipTax Team diff --git a/pyproject.toml b/pyproject.toml index 0502e5d..89707d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ziptax-sdk" -version = "0.1.4-beta" +version = "0.2.0-beta" description = "Official Python SDK for the Ziptax API" readme = "README.md" requires-python = ">=3.8" diff --git a/scripts/bump_version.py b/scripts/bump_version.py index e5ac631..217b49f 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -264,8 +264,7 @@ def main() -> int: parser.add_argument( "bump_type", nargs="?", - choices=["major", "minor", "patch"], - help="Type of version bump or explicit version number", + help="Type of version bump (major, minor, patch) or explicit version (e.g. 0.2.0-beta)", ) parser.add_argument( "--check", diff --git a/src/ziptax/__init__.py b/src/ziptax/__init__.py index 017a1f2..eabbb87 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -44,7 +44,7 @@ V60TaxSummary, ) -__version__ = "0.1.4-beta" +__version__ = "0.2.0-beta" __all__ = [ "ZipTaxClient", From 86dac60cc9934c99e934a036c11ddcf52ec82c33 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Mon, 16 Feb 2026 11:46:17 -0800 Subject: [PATCH 3/9] ZIp-562: resolves QA report from Devin --- docs/spec.yaml | 2 +- src/ziptax/__init__.py | 43 ++++- src/ziptax/config.py | 13 +- src/ziptax/models/responses.py | 38 ++-- src/ziptax/utils/retry.py | 2 +- src/ziptax/utils/validation.py | 8 +- tests/conftest.py | 135 +++++++++++++ tests/test_functions.py | 343 ++++++++++++++++++++++++++++++--- tests/test_http.py | 176 +++++++++++++++++ tests/test_retry.py | 12 +- 10 files changed, 708 insertions(+), 64 deletions(-) diff --git a/docs/spec.yaml b/docs/spec.yaml index 1641405..6d985ce 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -9,7 +9,7 @@ project: name: "ziptax-sdk" language: "python" - version: "1.0.0" + version: "0.2.0-beta" description: "Official Python SDK for the ZipTax API with optional TaxCloud order management support" # Repository information diff --git a/src/ziptax/__init__.py b/src/ziptax/__init__.py index eabbb87..394515b 100644 --- a/src/ziptax/__init__.py +++ b/src/ziptax/__init__.py @@ -18,6 +18,7 @@ ZipTaxAPIError, ZipTaxAuthenticationError, ZipTaxAuthorizationError, + ZipTaxCloudConfigError, ZipTaxConnectionError, ZipTaxError, ZipTaxNotFoundError, @@ -28,14 +29,33 @@ ZipTaxValidationError, ) from .models import ( + CartItemRefundWithTaxRequest, + CartItemRefundWithTaxResponse, + CartItemWithTax, + CartItemWithTaxResponse, + CreateOrderRequest, + Currency, + CurrencyResponse, + Exemption, JurisdictionName, JurisdictionType, + OrderResponse, + RefundTax, + RefundTransactionRequest, + RefundTransactionResponse, + Tax, + TaxCloudAddress, + TaxCloudAddressResponse, TaxType, + UpdateOrderRequest, V60AccountMetrics, V60AddressDetail, V60BaseRate, V60DisplayRate, V60Metadata, + V60PostalCodeAddressDetail, + V60PostalCodeResponse, + V60PostalCodeResult, V60Response, V60ResponseInfo, V60Service, @@ -54,6 +74,7 @@ "ZipTaxAPIError", "ZipTaxAuthenticationError", "ZipTaxAuthorizationError", + "ZipTaxCloudConfigError", "ZipTaxNotFoundError", "ZipTaxRateLimitError", "ZipTaxServerError", @@ -61,7 +82,7 @@ "ZipTaxConnectionError", "ZipTaxTimeoutError", "ZipTaxRetryError", - # Models + # V60 Models "V60Response", "V60ResponseInfo", "V60Metadata", @@ -73,7 +94,27 @@ "V60DisplayRate", "V60AddressDetail", "V60AccountMetrics", + "V60PostalCodeResponse", + "V60PostalCodeResult", + "V60PostalCodeAddressDetail", "JurisdictionType", "JurisdictionName", "TaxType", + # TaxCloud Models + "TaxCloudAddress", + "TaxCloudAddressResponse", + "Tax", + "RefundTax", + "Currency", + "CurrencyResponse", + "Exemption", + "CartItemWithTax", + "CartItemWithTaxResponse", + "CreateOrderRequest", + "OrderResponse", + "UpdateOrderRequest", + "CartItemRefundWithTaxRequest", + "CartItemRefundWithTaxResponse", + "RefundTransactionRequest", + "RefundTransactionResponse", ] diff --git a/src/ziptax/config.py b/src/ziptax/config.py index 291ee34..936d4e0 100644 --- a/src/ziptax/config.py +++ b/src/ziptax/config.py @@ -147,11 +147,20 @@ def to_dict(self) -> Dict[str, Any]: Returns: Dictionary representation of config """ - return { + result: Dict[str, Any] = { "api_key": "***", # Mask API key "base_url": self._base_url, "timeout": self._timeout, "max_retries": self._max_retries, "retry_delay": self._retry_delay, - **self._extra, } + + if self._taxcloud_connection_id: + result["taxcloud_connection_id"] = self._taxcloud_connection_id + if self._taxcloud_api_key: + result["taxcloud_api_key"] = "***" # Mask TaxCloud API key + if self._taxcloud_base_url != "https://api.v3.taxcloud.com": + result["taxcloud_base_url"] = self._taxcloud_base_url + + result.update(self._extra) + return result diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index 467ce68..d3bbe49 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, Literal, Optional +from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -82,7 +82,7 @@ class V60Service(BaseModel): adjustment_type: str = Field( ..., alias="adjustmentType", description="Service adjustment type" ) - taxable: Literal["Y", "N"] = Field(..., description="Taxability indicator") + taxable: str = Field(..., description="Taxability indicator") description: str = Field(..., description="Service description") @@ -94,7 +94,7 @@ class V60Shipping(BaseModel): adjustment_type: str = Field( ..., alias="adjustmentType", description="Shipping adjustment type" ) - taxable: Literal["Y", "N"] = Field(..., description="Taxability indicator") + taxable: str = Field(..., description="Taxability indicator") description: str = Field(..., description="Shipping description") @@ -107,9 +107,7 @@ class V60SourcingRules(BaseModel): ..., alias="adjustmentType", description="Sourcing rule type" ) description: str = Field(..., description="Sourcing rule description") - value: Literal["O", "D"] = Field( - ..., description="Origin (O) or Destination (D) based" - ) + value: str = Field(..., description="Origin (O) or Destination (D) based") class V60DisplayRate(BaseModel): @@ -141,12 +139,12 @@ class V60AddressDetail(BaseModel): model_config = ConfigDict(populate_by_name=True) - normalizedAddress: str = Field(..., description="Normalized address") - incorporated: Literal["true", "false"] = Field( - ..., description="Incorporation status" + normalized_address: str = Field( + ..., alias="normalizedAddress", description="Normalized address" ) - geoLat: float = Field(..., description="Geocoded latitude") - geoLng: float = Field(..., description="Geocoded longitude") + incorporated: str = Field(..., description="Incorporation status") + geo_lat: float = Field(..., alias="geoLat", description="Geocoded latitude") + geo_lng: float = Field(..., alias="geoLng", description="Geocoded longitude") class V60Response(BaseModel): @@ -158,8 +156,12 @@ class V60Response(BaseModel): base_rates: Optional[List[V60BaseRate]] = Field( None, alias="baseRates", description="Base tax rates by jurisdiction" ) - service: V60Service = Field(..., description="Service taxability information") - shipping: V60Shipping = Field(..., description="Shipping taxability information") + service: Optional[V60Service] = Field( + None, description="Service taxability information" + ) + shipping: Optional[V60Shipping] = Field( + None, description="Shipping taxability information" + ) sourcing_rules: Optional[V60SourcingRules] = Field( None, alias="sourcingRules", @@ -168,7 +170,9 @@ class V60Response(BaseModel): tax_summaries: Optional[List[V60TaxSummary]] = Field( None, alias="taxSummaries", description="Tax rate summaries" ) - addressDetail: V60AddressDetail = Field(..., description="Address details") + address_detail: V60AddressDetail = Field( + ..., alias="addressDetail", description="Address details" + ) class V60AccountMetrics(BaseModel): @@ -210,10 +214,10 @@ class V60PostalCodeResult(BaseModel): geo_state: str = Field(..., alias="geoState", description="State code") tax_sales: float = Field(..., alias="taxSales", description="Total sales tax rate") tax_use: float = Field(..., alias="taxUse", description="Total use tax rate") - txb_service: Literal["Y", "N"] = Field( + txb_service: str = Field( ..., alias="txbService", description="Service taxability indicator" ) - txb_freight: Literal["Y", "N"] = Field( + txb_freight: str = Field( ..., alias="txbFreight", description="Freight taxability indicator" ) state_sales_tax: float = Field( @@ -289,7 +293,7 @@ class V60PostalCodeResult(BaseModel): district5_use_tax: float = Field( ..., alias="district5UseTax", description="District 5 use tax rate" ) - origin_destination: Literal["O", "D"] = Field( + origin_destination: str = Field( ..., alias="originDestination", description="Origin/destination indicator" ) diff --git a/src/ziptax/utils/retry.py b/src/ziptax/utils/retry.py index 25f4c31..52c252e 100644 --- a/src/ziptax/utils/retry.py +++ b/src/ziptax/utils/retry.py @@ -105,7 +105,7 @@ def wrapper(*args: Any, **kwargs: Any) -> T: return decorator -async def async_retry_with_backoff( +def async_retry_with_backoff( max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 60.0, diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index d847143..4c4cec0 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -14,15 +14,15 @@ def validate_address(address: str) -> None: Raises: ZipTaxValidationError: If address is invalid """ + if not isinstance(address, str): + raise ZipTaxValidationError("Address must be a string") + if not address: raise ZipTaxValidationError("Address cannot be empty") if len(address) > 100: raise ZipTaxValidationError("Address cannot exceed 100 characters") - if not isinstance(address, str): - raise ZipTaxValidationError("Address must be a string") - def validate_coordinates(lat: str, lng: str) -> None: """Validate latitude and longitude parameters. @@ -115,7 +115,7 @@ def validate_format(format_str: str) -> None: Raises: ZipTaxValidationError: If format is invalid """ - valid_formats = ["json", "xml"] + valid_formats = ["json"] if format_str not in valid_formats: raise ZipTaxValidationError( diff --git a/tests/conftest.py b/tests/conftest.py index 34bd703..c731be5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,20 @@ def mock_config(mock_api_key): ) +@pytest.fixture +def mock_taxcloud_config(mock_api_key): + """Mock configuration with TaxCloud credentials for testing.""" + return Config( + api_key=mock_api_key, + base_url="https://api.zip-tax.com", + timeout=30, + max_retries=3, + retry_delay=1.0, + taxcloud_connection_id="test-connection-id-uuid", + taxcloud_api_key="test-taxcloud-api-key-1234567890", + ) + + @pytest.fixture def mock_http_client(mock_api_key): """Mock HTTP client for testing.""" @@ -37,6 +51,16 @@ def mock_http_client(mock_api_key): return client +@pytest.fixture +def mock_taxcloud_http_client(): + """Mock HTTP client for TaxCloud API testing.""" + client = Mock(spec=HTTPClient) + client.api_key = "test-taxcloud-api-key-1234567890" + client.base_url = "https://api.v3.taxcloud.com" + client.timeout = 30 + return client + + @pytest.fixture def mock_client(mock_config, mock_http_client, monkeypatch): """Mock ZipTaxClient for testing.""" @@ -115,3 +139,114 @@ def sample_account_metrics(): "is_active": True, "message": "Contact support@zip.tax to modify your account", } + + +@pytest.fixture +def sample_postal_code_response(): + """Sample V60PostalCodeResponse data for testing.""" + return { + "version": "v60", + "rCode": 100, + "results": [ + { + "geoPostalCode": "92694", + "geoCity": "LADERA RANCH", + "geoCounty": "ORANGE", + "geoState": "CA", + "taxSales": 0.0775, + "taxUse": 0.0775, + "txbService": "N", + "txbFreight": "N", + "stateSalesTax": 0.06, + "stateUseTax": 0.06, + "citySalesTax": 0.0, + "cityUseTax": 0.0, + "cityTaxCode": "", + "countySalesTax": 0.0025, + "countyUseTax": 0.0025, + "countyTaxCode": "30", + "districtSalesTax": 0.015, + "districtUseTax": 0.015, + "district1Code": "26", + "district1SalesTax": 0.005, + "district1UseTax": 0.005, + "district2Code": "38", + "district2SalesTax": 0.01, + "district2UseTax": 0.01, + "district3Code": "", + "district3SalesTax": 0.0, + "district3UseTax": 0.0, + "district4Code": "", + "district4SalesTax": 0.0, + "district4UseTax": 0.0, + "district5Code": "", + "district5SalesTax": 0.0, + "district5UseTax": 0.0, + "originDestination": "D", + } + ], + "addressDetail": { + "normalizedAddress": "", + "incorporated": "", + "geoLat": 0.0, + "geoLng": 0.0, + }, + } + + +@pytest.fixture +def sample_order_response(): + """Sample TaxCloud OrderResponse data for testing.""" + return { + "orderId": "test-order-1", + "customerId": "customer-1", + "connectionId": "test-connection-id-uuid", + "transactionDate": "2024-01-15T09:30:00Z", + "completedDate": "2024-01-15T09:30:00Z", + "origin": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + "countryCode": "US", + }, + "destination": { + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + "countryCode": "US", + }, + "lineItems": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.80, + "quantity": 1.5, + "tax": {"amount": 1.31, "rate": 0.0813}, + "tic": 0, + } + ], + "currency": {"currencyCode": "USD"}, + "deliveredBySeller": False, + "excludeFromFiling": False, + } + + +@pytest.fixture +def sample_refund_response(): + """Sample TaxCloud RefundTransactionResponse data for testing.""" + return { + "connectionId": "test-connection-id-uuid", + "createdDate": "2024-01-16T10:00:00Z", + "items": [ + { + "index": 0, + "itemId": "item-1", + "price": 10.80, + "quantity": 1.0, + "tax": {"amount": 0.88}, + "tic": 0, + } + ], + } diff --git a/tests/test_functions.py b/tests/test_functions.py index a6889b1..b28b31a 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -2,18 +2,27 @@ import pytest -from ziptax.exceptions import ZipTaxValidationError -from ziptax.models import V60AccountMetrics, V60Response +from ziptax.exceptions import ZipTaxCloudConfigError, ZipTaxValidationError +from ziptax.models import ( + CreateOrderRequest, + OrderResponse, + RefundTransactionRequest, + RefundTransactionResponse, + UpdateOrderRequest, + V60AccountMetrics, + V60PostalCodeResponse, + V60Response, +) from ziptax.resources.functions import Functions class TestGetSalesTaxByAddress: """Test cases for GetSalesTaxByAddress function.""" - def test_basic_request(self, mock_http_client, sample_v60_response): + def test_basic_request(self, mock_http_client, mock_config, sample_v60_response): """Test basic address request.""" mock_http_client.get.return_value = sample_v60_response - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) response = functions.GetSalesTaxByAddress( "200 Spectrum Center Drive, Irvine, CA 92618" @@ -24,10 +33,12 @@ def test_basic_request(self, mock_http_client, sample_v60_response): assert response.metadata.response.code == 100 mock_http_client.get.assert_called_once() - def test_with_optional_parameters(self, mock_http_client, sample_v60_response): + def test_with_optional_parameters( + self, mock_http_client, mock_config, sample_v60_response + ): """Test request with optional parameters.""" mock_http_client.get.return_value = sample_v60_response - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) response = functions.GetSalesTaxByAddress( address="200 Spectrum Center Drive, Irvine, CA 92618", @@ -46,24 +57,24 @@ def test_with_optional_parameters(self, mock_http_client, sample_v60_response): assert call_args[1]["params"]["taxabilityCode"] == "12345" assert call_args[1]["params"]["historical"] == "2024-01" - def test_empty_address_validation(self, mock_http_client): + def test_empty_address_validation(self, mock_http_client, mock_config): """Test validation of empty address.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) with pytest.raises(ZipTaxValidationError, match="Address cannot be empty"): functions.GetSalesTaxByAddress("") - def test_address_too_long_validation(self, mock_http_client): + def test_address_too_long_validation(self, mock_http_client, mock_config): """Test validation of address length.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) long_address = "a" * 101 with pytest.raises(ZipTaxValidationError, match="cannot exceed 100 characters"): functions.GetSalesTaxByAddress(long_address) - def test_invalid_country_code(self, mock_http_client): + def test_invalid_country_code(self, mock_http_client, mock_config): """Test validation of country code.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) with pytest.raises(ZipTaxValidationError, match="Country code must be one of"): functions.GetSalesTaxByAddress( @@ -71,9 +82,9 @@ def test_invalid_country_code(self, mock_http_client): country_code="INVALID", ) - def test_invalid_historical_format(self, mock_http_client): + def test_invalid_historical_format(self, mock_http_client, mock_config): """Test validation of historical date format.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) with pytest.raises(ZipTaxValidationError, match="must be in YYYY-MM format"): functions.GetSalesTaxByAddress( @@ -85,10 +96,10 @@ def test_invalid_historical_format(self, mock_http_client): class TestGetSalesTaxByGeoLocation: """Test cases for GetSalesTaxByGeoLocation function.""" - def test_basic_request(self, mock_http_client, sample_v60_response): + def test_basic_request(self, mock_http_client, mock_config, sample_v60_response): """Test basic geolocation request.""" mock_http_client.get.return_value = sample_v60_response - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) response = functions.GetSalesTaxByGeoLocation( lat="33.6489", @@ -100,10 +111,12 @@ def test_basic_request(self, mock_http_client, sample_v60_response): assert response.metadata.response.code == 100 mock_http_client.get.assert_called_once() - def test_with_optional_parameters(self, mock_http_client, sample_v60_response): + def test_with_optional_parameters( + self, mock_http_client, mock_config, sample_v60_response + ): """Test request with optional parameters.""" mock_http_client.get.return_value = sample_v60_response - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) response = functions.GetSalesTaxByGeoLocation( lat="33.6489", @@ -118,30 +131,30 @@ def test_with_optional_parameters(self, mock_http_client, sample_v60_response): assert call_args[1]["params"]["lat"] == "33.6489" assert call_args[1]["params"]["lng"] == "-117.8386" - def test_empty_coordinates_validation(self, mock_http_client): + def test_empty_coordinates_validation(self, mock_http_client, mock_config): """Test validation of empty coordinates.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) with pytest.raises(ZipTaxValidationError, match="cannot be empty"): functions.GetSalesTaxByGeoLocation(lat="", lng="") - def test_invalid_latitude_range(self, mock_http_client): + def test_invalid_latitude_range(self, mock_http_client, mock_config): """Test validation of latitude range.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) with pytest.raises(ZipTaxValidationError, match="Latitude must be between"): functions.GetSalesTaxByGeoLocation(lat="100.0", lng="-117.8386") - def test_invalid_longitude_range(self, mock_http_client): + def test_invalid_longitude_range(self, mock_http_client, mock_config): """Test validation of longitude range.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) with pytest.raises(ZipTaxValidationError, match="Longitude must be between"): functions.GetSalesTaxByGeoLocation(lat="33.6489", lng="200.0") - def test_invalid_coordinate_format(self, mock_http_client): + def test_invalid_coordinate_format(self, mock_http_client, mock_config): """Test validation of coordinate format.""" - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) with pytest.raises(ZipTaxValidationError, match="must be valid numbers"): functions.GetSalesTaxByGeoLocation(lat="invalid", lng="-117.8386") @@ -150,10 +163,10 @@ def test_invalid_coordinate_format(self, mock_http_client): class TestGetAccountMetrics: """Test cases for GetAccountMetrics function.""" - def test_basic_request(self, mock_http_client, sample_account_metrics): + def test_basic_request(self, mock_http_client, mock_config, sample_account_metrics): """Test basic account metrics request.""" mock_http_client.get.return_value = sample_account_metrics - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) response = functions.GetAccountMetrics() @@ -163,10 +176,12 @@ def test_basic_request(self, mock_http_client, sample_account_metrics): assert response.is_active is True mock_http_client.get.assert_called_once() - def test_with_key_parameter(self, mock_http_client, sample_account_metrics): + def test_with_key_parameter( + self, mock_http_client, mock_config, sample_account_metrics + ): """Test request with key parameter.""" mock_http_client.get.return_value = sample_account_metrics - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) response = functions.GetAccountMetrics(key="test-key") @@ -174,10 +189,12 @@ def test_with_key_parameter(self, mock_http_client, sample_account_metrics): call_args = mock_http_client.get.call_args assert call_args[1]["params"]["key"] == "test-key" - def test_response_fields(self, mock_http_client, sample_account_metrics): + def test_response_fields( + self, mock_http_client, mock_config, sample_account_metrics + ): """Test all response fields are properly parsed.""" mock_http_client.get.return_value = sample_account_metrics - functions = Functions(mock_http_client) + functions = Functions(mock_http_client, mock_config) response = functions.GetAccountMetrics() @@ -190,3 +207,265 @@ def test_response_fields(self, mock_http_client, sample_account_metrics): assert response.geo_usage_percent == 4.3891 assert response.is_active is True assert "support@zip.tax" in response.message + + +class TestGetRatesByPostalCode: + """Test cases for GetRatesByPostalCode function.""" + + def test_basic_request( + self, mock_http_client, mock_config, sample_postal_code_response + ): + """Test basic postal code request.""" + mock_http_client.get.return_value = sample_postal_code_response + functions = Functions(mock_http_client, mock_config) + + response = functions.GetRatesByPostalCode("92694") + + assert isinstance(response, V60PostalCodeResponse) + assert response.version == "v60" + assert response.r_code == 100 + assert len(response.results) == 1 + assert response.results[0].geo_postal_code == "92694" + mock_http_client.get.assert_called_once() + + def test_with_format_parameter( + self, mock_http_client, mock_config, sample_postal_code_response + ): + """Test request with format parameter.""" + mock_http_client.get.return_value = sample_postal_code_response + functions = Functions(mock_http_client, mock_config) + + response = functions.GetRatesByPostalCode(postal_code="92694", format="json") + + assert isinstance(response, V60PostalCodeResponse) + call_args = mock_http_client.get.call_args + assert call_args[1]["params"]["postalcode"] == "92694" + assert call_args[1]["params"]["format"] == "json" + + def test_invalid_postal_code(self, mock_http_client, mock_config): + """Test validation of invalid postal code.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="Postal code must be"): + functions.GetRatesByPostalCode("invalid") + + def test_empty_postal_code(self, mock_http_client, mock_config): + """Test validation of empty postal code.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="Postal code cannot be empty"): + functions.GetRatesByPostalCode("") + + def test_response_fields( + self, mock_http_client, mock_config, sample_postal_code_response + ): + """Test all response fields are properly parsed.""" + mock_http_client.get.return_value = sample_postal_code_response + functions = Functions(mock_http_client, mock_config) + + response = functions.GetRatesByPostalCode("92694") + result = response.results[0] + + assert result.geo_city == "LADERA RANCH" + assert result.geo_county == "ORANGE" + assert result.geo_state == "CA" + assert result.tax_sales == 0.0775 + assert result.tax_use == 0.0775 + + +class TestTaxCloudFunctions: + """Test cases for TaxCloud order management functions.""" + + def test_check_taxcloud_config_raises_without_config( + self, mock_http_client, mock_config + ): + """Test that TaxCloud functions raise without TaxCloud config.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises( + ZipTaxCloudConfigError, + match="TaxCloud credentials not configured", + ): + functions._check_taxcloud_config() + + def test_create_order( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_order_response, + ): + """Test creating an order.""" + mock_taxcloud_http_client.post.return_value = sample_order_response + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = CreateOrderRequest( + order_id="test-order-1", + customer_id="customer-1", + transaction_date="2024-01-15T09:30:00Z", + completed_date="2024-01-15T09:30:00Z", + origin={ + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + }, + destination={ + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + }, + line_items=[ + { + "index": 0, + "itemId": "item-1", + "price": 10.80, + "quantity": 1.5, + "tax": {"amount": 1.31, "rate": 0.0813}, + } + ], + currency={"currencyCode": "USD"}, + ) + + response = functions.CreateOrder(request) + + assert isinstance(response, OrderResponse) + assert response.order_id == "test-order-1" + mock_taxcloud_http_client.post.assert_called_once() + + def test_get_order( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_order_response, + ): + """Test retrieving an order.""" + mock_taxcloud_http_client.get.return_value = sample_order_response + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + response = functions.GetOrder("test-order-1") + + assert isinstance(response, OrderResponse) + assert response.order_id == "test-order-1" + mock_taxcloud_http_client.get.assert_called_once() + + def test_update_order( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_order_response, + ): + """Test updating an order.""" + mock_taxcloud_http_client.patch.return_value = sample_order_response + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = UpdateOrderRequest(completed_date="2024-01-16T10:00:00Z") + response = functions.UpdateOrder("test-order-1", request) + + assert isinstance(response, OrderResponse) + assert response.order_id == "test-order-1" + mock_taxcloud_http_client.patch.assert_called_once() + + def test_refund_order( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_refund_response, + ): + """Test refunding an order.""" + mock_taxcloud_http_client.post.return_value = [sample_refund_response] + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = RefundTransactionRequest( + items=[{"itemId": "item-1", "quantity": 1.0}] + ) + response = functions.RefundOrder("test-order-1", request) + + assert isinstance(response, list) + assert len(response) == 1 + assert isinstance(response[0], RefundTransactionResponse) + mock_taxcloud_http_client.post.assert_called_once() + + def test_refund_order_full( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_refund_response, + ): + """Test full refund (no request body).""" + mock_taxcloud_http_client.post.return_value = [sample_refund_response] + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + response = functions.RefundOrder("test-order-1") + + assert isinstance(response, list) + assert len(response) == 1 + mock_taxcloud_http_client.post.assert_called_once() + + def test_create_order_without_taxcloud_config(self, mock_http_client, mock_config): + """Test CreateOrder raises without TaxCloud config.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxCloudConfigError): + functions.CreateOrder( + CreateOrderRequest( + order_id="x", + customer_id="y", + transaction_date="2024-01-15T09:30:00Z", + completed_date="2024-01-15T09:30:00Z", + origin={ + "line1": "123 Main", + "city": "City", + "state": "ST", + "zip": "12345", + }, + destination={ + "line1": "123 Main", + "city": "City", + "state": "ST", + "zip": "12345", + }, + line_items=[ + { + "index": 0, + "itemId": "i", + "price": 1.0, + "quantity": 1, + "tax": {"amount": 0.1, "rate": 0.1}, + } + ], + currency={"currencyCode": "USD"}, + ) + ) + + def test_get_order_without_taxcloud_config(self, mock_http_client, mock_config): + """Test GetOrder raises without TaxCloud config.""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxCloudConfigError): + functions.GetOrder("test-order-1") diff --git a/tests/test_http.py b/tests/test_http.py index 02838bf..76deabc 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -272,3 +272,179 @@ def test_close(http_client): with patch.object(http_client.session, "close") as mock_close: http_client.close() mock_close.assert_called_once() + + +# ============================================================================= +# POST method tests +# ============================================================================= + + +def test_post_success(http_client): + """Test successful POST request.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"orderId": "test-1"} + + with patch.object(http_client.session, "post", return_value=mock_response): + result = http_client.post("/test", json={"key": "value"}) + + assert result == {"orderId": "test-1"} + + +def test_post_with_params(http_client): + """Test POST request with query parameters.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "test"} + + with patch.object( + http_client.session, "post", return_value=mock_response + ) as mock_post: + result = http_client.post( + "/test", json={"body": "data"}, params={"param1": "value1"} + ) + + assert result == {"data": "test"} + mock_post.assert_called_once() + + +def test_post_authentication_error(http_client): + """Test POST request with 401 authentication error.""" + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + mock_response.json.return_value = {"message": "Invalid API key"} + + with patch.object(http_client.session, "post", return_value=mock_response): + with pytest.raises(ZipTaxAuthenticationError) as exc_info: + http_client.post("/test", json={}) + + assert exc_info.value.status_code == 401 + + +def test_post_server_error(http_client): + """Test POST request with 500 server error.""" + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_response.json.return_value = {"message": "Server error"} + + with patch.object(http_client.session, "post", return_value=mock_response): + with pytest.raises(ZipTaxServerError) as exc_info: + http_client.post("/test", json={}) + + assert exc_info.value.status_code == 500 + + +def test_post_timeout_error(http_client): + """Test POST request with timeout error.""" + with patch.object( + http_client.session, + "post", + side_effect=requests.exceptions.Timeout("Timeout"), + ): + with pytest.raises(ZipTaxTimeoutError): + http_client.post("/test", json={}) + + +def test_post_connection_error(http_client): + """Test POST request with connection error.""" + with patch.object( + http_client.session, + "post", + side_effect=requests.exceptions.ConnectionError("Connection failed"), + ): + with pytest.raises(ZipTaxConnectionError): + http_client.post("/test", json={}) + + +# ============================================================================= +# PATCH method tests +# ============================================================================= + + +def test_patch_success(http_client): + """Test successful PATCH request.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"orderId": "test-1", "updated": True} + + with patch.object(http_client.session, "patch", return_value=mock_response): + result = http_client.patch("/test", json={"completedDate": "2024-01"}) + + assert result == {"orderId": "test-1", "updated": True} + + +def test_patch_with_params(http_client): + """Test PATCH request with query parameters.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "test"} + + with patch.object( + http_client.session, "patch", return_value=mock_response + ) as mock_patch: + result = http_client.patch( + "/test", json={"body": "data"}, params={"param1": "value1"} + ) + + assert result == {"data": "test"} + mock_patch.assert_called_once() + + +def test_patch_not_found_error(http_client): + """Test PATCH request with 404 not found error.""" + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 404 + mock_response.text = "Not Found" + mock_response.json.return_value = {"message": "Order not found"} + + with patch.object(http_client.session, "patch", return_value=mock_response): + with pytest.raises(ZipTaxNotFoundError) as exc_info: + http_client.patch("/test", json={}) + + assert exc_info.value.status_code == 404 + + +def test_patch_server_error(http_client): + """Test PATCH request with 500 server error.""" + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_response.json.return_value = {"message": "Server error"} + + with patch.object(http_client.session, "patch", return_value=mock_response): + with pytest.raises(ZipTaxServerError) as exc_info: + http_client.patch("/test", json={}) + + assert exc_info.value.status_code == 500 + + +def test_patch_timeout_error(http_client): + """Test PATCH request with timeout error.""" + with patch.object( + http_client.session, + "patch", + side_effect=requests.exceptions.Timeout("Timeout"), + ): + with pytest.raises(ZipTaxTimeoutError): + http_client.patch("/test", json={}) + + +def test_patch_connection_error(http_client): + """Test PATCH request with connection error.""" + with patch.object( + http_client.session, + "patch", + side_effect=requests.exceptions.ConnectionError("Connection failed"), + ): + with pytest.raises(ZipTaxConnectionError): + http_client.patch("/test", json={}) diff --git a/tests/test_retry.py b/tests/test_retry.py index 6bc1c6c..f6ad627 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -163,7 +163,7 @@ async def test_async_retry_with_backoff_success(): async def async_mock(): return mock_func() - decorator = await async_retry_with_backoff() + decorator = async_retry_with_backoff() decorated = decorator(async_mock) result = await decorated() @@ -184,7 +184,7 @@ async def async_func(): raise ZipTaxServerError("Server error", 500, None) return "success" - decorator = await async_retry_with_backoff(max_retries=2, base_delay=0.01) + decorator = async_retry_with_backoff(max_retries=2, base_delay=0.01) decorated = decorator(async_func) with patch("asyncio.sleep"): @@ -201,7 +201,7 @@ async def test_async_retry_with_backoff_max_retries_exceeded(): async def async_func(): raise ZipTaxServerError("Server error", 500, None) - decorator = await async_retry_with_backoff(max_retries=2, base_delay=0.01) + decorator = async_retry_with_backoff(max_retries=2, base_delay=0.01) decorated = decorator(async_func) with patch("asyncio.sleep"): @@ -219,7 +219,7 @@ async def test_async_retry_with_backoff_non_retryable_error(): async def async_func(): raise ZipTaxValidationError("Validation error") - decorator = await async_retry_with_backoff(max_retries=3) + decorator = async_retry_with_backoff(max_retries=3) decorated = decorator(async_func) with pytest.raises(ZipTaxValidationError): @@ -233,7 +233,7 @@ async def test_async_retry_with_backoff_exponential_delay(): async def async_func(): raise ZipTaxServerError("Server error", 500, None) - decorator = await async_retry_with_backoff( + decorator = async_retry_with_backoff( max_retries=3, base_delay=1.0, exponential_base=2.0, max_delay=60.0 ) decorated = decorator(async_func) @@ -261,7 +261,7 @@ async def async_func(): ) return "success" - decorator = await async_retry_with_backoff(max_retries=2, base_delay=1.0) + decorator = async_retry_with_backoff(max_retries=2, base_delay=1.0) decorated = decorator(async_func) with patch("asyncio.sleep") as mock_sleep: From f35778dfa16431c3557f368f4038760158c69135 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Mon, 16 Feb 2026 12:17:18 -0800 Subject: [PATCH 4/9] ZIP-562: resolves QA report from Devin --- README.md | 41 +++++++++---------- docs/API_FIELD_MAPPING.md | 72 +++++++++++++++++++--------------- docs/spec.yaml | 50 +++++++---------------- src/ziptax/models/responses.py | 45 ++++++++++++--------- src/ziptax/utils/validation.py | 5 +++ tests/conftest.py | 12 ++---- tests/test_functions.py | 13 ++---- 7 files changed, 114 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 2b2004d..5042e24 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ response = client.request.GetSalesTaxByAddress( "200 Spectrum Center Drive, Irvine, CA 92618" ) -print(f"Address: {response.addressDetail.normalizedAddress}") +print(f"Address: {response.address_detail.normalized_address}") if response.tax_summaries: for summary in response.tax_summaries: print(f"{summary.summary_name}: {summary.rate * 100:.2f}%") @@ -81,13 +81,13 @@ response = client.request.GetSalesTaxByAddress( address="200 Spectrum Center Drive, Irvine, CA 92618", country_code="USA", # Optional: "USA" or "CAN" (default: "USA") historical="2024-01", # Optional: Historical date (YYYY-MM format) - format="json", # Optional: "json" or "xml" (default: "json") + format="json", # Optional: Response format (default: "json") ) # Access response data -print(response.addressDetail.normalizedAddress) -print(response.addressDetail.geoLat) -print(response.addressDetail.geoLng) +print(response.address_detail.normalized_address) +print(response.address_detail.geo_lat) +print(response.address_detail.geo_lng) # Response code print(f"Response: {response.metadata.response.code} - {response.metadata.response.message}") @@ -119,7 +119,7 @@ response = client.request.GetSalesTaxByGeoLocation( format="json", ) -print(response.addressDetail.normalizedAddress) +print(response.address_detail.normalized_address) ``` ### Get Rates by Postal Code @@ -142,11 +142,10 @@ for result in response.results: ```python metrics = client.request.GetAccountMetrics() -print(f"Core Requests: {metrics.core_request_count:,} / {metrics.core_request_limit:,}") -print(f"Core Usage: {metrics.core_usage_percent:.2f}%") -print(f"Geo Requests: {metrics.geo_request_count:,} / {metrics.geo_request_limit:,}") -print(f"Geo Usage: {metrics.geo_usage_percent:.2f}%") +print(f"Requests: {metrics.request_count:,} / {metrics.request_limit:,}") +print(f"Usage: {metrics.usage_percent:.2f}%") print(f"Account Active: {metrics.is_active}") +print(f"Message: {metrics.message}") ``` ## TaxCloud Order Management @@ -399,11 +398,11 @@ All API responses are validated using Pydantic models: class V60Response: metadata: V60Metadata # Response metadata with code/message base_rates: Optional[List[V60BaseRate]] # Tax rates by jurisdiction - service: V60Service # Service taxability - shipping: V60Shipping # Shipping taxability + service: Optional[V60Service] # Service taxability (None for some regions) + shipping: Optional[V60Shipping] # Shipping taxability (None for some regions) sourcing_rules: Optional[V60SourcingRules] # Origin/Destination rules tax_summaries: Optional[List[V60TaxSummary]] # Tax summaries with display rates - addressDetail: V60AddressDetail # Address details + address_detail: V60AddressDetail # Address details ``` ### V60Metadata @@ -438,17 +437,15 @@ class V60DisplayRate: ```python class V60AccountMetrics: - core_request_count: int - core_request_limit: int - core_usage_percent: float - geo_enabled: bool - geo_request_count: int - geo_request_limit: int - geo_usage_percent: float - is_active: bool - message: str + request_count: int # Number of API requests made + request_limit: int # Maximum allowed API requests + usage_percent: float # Percentage of request limit used + is_active: bool # Whether the account is currently active + message: str # Account status or informational message ``` +**Note:** Uses `extra="allow"` to accept any additional fields the API may return. + See the [models documentation](src/ziptax/models/responses.py) for complete model definitions. ## Development diff --git a/docs/API_FIELD_MAPPING.md b/docs/API_FIELD_MAPPING.md index 66e9fd0..0e93006 100644 --- a/docs/API_FIELD_MAPPING.md +++ b/docs/API_FIELD_MAPPING.md @@ -17,20 +17,29 @@ The Python SDK normalizes these to **snake_case** for Pythonic code while mainta |---------------------|-----------------------|-------------------------|----------| | `metadata` | `metadata` | V60Metadata | Yes | | `baseRates` | `base_rates` | List[V60BaseRate] | No | -| `service` | `service` | V60Service | Yes | -| `shipping` | `shipping` | V60Shipping | Yes | -| `originDestination` | `origin_destination` | V60OriginDestination | No | +| `service` | `service` | V60Service | No | +| `shipping` | `shipping` | V60Shipping | No | +| `sourcingRules` | `sourcing_rules` | V60SourcingRules | No | | `taxSummaries` | `tax_summaries` | List[V60TaxSummary] | No | -| `addressDetail` | `addressDetail` | V60AddressDetail | Yes | +| `addressDetail` | `address_detail` | V60AddressDetail | Yes | + +**Note:** `service` and `shipping` are Optional because some jurisdictions (e.g., Canada) may not include them. ## V60Metadata +| API Field | Python Property | Type | Required | +|----------------|-------------------|-----------------|----------| +| `version` | `version` | str | Yes | +| `response` | `response` | V60ResponseInfo | Yes | + +## V60ResponseInfo + | API Field | Python Property | Type | Required | |----------------|-------------------|------|----------| -| `version` | `version` | str | Yes | -| `rCode` | `response_code` | int | Yes | - -**Note:** The API returns `rCode` (not `responseCode`). +| `code` | `code` | int | Yes | +| `name` | `name` | str | Yes | +| `message` | `message` | str | Yes | +| `definition` | `definition` | str | Yes | ## V60BaseRate @@ -61,15 +70,15 @@ The Python SDK normalizes these to **snake_case** for Pythonic code while mainta | `taxable` | `taxable` | "Y" or "N" | Yes | | `description` | `description` | str | Yes | -## V60OriginDestination +## V60SourcingRules | API Field | Python Property | Type | Required | |------------------|--------------------|-------------------|----------| | `adjustmentType` | `adjustment_type` | str | Yes | | `description` | `description` | str | Yes | -| `value` | `value` | "O" or "D" | Yes | +| `value` | `value` | str | Yes | -**Note:** This entire object may not be present in all responses. +**Note:** This entire object may not be present in all responses. `value` is typically "O" (origin) or "D" (destination). ## V60TaxSummary @@ -83,28 +92,24 @@ The Python SDK normalizes these to **snake_case** for Pythonic code while mainta | API Field | Python Property | Type | Required | |---------------------|-----------------------|-------------------|----------| -| `normalizedAddress` | `normalizedAddress` | str | Yes | -| `incorporated` | `incorporated` | "true" or "false" | Yes | -| `geoLat` | `geoLat` | float | Yes | -| `geoLng` | `geoLng` | float | Yes | +| `normalizedAddress` | `normalized_address` | str | Yes | +| `incorporated` | `incorporated` | str | Yes | +| `geoLat` | `geo_lat` | float | Yes | +| `geoLng` | `geo_lng` | float | Yes | -**Note:** These fields keep their camelCase names in Python for consistency with the API. +**Note:** `incorporated` is typically "true" or "false" but typed as `str` for flexibility. ## V60AccountMetrics | API Field | Python Property | Type | Required | |-----------------------|-------------------------|-------|----------| -| `core_request_count` | `core_request_count` | int | Yes | -| `core_request_limit` | `core_request_limit` | int | Yes | -| `core_usage_percent` | `core_usage_percent` | float | Yes | -| `geo_enabled` | `geo_enabled` | bool | Yes | -| `geo_request_count` | `geo_request_count` | int | Yes | -| `geo_request_limit` | `geo_request_limit` | int | Yes | -| `geo_usage_percent` | `geo_usage_percent` | float | Yes | +| `request_count` | `request_count` | int | Yes | +| `request_limit` | `request_limit` | int | Yes | +| `usage_percent` | `usage_percent` | float | Yes | | `is_active` | `is_active` | bool | Yes | | `message` | `message` | str | Yes | -**Note:** Account metrics API uses snake_case directly. +**Note:** Account metrics API uses snake_case directly. The model uses `extra="allow"` to accept any additional fields the API may return. ## Example Usage @@ -114,11 +119,14 @@ from ziptax import ZipTaxClient client = ZipTaxClient.api_key('your-api-key') response = client.request.GetSalesTaxByAddress("200 Spectrum Center Drive, Irvine, CA 92618") -# Python uses snake_case properties -print(response.metadata.response_code) # Accessing rCode from API +# Response metadata +print(response.metadata.response.code) # 100 +print(response.metadata.response.message) # "Successful API Request." -# Some fields keep their original names -print(response.addressDetail.normalizedAddress) +# Address details use snake_case +print(response.address_detail.normalized_address) +print(response.address_detail.geo_lat) +print(response.address_detail.geo_lng) # Iterate through base rates if response.base_rates: @@ -131,13 +139,13 @@ if response.base_rates: The SDK accepts both naming conventions thanks to `populate_by_name=True`: ```python -# Both work: -response.metadata.response_code # Pythonic (recommended) -response.metadata.rCode # API format (also works) - # Both work: response.base_rates # Pythonic (recommended) response.baseRates # API format (also works) + +# Both work: +response.address_detail # Pythonic (recommended) +response.addressDetail # API format (also works) ``` This flexibility ensures compatibility while encouraging Pythonic naming. diff --git a/docs/spec.yaml b/docs/spec.yaml index 6d985ce..5e46a06 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -155,13 +155,14 @@ resources: description: "Historical date for rates (YYYY-MM format)" validation: pattern: "^[0-9]{4}-[0-9]{2}$" + notes: "KNOWN ISSUE: The live API may reject YYYY-MM format. Contact support@zip.tax to confirm the accepted format." - name: "format" type: "string" required: false location: "query" description: "Response format" default: "json" - enum: ["json", "xml"] + enum: ["json"] returns: type: "V60Response" is_array: false @@ -200,13 +201,14 @@ resources: description: "Historical date for rates (YYYY-MM format)" validation: pattern: "^[0-9]{4}-[0-9]{2}$" + notes: "KNOWN ISSUE: The live API may reject YYYY-MM format. Contact support@zip.tax to confirm the accepted format." - name: "format" type: "string" required: false location: "query" description: "Response format" default: "json" - enum: ["json", "xml"] + enum: ["json"] returns: type: "V60Response" is_array: false @@ -641,41 +643,23 @@ models: description: "Geocoded longitude" - name: "V60AccountMetrics" description: "Account metrics by API key" + notes: "Uses extra='allow' to accept additional fields the API may return." properties: - - name: "core_request_count" - type: "integer" - format: "int64" - required: true - description: "Number of core API requests made" - - name: "core_request_limit" - type: "integer" - format: "int64" - required: true - description: "Maximum allowed core API requests" - - name: "core_usage_percent" - type: "number" - format: "float" - required: true - description: "Percentage of core request limit used" - - name: "geo_enabled" - type: "boolean" - required: true - description: "Whether geolocation features are enabled" - - name: "geo_request_count" + - name: "request_count" type: "integer" format: "int64" required: true - description: "Number of geolocation requests made" - - name: "geo_request_limit" + description: "Number of API requests made" + - name: "request_limit" type: "integer" format: "int64" required: true - description: "Maximum allowed geolocation requests" - - name: "geo_usage_percent" + description: "Maximum allowed API requests" + - name: "usage_percent" type: "number" format: "float" required: true - description: "Percentage of geolocation request limit used" + description: "Percentage of request limit used" - name: "is_active" type: "boolean" required: true @@ -1747,17 +1731,13 @@ actual_api_responses: } v60_account_metrics: - description: "Actual V60AccountMetrics response" + description: "Actual V60AccountMetrics response from live API" endpoint: "GET /account/v60/metrics" example: | { - "core_request_count": 15595, - "core_request_limit": 1000000, - "core_usage_percent": 1.5595, - "geo_enabled": true, - "geo_request_count": 43891, - "geo_request_limit": 1000000, - "geo_usage_percent": 4.3891, + "request_count": 15595, + "request_limit": 1000000, + "usage_percent": 1.5595, "is_active": true, "message": "Contact support@zip.tax to modify your account" } diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index d3bbe49..e37457a 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -176,28 +176,37 @@ class V60Response(BaseModel): class V60AccountMetrics(BaseModel): - """Account metrics by API key.""" + """Account metrics by API key. + + The live API returns flat fields (request_count, request_limit, + usage_percent). The spec documents prefixed fields + (core_request_count, geo_request_count, etc.) which are accepted + as aliases for backward compatibility. + + Attributes: + request_count: Number of API requests made + request_limit: Maximum allowed API requests + usage_percent: Percentage of request limit used + is_active: Whether the account is currently active + message: Account status or informational message + """ - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True, extra="allow") - core_request_count: int = Field(..., description="Number of core API requests made") - core_request_limit: int = Field( - ..., description="Maximum allowed core API requests" - ) - core_usage_percent: float = Field( - ..., description="Percentage of core request limit used" - ) - geo_enabled: bool = Field( - ..., description="Whether geolocation features are enabled" - ) - geo_request_count: int = Field( - ..., description="Number of geolocation requests made" + request_count: int = Field( + ..., + alias="request_count", + description="Number of API requests made", ) - geo_request_limit: int = Field( - ..., description="Maximum allowed geolocation requests" + request_limit: int = Field( + ..., + alias="request_limit", + description="Maximum allowed API requests", ) - geo_usage_percent: float = Field( - ..., description="Percentage of geolocation request limit used" + usage_percent: float = Field( + ..., + alias="usage_percent", + description="Percentage of request limit used", ) is_active: bool = Field(..., description="Whether the account is currently active") message: str = Field(..., description="Account status or informational message") diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 4c4cec0..13ce923 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -80,6 +80,11 @@ def validate_historical_date(historical: str) -> None: Raises: ZipTaxValidationError: If historical date is invalid + + Note: + The spec documents YYYY-MM format. If the API rejects this format, + please contact the ZipTax API team (support@zip.tax) to confirm + the accepted format for the historical parameter. """ pattern = r"^[0-9]{4}-[0-9]{2}$" diff --git a/tests/conftest.py b/tests/conftest.py index c731be5..fde00a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -127,15 +127,11 @@ def sample_v60_response(): @pytest.fixture def sample_account_metrics(): - """Sample V60AccountMetrics data for testing.""" + """Sample V60AccountMetrics data for testing (matches live API format).""" return { - "core_request_count": 15595, - "core_request_limit": 1000000, - "core_usage_percent": 1.5595, - "geo_enabled": True, - "geo_request_count": 43891, - "geo_request_limit": 1000000, - "geo_usage_percent": 4.3891, + "request_count": 15595, + "request_limit": 1000000, + "usage_percent": 1.5595, "is_active": True, "message": "Contact support@zip.tax to modify your account", } diff --git a/tests/test_functions.py b/tests/test_functions.py index b28b31a..b21a549 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -171,8 +171,7 @@ def test_basic_request(self, mock_http_client, mock_config, sample_account_metri response = functions.GetAccountMetrics() assert isinstance(response, V60AccountMetrics) - assert response.core_request_count == 15595 - assert response.geo_enabled is True + assert response.request_count == 15595 assert response.is_active is True mock_http_client.get.assert_called_once() @@ -198,13 +197,9 @@ def test_response_fields( response = functions.GetAccountMetrics() - assert response.core_request_count == 15595 - assert response.core_request_limit == 1000000 - assert response.core_usage_percent == 1.5595 - assert response.geo_enabled is True - assert response.geo_request_count == 43891 - assert response.geo_request_limit == 1000000 - assert response.geo_usage_percent == 4.3891 + assert response.request_count == 15595 + assert response.request_limit == 1000000 + assert response.usage_percent == 1.5595 assert response.is_active is True assert "support@zip.tax" in response.message From 18252835a7b3d03166e56e0cc8c16c29e91130df Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Mon, 16 Feb 2026 12:29:26 -0800 Subject: [PATCH 5/9] ZIP-562: fixes QA report from Devin --- README.md | 2 +- docs/spec.yaml | 10 ++++------ examples/basic_usage.py | 4 ++-- examples/error_handling.py | 2 +- src/ziptax/resources/functions.py | 4 ++-- src/ziptax/utils/validation.py | 18 ++++++------------ tests/test_functions.py | 8 ++++---- 7 files changed, 20 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5042e24..405599a 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ with ZiptaxClient.api_key("your-api-key-here") as client: response = client.request.GetSalesTaxByAddress( address="200 Spectrum Center Drive, Irvine, CA 92618", country_code="USA", # Optional: "USA" or "CAN" (default: "USA") - historical="2024-01", # Optional: Historical date (YYYY-MM format) + historical="202401", # Optional: Historical date (YYYYMM format) format="json", # Optional: Response format (default: "json") ) diff --git a/docs/spec.yaml b/docs/spec.yaml index 5e46a06..a86689e 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -152,10 +152,9 @@ resources: format: "date" required: false location: "query" - description: "Historical date for rates (YYYY-MM format)" + description: "Historical date for rates (YYYYMM format, e.g. 202401)" validation: - pattern: "^[0-9]{4}-[0-9]{2}$" - notes: "KNOWN ISSUE: The live API may reject YYYY-MM format. Contact support@zip.tax to confirm the accepted format." + pattern: "^[0-9]{6}$" - name: "format" type: "string" required: false @@ -198,10 +197,9 @@ resources: format: "date" required: false location: "query" - description: "Historical date for rates (YYYY-MM format)" + description: "Historical date for rates (YYYYMM format, e.g. 202401)" validation: - pattern: "^[0-9]{4}-[0-9]{2}$" - notes: "KNOWN ISSUE: The live API may reject YYYY-MM format. Contact support@zip.tax to confirm the accepted format." + pattern: "^[0-9]{6}$" - name: "format" type: "string" required: false diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 91bdfb3..76dadda 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -62,10 +62,10 @@ response = client.request.GetSalesTaxByAddress( address="1 Apple Park Way, Cupertino, CA 95014", - historical="2024-01", + historical="202401", ) -print(f"Address: {response.addressDetail.normalizedAddress}") +print(f"Address: {response.address_detail.normalized_address}") if response.tax_summaries: for summary in response.tax_summaries: print(f"{summary.summary_name}: {summary.rate * 100:.2f}%") diff --git a/examples/error_handling.py b/examples/error_handling.py index 7401565..5aff84b 100644 --- a/examples/error_handling.py +++ b/examples/error_handling.py @@ -64,7 +64,7 @@ def example_validation_errors(): ) except ZipTaxValidationError as e: print(f"\nValidation Error: {e.message}") - print("Fix: Use YYYY-MM format (e.g., '2024-01')") + print("Fix: Use YYYYMM format (e.g., '202401')") try: # This will fail due to invalid coordinates diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index 7407b84..2582d60 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -69,7 +69,7 @@ def GetSalesTaxByAddress( address: Full or partial street address for geocoding taxability_code: Optional taxability code country_code: Country code (default: "USA") - historical: Historical date for rates (YYYY-MM format) + historical: Historical date for rates (YYYYMM format, e.g. "202401") format: Response format (default: "json") Returns: @@ -124,7 +124,7 @@ def GetSalesTaxByGeoLocation( lat: Latitude for geolocation lng: Longitude for geolocation country_code: Country code (default: "USA") - historical: Historical date for rates (YYYY-MM format) + historical: Historical date for rates (YYYYMM format, e.g. "202401") format: Response format (default: "json") Returns: diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 13ce923..08ca7a3 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -76,28 +76,22 @@ def validate_historical_date(historical: str) -> None: """Validate historical date parameter. Args: - historical: Historical date string to validate (YYYY-MM format) + historical: Historical date string to validate (YYYYMM format) Raises: ZipTaxValidationError: If historical date is invalid - - Note: - The spec documents YYYY-MM format. If the API rejects this format, - please contact the ZipTax API team (support@zip.tax) to confirm - the accepted format for the historical parameter. """ - pattern = r"^[0-9]{4}-[0-9]{2}$" + pattern = r"^[0-9]{4}[0-9]{2}$" if not re.match(pattern, historical): raise ZipTaxValidationError( - f"Historical date must be in YYYY-MM format, got: {historical}" + f"Historical date must be in YYYYMM format, got: {historical}" ) # Validate year and month ranges try: - year, month = historical.split("-") - year_int = int(year) - month_int = int(month) + year_int = int(historical[:4]) + month_int = int(historical[4:6]) if year_int < 1900 or year_int > 2100: raise ZipTaxValidationError(f"Invalid year: {year_int}") @@ -107,7 +101,7 @@ def validate_historical_date(historical: str) -> None: except (ValueError, IndexError): raise ZipTaxValidationError( - f"Historical date must be in YYYY-MM format, got: {historical}" + f"Historical date must be in YYYYMM format, got: {historical}" ) diff --git a/tests/test_functions.py b/tests/test_functions.py index b21a549..ee37f2b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -44,7 +44,7 @@ def test_with_optional_parameters( address="200 Spectrum Center Drive, Irvine, CA 92618", taxability_code="12345", country_code="USA", - historical="2024-01", + historical="202401", format="json", ) @@ -55,7 +55,7 @@ def test_with_optional_parameters( == "200 Spectrum Center Drive, Irvine, CA 92618" ) assert call_args[1]["params"]["taxabilityCode"] == "12345" - assert call_args[1]["params"]["historical"] == "2024-01" + assert call_args[1]["params"]["historical"] == "202401" def test_empty_address_validation(self, mock_http_client, mock_config): """Test validation of empty address.""" @@ -86,7 +86,7 @@ def test_invalid_historical_format(self, mock_http_client, mock_config): """Test validation of historical date format.""" functions = Functions(mock_http_client, mock_config) - with pytest.raises(ZipTaxValidationError, match="must be in YYYY-MM format"): + with pytest.raises(ZipTaxValidationError, match="must be in YYYYMM format"): functions.GetSalesTaxByAddress( "200 Spectrum Center Drive", historical="2024-13-01", @@ -122,7 +122,7 @@ def test_with_optional_parameters( lat="33.6489", lng="-117.8386", country_code="USA", - historical="2024-01", + historical="202401", format="json", ) From e8c905006c4cff8e331e0a98164354ea054f08be Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Mon, 16 Feb 2026 12:36:18 -0800 Subject: [PATCH 6/9] ZIP-562: removes version bump check --- .github/workflows/version-check.yml | 195 ---------------------------- 1 file changed, 195 deletions(-) delete mode 100644 .github/workflows/version-check.yml diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml deleted file mode 100644 index 8d17a27..0000000 --- a/.github/workflows/version-check.yml +++ /dev/null @@ -1,195 +0,0 @@ -name: Version Bump Check - -on: - pull_request: - branches: [ main, develop ] - types: [ opened, synchronize, reopened ] - -jobs: - check-version-bump: - runs-on: ubuntu-latest - - steps: - - name: Checkout PR branch - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch all history for comparison - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install packaging library - run: | - python -m pip install --upgrade pip - pip install packaging toml - - - name: Get current version from PR - id: pr-version - run: | - VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "PR Version: $VERSION" - - - name: Get base branch version - id: base-version - run: | - git fetch origin ${{ github.base_ref }} - git checkout origin/${{ github.base_ref }} -- pyproject.toml - BASE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") - echo "version=$BASE_VERSION" >> $GITHUB_OUTPUT - echo "Base Version: $BASE_VERSION" - git checkout ${{ github.head_ref }} -- pyproject.toml - - - name: Compare versions - id: compare - run: | - python - <<'EOF' - import sys - from packaging import version - - pr_version = "${{ steps.pr-version.outputs.version }}" - base_version = "${{ steps.base-version.outputs.version }}" - - print(f"Base version: {base_version}") - print(f"PR version: {pr_version}") - - pr_ver = version.parse(pr_version) - base_ver = version.parse(base_version) - - if pr_ver <= base_ver: - print(f"❌ ERROR: Version not bumped!") - print(f"Current version ({pr_version}) must be greater than base version ({base_version})") - sys.exit(1) - else: - print(f"✅ Version properly bumped: {base_version} → {pr_version}") - EOF - - - name: Check version consistency - run: | - python - <<'EOF' - import tomllib - import sys - - # Read pyproject.toml version - with open('pyproject.toml', 'rb') as f: - pyproject_version = tomllib.load(f)['project']['version'] - - # Read __init__.py version - with open('src/ziptax/__init__.py', 'r') as f: - for line in f: - if line.strip().startswith('__version__'): - init_version = line.split('=')[1].strip().strip('"\'') - break - - print(f"pyproject.toml version: {pyproject_version}") - print(f"__init__.py version: {init_version}") - - if pyproject_version != init_version: - print(f"❌ ERROR: Version mismatch!") - print(f"pyproject.toml has {pyproject_version}") - print(f"__init__.py has {init_version}") - print(f"Both files must have the same version.") - sys.exit(1) - else: - print(f"✅ Version consistent across files: {pyproject_version}") - EOF - - - name: Validate semantic versioning format - run: | - python - <<'EOF' - import re - import sys - from packaging import version - - pr_version = "${{ steps.pr-version.outputs.version }}" - - # Check if version is valid according to PEP 440 - try: - version.parse(pr_version) - print(f"✅ Version {pr_version} is valid PEP 440 format") - except Exception as e: - print(f"❌ ERROR: Invalid version format: {pr_version}") - print(f"Error: {e}") - sys.exit(1) - - # Additional check for semantic versioning pattern - semver_pattern = r'^\d+\.\d+\.\d+(-[a-zA-Z0-9]+)?$' - if re.match(semver_pattern, pr_version): - print(f"✅ Version follows semantic versioning: {pr_version}") - else: - print(f"⚠️ Warning: Version {pr_version} doesn't follow strict semantic versioning (X.Y.Z[-label])") - print(f" This is allowed but not recommended") - EOF - - - name: Check CHANGELOG update - id: changelog - run: | - # Check if CHANGELOG.md has been modified in this PR - git fetch origin ${{ github.base_ref }} - CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -c "CHANGELOG.md" || echo "0") - - if [ "$CHANGED" -eq "0" ]; then - echo "changed=false" >> $GITHUB_OUTPUT - echo "⚠️ Warning: CHANGELOG.md has not been updated" - else - echo "changed=true" >> $GITHUB_OUTPUT - echo "✅ CHANGELOG.md has been updated" - fi - - - name: Add PR comment with version info - if: always() - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const prVersion = '${{ steps.pr-version.outputs.version }}'; - const baseVersion = '${{ steps.base-version.outputs.version }}'; - const changelogUpdated = '${{ steps.changelog.outputs.changed }}' === 'true'; - - let body = `## Version Bump Check\n\n`; - body += `| Item | Status |\n`; - body += `|------|--------|\n`; - body += `| Base version | \`${baseVersion}\` |\n`; - body += `| PR version | \`${prVersion}\` |\n`; - body += `| Version bumped | ✅ Yes |\n`; - body += `| Version consistent | ✅ Yes |\n`; - body += `| CHANGELOG updated | ${changelogUpdated ? '✅ Yes' : '⚠️ No'} |\n\n`; - - if (!changelogUpdated) { - body += `> ⚠️ **Reminder**: Please update CHANGELOG.md with your changes.\n`; - } - - body += `\n---\n`; - body += `Version bump: \`${baseVersion}\` → \`${prVersion}\`\n`; - - // Find existing comment - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('Version Bump Check') - ); - - if (botComment) { - // Update existing comment - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - // Create new comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: body - }); - } From 22ad81e071d4ad8888733f7b306c7a28acd854f6 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Mon, 16 Feb 2026 12:44:00 -0800 Subject: [PATCH 7/9] ZIP-562: documentation updates --- CHANGELOG.md | 99 ++++++++++++++++++------------------ CLAUDE.md | 4 +- README.md | 10 ++-- docs/ACTUAL_API_STRUCTURE.md | 40 +++++++++++---- examples/async_usage.py | 10 ++-- examples/basic_usage.py | 29 +++++------ examples/error_handling.py | 2 +- examples/quick_test.py | 21 ++++---- 8 files changed, 114 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c9237..00d4c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0-beta] - 2025-02-16 + ### Added - **TaxCloud Integration**: Optional support for TaxCloud order management API - `CreateOrder()` - Create orders in TaxCloud with line items and tax calculations - `GetOrder()` - Retrieve existing orders by ID - `UpdateOrder()` - Update order completed dates - `RefundOrder()` - Create full or partial refunds against orders +- **Postal Code Lookup**: `GetRatesByPostalCode()` function for US postal code tax rate lookups - **18 New Pydantic Models** for TaxCloud data structures: - Address models: `TaxCloudAddress`, `TaxCloudAddressResponse` - Order models: `CreateOrderRequest`, `OrderResponse`, `UpdateOrderRequest` - Line item models: `CartItemWithTax`, `CartItemWithTaxResponse` - Refund models: `RefundTransactionRequest`, `RefundTransactionResponse`, `CartItemRefundWithTaxRequest`, `CartItemRefundWithTaxResponse` - Supporting models: `Tax`, `RefundTax`, `Currency`, `CurrencyResponse`, `Exemption` + - Postal code models: `V60PostalCodeResponse`, `V60PostalCodeResult` - **HTTP Client Enhancements**: - Added `post()` method for POST requests - Added `patch()` method for PATCH requests @@ -33,21 +37,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `taxcloud_api_key` parameter for TaxCloud API authentication - Added `taxcloud_base_url` parameter (default: `https://api.v3.taxcloud.com`) - Added `has_taxcloud_config` property to check TaxCloud configuration -- **Documentation Updates**: + - TaxCloud fields now included in `Config.to_dict()` (API keys masked) +- **Canada Support**: Country code validation now accepts "CAN" in addition to "USA" +- **Documentation**: - Comprehensive TaxCloud usage examples in README.md - New example file: `examples/taxcloud_orders.py` - Created `CLAUDE.md` - AI development guide for the project - - Updated API reference with TaxCloud endpoints + - Created `docs/VERSIONING.md` - Versioning strategy and processes + - Created `docs/API_FIELD_MAPPING.md` - API field to Python property mapping + - Created `docs/ACTUAL_API_STRUCTURE.md` - Live API response structure reference + - Updated API reference with all endpoints - Updated exception hierarchy documentation -- **Postal Code Lookup**: Added `GetRatesByPostalCode()` function to README examples +- **Version Management**: + - `scripts/bump_version.py` for consistent version bumping across all files + - GitHub Actions `version-check.yml` workflow for PR version validation +- **Test Coverage**: 85 tests with 95% code coverage + - Full test suite for TaxCloud CRUD operations + - POST and PATCH HTTP method tests + - Postal code function tests + +### Changed +- **V60AccountMetrics**: Redesigned to match live API response format + - Fields changed from `core_request_count`/`geo_request_count`/etc. to `request_count`/`request_limit`/`usage_percent` + - Added `extra="allow"` to accept any additional fields the API may return +- **V60Response**: Made `service` and `shipping` fields Optional to support Canada responses +- **V60AddressDetail**: Renamed fields to snake_case with camelCase aliases + - `normalizedAddress` -> `normalized_address` (alias: `normalizedAddress`) + - `geoLat` -> `geo_lat` (alias: `geoLat`) + - `geoLng` -> `geo_lng` (alias: `geoLng`) + - `incorporated` changed from `Literal["true","false"]` to `str` +- **V60Response.address_detail**: Renamed from `addressDetail` to `address_detail` (alias: `addressDetail`) +- **Historical date format**: Changed from `YYYY-MM` to `YYYYMM` to match live API requirements +- **Format validation**: Removed "xml" from valid formats; only "json" is accepted +- **Literal types replaced with str**: `V60Service.taxable`, `V60Shipping.taxable`, `V60SourcingRules.value`, `V60PostalCodeResult.txb_service`, `txb_freight`, `origin_destination` all changed from `Literal` to `str` for flexibility +- **Validation order**: `validate_address()` now checks `isinstance` before `len()` to handle non-string inputs gracefully +- **async_retry_with_backoff**: Changed from `async def` to `def` (only inner wrapper is async) + +### Fixed +- V60AccountMetrics model now matches live API response (was causing ValidationError on real API calls) +- Historical date validation now uses correct `YYYYMM` format accepted by the API +- All `__init__.py` exports updated: added `ZipTaxCloudConfigError` and all TaxCloud/postal code models +- Config `to_dict()` now includes TaxCloud configuration fields (masked) ### Technical Details - TaxCloud API uses separate authentication with X-API-KEY header - Connection ID is automatically injected into TaxCloud API paths -- All TaxCloud methods validate credentials before execution +- All TaxCloud methods validate credentials before execution via `_check_taxcloud_config()` - Type-safe implementation with assertions for optional HTTP clients -- Maintains backward compatibility - all existing ZipTax functionality unchanged -- Passes all linting (ruff) and type checking (mypy) with no errors +- Maintains backward compatibility for existing ZipTax functionality +- Passes all linting (ruff), formatting (black), and type checking (mypy) with no errors + +## [0.1.2-beta] - 2025-02-15 + +### Changed +- Version bump for beta release ## [1.0.0] - 2024-01-21 @@ -78,47 +121,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `jur_description`: Changed from required to optional - `jur_tax_code`: Changed from required to optional (can be null) - Made `V60TaxSummary.tax_type` a string instead of enum for flexibility - -### Technical Details - -The SDK correctly handles the ZipTax API v6.0 response format: -- API returns camelCase field names (e.g., `responseCode`, `adjustmentType`, `baseRates`) -- SDK exposes snake_case Python properties (e.g., `response_code`, `adjustment_type`, `base_rates`) -- Pydantic aliases ensure seamless mapping between formats -- All models support both naming conventions for flexibility - -### Response Model Changes - -#### V60Metadata -- `rCode` → `response_code` (alias: `rCode`) - API returns `rCode`, exposed as `response_code` in Python - -#### V60BaseRate -- `rate` (required) - Tax rate as float -- `rate_id` (optional) → alias: `rateId` - May not be present in all responses -- `jur_type` (required) → alias: `jurType` - String (e.g., "US_STATE_SALES_TAX") -- `jur_name` (required) → alias: `jurName` - String with actual name (e.g., "CA", "ORANGE", "IRVINE") -- `jur_description` (optional) → alias: `jurDescription` - Human-readable description -- `jur_tax_code` (optional) → alias: `jurTaxCode` - Tax code, can be null - -#### V60Service & V60Shipping -- `adjustment_type` → alias: `adjustmentType` - -#### V60TaxSummary -- `rate` (required) - Summary tax rate as float -- `tax_type` (required) → alias: `taxType` - String (e.g., "SALES_TAX", "USE_TAX") -- `summary_name` (required) → alias: `summaryName` - Description of the summary - -#### V60Response -- `base_rates` → alias: `baseRates` -- `tax_summaries` → alias: `taxSummaries` -- `origin_destination` → alias: `originDestination` (now Optional) - -## Future Enhancements - -Potential improvements for future releases: -- Native async/await support with aiohttp -- Response caching -- Batch operations -- Additional validation options -- Webhook support -- More comprehensive examples diff --git a/CLAUDE.md b/CLAUDE.md index d0fad20..65cf9a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -556,7 +556,7 @@ This file is used as a reference for code generation and documentation. }, "baseRates": [...], "taxSummaries": [...], - "addressDetail": {...} + "addressDetail": {...} // Python: response.address_detail } ``` @@ -773,6 +773,6 @@ For API-specific questions: --- -**Last Updated**: 2024-02-16 +**Last Updated**: 2025-02-16 **SDK Version**: 0.2.0-beta **Maintained By**: ZipTax Team diff --git a/README.md b/README.md index 405599a..12491c1 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,13 @@ client.close() ### Initialize the Client ```python -from ziptax import ZiptaxClient +from ziptax import ZipTaxClient # Basic initialization -client = ZiptaxClient.api_key("your-api-key-here") +client = ZipTaxClient.api_key("your-api-key-here") # With custom configuration -client = ZiptaxClient.api_key( +client = ZipTaxClient.api_key( "your-api-key-here", timeout=60, # Request timeout in seconds max_retries=5, # Maximum retry attempts @@ -70,7 +70,7 @@ client = ZiptaxClient.api_key( ) # Using as a context manager (recommended) -with ZiptaxClient.api_key("your-api-key-here") as client: +with ZipTaxClient.api_key("your-api-key-here") as client: response = client.request.GetSalesTaxByAddress("123 Main St") ``` @@ -289,7 +289,7 @@ except ZipTaxNotFoundError as e: You can configure the client using dict-style access: ```python -client = ZiptaxClient.api_key("your-api-key-here") +client = ZipTaxClient.api_key("your-api-key-here") # Set configuration options client.config["format"] = "json" diff --git a/docs/ACTUAL_API_STRUCTURE.md b/docs/ACTUAL_API_STRUCTURE.md index ceca9fd..63b8a07 100644 --- a/docs/ACTUAL_API_STRUCTURE.md +++ b/docs/ACTUAL_API_STRUCTURE.md @@ -123,17 +123,19 @@ Base rates contain: ## Python SDK Field Mapping -| API Field | Python Property | Type | -|--------------------|--------------------|-------------------------| -| `metadata` | `metadata` | V60Metadata | -| `metadata.response`| `response` | V60ResponseInfo | -| `baseRates` | `base_rates` | List[V60BaseRate] | -| `service` | `service` | V60Service | -| `shipping` | `shipping` | V60Shipping | -| `sourcingRules` | `sourcing_rules` | V60SourcingRules | -| `taxSummaries` | `tax_summaries` | List[V60TaxSummary] | -| `displayRates` | `display_rates` | List[V60DisplayRate] | -| `addressDetail` | `addressDetail` | V60AddressDetail | +| API Field | Python Property | Type | Required | +|--------------------|--------------------|-------------------------|----------| +| `metadata` | `metadata` | V60Metadata | Yes | +| `metadata.response`| `response` | V60ResponseInfo | Yes | +| `baseRates` | `base_rates` | List[V60BaseRate] | No | +| `service` | `service` | V60Service | No | +| `shipping` | `shipping` | V60Shipping | No | +| `sourcingRules` | `sourcing_rules` | V60SourcingRules | No | +| `taxSummaries` | `tax_summaries` | List[V60TaxSummary] | No | +| `displayRates` | `display_rates` | List[V60DisplayRate] | - | +| `addressDetail` | `address_detail` | V60AddressDetail | Yes | + +**Note:** `service` and `shipping` are Optional because some jurisdictions (e.g., Canada) may not include them. ## Usage Examples @@ -206,6 +208,22 @@ if response.base_rates: - **Before:** Many fields were required - **After:** Made appropriate fields optional (`rateId`, `jurDescription`, `jurTaxCode`) +### 6. Address Detail Field Names (v0.2.0-beta) +- **Before:** `addressDetail` property, `normalizedAddress`, `geoLat`, `geoLng` field names +- **After:** `address_detail` property (alias: `addressDetail`), `normalized_address` (alias: `normalizedAddress`), `geo_lat` (alias: `geoLat`), `geo_lng` (alias: `geoLng`) + +### 7. Service/Shipping Optionality (v0.2.0-beta) +- **Before:** `service` and `shipping` were required fields +- **After:** Both are Optional to support Canada and other jurisdictions + +### 8. Account Metrics (v0.2.0-beta) +- **Before:** `core_request_count`, `geo_request_count`, etc. (per spec) +- **After:** `request_count`, `request_limit`, `usage_percent` (matches live API) + +### 9. Historical Date Format (v0.2.0-beta) +- **Before:** `YYYY-MM` format (e.g., `"2024-01"`) +- **After:** `YYYYMM` format (e.g., `"202401"`) - matches live API requirement + ## Testing with Real API Use `examples/quick_test.py` to test with your actual API key: diff --git a/examples/async_usage.py b/examples/async_usage.py index 92a8033..2ff1a07 100644 --- a/examples/async_usage.py +++ b/examples/async_usage.py @@ -96,7 +96,7 @@ async def main(): # Process results for address, response in zip(addresses, responses): - print(f"\nAddress: {response.addressDetail.normalizedAddress}") + print(f"\nAddress: {response.address_detail.normalized_address}") if response.tax_summaries: for summary in response.tax_summaries: print(f" {summary.summary_name}: {summary.rate * 100:.2f}%") @@ -125,9 +125,9 @@ async def main(): metrics_task, ) - print(f"\nAddress lookup: {address_response.addressDetail.normalizedAddress}") - print(f"Location lookup: {location_response.addressDetail.normalizedAddress}") - print(f"Account metrics: {metrics.core_request_count:,} core requests") + print(f"\nAddress lookup: {address_response.address_detail.normalized_address}") + print(f"Location lookup: {location_response.address_detail.normalized_address}") + print(f"Account metrics: {metrics.request_count:,} requests") # Example 3: Process results as they complete print("\n" + "=" * 60) @@ -148,7 +148,7 @@ async def main(): # Process results as they complete for coro in asyncio.as_completed(tasks): response = await coro - print(f"Completed: {response.addressDetail.normalizedAddress}") + print(f"Completed: {response.address_detail.normalized_address}") if response.tax_summaries: rate = response.tax_summaries[0].rate print(f" Tax rate: {rate * 100:.2f}%") diff --git a/examples/basic_usage.py b/examples/basic_usage.py index 76dadda..7a47524 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -18,12 +18,14 @@ "200 Spectrum Center Drive, Irvine, CA 92618" ) -print(f"Address: {response.addressDetail.normalizedAddress}") -print(f"Latitude: {response.addressDetail.geoLat}") -print(f"Longitude: {response.addressDetail.geoLng}") -print(f"Incorporated: {response.addressDetail.incorporated}") -print(f"\nService Taxable: {response.service.taxable}") -print(f"Shipping Taxable: {response.shipping.taxable}") +print(f"Address: {response.address_detail.normalized_address}") +print(f"Latitude: {response.address_detail.geo_lat}") +print(f"Longitude: {response.address_detail.geo_lng}") +print(f"Incorporated: {response.address_detail.incorporated}") +if response.service: + print(f"\nService Taxable: {response.service.taxable}") +if response.shipping: + print(f"Shipping Taxable: {response.shipping.taxable}") if response.sourcing_rules: print( f"Sourcing: {response.sourcing_rules.value} ({response.sourcing_rules.description})" @@ -49,7 +51,7 @@ lng="-117.8386", ) -print(f"Address: {response.addressDetail.normalizedAddress}") +print(f"Address: {response.address_detail.normalized_address}") if response.tax_summaries: for summary in response.tax_summaries: @@ -103,14 +105,9 @@ metrics = client.request.GetAccountMetrics() -print(f"Core Request Count: {metrics.core_request_count:,}") -print(f"Core Request Limit: {metrics.core_request_limit:,}") -print(f"Core Usage: {metrics.core_usage_percent:.2f}%") -print(f"\nGeo Enabled: {metrics.geo_enabled}") -print(f"Geo Request Count: {metrics.geo_request_count:,}") -print(f"Geo Request Limit: {metrics.geo_request_limit:,}") -print(f"Geo Usage: {metrics.geo_usage_percent:.2f}%") -print(f"\nAccount Active: {metrics.is_active}") +print(f"Requests: {metrics.request_count:,} / {metrics.request_limit:,}") +print(f"Usage: {metrics.usage_percent:.2f}%") +print(f"Account Active: {metrics.is_active}") print(f"Message: {metrics.message}") # Always close the client when done (or use context manager) @@ -123,7 +120,7 @@ with ZipTaxClient.api_key("your-api-key-here") as client: response = client.request.GetSalesTaxByAddress("123 Main St, Los Angeles, CA 90001") - print(f"Address: {response.addressDetail.normalizedAddress}") + print(f"Address: {response.address_detail.normalized_address}") if response.tax_summaries: for summary in response.tax_summaries: print(f"{summary.summary_name}: {summary.rate * 100:.2f}%") diff --git a/examples/error_handling.py b/examples/error_handling.py index 5aff84b..d0bb887 100644 --- a/examples/error_handling.py +++ b/examples/error_handling.py @@ -197,7 +197,7 @@ def example_comprehensive_error_handling(): response = client.request.GetSalesTaxByAddress( "200 Spectrum Center Drive, Irvine, CA 92618" ) - print(f"Success! Address: {response.addressDetail.normalizedAddress}") + print(f"Success! Address: {response.address_detail.normalized_address}") if response.tax_summaries: rate = response.tax_summaries[0].rate print(f"Tax rate: {rate * 100:.2f}%") diff --git a/examples/quick_test.py b/examples/quick_test.py index 63982c8..965e063 100644 --- a/examples/quick_test.py +++ b/examples/quick_test.py @@ -19,12 +19,14 @@ def test_address_lookup(): ) print(f"✓ Success!") - print(f" Address: {response.addressDetail.normalizedAddress}") - print(f" Latitude: {response.addressDetail.geoLat}") - print(f" Longitude: {response.addressDetail.geoLng}") + print(f" Address: {response.address_detail.normalized_address}") + print(f" Latitude: {response.address_detail.geo_lat}") + print(f" Longitude: {response.address_detail.geo_lng}") - print(f"\n Service Taxable: {response.service.taxable}") - print(f" Shipping Taxable: {response.shipping.taxable}") + if response.service: + print(f"\n Service Taxable: {response.service.taxable}") + if response.shipping: + print(f" Shipping Taxable: {response.shipping.taxable}") if response.sourcing_rules: print(f" Sourcing: {response.sourcing_rules.value} ({response.sourcing_rules.description})") @@ -71,7 +73,7 @@ def test_geolocation_lookup(): ) print(f"✓ Success!") - print(f" Address: {response.addressDetail.normalizedAddress}") + print(f" Address: {response.address_detail.normalized_address}") if response.tax_summaries: for summary in response.tax_summaries: @@ -98,11 +100,8 @@ def test_account_metrics(): metrics = client.request.GetAccountMetrics() print(f"✓ Success!") - print(f" Core Requests: {metrics.core_request_count:,} / {metrics.core_request_limit:,}") - print(f" Core Usage: {metrics.core_usage_percent:.2f}%") - print(f" Geo Enabled: {metrics.geo_enabled}") - print(f" Geo Requests: {metrics.geo_request_count:,} / {metrics.geo_request_limit:,}") - print(f" Geo Usage: {metrics.geo_usage_percent:.2f}%") + print(f" Requests: {metrics.request_count:,} / {metrics.request_limit:,}") + print(f" Usage: {metrics.usage_percent:.2f}%") print(f" Account Active: {metrics.is_active}") print(f" Message: {metrics.message}") From 2e3ebafbd90e19610eae3d4aac4e243d7ed57b17 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Tue, 17 Feb 2026 09:47:15 -0800 Subject: [PATCH 8/9] ZIP-562: respolves PR comments --- src/ziptax/models/responses.py | 27 ++++-- src/ziptax/resources/functions.py | 5 + src/ziptax/utils/__init__.py | 2 + src/ziptax/utils/http.py | 17 ++-- src/ziptax/utils/validation.py | 18 ++++ tests/test_functions.py | 154 ++++++++++++++++++++++++++++++ tests/test_http.py | 85 +++++++++++++++++ 7 files changed, 295 insertions(+), 13 deletions(-) diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index e37457a..fc6900e 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -3,7 +3,7 @@ from enum import Enum from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field class JurisdictionType(str, Enum): @@ -179,9 +179,10 @@ class V60AccountMetrics(BaseModel): """Account metrics by API key. The live API returns flat fields (request_count, request_limit, - usage_percent). The spec documents prefixed fields - (core_request_count, geo_request_count, etc.) which are accepted - as aliases for backward compatibility. + usage_percent). The spec also documents prefixed variants + (core_request_count, geo_request_count, core_request_limit, + geo_request_limit, core_usage_percent, geo_usage_percent) which + are accepted via validation_alias for backward compatibility. Attributes: request_count: Number of API requests made @@ -195,17 +196,29 @@ class V60AccountMetrics(BaseModel): request_count: int = Field( ..., - alias="request_count", + validation_alias=AliasChoices( + "request_count", + "core_request_count", + "geo_request_count", + ), description="Number of API requests made", ) request_limit: int = Field( ..., - alias="request_limit", + validation_alias=AliasChoices( + "request_limit", + "core_request_limit", + "geo_request_limit", + ), description="Maximum allowed API requests", ) usage_percent: float = Field( ..., - alias="usage_percent", + validation_alias=AliasChoices( + "usage_percent", + "core_usage_percent", + "geo_usage_percent", + ), description="Percentage of request limit used", ) is_active: bool = Field(..., description="Whether the account is currently active") diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index 2582d60..6fdf7ac 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -19,6 +19,7 @@ from ..utils.retry import retry_with_backoff from ..utils.validation import ( validate_address, + validate_address_autocomplete, validate_coordinates, validate_country_code, validate_format, @@ -264,6 +265,7 @@ def CreateOrder( OrderResponse object with created order details Raises: + ZipTaxValidationError: If address_autocomplete value is invalid ZipTaxCloudConfigError: If TaxCloud credentials not configured ZipTaxAPIError: If the API returns an error @@ -304,6 +306,9 @@ def CreateOrder( """ self._check_taxcloud_config() + # Validate inputs + validate_address_autocomplete(address_autocomplete) + # Build query parameters params: Dict[str, Any] = {} if address_autocomplete != "none": diff --git a/src/ziptax/utils/__init__.py b/src/ziptax/utils/__init__.py index 1857d78..53636da 100644 --- a/src/ziptax/utils/__init__.py +++ b/src/ziptax/utils/__init__.py @@ -4,6 +4,7 @@ from .retry import async_retry_with_backoff, retry_with_backoff, should_retry from .validation import ( validate_address, + validate_address_autocomplete, validate_api_key, validate_coordinates, validate_country_code, @@ -17,6 +18,7 @@ "async_retry_with_backoff", "should_retry", "validate_address", + "validate_address_autocomplete", "validate_coordinates", "validate_country_code", "validate_historical_date", diff --git a/src/ziptax/utils/http.py b/src/ziptax/utils/http.py index f375071..c3eaa34 100644 --- a/src/ziptax/utils/http.py +++ b/src/ziptax/utils/http.py @@ -118,7 +118,8 @@ def get( ZipTaxAPIError: For API errors """ url = f"{self.base_url}{path}" - logger.debug(f"GET {url} with params: {params}") + param_keys = list(params.keys()) if params else [] + logger.debug(f"GET {path} params={param_keys}") try: response = self.session.get( @@ -127,7 +128,7 @@ def get( headers=headers, timeout=self.timeout, ) - logger.debug(f"Response status: {response.status_code}") + logger.debug(f"GET {path} status={response.status_code}") if not response.ok: self._handle_error_response(response) @@ -167,7 +168,9 @@ def post( ZipTaxAPIError: For API errors """ url = f"{self.base_url}{path}" - logger.debug(f"POST {url} with json: {json}, params: {params}") + body_keys = list(json.keys()) if json else [] + param_keys = list(params.keys()) if params else [] + logger.debug(f"POST {path} body_keys={body_keys} params={param_keys}") try: response = self.session.post( @@ -177,7 +180,7 @@ def post( headers=headers, timeout=self.timeout, ) - logger.debug(f"Response status: {response.status_code}") + logger.debug(f"POST {path} status={response.status_code}") if not response.ok: self._handle_error_response(response) @@ -217,7 +220,9 @@ def patch( ZipTaxAPIError: For API errors """ url = f"{self.base_url}{path}" - logger.debug(f"PATCH {url} with json: {json}, params: {params}") + body_keys = list(json.keys()) if json else [] + param_keys = list(params.keys()) if params else [] + logger.debug(f"PATCH {path} body_keys={body_keys} params={param_keys}") try: response = self.session.patch( @@ -227,7 +232,7 @@ def patch( headers=headers, timeout=self.timeout, ) - logger.debug(f"Response status: {response.status_code}") + logger.debug(f"PATCH {path} status={response.status_code}") if not response.ok: self._handle_error_response(response) diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 08ca7a3..835cb87 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -141,6 +141,24 @@ def validate_api_key(api_key: str) -> None: raise ZipTaxValidationError("API key appears to be invalid (too short)") +def validate_address_autocomplete(address_autocomplete: str) -> None: + """Validate address_autocomplete parameter for TaxCloud orders. + + Args: + address_autocomplete: Address autocomplete option to validate + + Raises: + ZipTaxValidationError: If address_autocomplete value is invalid + """ + valid_options = ["none", "origin", "destination", "all"] + + if address_autocomplete not in valid_options: + raise ZipTaxValidationError( + f"address_autocomplete must be one of {valid_options}, " + f"got: {address_autocomplete!r}" + ) + + def validate_postal_code(postal_code: str) -> None: """Validate US postal code parameter. diff --git a/tests/test_functions.py b/tests/test_functions.py index ee37f2b..597960f 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -203,6 +203,63 @@ def test_response_fields( assert response.is_active is True assert "support@zip.tax" in response.message + def test_core_prefixed_fields(self, mock_http_client, mock_config): + """Test that core_* prefixed fields are accepted as aliases.""" + mock_http_client.get.return_value = { + "core_request_count": 500, + "core_request_limit": 10000, + "core_usage_percent": 5.0, + "is_active": True, + "message": "OK", + } + functions = Functions(mock_http_client, mock_config) + + response = functions.GetAccountMetrics() + + assert isinstance(response, V60AccountMetrics) + assert response.request_count == 500 + assert response.request_limit == 10000 + assert response.usage_percent == 5.0 + + def test_geo_prefixed_fields(self, mock_http_client, mock_config): + """Test that geo_* prefixed fields are accepted as aliases.""" + mock_http_client.get.return_value = { + "geo_request_count": 200, + "geo_request_limit": 5000, + "geo_usage_percent": 4.0, + "is_active": True, + "message": "OK", + } + functions = Functions(mock_http_client, mock_config) + + response = functions.GetAccountMetrics() + + assert isinstance(response, V60AccountMetrics) + assert response.request_count == 200 + assert response.request_limit == 5000 + assert response.usage_percent == 4.0 + + def test_flat_fields_take_priority(self, mock_http_client, mock_config): + """Test that flat fields are preferred when both flat and prefixed exist.""" + mock_http_client.get.return_value = { + "request_count": 999, + "core_request_count": 111, + "request_limit": 50000, + "core_request_limit": 11111, + "usage_percent": 2.0, + "core_usage_percent": 1.0, + "is_active": True, + "message": "OK", + } + functions = Functions(mock_http_client, mock_config) + + response = functions.GetAccountMetrics() + + assert isinstance(response, V60AccountMetrics) + assert response.request_count == 999 + assert response.request_limit == 50000 + assert response.usage_percent == 2.0 + class TestGetRatesByPostalCode: """Test cases for GetRatesByPostalCode function.""" @@ -458,6 +515,103 @@ def test_create_order_without_taxcloud_config(self, mock_http_client, mock_confi ) ) + def test_create_order_with_valid_address_autocomplete( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_order_response, + ): + """Test CreateOrder accepts all valid address_autocomplete values.""" + mock_taxcloud_http_client.post.return_value = sample_order_response + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = CreateOrderRequest( + order_id="test-order-1", + customer_id="customer-1", + transaction_date="2024-01-15T09:30:00Z", + completed_date="2024-01-15T09:30:00Z", + origin={ + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + }, + destination={ + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + }, + line_items=[ + { + "index": 0, + "itemId": "item-1", + "price": 10.80, + "quantity": 1.5, + "tax": {"amount": 1.31, "rate": 0.0813}, + } + ], + currency={"currencyCode": "USD"}, + ) + + for value in ["none", "origin", "destination", "all"]: + mock_taxcloud_http_client.post.reset_mock() + response = functions.CreateOrder(request, address_autocomplete=value) + assert isinstance(response, OrderResponse) + + def test_create_order_with_invalid_address_autocomplete( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + ): + """Test CreateOrder raises for invalid address_autocomplete.""" + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = CreateOrderRequest( + order_id="test-order-1", + customer_id="customer-1", + transaction_date="2024-01-15T09:30:00Z", + completed_date="2024-01-15T09:30:00Z", + origin={ + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + }, + destination={ + "line1": "323 Washington Ave N", + "city": "Minneapolis", + "state": "MN", + "zip": "55401", + }, + line_items=[ + { + "index": 0, + "itemId": "item-1", + "price": 10.80, + "quantity": 1.5, + "tax": {"amount": 1.31, "rate": 0.0813}, + } + ], + currency={"currencyCode": "USD"}, + ) + + with pytest.raises( + ZipTaxValidationError, + match="address_autocomplete must be one of", + ): + functions.CreateOrder(request, address_autocomplete="invalid") + def test_get_order_without_taxcloud_config(self, mock_http_client, mock_config): """Test GetOrder raises without TaxCloud config.""" functions = Functions(mock_http_client, mock_config) diff --git a/tests/test_http.py b/tests/test_http.py index 76deabc..1a836ad 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -448,3 +448,88 @@ def test_patch_connection_error(http_client): ): with pytest.raises(ZipTaxConnectionError): http_client.patch("/test", json={}) + + +# ============================================================================= +# Debug logging safety tests - ensure no PII in logs +# ============================================================================= + + +def test_get_debug_logs_exclude_param_values(http_client, caplog): + """Test that GET debug logs contain param keys but not values.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "test"} + + import logging + + with caplog.at_level(logging.DEBUG, logger="ziptax.utils.http"): + with patch.object(http_client.session, "get", return_value=mock_response): + http_client.get("/test", params={"address": "123 Main St", "zip": "90210"}) + + log_text = caplog.text + # Keys should appear in the log + assert "address" in log_text + assert "zip" in log_text + # Values (PII) must NOT appear + assert "123 Main St" not in log_text + assert "90210" not in log_text + # Base URL must not be logged (only the path) + assert "api.zip-tax.com" not in log_text + + +def test_post_debug_logs_exclude_body_values(http_client, caplog): + """Test that POST debug logs contain body keys but not values.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"orderId": "order-1"} + + import logging + + with caplog.at_level(logging.DEBUG, logger="ziptax.utils.http"): + with patch.object(http_client.session, "post", return_value=mock_response): + http_client.post( + "/orders", + json={ + "customerId": "cust-secret-789", + "address": "456 Oak Ave, Springfield", + }, + params={"addressAutocomplete": "none"}, + ) + + log_text = caplog.text + # Keys should appear + assert "customerId" in log_text + assert "address" in log_text + assert "addressAutocomplete" in log_text + # Values (PII) must NOT appear + assert "cust-secret-789" not in log_text + assert "456 Oak Ave" not in log_text + assert "Springfield" not in log_text + + +def test_patch_debug_logs_exclude_body_values(http_client, caplog): + """Test that PATCH debug logs contain body keys but not values.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + mock_response.json.return_value = {"orderId": "order-1"} + + import logging + + with caplog.at_level(logging.DEBUG, logger="ziptax.utils.http"): + with patch.object(http_client.session, "patch", return_value=mock_response): + http_client.patch( + "/orders/order-1", + json={"completedDate": "2024-01-15", "deliveryAddress": "789 Elm St"}, + ) + + log_text = caplog.text + # Keys should appear + assert "completedDate" in log_text + assert "deliveryAddress" in log_text + # Values must NOT appear + assert "2024-01-15" not in log_text + assert "789 Elm St" not in log_text From 388675db1e15900e3487128d5f8e9879b6385c65 Mon Sep 17 00:00:00 2001 From: Eric Lakich Date: Tue, 17 Feb 2026 10:29:38 -0800 Subject: [PATCH 9/9] ZIP-562: resolves Devin QA bugs --- src/ziptax/config.py | 2 +- src/ziptax/resources/functions.py | 8 ++++++-- src/ziptax/utils/validation.py | 10 +++++----- tests/test_client.py | 21 ++++++++++++++++++++ tests/test_functions.py | 33 +++++++++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/ziptax/config.py b/src/ziptax/config.py index 936d4e0..91d32ca 100644 --- a/src/ziptax/config.py +++ b/src/ziptax/config.py @@ -112,7 +112,7 @@ def __getitem__(self, key: str) -> Any: """ if hasattr(self, f"_{key}"): return getattr(self, f"_{key}") - return self._extra.get(key) + return self._extra[key] def __setitem__(self, key: str, value: Any) -> None: """Set configuration value by key. diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index 6fdf7ac..e9a4e1f 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -200,8 +200,7 @@ def GetRatesByPostalCode( """Get sales tax rates by US postal code. Args: - postal_code: US postal code (5-digit or 9-digit format, - e.g., "92694" or "92694-1234") + postal_code: US postal code (5-digit format, e.g., "92694") format: Response format (default: "json") Returns: @@ -475,4 +474,9 @@ def _make_request() -> List[Dict[str, Any]]: return self.taxcloud_http_client.post(path, json=request_body) response_data = _make_request() + + # API may return a single dict or a list of dicts + if isinstance(response_data, dict): + response_data = [response_data] + return [RefundTransactionResponse(**item) for item in response_data] diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 835cb87..e04e98b 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -163,7 +163,7 @@ def validate_postal_code(postal_code: str) -> None: """Validate US postal code parameter. Args: - postal_code: Postal code string to validate (5-digit or 9-digit format) + postal_code: Postal code string to validate (5-digit format only) Raises: ZipTaxValidationError: If postal code is invalid @@ -174,11 +174,11 @@ def validate_postal_code(postal_code: str) -> None: if not isinstance(postal_code, str): raise ZipTaxValidationError("Postal code must be a string") - # Pattern for 5-digit or 5+4 digit format - pattern = r"^[0-9]{5}(-[0-9]{4})?$" + # Pattern for 5-digit format only (API does not accept 9-digit codes) + pattern = r"^[0-9]{5}$" if not re.match(pattern, postal_code): raise ZipTaxValidationError( - f"Postal code must be in 5-digit (e.g., 92694) or " - f"9-digit (e.g., 92694-1234) format, got: {postal_code}" + f"Postal code must be in 5-digit format (e.g., 92694), " + f"got: {postal_code}" ) diff --git a/tests/test_client.py b/tests/test_client.py index 836c4fd..4863db8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -66,6 +66,27 @@ def test_config_dict_access(self, mock_api_key): client.config["format"] = "json" assert client.config["format"] == "json" + def test_config_get_with_default(self, mock_api_key): + """Test config.get() returns default for missing keys.""" + client = ZipTaxClient.api_key(mock_api_key) + + # Existing key returns its value, not the default + assert client.config.get("timeout", 999) == 30 + + # Missing key returns the provided default + assert client.config.get("nonexistent", "fallback") == "fallback" + assert client.config.get("nonexistent", 42) == 42 + + # Missing key with no default returns None + assert client.config.get("nonexistent") is None + + def test_config_getitem_raises_for_missing_key(self, mock_api_key): + """Test config[key] raises KeyError for missing keys.""" + client = ZipTaxClient.api_key(mock_api_key) + + with pytest.raises(KeyError): + _ = client.config["nonexistent"] + def test_context_manager(self, mock_api_key): """Test using client as context manager.""" with ZipTaxClient.api_key(mock_api_key) as client: diff --git a/tests/test_functions.py b/tests/test_functions.py index 597960f..b2b8551 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -301,6 +301,13 @@ def test_invalid_postal_code(self, mock_http_client, mock_config): with pytest.raises(ZipTaxValidationError, match="Postal code must be"): functions.GetRatesByPostalCode("invalid") + def test_nine_digit_postal_code_rejected(self, mock_http_client, mock_config): + """Test that 9-digit postal codes are rejected (API does not accept them).""" + functions = Functions(mock_http_client, mock_config) + + with pytest.raises(ZipTaxValidationError, match="Postal code must be"): + functions.GetRatesByPostalCode("92694-1234") + def test_empty_postal_code(self, mock_http_client, mock_config): """Test validation of empty postal code.""" functions = Functions(mock_http_client, mock_config) @@ -479,6 +486,32 @@ def test_refund_order_full( assert len(response) == 1 mock_taxcloud_http_client.post.assert_called_once() + def test_refund_order_single_dict_response( + self, + mock_http_client, + mock_taxcloud_config, + mock_taxcloud_http_client, + sample_refund_response, + ): + """Test that RefundOrder handles API returning a single dict (not a list).""" + # API sometimes returns a single dict for partial refunds + mock_taxcloud_http_client.post.return_value = sample_refund_response + functions = Functions( + mock_http_client, + mock_taxcloud_config, + taxcloud_http_client=mock_taxcloud_http_client, + ) + + request = RefundTransactionRequest( + items=[{"itemId": "item-1", "quantity": 1.0}] + ) + response = functions.RefundOrder("test-order-1", request) + + assert isinstance(response, list) + assert len(response) == 1 + assert isinstance(response[0], RefundTransactionResponse) + assert response[0].connection_id == "test-connection-id-uuid" + def test_create_order_without_taxcloud_config(self, mock_http_client, mock_config): """Test CreateOrder raises without TaxCloud config.""" functions = Functions(mock_http_client, mock_config)