Add support for custom taxation rules

This commit is contained in:
Raphael Michel
2018-02-28 23:03:25 +01:00
parent d8d00a7e26
commit 578c1ecfaf
8 changed files with 644 additions and 4 deletions

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.10 on 2018-02-28 21:02
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0082_auto_20180222_0938'),
]
operations = [
migrations.AddField(
model_name='taxrule',
name='custom_rules',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='orderfee',
name='fee_type',
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('other', 'Other fees')], max_length=100),
),
]

View File

@@ -1,3 +1,4 @@
import json
from decimal import Decimal
from django.db import models
@@ -88,6 +89,7 @@ class TaxRule(LoggedModel):
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)
def allow_delete(self):
from pretix.base.models.orders import OrderFee, OrderPosition
@@ -151,7 +153,27 @@ class TaxRule(LoggedModel):
rate=self.rate, name=self.name
)
def get_matching_rule(self, invoice_address):
rules = json.loads(self.custom_rules)
for r in rules:
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
continue
if r['country'] not in ('ZZ', 'EU') and 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 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
@@ -170,6 +192,10 @@ class TaxRule(LoggedModel):
return False
def tax_applicable(self, invoice_address):
if self.custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule.get('action', 'vat') == 'vat'
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!
return True

View File

@@ -4,8 +4,11 @@ from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db.models import Q
from django.forms import formset_factory
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries import Countries
from django_countries.fields import LazyTypedChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from pytz import common_timezones, timezone
@@ -907,6 +910,43 @@ class CommentForm(I18nModelForm):
}
class CountriesAndEU(Countries):
override = {
'ZZ': _('Any country'),
'EU': _('European Union')
}
first = ['ZZ', 'EU']
class TaxRuleLineForm(forms.Form):
country = LazyTypedChoiceField(
choices=CountriesAndEU(),
required=False
)
address_type = forms.ChoiceField(
choices=[
('', _('Any customer')),
('individual', _('Individual')),
('business', _('Business')),
('business_vat_id', _('Business with valid VAT ID')),
],
required=False
)
action = forms.ChoiceField(
choices=[
('vat', _('Charge VAT')),
('reverse', _('Reverse charge')),
('no', _('No VAT')),
],
)
TaxRuleLineFormSet = formset_factory(
TaxRuleLineForm,
can_order=False, can_delete=True, extra=0
)
class TaxRuleForm(I18nModelForm):
class Meta:
model = TaxRule

View File

@@ -1,5 +1,6 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load formset_tags %}
{% load bootstrap3 %}
{% block title %}
{% if rule %}
@@ -21,7 +22,7 @@
{% bootstrap_field form.rate addon_after="%" layout="control" %}
<legend>{% trans "Advanced settings" %}</legend>
<div class="alert alert-warning">
<span class="fa fa-w fa-legal fa-4x pull-left"></span>
<span class="fa fa-fw fa-legal fa-4x pull-left"></span>
{% blocktrans trimmed with docs="https://docs.pretix.eu/en/latest/user/events/taxes.html" %}
These settings are intended for advanced users. See the <a href="{{ docs }}">documentation</a>
for more information. Note that we are not responsible for the correct handling
@@ -32,6 +33,75 @@
{% bootstrap_field form.price_includes_tax layout="control" %}
{% bootstrap_field form.eu_reverse_charge layout="control" %}
{% bootstrap_field form.home_country layout="control" %}
<legend>{% trans "Custom taxation rules" %}</legend>
<div class="alert alert-warning">
<span class="fa fa-fw fa-exclamation-circle fa-4x pull-left"></span>
{% blocktrans trimmed %}
These settings are intended for professional users with very specific taxation situations.
If you create any rule here, the reverse charge settings above will be ignored. The rules will be
checked in order and once the first rule matches the order, it will be used and all further rules will
be ignored. If no rule matches, tax will be charged.
{% endblocktrans %}
<div class="clearfix"></div>
</div>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
{% bootstrap_form_errors form %}
<div class="row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-sm-4">
{% bootstrap_field form.country layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field form.address_type layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field form.action layout='inline' form_group_class="" %}
</div>
<div class="col-sm-2 text-right">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-sm-4">
{% bootstrap_field formset.empty_form.country layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field formset.empty_form.address_type layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field formset.empty_form.action layout='inline' form_group_class="" %}
</div>
<div class="col-sm-2 text-right">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new rule" %}</button>
</p>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -1,3 +1,4 @@
import json
import re
from collections import OrderedDict
from datetime import timedelta
@@ -40,8 +41,8 @@ from pretix.base.templatetags.money import money_filter
from pretix.control.forms.event import (
CommentForm, DisplaySettingsForm, EventDeleteForm, EventMetaValueForm,
EventSettingsForm, EventUpdateForm, InvoiceSettingsForm, MailSettingsForm,
PaymentSettingsForm, ProviderForm, TaxRuleForm, TicketSettingsForm,
WidgetCodeForm,
PaymentSettingsForm, ProviderForm, TaxRuleForm, TaxRuleLineFormSet,
TicketSettingsForm, WidgetCodeForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import nav_event_settings
@@ -952,9 +953,30 @@ class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView
'name': LazyI18nString.from_gettext(ugettext('VAT'))
}
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and self.formset.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
@cached_property
def formset(self):
return TaxRuleLineFormSet(
data=self.request.POST if self.request.method == "POST" else None,
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
return ctx
@transaction.atomic
def form_valid(self, form):
form.instance.event = self.request.event
form.instance.custom_rules = json.dumps([
f.cleaned_data for f in self.formset if f not in self.formset.deleted_forms
])
messages.success(self.request, _('The new tax rule has been created.'))
ret = super().form_valid(form)
form.instance.log_action('pretix.event.taxrule.added', user=self.request.user, data=dict(form.cleaned_data))
@@ -980,9 +1002,32 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView
except TaxRule.DoesNotExist:
raise Http404(_("The requested tax rule does not exist."))
def post(self, request, *args, **kwargs):
self.object = self.get_object(self.get_queryset())
form = self.get_form()
if form.is_valid() and self.formset.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
@cached_property
def formset(self):
return TaxRuleLineFormSet(
data=self.request.POST if self.request.method == "POST" else None,
initial=json.loads(self.object.custom_rules) if self.object.custom_rules else []
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
return ctx
@transaction.atomic
def form_valid(self, form):
messages.success(self.request, _('Your changes have been saved.'))
form.instance.custom_rules = json.dumps([
f.cleaned_data for f in self.formset if f not in self.formset.deleted_forms
])
if form.has_changed():
self.object.log_action(
'pretix.event.taxrule.changed', user=self.request.user, data={

View File

@@ -0,0 +1,387 @@
import json
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_countries.fields import Country
from pretix.base.models import Event, InvoiceAddress, Organizer, TaxRule
@pytest.fixture
def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now()
)
return event
@pytest.mark.django_db
def test_from_gross_price(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=True
)
tp = tr.tax(Decimal('100.00'))
assert tp.gross == Decimal('100')
assert tp.net == Decimal('90.91')
assert tp.tax == Decimal('100.00') - Decimal('90.91')
assert tp.rate == Decimal('10.00')
@pytest.mark.django_db
def test_from_net_price(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False
)
tp = tr.tax(Decimal('100.00'))
assert tp.gross == Decimal('110.00')
assert tp.net == Decimal('100.00')
assert tp.tax == Decimal('10.00')
assert tp.rate == Decimal('10.00')
@pytest.mark.django_db
def test_reverse_charge_no_address(event):
tr = TaxRule(
event=event, eu_reverse_charge=True,
rate=Decimal('10.00'), price_includes_tax=False
)
assert not tr.is_reverse_charge(None)
assert tr.tax_applicable(None)
@pytest.mark.django_db
def test_reverse_charge_no_country(event):
tr = TaxRule(
event=event, eu_reverse_charge=True,
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_individual_same_country(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=False,
country=Country('DE')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_individual_eu(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=False,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_individual_3rdc(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=False,
country=Country('US')
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_business_same_country(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=True,
country=Country('DE')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_business_eu(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_business_3rdc(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=True,
country=Country('US')
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_valid_vat_id_business_same_country(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=True,
country=Country('DE'),
vat_id='DE123456',
vat_id_validated=True
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_valid_vat_id_business_eu(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=True,
vat_id='AT12346',
vat_id_validated=True,
country=Country('AT')
)
assert tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_valid_vat_id_business_3rdc(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=True,
country=Country('US'),
vat_id='US12346',
vat_id_validated=True
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
@pytest.mark.django_db
def test_reverse_charge_disabled(event):
tr = TaxRule(
event=event, eu_reverse_charge=False, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False
)
ia = InvoiceAddress(
is_business=True,
vat_id='AT12346',
vat_id_validated=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_override(event):
tr = TaxRule(
event=event, eu_reverse_charge=True, home_country=Country('DE'),
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'ZZ', 'address_type': '', 'action': 'vat'}
])
)
ia = InvoiceAddress(
is_business=True,
vat_id='AT12346',
vat_id_validated=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_in_order(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'ZZ', 'address_type': '', 'action': 'vat'},
{'country': 'ZZ', 'address_type': '', 'action': 'reverse'}
])
)
ia = InvoiceAddress(
is_business=True,
vat_id='AT12346',
vat_id_validated=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_any_country(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'ZZ', 'address_type': '', 'action': 'no'},
])
)
ia = InvoiceAddress(
is_business=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_eu_country(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'EU', 'address_type': '', 'action': 'no'},
])
)
ia = InvoiceAddress(
is_business=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
ia = InvoiceAddress(
is_business=True,
country=Country('US')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_specific_country(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'AT', 'address_type': '', 'action': 'no'},
])
)
ia = InvoiceAddress(
is_business=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
ia = InvoiceAddress(
is_business=True,
country=Country('DE')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_individual(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'ZZ', 'address_type': 'individual', 'action': 'no'},
])
)
ia = InvoiceAddress(
is_business=False,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
ia = InvoiceAddress(
is_business=True,
country=Country('DE')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_business(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'ZZ', 'address_type': 'business', 'action': 'no'},
])
)
ia = InvoiceAddress(
is_business=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)
ia = InvoiceAddress(
is_business=False,
country=Country('DE')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
@pytest.mark.django_db
def test_custom_rules_vat_id(event):
tr = TaxRule(
event=event,
rate=Decimal('10.00'), price_includes_tax=False,
custom_rules=json.dumps([
{'country': 'EU', 'address_type': 'business_vat_id', 'action': 'reverse'},
])
)
ia = InvoiceAddress(
is_business=True,
country=Country('AT')
)
assert not tr.is_reverse_charge(ia)
assert tr.tax_applicable(ia)
ia = InvoiceAddress(
is_business=True,
country=Country('DE'),
vat_id='DE1234',
vat_id_validated=True
)
assert tr.is_reverse_charge(ia)
assert not tr.tax_applicable(ia)

View File

@@ -1,4 +1,5 @@
import datetime
import json
import os
from datetime import timedelta
from decimal import Decimal
@@ -415,6 +416,51 @@ class CheckoutTestCase(TestCase):
ia = InvoiceAddress.objects.get(pk=self.client.session['carts'][self.session_key].get('invoice_address'))
assert not ia.vat_id_validated
def test_custom_tax_rules(self):
self.tr19.custom_rules = json.dumps([
{'country': 'AT', 'address_type': '', 'action': 'vat'},
{'country': 'ZZ', 'address_type': '', 'action': 'reverse'},
])
self.tr19.save()
self.event.settings.invoice_address_vatid = True
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
with mock.patch('vat_moss.id.validate') as mock_validate:
mock_validate.return_value = ('AT', 'AT123456', 'Foo')
self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
}, follow=True)
cr1.refresh_from_db()
assert cr1.price == Decimal('23.00')
self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name': 'Bar',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
}, follow=True)
cr1.refresh_from_db()
assert cr1.price == Decimal('19.33')
def test_question_file_upload(self):
q1 = Question.objects.create(
event=self.event, question='Student ID', type=Question.TYPE_FILE,