From 41ac3fbfe483ffccf4c258936a9cdc3f4c3d7360 Mon Sep 17 00:00:00 2001 From: John Gathure Date: Tue, 18 Nov 2025 11:19:30 +0300 Subject: [PATCH 1/8] Add Canada VAT rules with province-specific rates based on postal codes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements CaVatRules class to support Canadian GST/HST/PST rates for all provinces and territories. The first letter of the postal code determines the region and corresponding tax rate (5%-15%). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + pyvat/vat_rules.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/.gitignore b/.gitignore index b3d0d92..2e46fc0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /*egg* /docs/_build .DS_Store +.venv diff --git a/pyvat/vat_rules.py b/pyvat/vat_rules.py index 303c0ef..9bf6d92 100644 --- a/pyvat/vat_rules.py +++ b/pyvat/vat_rules.py @@ -383,6 +383,82 @@ def get_sale_to_country_vat_charge(self, def get_vat_rate(self, item_type): return Decimal(14) + +class CaVatRules(): + """VAT rules for Canada. + + Canada has province-specific VAT rates (GST/HST) determined by the first + letter of the postal code: + - A: Newfoundland and Labrador (HST 15%) + - B: Nova Scotia (HST 15%) + - C: Prince Edward Island (HST 15%) + - E: New Brunswick (HST 15%) + - G, H, J: Quebec (GST 5% + QST 9.975% = 14.975%) + - K, L, M, N, P: Ontario (HST 13%) + - R: Manitoba (GST 5% + PST 7% = 12%) + - S: Saskatchewan (GST 5% + PST 6% = 11%) + - T: Alberta (GST 5%) + - V: British Columbia (GST 5% + PST 7% = 12%) + - X: Northwest Territories and Nunavut (GST 5%) + - Y: Yukon (GST 5%) + """ + + # Map of postal code prefix to VAT rate + PROVINCE_VAT_RATES = { + 'A': Decimal('15'), # Newfoundland and Labrador + 'B': Decimal('15'), # Nova Scotia + 'C': Decimal('15'), # Prince Edward Island + 'E': Decimal('15'), # New Brunswick + 'G': Decimal('14.975'), # Quebec + 'H': Decimal('14.975'), # Quebec + 'J': Decimal('14.975'), # Quebec + 'K': Decimal('13'), # Ontario + 'L': Decimal('13'), # Ontario + 'M': Decimal('13'), # Ontario + 'N': Decimal('13'), # Ontario + 'P': Decimal('13'), # Ontario + 'R': Decimal('12'), # Manitoba + 'S': Decimal('11'), # Saskatchewan + 'T': Decimal('5'), # Alberta + 'V': Decimal('12'), # British Columbia + 'X': Decimal('5'), # Northwest Territories and Nunavut + 'Y': Decimal('5'), # Yukon + } + + def get_sale_to_country_vat_charge(self, + date, + item_type, + buyer, + seller, + postal_code=None): + return VatCharge(VatChargeAction.charge, + buyer.country_code, + self.get_vat_rate(item_type, postal_code)) + + def get_vat_rate(self, item_type, postal_code=None): + """Get the VAT rate for Canada based on postal code. + + :param item_type: Item type (not used for Canada, same rate for all items) + :type item_type: ItemType + :param postal_code: Canadian postal code to determine province + :type postal_code: str + :returns: the VAT rate in percent + :rtype: decimal.Decimal + """ + if not postal_code: + # Default to GST (5%) if no postal code provided + return Decimal('5') + + # Get the first character of the postal code (uppercase) + postal_code_str = str(postal_code).strip().upper() + if not postal_code_str: + return Decimal('5') + + prefix = postal_code_str[0] + + # Return the province-specific rate, or default to GST + return self.PROVINCE_VAT_RATES.get(prefix, Decimal('5')) + # VAT rates updated July 1st 2025 VAT_RULES = { 'AT': AtVatRules(), @@ -415,6 +491,7 @@ def get_vat_rate(self, item_type): 'SK': ConstantEuVatRateRules(23), 'SI': ConstantEuVatRateRules(22), 'EG': EgVatRules(), + 'CA': CaVatRules(), } """VAT rules by country. From 1165054e9294316dfa0972cb60ad9086cfb5d510 Mon Sep 17 00:00:00 2001 From: Mathieu Lamiot Date: Tue, 24 Feb 2026 19:17:12 +0100 Subject: [PATCH 2/8] Fix init of CanadaVatRules --- pyvat/vat_rules.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyvat/vat_rules.py b/pyvat/vat_rules.py index 960045e..68173a8 100644 --- a/pyvat/vat_rules.py +++ b/pyvat/vat_rules.py @@ -672,6 +672,9 @@ class CanadaVatRules(NonEuVatRules): 'Y': Decimal('5'), # Yukon } + def __init__(self): + super(CanadaVatRules, self).__init__(0) + def get_sale_to_country_vat_charge(self, date, item_type, From 6bf2e552f7b1e148731f28a1800b39455ee57ba7 Mon Sep 17 00:00:00 2001 From: John Gathure Date: Fri, 10 Apr 2026 16:08:44 +0300 Subject: [PATCH 3/8] update rates and fix tests. --- pyvat/vat_rules.py | 73 +++++++++-------- ...st_france_seller_digital_goods_vat_rate.py | 10 +-- tests/test_new_countries.py | 78 ++++++++++++++++++- tests/test_sale_vat_charge.py | 17 ++-- tests/test_validators.py | 2 +- 5 files changed, 131 insertions(+), 49 deletions(-) diff --git a/pyvat/vat_rules.py b/pyvat/vat_rules.py index 68173a8..3bf852d 100644 --- a/pyvat/vat_rules.py +++ b/pyvat/vat_rules.py @@ -634,46 +634,55 @@ def __init__(self): class CanadaVatRules(NonEuVatRules): """VAT rules for Canada. - Canada has province-specific VAT rates (GST/HST) determined by the first - letter of the postal code: + Canada has province-specific VAT rates (GST/HST/PST) determined by the + first letter of the postal code. Where we are not registered for provincial + tax (PST/QST/RST), only the federal GST (5%) is charged. + - A: Newfoundland and Labrador (HST 15%) - - B: Nova Scotia (HST 15%) + - B: Nova Scotia (HST 14%) - C: Prince Edward Island (HST 15%) - E: New Brunswick (HST 15%) - - G, H, J: Quebec (GST 5% + QST 9.975% = 14.975%) + - G, H, J: Quebec (not registered β†’ GST 5%) - K, L, M, N, P: Ontario (HST 13%) - - R: Manitoba (GST 5% + PST 7% = 12%) - - S: Saskatchewan (GST 5% + PST 6% = 11%) + - R: Manitoba (not registered β†’ GST 5%) + - S: Saskatchewan (GST 5% + PST 6% = 11%, registered) - T: Alberta (GST 5%) - - V: British Columbia (GST 5% + PST 7% = 12%) + - V: British Columbia (GST 5% + PST 7% = 12%, registered) - X: Northwest Territories and Nunavut (GST 5%) - Y: Yukon (GST 5%) + - No postal code: Ontario rate (13%) as fallback + + No B2B exemption: Canadian B2B customers are subject to the same tax as B2C. """ - # Map of postal code prefix to VAT rate + # Default rate when no postal code is provided: Ontario rate (13%) + DEFAULT_VAT_RATE = Decimal('13') + + # Map of postal code prefix to VAT rate. + # Where not registered for provincial tax, only GST (5%) is charged. PROVINCE_VAT_RATES = { - 'A': Decimal('15'), # Newfoundland and Labrador - 'B': Decimal('15'), # Nova Scotia - 'C': Decimal('15'), # Prince Edward Island - 'E': Decimal('15'), # New Brunswick - 'G': Decimal('14.975'), # Quebec - 'H': Decimal('14.975'), # Quebec - 'J': Decimal('14.975'), # Quebec - 'K': Decimal('13'), # Ontario - 'L': Decimal('13'), # Ontario - 'M': Decimal('13'), # Ontario - 'N': Decimal('13'), # Ontario - 'P': Decimal('13'), # Ontario - 'R': Decimal('12'), # Manitoba - 'S': Decimal('11'), # Saskatchewan - 'T': Decimal('5'), # Alberta - 'V': Decimal('12'), # British Columbia - 'X': Decimal('5'), # Northwest Territories and Nunavut - 'Y': Decimal('5'), # Yukon + 'A': Decimal('15'), # Newfoundland and Labrador (HST) + 'B': Decimal('14'), # Nova Scotia (HST) + 'C': Decimal('15'), # Prince Edward Island (HST) + 'E': Decimal('15'), # New Brunswick (HST) + 'G': Decimal('5'), # Quebec β€” not registered, charge GST only + 'H': Decimal('5'), # Quebec β€” not registered, charge GST only + 'J': Decimal('5'), # Quebec β€” not registered, charge GST only + 'K': Decimal('13'), # Ontario (HST) + 'L': Decimal('13'), # Ontario (HST) + 'M': Decimal('13'), # Ontario (HST) + 'N': Decimal('13'), # Ontario (HST) + 'P': Decimal('13'), # Ontario (HST) + 'R': Decimal('5'), # Manitoba β€” not registered, charge GST only + 'S': Decimal('11'), # Saskatchewan (GST 5% + PST 6%, registered) + 'T': Decimal('5'), # Alberta (GST only) + 'V': Decimal('12'), # British Columbia (GST 5% + PST 7%, registered) + 'X': Decimal('5'), # Northwest Territories and Nunavut (GST only) + 'Y': Decimal('5'), # Yukon (GST only) } def __init__(self): - super(CanadaVatRules, self).__init__(0) + super(CanadaVatRules, self).__init__(self.DEFAULT_VAT_RATE) def get_sale_to_country_vat_charge(self, date, @@ -696,18 +705,18 @@ def get_vat_rate(self, item_type, postal_code=None): :rtype: decimal.Decimal """ if not postal_code: - # Default to GST (5%) if no postal code provided - return Decimal('5') + # No postal code: fall back to Ontario rate (13%) + return self.DEFAULT_VAT_RATE # Get the first character of the postal code (uppercase) postal_code_str = str(postal_code).strip().upper() if not postal_code_str: - return Decimal('5') + return self.DEFAULT_VAT_RATE prefix = postal_code_str[0] - # Return the province-specific rate, or default to GST - return self.PROVINCE_VAT_RATES.get(prefix, Decimal('5')) + # Return the province-specific rate, or fall back to Ontario rate + return self.PROVINCE_VAT_RATES.get(prefix, self.DEFAULT_VAT_RATE) # VAT rates updated July 1st 2025 VAT_RULES = { diff --git a/tests/test_france_seller_digital_goods_vat_rate.py b/tests/test_france_seller_digital_goods_vat_rate.py index 635bbd4..007e46e 100644 --- a/tests/test_france_seller_digital_goods_vat_rate.py +++ b/tests/test_france_seller_digital_goods_vat_rate.py @@ -158,7 +158,7 @@ def test_04_france_non_eu_special_cases(self): Validates: - Egypt: B2C 14%, B2B 0% (exempt) - Switzerland: B2C and B2B both 8.1% (NOT exempt) - - Canada: B2C and B2B both 0% (exempt) + - Canada: B2C and B2B both 13% (Ontario fallback, no B2B exemption) - Norway: B2C and B2B both 25% (NOT exempt) """ print("\n" + "="*80) @@ -177,11 +177,11 @@ def test_04_france_non_eu_special_cases(self): self._test_scenario('FR', 'CH', True, VatChargeAction.charge, Decimal('8.1'), "France β†’ Switzerland") - # Canada - B2B exempt (0%) + # Canada - no B2B exemption; no postal code falls back to Ontario rate (13%) self._test_scenario('FR', 'CA', False, VatChargeAction.charge, - Decimal('0'), "France β†’ Canada") + Decimal('13'), "France β†’ Canada") self._test_scenario('FR', 'CA', True, VatChargeAction.charge, - Decimal('0'), "France β†’ Canada") + Decimal('13'), "France β†’ Canada") # Norway - B2B NOT exempt (charges 25%) self._test_scenario('FR', 'NO', False, VatChargeAction.charge, @@ -234,7 +234,7 @@ def test_06_summary_table_validation(self): ('FR', 'MQ', Decimal('8.5'), Decimal('8.5'), VatChargeAction.charge, "Martinique"), ('FR', 'EG', Decimal('14'), Decimal('0'), VatChargeAction.no_charge, "Egypt"), ('FR', 'CH', Decimal('8.1'), Decimal('8.1'), VatChargeAction.charge, "Switzerland"), - ('FR', 'CA', Decimal('0'), Decimal('0'), VatChargeAction.charge, "Canada"), + ('FR', 'CA', Decimal('13'), Decimal('13'), VatChargeAction.charge, "Canada"), # Ontario fallback, no B2B exemption ('FR', 'NO', Decimal('25'), Decimal('25'), VatChargeAction.charge, "Norway"), ('FR', 'GB', Decimal('20'), Decimal('0'), VatChargeAction.reverse_charge, "Great Britain"), ] diff --git a/tests/test_new_countries.py b/tests/test_new_countries.py index 4dafdbc..4ba085b 100644 --- a/tests/test_new_countries.py +++ b/tests/test_new_countries.py @@ -8,7 +8,7 @@ try: from unittest2 import TestCase -except (ImportError): +except (ImportError, AttributeError): from unittest import TestCase @@ -20,7 +20,7 @@ def test_vat_rates(self): test_cases = [ ('EG', 'Egypt', 14), ('CH', 'Switzerland', 8.1), - ('CA', 'Canada', 0), + ('CA', 'Canada', 13), # No postal code β†’ Ontario fallback (13%) ('NO', 'Norway', 25), ('MC', 'Monaco', 20), ('RE', 'RΓ©union (DOM)', 8.5), @@ -80,7 +80,7 @@ def test_non_eu_b2b_vat_charges(self): # (country_code, country_name, expected_vat_rate, expected_action, description) ('EG', 'Egypt', Decimal('0'), VatChargeAction.no_charge, 'B2B exempt - no VAT charged'), ('CH', 'Switzerland', Decimal('8.1'), VatChargeAction.charge, 'B2B not exempt - VAT charged'), - ('CA', 'Canada', Decimal('0'), VatChargeAction.charge, 'B2B accepts VAT numbers, 0% rate applied'), + ('CA', 'Canada', Decimal('13'), VatChargeAction.charge, 'B2B not exempt - Ontario fallback rate (13%) applied'), ('NO', 'Norway', Decimal('25'), VatChargeAction.charge, 'B2B not exempt - VAT charged'), ] @@ -115,3 +115,75 @@ def test_non_eu_b2b_vat_charges(self): f"VAT should be charged in {country_name} ({country_code})" ) + +class CanadaProvinceVatRatesTestCase(TestCase): + """Test Canada province-specific VAT rates based on postal code prefix.""" + + def test_province_rates(self): + """Test that the correct VAT rate is returned for each province.""" + rules = VAT_RULES['CA'] + item_type = ItemType.generic_electronic_service + + test_cases = [ + # (postal_code, province, expected_rate, note) + (None, 'No postal code (Ontario fallback)', Decimal('13'), 'fallback'), + ('A1A 5T9', 'Newfoundland and Labrador', Decimal('15'), 'HST'), + ('B3H 1Y2', 'Nova Scotia', Decimal('14'), 'HST'), + ('C1A 4P3', 'Prince Edward Island', Decimal('15'), 'HST'), + ('E2L 4H8', 'New Brunswick', Decimal('15'), 'HST'), + ('G1A 0A2', 'Quebec', Decimal('5'), 'not registered β†’ GST only'), + ('H1A 0A1', 'Quebec', Decimal('5'), 'not registered β†’ GST only'), + ('J1A 1A1', 'Quebec', Decimal('5'), 'not registered β†’ GST only'), + ('K1A 0B1', 'Ontario', Decimal('13'), 'HST'), + ('L5B 4M7', 'Ontario', Decimal('13'), 'HST'), + ('M5V 3L9', 'Ontario', Decimal('13'), 'HST'), + ('N2L 3G1', 'Ontario', Decimal('13'), 'HST'), + ('P7B 5E1', 'Ontario', Decimal('13'), 'HST'), + ('R2C 0A1', 'Manitoba', Decimal('5'), 'not registered β†’ GST only'), + ('S7K 1A1', 'Saskatchewan', Decimal('11'), 'GST + PST (registered)'), + ('T5J 0N3', 'Alberta', Decimal('5'), 'GST only'), + ('V6B 4N6', 'British Columbia', Decimal('12'), 'GST + PST (registered)'), + ('X0A 0H0', 'Northwest Territories/Nunavut', Decimal('5'), 'GST only'), + ('Y1A 0A1', 'Yukon', Decimal('5'), 'GST only'), + ] + + for postal_code, province, expected_rate, note in test_cases: + with self.subTest(province=province, postal_code=postal_code): + rate = rules.get_vat_rate(item_type, postal_code) + self.assertEqual( + rate, + expected_rate, + f"{province} ({postal_code!r}) should be {expected_rate}% β€” {note}" + ) + + def test_b2b_no_exemption(self): + """Test that B2B transactions are charged the same rate as B2C.""" + test_cases = [ + ('V6B 4N6', Decimal('12'), 'British Columbia'), + ('S7K 1A1', Decimal('11'), 'Saskatchewan'), + ('K1A 0B1', Decimal('13'), 'Ontario'), + ('A1A 5T9', Decimal('15'), 'Newfoundland and Labrador'), + ('G1A 0A2', Decimal('5'), 'Quebec (not registered)'), + ('R2C 0A1', Decimal('5'), 'Manitoba (not registered)'), + (None, Decimal('13'), 'No postal code (Ontario fallback)'), + ] + + for postal_code, expected_rate, province in test_cases: + with self.subTest(province=province): + vat_charge = get_sale_vat_charge( + datetime.date(2024, 1, 1), + ItemType.generic_electronic_service, + Party(country_code='CA', is_business=True), + Party(country_code='FR', is_business=True), + postal_code=postal_code, + ) + self.assertEqual( + vat_charge.action, + VatChargeAction.charge, + f"{province}: B2B should still be charged VAT" + ) + self.assertEqual( + vat_charge.rate, + expected_rate, + f"{province}: B2B rate should match B2C rate ({expected_rate}%)" + ) diff --git a/tests/test_sale_vat_charge.py b/tests/test_sale_vat_charge.py index c5a71ec..a484ccb 100644 --- a/tests/test_sale_vat_charge.py +++ b/tests/test_sale_vat_charge.py @@ -11,7 +11,7 @@ DOM_COUNTRY_CODES, FRANCE_SAME_VAT_TERRITORY) try: from unittest2 import TestCase -except ImportError: +except (ImportError, AttributeError): from unittest import TestCase EXPECTED_VAT_RATES = { @@ -296,13 +296,14 @@ ItemType.enewspaper: Decimal('8.1'), }, 'CA': { - ItemType.generic_physical_good: Decimal(0), - ItemType.generic_electronic_service: Decimal(0), - ItemType.generic_telecommunications_service: Decimal(0), - ItemType.generic_broadcasting_service: Decimal(0), - ItemType.prepaid_broadcasting_service: Decimal(0), - ItemType.ebook: Decimal(0), - ItemType.enewspaper: Decimal(0), + # No postal code β†’ Ontario fallback (13%); no B2B exemption + ItemType.generic_physical_good: Decimal(13), + ItemType.generic_electronic_service: Decimal(13), + ItemType.generic_telecommunications_service: Decimal(13), + ItemType.generic_broadcasting_service: Decimal(13), + ItemType.prepaid_broadcasting_service: Decimal(13), + ItemType.ebook: Decimal(13), + ItemType.enewspaper: Decimal(13), }, 'NO': { ItemType.generic_physical_good: Decimal(25), diff --git a/tests/test_validators.py b/tests/test_validators.py index e0acb73..320dd3f 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -7,7 +7,7 @@ ) try: from unittest2 import TestCase -except ImportError: +except (ImportError, AttributeError): from unittest import TestCase VAT_NUMBER_FORMAT_CASES = { From acacc02b071dfda4aa75b5b64d47b21f6c479336 Mon Sep 17 00:00:00 2001 From: John Gathure Date: Tue, 14 Apr 2026 13:11:54 +0300 Subject: [PATCH 4/8] canadian regions info. --- pyvat/regions/__init__.py | 0 pyvat/regions/canada.py | 82 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 pyvat/regions/__init__.py create mode 100644 pyvat/regions/canada.py diff --git a/pyvat/regions/__init__.py b/pyvat/regions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyvat/regions/canada.py b/pyvat/regions/canada.py new file mode 100644 index 0000000..a8eed05 --- /dev/null +++ b/pyvat/regions/canada.py @@ -0,0 +1,82 @@ +"""Canadian provincial tax data indexed by postal code prefix. + +PROVINCE_RATES is a dict keyed by postal code prefix: + + { + 'M': ProvinceRate(prefix='M', province='Ontario', rate=Decimal('13'), tax_types=(HST,)), + 'V': ProvinceRate(prefix='V', province='British Columbia', rate=Decimal('12'), tax_types=(GST, PST)), + 'G': ProvinceRate(prefix='G', province='Quebec', rate=Decimal('5'), tax_types=(GST,)), + 'T': ProvinceRate(prefix='T', province='Alberta', rate=Decimal('5'), tax_types=(GST,)), + ... + } + +Lookup by postal code prefix: + + from pyvat.regions.canada import PROVINCE_RATES + + info = PROVINCE_RATES.get('M5V 3L9'[0].upper()) + # ProvinceRate(prefix='M', province='Ontario', rate=Decimal('13'), tax_types=('HST',)) +""" + +from decimal import Decimal +from typing import Dict, NamedTuple, Tuple + +from pyvat.vat_rules import CanadaVatRules + +# --------------------------------------------------------------------------- +# Tax type constants +# --------------------------------------------------------------------------- + +GST = "GST" +HST = "HST" +PST = "PST" +QST = "QST" +RST = "RST" + + +class ProvinceRate(NamedTuple): + prefix: str + province: str + rate: Decimal + tax_types: Tuple[str, ...] + + +_PROVINCE_META = ( + ("A", "Newfoundland and Labrador", (HST,)), + ("B", "Nova Scotia", (HST,)), + ("C", "Prince Edward Island", (HST,)), + ("E", "New Brunswick", (HST,)), + ("G", "Quebec", (GST,)), # not registered for QST + ("H", "Quebec", (GST,)), # not registered for QST + ("J", "Quebec", (GST,)), # not registered for QST + ("K", "Ontario", (HST,)), + ("L", "Ontario", (HST,)), + ("M", "Ontario", (HST,)), + ("N", "Ontario", (HST,)), + ("P", "Ontario", (HST,)), + ("R", "Manitoba", (GST,)), # not registered for RST + ("S", "Saskatchewan", (GST, PST)), + ("T", "Alberta", (GST,)), + ("V", "British Columbia", (GST, PST)), + ("X", "Northwest Territories and Nunavut", (GST,)), + ("Y", "Yukon", (GST,)), +) + +PROVINCE_RATES: Dict[str, ProvinceRate] = { + prefix: ProvinceRate( + prefix=prefix, + province=province, + rate=CanadaVatRules.PROVINCE_VAT_RATES[prefix], + tax_types=tax_types, + ) + for prefix, province, tax_types in _PROVINCE_META +} + + +def get_province_info(postal_code): + if not isinstance(postal_code, str) or len(postal_code.strip()) == 0: + raise ValueError("postal_code must be a non-empty string") + + postal_code_prefix = postal_code[0].upper() + info = PROVINCE_RATES.get(postal_code_prefix) + return info From 1264a7980754e33b417e658dcd5629fc7e045691 Mon Sep 17 00:00:00 2001 From: John Gathure Date: Tue, 14 Apr 2026 13:24:52 +0300 Subject: [PATCH 5/8] correctly find packages. --- setup.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 83ee5f2..ff779c3 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,10 @@ #!/usr/bin/env python -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +from setuptools import setup, find_packages with open("README.rst", "r") as fh: long_description = fh.read() -packages = [ - 'pyvat', -] +packages = find_packages() requires = [ 'requests>=1.0.0,<3.0', From 6d08df82aacc4534c71a7c08fc996cfb5daaad12 Mon Sep 17 00:00:00 2001 From: John Gathure Date: Tue, 14 Apr 2026 13:38:50 +0300 Subject: [PATCH 6/8] rework region rules. --- pyvat/region_rules.py | 130 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 pyvat/region_rules.py diff --git a/pyvat/region_rules.py b/pyvat/region_rules.py new file mode 100644 index 0000000..2bfb8ca --- /dev/null +++ b/pyvat/region_rules.py @@ -0,0 +1,130 @@ +from abc import ABC, abstractmethod +from decimal import Decimal +from typing import Dict, NamedTuple, Optional, Tuple + +from pyvat.vat_rules import CanadaVatRules + +# --------------------------------------------------------------------------- +# Base class +# --------------------------------------------------------------------------- + + +class RegionRules(ABC): + """Base class for country-specific region rules. + + Subclass this for each country that requires region-level tax logic + (e.g. provinces, states, cantons). Each subclass is responsible for + mapping a postal / zip code to a rate and a region descriptor. + """ + + @abstractmethod + def get_rate(self, postal_code: Optional[str] = None) -> Decimal: + """Return the applicable tax rate for the given postal code. + + Falls back to the country default when *postal_code* is absent or + unrecognised. + """ + + @abstractmethod + def get_region(self, postal_code: Optional[str] = None): + """Return the region descriptor for the given postal code, or ``None``.""" + + +# --------------------------------------------------------------------------- +# Canada +# --------------------------------------------------------------------------- + + +class CanadaRegionRules(RegionRules): + """Region-specific tax rules for Canada. + + The rate is determined by the first letter of the postal code. + Falls back to Ontario (13 %) when no postal code is provided. + """ + + # Tax type constants + GST = "GST" + HST = "HST" + PST = "PST" + QST = "QST" + RST = "RST" + + class ProvinceRate(NamedTuple): + prefix: str + province: str + rate: Decimal + tax_types: Tuple[str, ...] + + DEFAULT_RATE: Decimal = CanadaVatRules.DEFAULT_VAT_RATE + + # Populated after the class body β€” ProvinceRate and the tax-type constants + # are not accessible inside a class-body comprehension in Python 3. + PROVINCE_RATES: Dict[str, "CanadaRegionRules.ProvinceRate"] = {} + + def get_rate(self, postal_code: Optional[str] = None) -> Decimal: + """Return the combined tax rate for the given Canadian postal code. + + :param postal_code: Canadian postal code. Only the first character is + used to determine the province. Falls back to Ontario (13 %) when + absent or unrecognised. + :returns: Combined tax rate in percent (e.g. ``Decimal('13')``). + """ + region = self.get_region(postal_code) + return region.rate if region is not None else self.DEFAULT_RATE + + def get_region( + self, postal_code: Optional[str] = None + ) -> Optional["CanadaRegionRules.ProvinceRate"]: + """Return the province data for the given Canadian postal code. + + :param postal_code: Canadian postal code. Only the first character is + used to determine the province. + :returns: :class:`ProvinceRate` for the matching province, or ``None`` + when *postal_code* is absent or the prefix is unrecognised. + + The returned :class:`ProvinceRate` contains: + + - ``prefix`` β€” single uppercase letter identifying the province + (e.g. ``'M'`` for Ontario). + - ``province`` β€” full province or territory name + (e.g. ``'Ontario'``). + - ``rate`` β€” combined tax rate in percent as a + :class:`~decimal.Decimal` (e.g. ``Decimal('13')``). + - ``tax_types`` β€” tuple of applicable tax-type constants indicating + how the rate is composed (e.g. ``(HST,)`` for a single harmonised + levy or ``(GST, PST)`` for two separate components). + """ + if not postal_code: + return None + prefix = str(postal_code).strip().upper()[0] + return self.PROVINCE_RATES.get(prefix) + + +CanadaRegionRules.PROVINCE_RATES = { + prefix: CanadaRegionRules.ProvinceRate( + prefix=prefix, + province=province, + rate=CanadaVatRules.PROVINCE_VAT_RATES[prefix], + tax_types=tax_types, + ) + for prefix, province, tax_types in ( + ("A", "Newfoundland and Labrador", (CanadaRegionRules.HST,)), + ("B", "Nova Scotia", (CanadaRegionRules.HST,)), + ("C", "Prince Edward Island", (CanadaRegionRules.HST,)), + ("E", "New Brunswick", (CanadaRegionRules.HST,)), + ("G", "Quebec", (CanadaRegionRules.GST,)), # not registered for QST + ("H", "Quebec", (CanadaRegionRules.GST,)), # not registered for QST + ("J", "Quebec", (CanadaRegionRules.GST,)), # not registered for QST + ("K", "Ontario", (CanadaRegionRules.HST,)), + ("L", "Ontario", (CanadaRegionRules.HST,)), + ("M", "Ontario", (CanadaRegionRules.HST,)), + ("N", "Ontario", (CanadaRegionRules.HST,)), + ("P", "Ontario", (CanadaRegionRules.HST,)), + ("R", "Manitoba", (CanadaRegionRules.GST,)), # not registered for RST + ("S", "Saskatchewan", (CanadaRegionRules.GST, CanadaRegionRules.PST)), + ("T", "Alberta", (CanadaRegionRules.GST,)), + ("V", "British Columbia", (CanadaRegionRules.GST, CanadaRegionRules.PST)), + ("X", "Northwest Territories and Nunavut", (CanadaRegionRules.GST,)), + ("Y", "Yukon", (CanadaRegionRules.GST,)), + ) +} From b46fdc39c3b8dbdd401d56ca31a7e2189bb576b8 Mon Sep 17 00:00:00 2001 From: John Gathure Date: Tue, 14 Apr 2026 14:58:13 +0300 Subject: [PATCH 7/8] remove regions module, which has been refactored out to region_rules module. --- pyvat/regions/__init__.py | 0 pyvat/regions/canada.py | 82 --------------------------------------- 2 files changed, 82 deletions(-) delete mode 100644 pyvat/regions/__init__.py delete mode 100644 pyvat/regions/canada.py diff --git a/pyvat/regions/__init__.py b/pyvat/regions/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyvat/regions/canada.py b/pyvat/regions/canada.py deleted file mode 100644 index a8eed05..0000000 --- a/pyvat/regions/canada.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Canadian provincial tax data indexed by postal code prefix. - -PROVINCE_RATES is a dict keyed by postal code prefix: - - { - 'M': ProvinceRate(prefix='M', province='Ontario', rate=Decimal('13'), tax_types=(HST,)), - 'V': ProvinceRate(prefix='V', province='British Columbia', rate=Decimal('12'), tax_types=(GST, PST)), - 'G': ProvinceRate(prefix='G', province='Quebec', rate=Decimal('5'), tax_types=(GST,)), - 'T': ProvinceRate(prefix='T', province='Alberta', rate=Decimal('5'), tax_types=(GST,)), - ... - } - -Lookup by postal code prefix: - - from pyvat.regions.canada import PROVINCE_RATES - - info = PROVINCE_RATES.get('M5V 3L9'[0].upper()) - # ProvinceRate(prefix='M', province='Ontario', rate=Decimal('13'), tax_types=('HST',)) -""" - -from decimal import Decimal -from typing import Dict, NamedTuple, Tuple - -from pyvat.vat_rules import CanadaVatRules - -# --------------------------------------------------------------------------- -# Tax type constants -# --------------------------------------------------------------------------- - -GST = "GST" -HST = "HST" -PST = "PST" -QST = "QST" -RST = "RST" - - -class ProvinceRate(NamedTuple): - prefix: str - province: str - rate: Decimal - tax_types: Tuple[str, ...] - - -_PROVINCE_META = ( - ("A", "Newfoundland and Labrador", (HST,)), - ("B", "Nova Scotia", (HST,)), - ("C", "Prince Edward Island", (HST,)), - ("E", "New Brunswick", (HST,)), - ("G", "Quebec", (GST,)), # not registered for QST - ("H", "Quebec", (GST,)), # not registered for QST - ("J", "Quebec", (GST,)), # not registered for QST - ("K", "Ontario", (HST,)), - ("L", "Ontario", (HST,)), - ("M", "Ontario", (HST,)), - ("N", "Ontario", (HST,)), - ("P", "Ontario", (HST,)), - ("R", "Manitoba", (GST,)), # not registered for RST - ("S", "Saskatchewan", (GST, PST)), - ("T", "Alberta", (GST,)), - ("V", "British Columbia", (GST, PST)), - ("X", "Northwest Territories and Nunavut", (GST,)), - ("Y", "Yukon", (GST,)), -) - -PROVINCE_RATES: Dict[str, ProvinceRate] = { - prefix: ProvinceRate( - prefix=prefix, - province=province, - rate=CanadaVatRules.PROVINCE_VAT_RATES[prefix], - tax_types=tax_types, - ) - for prefix, province, tax_types in _PROVINCE_META -} - - -def get_province_info(postal_code): - if not isinstance(postal_code, str) or len(postal_code.strip()) == 0: - raise ValueError("postal_code must be a non-empty string") - - postal_code_prefix = postal_code[0].upper() - info = PROVINCE_RATES.get(postal_code_prefix) - return info From c6a1aac0ed6721e148d970e4be143756a69f6d84 Mon Sep 17 00:00:00 2001 From: John Gathure Date: Tue, 14 Apr 2026 17:08:15 +0300 Subject: [PATCH 8/8] add tests. --- tests/test_canada_region_rules.py | 171 ++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/test_canada_region_rules.py diff --git a/tests/test_canada_region_rules.py b/tests/test_canada_region_rules.py new file mode 100644 index 0000000..9b25789 --- /dev/null +++ b/tests/test_canada_region_rules.py @@ -0,0 +1,171 @@ +"""Tests for CanadaRegionRules.""" + +from decimal import Decimal + +try: + from unittest2 import TestCase +except (ImportError, AttributeError): + from unittest import TestCase + +from pyvat.region_rules import CanadaRegionRules + + +class CanadaRegionRulesGetRateTestCase(TestCase): + """Tests for CanadaRegionRules.get_rate().""" + + def setUp(self): + self.rules = CanadaRegionRules() + + def test_returns_ontario_fallback_when_no_postal_code(self): + self.assertEqual(self.rules.get_rate(None), Decimal('13')) + + def test_returns_ontario_fallback_for_empty_string(self): + self.assertEqual(self.rules.get_rate(''), Decimal('13')) + + def test_returns_ontario_fallback_for_unknown_prefix(self): + # 'Z' is not a valid Canadian postal prefix + self.assertEqual(self.rules.get_rate('Z1A 0A0'), Decimal('13')) + + def test_rate_by_province(self): + test_cases = [ + ('A1A 5T9', Decimal('15'), 'Newfoundland and Labrador (HST)'), + ('B3H 1Y2', Decimal('14'), 'Nova Scotia (HST)'), + ('C1A 4P3', Decimal('15'), 'Prince Edward Island (HST)'), + ('E2L 4H8', Decimal('15'), 'New Brunswick (HST)'), + ('G1A 0A2', Decimal('5'), 'Quebec (GST only β€” not registered for QST)'), + ('H1A 0A1', Decimal('5'), 'Quebec (GST only β€” not registered for QST)'), + ('J1A 1A1', Decimal('5'), 'Quebec (GST only β€” not registered for QST)'), + ('K1A 0B1', Decimal('13'), 'Ontario (HST)'), + ('L5B 4M7', Decimal('13'), 'Ontario (HST)'), + ('M5V 3L9', Decimal('13'), 'Ontario (HST)'), + ('N2L 3G1', Decimal('13'), 'Ontario (HST)'), + ('P7B 5E1', Decimal('13'), 'Ontario (HST)'), + ('R2C 0A1', Decimal('5'), 'Manitoba (GST only β€” not registered for RST)'), + ('S7K 1A1', Decimal('11'), 'Saskatchewan (GST + PST)'), + ('T5J 0N3', Decimal('5'), 'Alberta (GST only)'), + ('V6B 4N6', Decimal('12'), 'British Columbia (GST + PST)'), + ('X0A 0H0', Decimal('5'), 'Northwest Territories and Nunavut (GST only)'), + ('Y1A 0A1', Decimal('5'), 'Yukon (GST only)'), + ] + + for postal_code, expected_rate, description in test_cases: + with self.subTest(postal_code=postal_code, description=description): + self.assertEqual(self.rules.get_rate(postal_code), expected_rate) + + def test_lowercase_postal_code_is_normalised(self): + self.assertEqual(self.rules.get_rate('m5v 3l9'), Decimal('13')) + + def test_postal_code_with_leading_whitespace_is_normalised(self): + self.assertEqual(self.rules.get_rate(' M5V 3L9'), Decimal('13')) + + def test_prefix_only_is_accepted(self): + self.assertEqual(self.rules.get_rate('V'), Decimal('12')) + + +class CanadaRegionRulesGetRegionTestCase(TestCase): + """Tests for CanadaRegionRules.get_region().""" + + def setUp(self): + self.rules = CanadaRegionRules() + + def test_returns_none_when_no_postal_code(self): + self.assertIsNone(self.rules.get_region(None)) + + def test_returns_none_for_empty_string(self): + self.assertIsNone(self.rules.get_region('')) + + def test_returns_none_for_unknown_prefix(self): + self.assertIsNone(self.rules.get_region('Z1A 0A0')) + + def test_province_names(self): + test_cases = [ + ('A', 'Newfoundland and Labrador'), + ('B', 'Nova Scotia'), + ('C', 'Prince Edward Island'), + ('E', 'New Brunswick'), + ('G', 'Quebec'), + ('H', 'Quebec'), + ('J', 'Quebec'), + ('K', 'Ontario'), + ('L', 'Ontario'), + ('M', 'Ontario'), + ('N', 'Ontario'), + ('P', 'Ontario'), + ('R', 'Manitoba'), + ('S', 'Saskatchewan'), + ('T', 'Alberta'), + ('V', 'British Columbia'), + ('X', 'Northwest Territories and Nunavut'), + ('Y', 'Yukon'), + ] + + for prefix, expected_province in test_cases: + with self.subTest(prefix=prefix): + region = self.rules.get_region(prefix) + self.assertEqual(region.province, expected_province) + + def test_prefix_field_matches_lookup_key(self): + region = self.rules.get_region('M5V 3L9') + self.assertEqual(region.prefix, 'M') + + def test_rate_field_matches_get_rate(self): + for postal_code in ('A', 'B', 'C', 'E', 'G', 'K', 'R', 'S', 'T', 'V', 'X', 'Y'): + with self.subTest(postal_code=postal_code): + region = self.rules.get_region(postal_code) + self.assertEqual(region.rate, self.rules.get_rate(postal_code)) + + def test_lowercase_postal_code_is_normalised(self): + region = self.rules.get_region('v6b 4n6') + self.assertIsNotNone(region) + self.assertEqual(region.province, 'British Columbia') + + +class CanadaRegionRulesTaxTypesTestCase(TestCase): + """Tests for tax_types on ProvinceRate.""" + + def setUp(self): + self.rules = CanadaRegionRules() + + def test_hst_provinces_have_single_hst_tax_type(self): + hst_prefixes = ('A', 'B', 'C', 'E', 'K', 'L', 'M', 'N', 'P') + + for prefix in hst_prefixes: + with self.subTest(prefix=prefix): + region = self.rules.get_region(prefix) + self.assertEqual(region.tax_types, (CanadaRegionRules.HST,)) + + def test_gst_pst_provinces_have_two_tax_types(self): + for prefix in ('S', 'V'): + with self.subTest(prefix=prefix): + region = self.rules.get_region(prefix) + self.assertIn(CanadaRegionRules.GST, region.tax_types) + self.assertIn(CanadaRegionRules.PST, region.tax_types) + + def test_unregistered_and_gst_only_provinces_have_single_gst_tax_type(self): + # Quebec (QST), Manitoba (RST), Alberta, NWT/Nunavut, Yukon + gst_only_prefixes = ('G', 'H', 'J', 'R', 'T', 'X', 'Y') + + for prefix in gst_only_prefixes: + with self.subTest(prefix=prefix): + region = self.rules.get_region(prefix) + self.assertEqual(region.tax_types, (CanadaRegionRules.GST,)) + + +class CanadaRegionRulesConstantsTestCase(TestCase): + """Tests for tax-type constants on CanadaRegionRules.""" + + def test_constant_values(self): + self.assertEqual(CanadaRegionRules.GST, 'GST') + self.assertEqual(CanadaRegionRules.HST, 'HST') + self.assertEqual(CanadaRegionRules.PST, 'PST') + self.assertEqual(CanadaRegionRules.QST, 'QST') + self.assertEqual(CanadaRegionRules.RST, 'RST') + + def test_default_rate_is_ontario(self): + self.assertEqual(CanadaRegionRules.DEFAULT_RATE, Decimal('13')) + + def test_all_prefixes_are_covered(self): + # Valid Canada Post first-letter prefixes (D, F, I, O, Q, U, W, Z are not assigned) + expected_prefixes = {'A', 'B', 'C', 'E', 'G', 'H', 'J', 'K', 'L', 'M', + 'N', 'P', 'R', 'S', 'T', 'V', 'X', 'Y'} + self.assertEqual(set(CanadaRegionRules.PROVINCE_RATES.keys()), expected_prefixes)