Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
/*egg*
/docs/_build
.DS_Store
.venv
91 changes: 85 additions & 6 deletions pyvat/vat_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,20 +624,99 @@ def __init__(self):
super(ChVatRules, self).__init__(Decimal('8.1'))


class CanadaVatRules(NonEuVatRules):
"""VAT rules for Canada."""
class NorwayVatRules(NonEuVatRules):
"""VAT rules for Norway."""

def __init__(self):
super(CanadaVatRules, self).__init__(0)
super(NorwayVatRules, self).__init__(25)


class NorwayVatRules(NonEuVatRules):
"""VAT rules for Norway."""
class CanadaVatRules(NonEuVatRules):
"""VAT rules for Canada.

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 14%)
- C: Prince Edward Island (HST 15%)
- E: New Brunswick (HST 15%)
- G, H, J: Quebec (not registered → GST 5%)
- K, L, M, N, P: Ontario (HST 13%)
- 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%, 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.
"""

# 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 (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(NorwayVatRules, self).__init__(25)
super(CanadaVatRules, self).__init__(self.DEFAULT_VAT_RATE)

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:
# 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 self.DEFAULT_VAT_RATE

prefix = postal_code_str[0]

# 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 = {
Expand Down
10 changes: 5 additions & 5 deletions tests/test_france_seller_digital_goods_vat_rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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"),
]
Expand Down
78 changes: 75 additions & 3 deletions tests/test_new_countries.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

try:
from unittest2 import TestCase
except (ImportError):
except (ImportError, AttributeError):
from unittest import TestCase


Expand All @@ -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),
Expand Down Expand Up @@ -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'),
]

Expand Down Expand Up @@ -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}%)"
)
17 changes: 9 additions & 8 deletions tests/test_sale_vat_charge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
)
try:
from unittest2 import TestCase
except ImportError:
except (ImportError, AttributeError):
from unittest import TestCase

VAT_NUMBER_FORMAT_CASES = {
Expand Down
Loading