diff --git a/docs/spec.yaml b/docs/spec.yaml index 0f9347d..4307c72 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -181,6 +181,29 @@ resources: returns: type: "V60Response" is_array: false + - name: "GetRatesByPostalCode" + http_method: "GET" + path: "/request/v60/" + description: "Returns sales and use tax rate details from a US postal code input." + operation_id: "getTaxRatesV60ByPostalCode" + parameters: + - name: "postalcode" + type: "string" + required: true + location: "query" + description: "US postal code (5-digit or 9-digit format)" + validation: + pattern: "^[0-9]{5}(-[0-9]{4})?$" + - name: "format" + type: "string" + required: false + location: "query" + description: "Response format" + default: "json" + enum: ["json", "xml"] + returns: + type: "V60PostalCodeResponse" + is_array: false - name: "GetAccountMetrics" http_method: "GET" path: "/account/v60/metrics" @@ -496,6 +519,257 @@ models: required: true description: "Account status or informational message" + # --------------------------------------------------------------------------- + # Postal Code Lookup Models + # --------------------------------------------------------------------------- + - name: "V60PostalCodeResponse" + description: "Response for postal code lookup - returns flat structure with multiple results" + properties: + - name: "version" + type: "string" + required: true + description: "API version" + example: "v60" + - name: "r_code" + type: "integer" + format: "int64" + required: true + api_field: "rCode" + description: "Response code (100=success)" + example: 100 + - name: "results" + type: "array" + items_type: "V60PostalCodeResult" + required: true + description: "Array of tax rate results for the postal code" + - name: "address_detail" + type: "V60PostalCodeAddressDetail" + required: true + api_field: "addressDetail" + description: "Address details for postal code lookup" + + - name: "V60PostalCodeResult" + description: "Individual tax rate result for a postal code location" + properties: + - name: "geo_postal_code" + type: "string" + required: true + api_field: "geoPostalCode" + description: "Postal code" + - name: "geo_city" + type: "string" + required: true + api_field: "geoCity" + description: "City name" + - name: "geo_county" + type: "string" + required: true + api_field: "geoCounty" + description: "County name" + - name: "geo_state" + type: "string" + required: true + api_field: "geoState" + description: "State code" + - name: "tax_sales" + type: "number" + format: "float" + required: true + api_field: "taxSales" + description: "Total sales tax rate" + - name: "tax_use" + type: "number" + format: "float" + required: true + api_field: "taxUse" + description: "Total use tax rate" + - name: "txb_service" + type: "string" + required: true + api_field: "txbService" + description: "Service taxability indicator" + enum: ["Y", "N"] + - name: "txb_freight" + type: "string" + required: true + api_field: "txbFreight" + description: "Freight taxability indicator" + enum: ["Y", "N"] + - name: "state_sales_tax" + type: "number" + format: "float" + required: true + api_field: "stateSalesTax" + description: "State sales tax rate" + - name: "state_use_tax" + type: "number" + format: "float" + required: true + api_field: "stateUseTax" + description: "State use tax rate" + - name: "city_sales_tax" + type: "number" + format: "float" + required: true + api_field: "citySalesTax" + description: "City sales tax rate" + - name: "city_use_tax" + type: "number" + format: "float" + required: true + api_field: "cityUseTax" + description: "City use tax rate" + - name: "city_tax_code" + type: "string" + required: true + api_field: "cityTaxCode" + description: "City tax code" + - name: "county_sales_tax" + type: "number" + format: "float" + required: true + api_field: "countySalesTax" + description: "County sales tax rate" + - name: "county_use_tax" + type: "number" + format: "float" + required: true + api_field: "countyUseTax" + description: "County use tax rate" + - name: "county_tax_code" + type: "string" + required: true + api_field: "countyTaxCode" + description: "County tax code" + - name: "district_sales_tax" + type: "number" + format: "float" + required: true + api_field: "districtSalesTax" + description: "Total district sales tax rate" + - name: "district_use_tax" + type: "number" + format: "float" + required: true + api_field: "districtUseTax" + description: "Total district use tax rate" + - name: "district1_code" + type: "string" + required: true + api_field: "district1Code" + description: "District 1 tax code" + - name: "district1_sales_tax" + type: "number" + format: "float" + required: true + api_field: "district1SalesTax" + description: "District 1 sales tax rate" + - name: "district1_use_tax" + type: "number" + format: "float" + required: true + api_field: "district1UseTax" + description: "District 1 use tax rate" + - name: "district2_code" + type: "string" + required: true + api_field: "district2Code" + description: "District 2 tax code" + - name: "district2_sales_tax" + type: "number" + format: "float" + required: true + api_field: "district2SalesTax" + description: "District 2 sales tax rate" + - name: "district2_use_tax" + type: "number" + format: "float" + required: true + api_field: "district2UseTax" + description: "District 2 use tax rate" + - name: "district3_code" + type: "string" + required: true + api_field: "district3Code" + description: "District 3 tax code" + - name: "district3_sales_tax" + type: "number" + format: "float" + required: true + api_field: "district3SalesTax" + description: "District 3 sales tax rate" + - name: "district3_use_tax" + type: "number" + format: "float" + required: true + api_field: "district3UseTax" + description: "District 3 use tax rate" + - name: "district4_code" + type: "string" + required: true + api_field: "district4Code" + description: "District 4 tax code" + - name: "district4_sales_tax" + type: "number" + format: "float" + required: true + api_field: "district4SalesTax" + description: "District 4 sales tax rate" + - name: "district4_use_tax" + type: "number" + format: "float" + required: true + api_field: "district4UseTax" + description: "District 4 use tax rate" + - name: "district5_code" + type: "string" + required: true + api_field: "district5Code" + description: "District 5 tax code" + - name: "district5_sales_tax" + type: "number" + format: "float" + required: true + api_field: "district5SalesTax" + description: "District 5 sales tax rate" + - name: "district5_use_tax" + type: "number" + format: "float" + required: true + api_field: "district5UseTax" + description: "District 5 use tax rate" + - name: "origin_destination" + type: "string" + required: true + api_field: "originDestination" + description: "Origin/destination indicator" + enum: ["O", "D"] + + - name: "V60PostalCodeAddressDetail" + description: "Address details for postal code lookup" + properties: + - name: "normalized_address" + type: "string" + required: true + api_field: "normalizedAddress" + description: "Normalized address (not available for postal code lookups)" + - name: "incorporated" + type: "string" + required: true + description: "Incorporation status (not available for postal code lookups)" + - name: "geo_lat" + type: "number" + format: "float" + required: true + api_field: "geoLat" + description: "Latitude (0 for postal code lookups)" + - name: "geo_lng" + type: "number" + format: "float" + required: true + api_field: "geoLng" + description: "Longitude (0 for postal code lookups)" + # ----------------------------------------------------------------------------- # Dependencies @@ -861,6 +1135,131 @@ actual_api_responses: "message": "Contact support@zip.tax to modify your account" } + v60_postal_code_lookup: + description: "Actual V60 response for GetRatesByPostalCode" + endpoint: "GET /request/v60/" + example: | + { + "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, + "cityUseTax": 0, + "cityTaxCode": "", + "countySalesTax": 0.0025, + "countyUseTax": 0.0025, + "countyTaxCode": "", + "districtSalesTax": 0.015, + "districtUseTax": 0.015, + "district1Code": "37", + "district1SalesTax": 0, + "district1UseTax": 0, + "district2Code": "37", + "district2SalesTax": 0.005, + "district2UseTax": 0.005, + "district3Code": "", + "district3SalesTax": 0, + "district3UseTax": 0, + "district4Code": "30", + "district4SalesTax": 0.01, + "district4UseTax": 0.01, + "district5Code": "", + "district5SalesTax": 0, + "district5UseTax": 0, + "originDestination": "D" + }, + { + "geoPostalCode": "92694", + "geoCity": "SAN JUAN CAPISTRANO", + "geoCounty": "ORANGE", + "geoState": "CA", + "taxSales": 0.0775, + "taxUse": 0.0775, + "txbService": "N", + "txbFreight": "N", + "stateSalesTax": 0.06, + "stateUseTax": 0.06, + "citySalesTax": 0, + "cityUseTax": 0, + "cityTaxCode": "", + "countySalesTax": 0.0025, + "countyUseTax": 0.0025, + "countyTaxCode": "", + "districtSalesTax": 0.015, + "districtUseTax": 0.015, + "district1Code": "37", + "district1SalesTax": 0, + "district1UseTax": 0, + "district2Code": "37", + "district2SalesTax": 0.005, + "district2UseTax": 0.005, + "district3Code": "", + "district3SalesTax": 0, + "district3UseTax": 0, + "district4Code": "30", + "district4SalesTax": 0.01, + "district4UseTax": 0.01, + "district5Code": "", + "district5SalesTax": 0, + "district5UseTax": 0, + "originDestination": "D" + }, + { + "geoPostalCode": "92694", + "geoCity": "MISSION VIEJO", + "geoCounty": "ORANGE", + "geoState": "CA", + "taxSales": 0.0775, + "taxUse": 0.0775, + "txbService": "N", + "txbFreight": "N", + "stateSalesTax": 0.06, + "stateUseTax": 0.06, + "citySalesTax": 0, + "cityUseTax": 0, + "cityTaxCode": "", + "countySalesTax": 0.0025, + "countyUseTax": 0.0025, + "countyTaxCode": "", + "districtSalesTax": 0.015, + "districtUseTax": 0.015, + "district1Code": "37", + "district1SalesTax": 0, + "district1UseTax": 0, + "district2Code": "37", + "district2SalesTax": 0.005, + "district2UseTax": 0.005, + "district3Code": "", + "district3SalesTax": 0, + "district3UseTax": 0, + "district4Code": "30", + "district4SalesTax": 0.01, + "district4UseTax": 0.01, + "district5Code": "", + "district5SalesTax": 0, + "district5UseTax": 0, + "originDestination": "D" + } + ], + "addressDetail": { + "normalizedAddress": "feature available for geo address lookups only", + "incorporated": "feature available for geo address lookups only", + "geoLat": 0, + "geoLng": 0 + } + } + # ----------------------------------------------------------------------------- # CI/CD Quality Requirements # ----------------------------------------------------------------------------- diff --git a/examples/basic_usage.py b/examples/basic_usage.py index ba8a9bf..91bdfb3 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -25,7 +25,9 @@ print(f"\nService Taxable: {response.service.taxable}") print(f"Shipping Taxable: {response.shipping.taxable}") if response.sourcing_rules: - print(f"Sourcing: {response.sourcing_rules.value} ({response.sourcing_rules.description})") + print( + f"Sourcing: {response.sourcing_rules.value} ({response.sourcing_rules.description})" + ) if response.tax_summaries: print(f"\nTax Summaries:") @@ -68,9 +70,35 @@ for summary in response.tax_summaries: print(f"{summary.summary_name}: {summary.rate * 100:.2f}%") -# Example 4: Get account metrics +# Example 4: Get sales tax by postal code print("\n" + "=" * 60) -print("Example 4: Get Account Metrics") +print("Example 4: Get Sales Tax by Postal Code") +print("=" * 60) + +response = client.request.GetRatesByPostalCode("92694") + +print(f"Postal Code: {response.results[0].geo_postal_code}") +print(f"API Version: {response.version}") +print(f"Response Code: {response.r_code}") +print(f"\nFound {len(response.results)} location(s) for this postal code:\n") + +for result in response.results: + print(f"Location: {result.geo_city}, {result.geo_state} {result.geo_postal_code}") + print(f" County: {result.geo_county}") + print(f" Total Sales Tax: {result.tax_sales * 100:.2f}%") + print(f" Total Use Tax: {result.tax_use * 100:.2f}%") + print(f" Service Taxable: {result.txb_service}") + print(f" Freight Taxable: {result.txb_freight}") + print(f" Sourcing: {result.origin_destination}") + print(f" State Sales Tax: {result.state_sales_tax * 100:.2f}%") + print(f" County Sales Tax: {result.county_sales_tax * 100:.2f}%") + print(f" City Sales Tax: {result.city_sales_tax * 100:.2f}%") + print(f" District Sales Tax: {result.district_sales_tax * 100:.2f}%") + print() + +# Example 5: Get account metrics +print("=" * 60) +print("Example 5: Get Account Metrics") print("=" * 60) metrics = client.request.GetAccountMetrics() @@ -90,7 +118,7 @@ # Alternatively, use as a context manager print("\n" + "=" * 60) -print("Example 5: Using Context Manager") +print("Example 6: Using Context Manager") print("=" * 60) with ZipTaxClient.api_key("your-api-key-here") as client: diff --git a/src/ziptax.egg-info/SOURCES.txt b/src/ziptax.egg-info/SOURCES.txt index 1222c8f..1c12278 100644 --- a/src/ziptax.egg-info/SOURCES.txt +++ b/src/ziptax.egg-info/SOURCES.txt @@ -20,4 +20,6 @@ src/ziptax/utils/http.py src/ziptax/utils/retry.py src/ziptax/utils/validation.py tests/test_client.py -tests/test_functions.py \ No newline at end of file +tests/test_functions.py +tests/test_http.py +tests/test_retry.py \ No newline at end of file diff --git a/src/ziptax/models/__init__.py b/src/ziptax/models/__init__.py index decdaa0..758f805 100644 --- a/src/ziptax/models/__init__.py +++ b/src/ziptax/models/__init__.py @@ -9,6 +9,9 @@ V60BaseRate, V60DisplayRate, V60Metadata, + V60PostalCodeAddressDetail, + V60PostalCodeResponse, + V60PostalCodeResult, V60Response, V60ResponseInfo, V60Service, @@ -29,6 +32,9 @@ "V60DisplayRate", "V60AddressDetail", "V60AccountMetrics", + "V60PostalCodeResponse", + "V60PostalCodeResult", + "V60PostalCodeAddressDetail", "JurisdictionType", "JurisdictionName", "TaxType", diff --git a/src/ziptax/models/responses.py b/src/ziptax/models/responses.py index 022514c..c6e00ad 100644 --- a/src/ziptax/models/responses.py +++ b/src/ziptax/models/responses.py @@ -197,3 +197,138 @@ class V60AccountMetrics(BaseModel): ) is_active: bool = Field(..., description="Whether the account is currently active") message: str = Field(..., description="Account status or informational message") + + +class V60PostalCodeResult(BaseModel): + """Individual tax rate result for a postal code location.""" + + model_config = ConfigDict(populate_by_name=True) + + geo_postal_code: str = Field(..., alias="geoPostalCode", description="Postal code") + geo_city: str = Field(..., alias="geoCity", description="City name") + geo_county: str = Field(..., alias="geoCounty", description="County name") + 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( + ..., alias="txbService", description="Service taxability indicator" + ) + txb_freight: Literal["Y", "N"] = Field( + ..., alias="txbFreight", description="Freight taxability indicator" + ) + state_sales_tax: float = Field( + ..., alias="stateSalesTax", description="State sales tax rate" + ) + state_use_tax: float = Field( + ..., alias="stateUseTax", description="State use tax rate" + ) + city_sales_tax: float = Field( + ..., alias="citySalesTax", description="City sales tax rate" + ) + city_use_tax: float = Field( + ..., alias="cityUseTax", description="City use tax rate" + ) + city_tax_code: str = Field(..., alias="cityTaxCode", description="City tax code") + county_sales_tax: float = Field( + ..., alias="countySalesTax", description="County sales tax rate" + ) + county_use_tax: float = Field( + ..., alias="countyUseTax", description="County use tax rate" + ) + county_tax_code: str = Field( + ..., alias="countyTaxCode", description="County tax code" + ) + district_sales_tax: float = Field( + ..., alias="districtSalesTax", description="Total district sales tax rate" + ) + district_use_tax: float = Field( + ..., alias="districtUseTax", description="Total district use tax rate" + ) + district1_code: str = Field( + ..., alias="district1Code", description="District 1 tax code" + ) + district1_sales_tax: float = Field( + ..., alias="district1SalesTax", description="District 1 sales tax rate" + ) + district1_use_tax: float = Field( + ..., alias="district1UseTax", description="District 1 use tax rate" + ) + district2_code: str = Field( + ..., alias="district2Code", description="District 2 tax code" + ) + district2_sales_tax: float = Field( + ..., alias="district2SalesTax", description="District 2 sales tax rate" + ) + district2_use_tax: float = Field( + ..., alias="district2UseTax", description="District 2 use tax rate" + ) + district3_code: str = Field( + ..., alias="district3Code", description="District 3 tax code" + ) + district3_sales_tax: float = Field( + ..., alias="district3SalesTax", description="District 3 sales tax rate" + ) + district3_use_tax: float = Field( + ..., alias="district3UseTax", description="District 3 use tax rate" + ) + district4_code: str = Field( + ..., alias="district4Code", description="District 4 tax code" + ) + district4_sales_tax: float = Field( + ..., alias="district4SalesTax", description="District 4 sales tax rate" + ) + district4_use_tax: float = Field( + ..., alias="district4UseTax", description="District 4 use tax rate" + ) + district5_code: str = Field( + ..., alias="district5Code", description="District 5 tax code" + ) + district5_sales_tax: float = Field( + ..., alias="district5SalesTax", description="District 5 sales tax rate" + ) + district5_use_tax: float = Field( + ..., alias="district5UseTax", description="District 5 use tax rate" + ) + origin_destination: Literal["O", "D"] = Field( + ..., alias="originDestination", description="Origin/destination indicator" + ) + + +class V60PostalCodeAddressDetail(BaseModel): + """Address details for postal code lookup.""" + + model_config = ConfigDict(populate_by_name=True) + + normalized_address: str = Field( + ..., + alias="normalizedAddress", + description="Normalized address (not available for postal code lookups)", + ) + incorporated: str = Field( + ..., + description="Incorporation status (not available for postal code lookups)", + ) + geo_lat: float = Field( + ..., alias="geoLat", description="Latitude (0 for postal code lookups)" + ) + geo_lng: float = Field( + ..., alias="geoLng", description="Longitude (0 for postal code lookups)" + ) + + +class V60PostalCodeResponse(BaseModel): + """Response for postal code lookup. + + Returns flat structure with multiple results. + """ + + model_config = ConfigDict(populate_by_name=True) + + version: str = Field(..., description="API version") + r_code: int = Field(..., alias="rCode", description="Response code (100=success)") + results: List[V60PostalCodeResult] = Field( + ..., description="Array of tax rate results for the postal code" + ) + address_detail: V60PostalCodeAddressDetail = Field( + ..., alias="addressDetail", description="Address details for postal code lookup" + ) diff --git a/src/ziptax/resources/functions.py b/src/ziptax/resources/functions.py index 1e90a72..8aef24f 100644 --- a/src/ziptax/resources/functions.py +++ b/src/ziptax/resources/functions.py @@ -3,7 +3,7 @@ import logging from typing import Any, Dict, Optional -from ..models import V60AccountMetrics, V60Response +from ..models import V60AccountMetrics, V60PostalCodeResponse, V60Response from ..utils.http import HTTPClient from ..utils.retry import retry_with_backoff from ..utils.validation import ( @@ -12,6 +12,7 @@ validate_country_code, validate_format, validate_historical_date, + validate_postal_code, ) logger = logging.getLogger(__name__) @@ -169,3 +170,44 @@ def _make_request() -> Dict[str, Any]: response_data = _make_request() return V60AccountMetrics(**response_data) + + def GetRatesByPostalCode( + self, + postal_code: str, + format: str = "json", + ) -> V60PostalCodeResponse: + """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") + format: Response format (default: "json") + + Returns: + V60PostalCodeResponse object with tax rate information for all locations + within the postal code + + Raises: + ZipTaxValidationError: If input parameters are invalid + ZipTaxAPIError: If the API returns an error + """ + # Validate inputs + validate_postal_code(postal_code) + validate_format(format) + + # Build query parameters + params: Dict[str, Any] = { + "postalcode": postal_code, + "format": format, + } + + # 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.get("/request/v60/", params=params) + + response_data = _make_request() + return V60PostalCodeResponse(**response_data) diff --git a/src/ziptax/utils/validation.py b/src/ziptax/utils/validation.py index 7737788..d847143 100644 --- a/src/ziptax/utils/validation.py +++ b/src/ziptax/utils/validation.py @@ -140,3 +140,28 @@ def validate_api_key(api_key: str) -> None: if len(api_key) < 10: raise ZipTaxValidationError("API key appears to be invalid (too short)") + + +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) + + Raises: + ZipTaxValidationError: If postal code is invalid + """ + if not postal_code: + raise ZipTaxValidationError("Postal code cannot be empty") + + 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})?$" + + 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}" + )