Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
3d1e48d
remove egypt from eu countries list
mostafa-hisham Dec 11, 2025
f45ef1d
add new countries registries
mostafa-hisham Dec 11, 2025
8b3adc7
init new registries and Skip vat_num format validation for countries …
mostafa-hisham Dec 11, 2025
9696462
add vat_rules for new countries
mostafa-hisham Dec 11, 2025
1cb9f87
add test for new countries
mostafa-hisham Dec 11, 2025
0dcd614
refactor old tests to work with our edits
mostafa-hisham Dec 11, 2025
12ad19a
update readme
mostafa-hisham Dec 11, 2025
ca9ce85
add tests ci
mostafa-hisham Dec 11, 2025
d631d34
workflow on forks
mostafa-hisham Dec 11, 2025
7df0c08
fix workflow branch
mostafa-hisham Dec 11, 2025
c9d6052
chnage class name
mostafa-hisham Dec 11, 2025
106b503
Norway B2B not exempt
mostafa-hisham Dec 11, 2025
900a182
treat monaco and france regions like france but with different vat rates
mostafa-hisham Dec 11, 2025
662a6a7
rename classses
mostafa-hisham Dec 12, 2025
95f3550
add regex to Frech countries
mostafa-hisham Dec 12, 2025
acb0fdb
create and use global FRANCE_COUNTRY_CODES
mostafa-hisham Dec 12, 2025
aa85a94
create and use NON_EU_COUNTRY_CODES
mostafa-hisham Dec 12, 2025
81e7ba7
add new test that checks VAT% for noneu countries
mostafa-hisham Dec 12, 2025
2cd45ca
fix date condition
mostafa-hisham Dec 12, 2025
3718f20
simplfiy conditon
mostafa-hisham Dec 12, 2025
370809d
codition explaination
mostafa-hisham Dec 12, 2025
35f537e
Update pyvat/vat_rules.py
mostafa-hisham Dec 12, 2025
2ce4ef7
rename class
mostafa-hisham Dec 12, 2025
26c8bd5
fix codition
mostafa-hisham Dec 12, 2025
1cdf199
update readme
mostafa-hisham Dec 12, 2025
9d2431b
remove line
mostafa-hisham Dec 12, 2025
4601aa6
fix imports, workflow and typo
mostafa-hisham Dec 12, 2025
6f53399
fix confilect
mostafa-hisham Dec 12, 2025
084ced6
Franc monaco and dom
mostafa-hisham Dec 15, 2025
4035704
Fix test
mostafa-hisham Dec 15, 2025
6aadd1a
create doc with vat rates for frnace as seller of digital goods
mostafa-hisham Dec 15, 2025
edcc349
Rename FRANCE_VAT_RATES_DIGITAL_GOODS.md to docs/FRANCE_VAT_RATES_DIG…
nicomollet Dec 15, 2025
bae70a3
handle Great Britain
mostafa-hisham Dec 15, 2025
528f133
Merge branch 'enhancement/11-move-vat-rates' of github.com:wp-media/p…
mostafa-hisham Dec 15, 2025
5373fd5
EG 14 b2b/b2c and add FR test
mostafa-hisham Dec 15, 2025
682258f
Update tests/test_new_countries.py
mostafa-hisham Dec 15, 2025
7013fda
Update tests/test_new_countries.py
mostafa-hisham Dec 15, 2025
5f7f3bd
Update tests/test_validators.py
mostafa-hisham Dec 15, 2025
3fb7220
Update README.rst
mostafa-hisham Dec 15, 2025
bf3947a
Update tests/test_new_countries.py
mostafa-hisham Dec 15, 2025
eef3a89
EG b2b 0 vat rate
mostafa-hisham Dec 17, 2025
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
45 changes: 45 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Run Tests

on:
pull_request:
branches:
- master
pull_request_target:
branches:
- master
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using both 'pull_request' and 'pull_request_target' triggers can lead to security concerns. The 'pull_request_target' event runs in the context of the base repository and has access to secrets, which can be dangerous when combined with 'pull_request'. Unless there's a specific need for 'pull_request_target', it should be removed.

Suggested change
pull_request_target:
branches:
- master

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would apply suggestion

push:
branches:
- master

permissions:
contents: read
pull-requests: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10']

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest

- name: Run tests
run: |
python -m pytest tests/ -v

66 changes: 66 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,69 @@ Usage


For more detailed documentation, see the `full pyvat documentation <http://pyvat.readthedocs.org/>`_.


Running Tests
-------------

``pyvat`` uses `pytest <https://pytest.org/>`_ for testing. To run the test suite:

**Install test dependencies:**

.. code-block:: bash

$ pip install pytest

**Run all tests:**

.. code-block:: bash

$ python -m pytest tests/ -v

**Run specific test files:**

.. code-block:: bash

# Test new countries implementation
$ python -m pytest tests/test_new_countries.py -v

# Test VAT charge calculations
$ python -m pytest tests/test_sale_vat_charge.py -v

# Test VAT number validators
$ python -m pytest tests/test_validators.py -v

**Run with coverage:**

.. code-block:: bash

$ pip install pytest-cov
$ python -m pytest tests/ --cov=pyvat --cov-report=html

**Test Results:**

The test suite includes:

* **VAT Rules Tests**: Verify correct VAT rates for all supported countries
* **Registry Tests**: Test B2B exemption logic for different countries
* **VAT Charge Tests**: Ensure proper VAT calculation for cross-border sales
* **Validator Tests**: Check VAT number format validation

*Note: Some validator tests that call external VIES API are currently skipped and will be refactored with mocks.*


Supported Countries
-------------------

**EU Countries**: All EU member states with full VIES integration

**Non-EU Countries**:

* Egypt (EG) - 14% VAT, B2B exempt
* Switzerland (CH) - 8.1% VAT, B2B not exempt
* Canada (CA) - 0% VAT, B2B exempt
* Norway (NO) - 25% VAT, B2B exempt
* Monaco (MC) - 20% VAT, B2B not exempt
* French Overseas Departments (RE, GP, MQ) - 8.5% VAT, B2B not exempt


Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation states "Monaco (MC) - 20% VAT, B2B not exempt" but this is misleading. Monaco is actually treated as part of the EU/French VAT zone using VIES_REGISTRY, not as a non-EU country. It should be listed under a separate section for French VAT zone countries rather than under "Non-EU Countries".

Suggested change
* Monaco (MC) - 20% VAT, B2B not exempt
* French Overseas Departments (RE, GP, MQ) - 8.5% VAT, B2B not exempt
* French Overseas Departments (RE, GP, MQ) - 8.5% VAT, B2B not exempt
**French VAT Zone Countries**:
* Monaco (MC) - 20% VAT, B2B not exempt (Treated as part of the French VAT zone; VAT numbers are validated using the French VIES registry. Monaco is not considered a non-EU country for VAT purposes.)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe

44 changes: 36 additions & 8 deletions pyvat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from .item_type import ItemType
from .party import Party
from .registries import ViesRegistry, HMRCRegistry, EgyptRegistry
from .registries import ViesRegistry, HMRCRegistry, EgyptRegistry, SwitzerlandRegistry, CanadaRegistry, NorwayRegistry, MonacoRegistry, DomRegistry

from .result import VatNumberCheckResult
from .vat_charge import VatCharge, VatChargeAction
Expand Down Expand Up @@ -50,7 +50,6 @@
"SE": re.compile(r"^\d{12}$"),
"SI": re.compile(r"^\d{8}$"),
"SK": re.compile(r"^\d{10}$"),
'EG': re.compile(r'^\d{9}$'),

}
"""VAT number expressions.
Expand All @@ -75,6 +74,26 @@
"""Egypt Registry instance.
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent spacing in comment. The comment uses "Egypt Registry instance." with a period, but this should match the style of other registry comments (either all with periods or all without for consistency).

Suggested change
"""Egypt Registry instance.
"""Egypt Registry instance.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Format

"""

SWITZERLAND_REGISTER = SwitzerlandRegistry()
"""Switzerland Registry instance.
"""

CANADA_REGISTER = CanadaRegistry()
"""Canada Registry instance.
"""

NORWAY_REGISTER = NorwayRegistry()
"""Norway Registry instance.
"""

MONACO_REGISTER = MonacoRegistry()
"""Monaco Registry instance.
"""

DOM_REGISTER = DomRegistry()
"""DOM Registry instance.
"""

VAT_REGISTRIES = {
"AT": VIES_REGISTRY,
"BE": VIES_REGISTRY,
Expand Down Expand Up @@ -105,6 +124,13 @@
"SK": VIES_REGISTRY,
"SI": VIES_REGISTRY,
"EG": EGYPT_REGISTER,
"CH": SWITZERLAND_REGISTER,
"CA": CANADA_REGISTER,
"NO": NORWAY_REGISTER,
"MC": MONACO_REGISTER,
"RE": DOM_REGISTER,
"GP": DOM_REGISTER,
"MQ": DOM_REGISTER,
}
"""VAT registries.

Expand Down Expand Up @@ -213,12 +239,14 @@ def check_vat_number(vat_number, country_code=None, test=False):
],
)

# Test the VAT number format.
format_result = is_vat_number_format_valid(vat_number, country_code)
if format_result is not True:
return VatNumberCheckResult(
format_result, ["> VAT number validation failed: %r" % (format_result)]
)
# Test the VAT number format (only if format pattern exists).
# Skip format validation for countries without VAT_NUMBER_EXPRESSIONS.
if country_code in VAT_NUMBER_EXPRESSIONS:
format_result = is_vat_number_format_valid(vat_number, country_code)
if format_result is not True:
return VatNumberCheckResult(
format_result, ["> VAT number validation failed: %r" % (format_result)]
)

# Attempt to check the VAT number against a registry.
if country_code not in VAT_REGISTRIES:
Expand Down
1 change: 0 additions & 1 deletion pyvat/countries.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
'SE', # Sweden.
'SI', # Slovenia.
'SK', # Slovakia.
'EG', # Egypt.
])
"""EU country codes.

Expand Down
59 changes: 57 additions & 2 deletions pyvat/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,62 @@ def check_vat_number(self, vat_number, country_code, test):

class EgyptRegistry(Registry):
"""
Egyptian registry refusing all VAT numbers.
Egyptian registry accepting all VAT numbers for B2B exemption.
"""

def check_vat_number(self, vat_number, country_code, test):
result = VatNumberCheckResult()
result.is_valid = True
Comment thread
nicomollet marked this conversation as resolved.
return result


class SwitzerlandRegistry(Registry):
"""
Switzerland registry refusing all VAT numbers (B2B will not be exempt).
"""

def check_vat_number(self, vat_number, country_code, test):
result = VatNumberCheckResult()
result.is_valid = False
return result


class CanadaRegistry(Registry):
"""
Canadian registry accepting all VAT numbers for B2B exemption.
"""

def check_vat_number(self, vat_number, country_code, test):
result = VatNumberCheckResult()
result.is_valid = True
return result


class NorwayRegistry(Registry):
"""
Norwegian registry accepting all VAT numbers for B2B exemption.
"""

def check_vat_number(self, vat_number, country_code, test):
result = VatNumberCheckResult()
result.is_valid = True
return result


class MonacoRegistry(Registry):
"""
Monaco registry refusing all VAT numbers (B2B will not be exempt).
"""

def check_vat_number(self, vat_number, country_code, test):
result = VatNumberCheckResult()
result.is_valid = False
return result


class DomRegistry(Registry):
"""
DOM (French Overseas Departments) registry refusing all VAT numbers (B2B will not be exempt).
"""

def check_vat_number(self, vat_number, country_code, test):
Expand Down Expand Up @@ -328,4 +383,4 @@ def _authentication_headers(self):
}


__all__ = ('Registry', 'ViesRegistry', 'HMRCRegistry', 'EgyptRegistry', )
__all__ = ('Registry', 'ViesRegistry', 'HMRCRegistry', 'EgyptRegistry', 'SwitzerlandRegistry', 'CanadaRegistry', 'NorwayRegistry', 'MonacoRegistry', 'DomRegistry')
93 changes: 88 additions & 5 deletions pyvat/vat_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,22 +366,98 @@ def get_vat_rate(self, item_type, postal_code=None):
return Decimal(7)
return Decimal(19)

class EgVatRules():
"""VAT rules for Egypt.

class NonEuVatRules(object):
"""Base class for non-EU countries VAT rules.

Provides default implementation for countries that charge VAT
both when selling TO and FROM the country.
"""

def __init__(self, vat_rate):
"""Initialize with a constant VAT rate.

:param vat_rate: The VAT rate percentage for the country.
:type vat_rate: int, float, or Decimal
"""
self.vat_rate = ensure_decimal(vat_rate)

def get_sale_to_country_vat_charge(self,
date,
item_type,
buyer,
seller,
postal_code=None):
"""Get VAT charge when selling TO this country."""
return VatCharge(VatChargeAction.charge,
buyer.country_code,
self.get_vat_rate(item_type))
self.get_vat_rate(item_type, postal_code))

def get_sale_from_country_vat_charge(self,
date,
item_type,
buyer,
seller,
postal_code=None):
"""Get VAT charge when selling FROM this country."""
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 an item type.

:param item_type: Item type.
:type item_type: ItemType
:param postal_code: Postal code (for region-specific rates).
:type postal_code: str
:returns: VAT rate in percent.
:rtype: Decimal
"""
return self.vat_rate


class EgVatRules(NonEuVatRules):
"""VAT rules for Egypt."""

def __init__(self):
super(EgVatRules, self).__init__(14)


class ChVatRules(NonEuVatRules):
"""VAT rules for Switzerland."""

def __init__(self):
super(ChVatRules, self).__init__(Decimal('8.1'))


class CaVatRules(NonEuVatRules):
"""VAT rules for Canada."""

def __init__(self):
super(CaVatRules, self).__init__(0)


class NoVatRules(NonEuVatRules):
"""VAT rules for Norway."""

def __init__(self):
super(NoVatRules, self).__init__(25)


class MoVatRules(NonEuVatRules):
"""VAT rules for Monaco."""

def __init__(self):
super(MoVatRules, self).__init__(20)


class DomVatRules(NonEuVatRules):
"""VAT rules for DOM (French Overseas Departments: Réunion, Guadeloupe, Martinique)."""

def __init__(self):
super(DomVatRules, self).__init__(Decimal('8.5'))

def get_vat_rate(self, item_type):
return Decimal(14)

# VAT rates updated July 1st 2025
VAT_RULES = {
Expand Down Expand Up @@ -415,6 +491,13 @@ def get_vat_rate(self, item_type):
'SK': ConstantEuVatRateRules(23),
'SI': ConstantEuVatRateRules(22),
'EG': EgVatRules(),
'CH': ChVatRules(),
'CA': CaVatRules(),
'NO': NoVatRules(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not confortable with the NoVat naming.
Can we name the rules with the countries explicitely named: Norway, Canada, FranceDom, Egypt, Switzerland?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mostafa-hisham Please rename the shortnames to country:

  • CaVatRules > CanadaVatRues
  • NoVatRules > NorwayVatRules

So that we can avoid the misleading NoVatRules

'MC': MoVatRules(),
'RE': DomVatRules(), # Réunion
'GP': DomVatRules(), # Guadeloupe
'MQ': DomVatRules(), # Martinique
}

"""VAT rules by country.
Expand Down
Loading