diff --git a/docs/spec.yaml b/docs/spec.yaml index c9931b0..a974bb8 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -181,6 +181,30 @@ 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 postal code input." + operation_id: "getTaxRatesV60" + parameters: + - name: "postalcode" + type: "string" + required: true + location: "query" + description: "US postal code (5-digit format, e.g., 92694)" + validation: + pattern: "^[0-9]{5}$" + max_length: 5 + - 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" @@ -450,6 +474,250 @@ models: format: "float" required: true description: "Geocoded longitude" + - name: "V60PostalCodeResponse" + description: "Response for v6.0 postal code lookup - legacy format" + properties: + - name: "version" + type: "string" + required: true + description: "API version" + example: "v60" + - name: "rCode" + type: "integer" + format: "int64" + required: true + 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: "addressDetail" + type: "V60PostalCodeAddressDetail" + required: true + description: "Address details for postal code lookup" + + - name: "V60PostalCodeResult" + description: "Individual tax rate result for a postal code" + properties: + - name: "geoPostalCode" + type: "string" + required: true + description: "Postal code" + example: "92694" + - name: "geoCity" + type: "string" + required: true + description: "City name" + example: "LADERA RANCH" + - name: "geoCounty" + type: "string" + required: true + description: "County name" + example: "ORANGE" + - name: "geoState" + type: "string" + required: true + description: "State abbreviation" + example: "CA" + - name: "taxSales" + type: "number" + format: "float" + required: true + description: "Total sales tax rate" + example: 0.0775 + - name: "taxUse" + type: "number" + format: "float" + required: true + description: "Total use tax rate" + example: 0.0775 + - name: "txbService" + type: "string" + required: true + description: "Service taxability indicator" + enum: ["Y", "N"] + - name: "txbFreight" + type: "string" + required: true + description: "Freight taxability indicator" + enum: ["Y", "N"] + - name: "stateSalesTax" + type: "number" + format: "float" + required: true + description: "State sales tax rate" + example: 0.06 + - name: "stateUseTax" + type: "number" + format: "float" + required: true + description: "State use tax rate" + example: 0.06 + - name: "citySalesTax" + type: "number" + format: "float" + required: true + description: "City sales tax rate" + example: 0 + - name: "cityUseTax" + type: "number" + format: "float" + required: true + description: "City use tax rate" + example: 0 + - name: "cityTaxCode" + type: "string" + required: true + description: "City tax code" + example: "" + - name: "countySalesTax" + type: "number" + format: "float" + required: true + description: "County sales tax rate" + example: 0.0025 + - name: "countyUseTax" + type: "number" + format: "float" + required: true + description: "County use tax rate" + example: 0.0025 + - name: "countyTaxCode" + type: "string" + required: true + description: "County tax code" + example: "" + - name: "districtSalesTax" + type: "number" + format: "float" + required: true + description: "Total district sales tax rate" + example: 0.015 + - name: "districtUseTax" + type: "number" + format: "float" + required: true + description: "Total district use tax rate" + example: 0.015 + - name: "district1Code" + type: "string" + required: true + description: "District 1 code" + example: "37" + - name: "district1SalesTax" + type: "number" + format: "float" + required: true + description: "District 1 sales tax rate" + example: 0 + - name: "district1UseTax" + type: "number" + format: "float" + required: true + description: "District 1 use tax rate" + example: 0 + - name: "district2Code" + type: "string" + required: true + description: "District 2 code" + example: "37" + - name: "district2SalesTax" + type: "number" + format: "float" + required: true + description: "District 2 sales tax rate" + example: 0.005 + - name: "district2UseTax" + type: "number" + format: "float" + required: true + description: "District 2 use tax rate" + example: 0.005 + - name: "district3Code" + type: "string" + required: true + description: "District 3 code" + example: "" + - name: "district3SalesTax" + type: "number" + format: "float" + required: true + description: "District 3 sales tax rate" + example: 0 + - name: "district3UseTax" + type: "number" + format: "float" + required: true + description: "District 3 use tax rate" + example: 0 + - name: "district4Code" + type: "string" + required: true + description: "District 4 code" + example: "30" + - name: "district4SalesTax" + type: "number" + format: "float" + required: true + description: "District 4 sales tax rate" + example: 0.01 + - name: "district4UseTax" + type: "number" + format: "float" + required: true + description: "District 4 use tax rate" + example: 0.01 + - name: "district5Code" + type: "string" + required: true + description: "District 5 code" + example: "" + - name: "district5SalesTax" + type: "number" + format: "float" + required: true + description: "District 5 sales tax rate" + example: 0 + - name: "district5UseTax" + type: "number" + format: "float" + required: true + description: "District 5 use tax rate" + example: 0 + - name: "originDestination" + type: "string" + required: true + description: "Origin/destination indicator" + enum: ["O", "D"] + + - name: "V60PostalCodeAddressDetail" + description: "Address detail information for postal code lookup" + properties: + - name: "normalizedAddress" + type: "string" + required: true + description: "Normalized address (limited for postal code lookups)" + example: "feature available for geo address lookups only" + - name: "incorporated" + type: "string" + required: true + description: "Incorporation status (limited for postal code lookups)" + example: "feature available for geo address lookups only" + - name: "geoLat" + type: "number" + format: "float" + required: true + description: "Geocoded latitude (0 for postal code lookups)" + example: 0 + - name: "geoLng" + type: "number" + format: "float" + required: true + description: "Geocoded longitude (0 for postal code lookups)" + example: 0 + - name: "V60AccountMetrics" description: "Account metrics by API key" properties: @@ -855,6 +1123,95 @@ actual_api_responses: } } + v60_postal_code_lookup: + description: "Actual V60 response for GetRatesByPostalCode" + endpoint: "GET /request/v60/?postalcode=92694" + 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" + } + ], + "addressDetail": { + "normalizedAddress": "feature available for geo address lookups only", + "incorporated": "feature available for geo address lookups only", + "geoLat": 0, + "geoLng": 0 + } + } + v60_account_metrics: description: "Actual V60AccountMetrics response" endpoint: "GET /account/v60/metrics" diff --git a/examples/basic-usage.ts b/examples/basic-usage.ts index 75c02e0..06abd5e 100644 --- a/examples/basic-usage.ts +++ b/examples/basic-usage.ts @@ -43,6 +43,17 @@ async function main() { console.log('Address:', taxByGeo.addressDetail.normalizedAddress); console.log('---'); + // Get rates by postal code + const ratesByPostalCode = await client.getRatesByPostalCode({ + postalcode: '92694', + }); + + console.log('Rates by Postal Code:'); + console.log('Postal Code:', ratesByPostalCode.results[0]?.geoPostalCode); + console.log('Total Sales Tax:', ratesByPostalCode.results[0]?.taxSales); + console.log('Cities:', ratesByPostalCode.results.map((r) => r.geoCity).join(', ')); + console.log('---'); + // Get account metrics const metrics = await client.getAccountMetrics(); diff --git a/src/client.ts b/src/client.ts index e7f5753..e136f00 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,9 +14,10 @@ import { DEFAULT_CONFIG, GetSalesTaxByAddressParams, GetSalesTaxByGeoLocationParams, + GetRatesByPostalCodeParams, GetAccountMetricsParams, } from './config'; -import { V60Response, V60AccountMetrics } from './models'; +import { V60Response, V60PostalCodeResponse, V60AccountMetrics } from './models'; /** * ZipTax API client @@ -110,6 +111,26 @@ export class ZiptaxClient { }); } + /** + * Get sales and use tax rate details from a postal code input + * @param params - Query parameters + * @returns V60PostalCodeResponse with tax rate details + */ + async getRatesByPostalCode(params: GetRatesByPostalCodeParams): Promise { + // Validate required parameters + validateRequired(params.postalcode, 'postalcode'); + validateMaxLength(params.postalcode, 5, 'postalcode'); + validatePattern(params.postalcode, /^[0-9]{5}$/, 'postalcode', '5-digit format'); + + // Make API request + return this.httpClient.get('/request/v60/', { + params: { + postalcode: params.postalcode, + format: params.format || 'json', + }, + }); + } + /** * Get account metrics related to sales and use tax * @param params - Query parameters (optional) diff --git a/src/config.ts b/src/config.ts index d2f070b..67c7f73 100644 --- a/src/config.ts +++ b/src/config.ts @@ -61,6 +61,16 @@ export interface GetSalesTaxByGeoLocationParams { format?: 'json' | 'xml'; } +/** + * Query parameters for GetRatesByPostalCode + */ +export interface GetRatesByPostalCodeParams { + /** US postal code (5-digit format, e.g., 92694) */ + postalcode: string; + /** Response format (default: json) */ + format?: 'json' | 'xml'; +} + /** * Query parameters for GetAccountMetrics */ diff --git a/src/models/responses.ts b/src/models/responses.ts index f5c04d6..dfd6570 100644 --- a/src/models/responses.ts +++ b/src/models/responses.ts @@ -139,6 +139,108 @@ export interface V60Response { addressDetail: V60AddressDetail; } +/** + * Individual tax rate result for a postal code + */ +export interface V60PostalCodeResult { + /** Postal code */ + geoPostalCode: string; + /** City name */ + geoCity: string; + /** County name */ + geoCounty: string; + /** State abbreviation */ + geoState: string; + /** Total sales tax rate */ + taxSales: number; + /** Total use tax rate */ + taxUse: number; + /** Service taxability indicator */ + txbService: 'Y' | 'N'; + /** Freight taxability indicator */ + txbFreight: 'Y' | 'N'; + /** State sales tax rate */ + stateSalesTax: number; + /** State use tax rate */ + stateUseTax: number; + /** City sales tax rate */ + citySalesTax: number; + /** City use tax rate */ + cityUseTax: number; + /** City tax code */ + cityTaxCode: string; + /** County sales tax rate */ + countySalesTax: number; + /** County use tax rate */ + countyUseTax: number; + /** County tax code */ + countyTaxCode: string; + /** Total district sales tax rate */ + districtSalesTax: number; + /** Total district use tax rate */ + districtUseTax: number; + /** District 1 code */ + district1Code: string; + /** District 1 sales tax rate */ + district1SalesTax: number; + /** District 1 use tax rate */ + district1UseTax: number; + /** District 2 code */ + district2Code: string; + /** District 2 sales tax rate */ + district2SalesTax: number; + /** District 2 use tax rate */ + district2UseTax: number; + /** District 3 code */ + district3Code: string; + /** District 3 sales tax rate */ + district3SalesTax: number; + /** District 3 use tax rate */ + district3UseTax: number; + /** District 4 code */ + district4Code: string; + /** District 4 sales tax rate */ + district4SalesTax: number; + /** District 4 use tax rate */ + district4UseTax: number; + /** District 5 code */ + district5Code: string; + /** District 5 sales tax rate */ + district5SalesTax: number; + /** District 5 use tax rate */ + district5UseTax: number; + /** Origin/destination indicator */ + originDestination: 'O' | 'D'; +} + +/** + * Address detail information for postal code lookup + */ +export interface V60PostalCodeAddressDetail { + /** Normalized address (limited for postal code lookups) */ + normalizedAddress: string; + /** Incorporation status (limited for postal code lookups) */ + incorporated: string; + /** Geocoded latitude (0 for postal code lookups) */ + geoLat: number; + /** Geocoded longitude (0 for postal code lookups) */ + geoLng: number; +} + +/** + * Response for v6.0 postal code lookup - legacy format + */ +export interface V60PostalCodeResponse { + /** API version */ + version: string; + /** Response code (100=success) */ + rCode: number; + /** Array of tax rate results for the postal code */ + results: V60PostalCodeResult[]; + /** Address details for postal code lookup */ + addressDetail: V60PostalCodeAddressDetail; +} + /** * Account metrics by API key */ diff --git a/tests/client.test.ts b/tests/client.test.ts index 22c2667..03e92e8 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -64,6 +64,55 @@ const mockV60Response = { }, }; +const mockPostalCodeResponse = { + version: 'v60', + rCode: 100, + results: [ + { + geoPostalCode: '92694', + geoCity: 'LADERA RANCH', + geoCounty: 'ORANGE', + geoState: 'CA', + taxSales: 0.0775, + taxUse: 0.0775, + txbService: 'N' as const, + txbFreight: 'N' as const, + 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' as const, + }, + ], + addressDetail: { + normalizedAddress: 'feature available for geo address lookups only', + incorporated: 'feature available for geo address lookups only', + geoLat: 0, + geoLng: 0, + }, +}; + const mockAccountMetrics = { core_request_count: 15595, core_request_limit: 1000000, @@ -211,6 +260,67 @@ describe('ZiptaxClient', () => { }); }); + describe('getRatesByPostalCode', () => { + it('should get tax rates by postal code', async () => { + mockHttpClient.get.mockResolvedValue(mockPostalCodeResponse); + const client = new ZiptaxClient({ apiKey: 'test-api-key' }); + + const result = await client.getRatesByPostalCode({ + postalcode: '92694', + }); + + expect(result).toEqual(mockPostalCodeResponse); + expect(mockHttpClient.get).toHaveBeenCalledWith('/request/v60/', { + params: { + postalcode: '92694', + format: 'json', + }, + }); + }); + + it('should throw error for missing postal code', async () => { + const client = new ZiptaxClient({ apiKey: 'test-api-key' }); + await expect(client.getRatesByPostalCode({ postalcode: '' })).rejects.toThrow( + ZiptaxValidationError + ); + }); + + it('should validate postal code format (must be 5 digits)', async () => { + const client = new ZiptaxClient({ apiKey: 'test-api-key' }); + await expect(client.getRatesByPostalCode({ postalcode: '1234' })).rejects.toThrow( + ZiptaxValidationError + ); + }); + + it('should validate postal code format (must be numeric)', async () => { + const client = new ZiptaxClient({ apiKey: 'test-api-key' }); + await expect(client.getRatesByPostalCode({ postalcode: 'ABCDE' })).rejects.toThrow( + ZiptaxValidationError + ); + }); + + it('should validate postal code max length', async () => { + const client = new ZiptaxClient({ apiKey: 'test-api-key' }); + await expect(client.getRatesByPostalCode({ postalcode: '123456' })).rejects.toThrow( + ZiptaxValidationError + ); + }); + + it('should accept format parameter', async () => { + mockHttpClient.get.mockResolvedValue(mockPostalCodeResponse); + const client = new ZiptaxClient({ apiKey: 'test-api-key' }); + + await client.getRatesByPostalCode({ postalcode: '92694', format: 'xml' }); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/request/v60/', { + params: { + postalcode: '92694', + format: 'xml', + }, + }); + }); + }); + describe('getAccountMetrics', () => { it('should get account metrics', async () => { mockHttpClient.get.mockResolvedValue(mockAccountMetrics);