Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
130 changes: 130 additions & 0 deletions pyvat/region_rules.py
Original file line number Diff line number Diff line change
@@ -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,)),
)
}
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
9 changes: 2 additions & 7 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
Loading
Loading