Files
pretix_original/src/pretix/base/models/tax.py

469 lines
17 KiB
Python

#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
from decimal import Decimal
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.deconstruct import deconstructible
from django.utils.formats import localize
from django.utils.functional import lazy
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.fields import I18nCharField
from i18nfield.strings import LazyI18nString
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from pretix.base.templatetags.money import money_filter
from pretix.helpers.countries import FastCountryField
class TaxedPrice:
def __init__(self, *, gross: Decimal, net: Decimal, tax: Decimal, rate: Decimal, name: str):
if net + tax != gross:
raise ValueError('Net value and tax value need to add to the gross value')
self.gross = gross
self.net = net
self.tax = tax
self.rate = rate
self.name = name
def __repr__(self):
return '{} + {}% = {}'.format(localize(self.net), localize(self.rate), localize(self.gross))
def print(self, currency):
return '{} + {}% = {}'.format(
money_filter(self.net, currency),
localize(self.rate),
money_filter(self.gross, currency)
)
def __sub__(self, other):
newgross = self.gross - other.gross
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
Decimal('10') ** self.gross.as_tuple().exponent
)
return TaxedPrice(
gross=newgross,
net=newnet,
tax=newgross - newnet,
rate=self.rate,
name=self.name,
)
def __mul__(self, other):
newgross = self.gross * other
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
Decimal('10') ** self.gross.as_tuple().exponent
)
return TaxedPrice(
gross=newgross,
net=newnet,
tax=newgross - newnet,
rate=self.rate,
name=self.name,
)
def __eq__(self, other):
return (
self.gross == other.gross and
self.net == other.net and
self.tax == other.tax and
self.rate == other.rate and
self.name == other.name
)
TAXED_ZERO = TaxedPrice(
gross=Decimal('0.00'),
net=Decimal('0.00'),
tax=Decimal('0.00'),
rate=Decimal('0.00'),
name=''
)
EU_COUNTRIES = {
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT',
'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
}
EU_CURRENCIES = {
'BG': 'BGN',
'GB': 'GBP',
'HR': 'HRK',
'CZ': 'CZK',
'DK': 'DKK',
'HU': 'HUF',
'PL': 'PLN',
'RO': 'RON',
'SE': 'SEK'
}
VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH', 'NO'}
format_html_lazy = lazy(format_html, str)
def is_eu_country(cc):
cc = str(cc)
return cc in EU_COUNTRIES
def ask_for_vat_id(cc):
cc = str(cc)
return cc in VAT_ID_COUNTRIES
def cc_to_vat_prefix(country_code):
country_code = str(country_code)
if country_code == 'GR':
return 'EL'
return country_code
@deconstructible
class CustomRulesValidator:
def __call__(self, value):
if not isinstance(value, dict):
try:
val = json.loads(value)
except ValueError:
raise ValidationError(_('Your layout file is not a valid JSON file.'))
else:
val = value
with open(finders.find('schema/tax-rules-custom.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
e = str(e).replace('%', '%%')
raise ValidationError(_('Your set of rules is not valid. Error message: {}').format(e))
class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
internal_name = models.CharField(
verbose_name=_('Internal name'),
max_length=190,
null=True, blank=True,
)
name = I18nCharField(
verbose_name=_('Official name'),
help_text=_('Should be short, e.g. "VAT"'),
max_length=190,
)
rate = models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[
MaxValueValidator(
limit_value=Decimal("100.00"),
),
MinValueValidator(
limit_value=Decimal("0.00"),
),
],
verbose_name=_("Tax rate"),
)
price_includes_tax = models.BooleanField(
verbose_name=_("The configured product prices include the tax amount"),
default=True,
)
keep_gross_if_rate_changes = models.BooleanField(
verbose_name=_("Keep gross amount constant if the tax rate changes based on the invoice address"),
default=False,
)
eu_reverse_charge = models.BooleanField(
verbose_name=_("Use EU reverse charge taxation rules"),
default=False,
help_text=format_html_lazy(
'<span class="label label-warning" data-toggle="tooltip" title="{}">{}</span> {}',
_('This feature will be removed in the future as it does not handle VAT for non-business customers in '
'other EU countries in a way that works for all organizers. Use custom rules instead.'),
_('DEPRECATED'),
_("Not recommended. Most events will NOT be qualified for reverse charge since the place of "
"taxation is the location of the event. This option disables charging VAT for all customers "
"outside the EU and for business customers in different EU countries who entered a valid EU VAT "
"ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax "
"calculation. USE AT YOUR OWN RISK.")
),
)
home_country = FastCountryField(
verbose_name=_('Merchant country'),
blank=True,
help_text=_('Your country of residence. This is the country the EU reverse charge rule will not apply in, '
'if configured above.'),
)
custom_rules = models.TextField(blank=True, null=True)
class Meta:
ordering = ('event', 'rate', 'id')
class SaleNotAllowed(Exception):
pass
def allow_delete(self):
from pretix.base.models.orders import (
OrderFee, OrderPosition, Transaction,
)
return (
not Transaction.objects.filter(tax_rule=self, order__event=self.event).exists()
and not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
and not self.event.items.filter(tax_rule=self).exists()
and self.event.settings.tax_rate_default != self
)
@classmethod
def zero(cls):
return cls(
event=None,
name='',
rate=Decimal('0.00'),
price_includes_tax=True,
eu_reverse_charge=False
)
def clean(self):
if self.eu_reverse_charge and not self.home_country:
raise ValidationError(_('You need to set your home country to use the reverse charge feature.'))
def __str__(self):
if self.price_includes_tax:
s = _('incl. {rate}% {name}').format(rate=self.rate, name=self.name)
else:
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
if self.eu_reverse_charge:
s += ' ({})'.format(_('reverse charge enabled'))
if self.internal_name:
return f'{self.internal_name} ({s})'
return str(s)
@property
def has_custom_rules(self):
return self.custom_rules and self.custom_rules != '[]'
def tax_rate_for(self, invoice_address):
if not self._tax_applicable(invoice_address):
return Decimal('0.00')
if self.has_custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
return Decimal(rule.get('rate'))
return Decimal(self.rate)
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, force_fixed_gross_price=False):
from .event import Event
try:
currency = currency or self.event.currency
except Event.DoesNotExist:
pass
rate = Decimal(self.rate)
if override_tax_rate is not None:
rate = override_tax_rate
elif invoice_address:
adjust_rate = self.tax_rate_for(invoice_address)
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price or self.keep_gross_if_rate_changes) and base_price_is == 'gross':
rate = adjust_rate
elif adjust_rate != rate:
if self.keep_gross_if_rate_changes:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.gross
base_price_is = 'gross'
subtract_from_gross = Decimal('0.00')
else:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.net
base_price_is = 'net'
subtract_from_gross = Decimal('0.00')
rate = adjust_rate
def _limit_subtract(base_price, subtract_from_gross):
if not subtract_from_gross:
return base_price
if base_price >= Decimal('0.00'):
# For positive prices, make sure they don't go negative because of bundles
return max(Decimal('0.00'), base_price - subtract_from_gross)
else:
# If the price is already negative, we don't really care any more
return base_price - subtract_from_gross
if rate == Decimal('0.00'):
gross = _limit_subtract(base_price, subtract_from_gross)
return TaxedPrice(
net=gross,
gross=gross,
tax=Decimal('0.00'),
rate=rate,
name=self.name,
)
if base_price_is == 'auto':
if self.price_includes_tax:
base_price_is = 'gross'
else:
base_price_is = 'net'
if base_price_is == 'gross':
gross = _limit_subtract(base_price, subtract_from_gross)
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency)
elif base_price_is == 'net':
net = base_price
gross = round_decimal((net * (1 + rate / 100)), currency)
if subtract_from_gross:
gross = _limit_subtract(gross, subtract_from_gross)
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency)
else:
raise ValueError('Unknown base price type: {}'.format(base_price_is))
return TaxedPrice(
net=net, gross=gross, tax=gross - net,
rate=rate, name=self.name
)
@property
def _custom_rules(self):
if not self.custom_rules:
return []
return json.loads(self.custom_rules)
def get_matching_rule(self, invoice_address):
rules = self._custom_rules
if invoice_address:
for r in rules:
if r['country'] == 'ZZ': # Rule: Any country
pass
elif r['country'] == 'EU': # Rule: Any EU country
if not is_eu_country(invoice_address.country):
continue
elif '-' in r['country']: # Rule: Specific country and state
if r['country'] != str(invoice_address.country) + '-' + str(invoice_address.state):
continue
else: # Rule: Specific country
if r['country'] != str(invoice_address.country):
continue
if r['address_type'] == 'individual' and invoice_address.is_business:
continue
if r['address_type'] in ('business', 'business_vat_id') and not invoice_address.is_business:
continue
if r['address_type'] == 'business_vat_id' and (not invoice_address.vat_id or not invoice_address.vat_id_validated):
continue
return r
return {'action': 'vat'}
def invoice_text(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
t = rule.get('invoice_text', {})
if t and any(l for l in t.values()):
return str(LazyI18nString(t))
if self.is_reverse_charge(invoice_address):
if is_eu_country(invoice_address.country):
return pgettext(
"invoice",
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
"rests with the service recipient."
)
else:
return pgettext(
"invoice",
"VAT liability rests with the service recipient."
)
def is_reverse_charge(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule['action'] == 'reverse'
if not self.eu_reverse_charge:
return False
if not invoice_address or not invoice_address.country:
return False
if not is_eu_country(invoice_address.country):
return False
if invoice_address.country == self.home_country:
return False
if invoice_address.is_business and invoice_address.vat_id and invoice_address.vat_id_validated:
return True
return False
def _require_approval(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'require_approval':
return True
return False
def _tax_applicable(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
return rule.get('action', 'vat') in ('vat', 'require_approval')
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!
return True
if not invoice_address or not invoice_address.country:
# No country specified? Always apply VAT!
return True
if not is_eu_country(invoice_address.country):
# Non-EU country? Never apply VAT!
return False
if invoice_address.country == self.home_country:
# Within same EU country? Always apply VAT!
return True
if invoice_address.is_business and invoice_address.vat_id and invoice_address.vat_id_validated:
# Reverse charge case
return False
# Consumer in different EU country / invalid VAT
return True
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.event:
self.event.cache.clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event:
self.event.cache.clear()