Tax rules and reverse charge (#559)

Tax rules and reverse charge
This commit is contained in:
Raphael Michel
2017-08-23 13:13:16 +03:00
committed by GitHub
parent b9ec5ea83c
commit 56338be13e
82 changed files with 2934 additions and 428 deletions

View File

@@ -1,5 +1,7 @@
from django_countries.serializers import CountryFieldMixin
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event
from pretix.base.models import Event, TaxRule
from pretix.base.models.event import SubEvent
from pretix.base.models.items import SubEventItem, SubEventItemVariation
@@ -33,3 +35,9 @@ class SubEventSerializer(I18nAwareModelSerializer):
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location',
'item_price_overrides', 'variation_price_overrides')
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta:
model = TaxRule
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from rest_framework import serializers
from pretix.api.serializers.i18n import I18nAwareModelSerializer
@@ -21,14 +23,23 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
'position')
class ItemTaxRateField(serializers.Field):
def to_representation(self, i):
if i.tax_rule:
return str(Decimal(i.tax_rule.rate))
else:
return str(Decimal('0.00'))
class ItemSerializer(I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True)
variations = InlineItemVariationSerializer(many=True)
tax_rate = ItemTaxRateField(source='*', read_only=True)
class Meta:
model = Item
fields = ('id', 'category', 'name', 'active', 'description',
'default_price', 'free_price', 'tax_rate', 'admission',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel',
'min_per_order', 'max_per_order', 'has_variations',

View File

@@ -22,7 +22,8 @@ class InvoiceAdddressSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceAddress
fields = ('last_modified', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id')
fields = ('last_modified', 'is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
'vat_id_validated')
class AnswerSerializer(I18nAwareModelSerializer):
@@ -97,7 +98,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads',
'answers')
'answers', 'tax_rule')
class OrderSerializer(I18nAwareModelSerializer):
@@ -109,13 +110,13 @@ class OrderSerializer(I18nAwareModelSerializer):
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value',
'total', 'comment', 'invoice_address', 'positions', 'downloads')
'payment_fee_tax_rule', 'total', 'comment', 'invoice_address', 'positions', 'downloads')
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceLine
fields = ('description', 'gross_value', 'tax_value', 'tax_rate')
fields = ('description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
class InvoiceSerializer(I18nAwareModelSerializer):
@@ -126,4 +127,5 @@ class InvoiceSerializer(I18nAwareModelSerializer):
class Meta:
model = Invoice
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_to', 'date', 'refers', 'locale',
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines')
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date')

View File

@@ -22,6 +22,7 @@ event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
# Force import of all plugins to give them a chance to register URLs with the router

View File

@@ -1,8 +1,10 @@
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import filters, viewsets
from pretix.api.serializers.event import EventSerializer, SubEventSerializer
from pretix.base.models import Event, ItemCategory
from pretix.api.serializers.event import (
EventSerializer, SubEventSerializer, TaxRuleSerializer,
)
from pretix.base.models import Event, ItemCategory, TaxRule
from pretix.base.models.event import SubEvent
@@ -32,3 +34,11 @@ class SubEventViewSet(viewsets.ReadOnlyModelViewSet):
return self.request.event.subevents.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set'
)
class TaxRuleViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = TaxRuleSerializer
queryset = TaxRule.objects.none()
def get_queryset(self):
return self.request.event.tax_rules.all()

View File

@@ -1,3 +1,5 @@
import django_filters
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import detail_route
@@ -12,6 +14,14 @@ from pretix.base.models import Item, ItemCategory, Question, Quota
class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
def tax_rate_qs(self, queryset, name, value):
if value in ("0", "None", "0.00"):
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
else:
return queryset.filter(tax_rule__rate=value)
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
@@ -26,7 +36,7 @@ class ItemViewSet(viewsets.ReadOnlyModelViewSet):
filter_class = ItemFilter
def get_queryset(self):
return self.request.event.items.prefetch_related('variations', 'addons').all()
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons').all()
class ItemCategoryFilter(FilterSet):

View File

@@ -11,7 +11,7 @@ class PretixBaseConfig(AppConfig):
from . import payment # NOQA
from . import exporters # NOQA
from . import invoice # NOQA
from .services import export, mail, tickets, cart, orders, cleanup, update_check # NOQA
from .services import export, mail, tickets, cart, orders, invoices, cleanup, update_check # NOQA
try:
from .celery_app import app as celery_app # NOQA

View File

@@ -1,4 +1,5 @@
import json
from decimal import Decimal
from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver
@@ -32,7 +33,8 @@ class JSONExporter(BaseExporter):
'name': str(item.name),
'category': item.category_id,
'price': item.default_price,
'tax_rate': item.tax_rate,
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'),
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
'admission': item.admission,
'active': item.active,
'variations': [
@@ -44,7 +46,7 @@ class JSONExporter(BaseExporter):
'name': str(variation)
} for variation in item.variations.all()
]
} for item in self.event.items.all().prefetch_related('variations')
} for item in self.event.items.select_related('tax_rule').prefetch_related('variations')
],
'questions': [
{

View File

@@ -3,11 +3,13 @@ from decimal import Decimal
from io import BytesIO
from typing import Tuple
import vat_moss.exchange_rates
from django.contrib.staticfiles import finders
from django.dispatch import receiver
from django.utils.formats import date_format, localize
from django.utils.translation import pgettext
from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
@@ -15,10 +17,11 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
BaseDocTemplate, Frame, NextPageTemplate, PageTemplate, Paragraph, Spacer,
Table, TableStyle,
BaseDocTemplate, Frame, KeepTogether, NextPageTemplate, PageTemplate,
Paragraph, Spacer, Table, TableStyle,
)
from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Invoice
from pretix.base.signals import register_invoice_renderers
@@ -86,6 +89,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
stylesheet = StyleSheet1()
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName='OpenSansBd', fontSize=8, leading=12))
stylesheet.add(ParagraphStyle(name='Fineprint', fontName='OpenSans', fontSize=8, leading=10))
return stylesheet
def _register_fonts(self):
@@ -355,12 +360,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
localize(line.net_value) + " " + self.invoice.event.currency,
localize(line.gross_value) + " " + self.invoice.event.currency,
))
taxvalue_map[line.tax_rate] += line.tax_value
grossvalue_map[line.tax_rate] += line.gross_value
taxvalue_map[line.tax_rate, line.tax_name] += line.tax_value
grossvalue_map[line.tax_rate, line.tax_name] += line.gross_value
total += line.gross_value
tdata.append(
[pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + self.invoice.event.currency])
tdata.append([
pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + self.invoice.event.currency
])
colwidths = [a * doc.width for a in (.55, .15, .15, .15)]
table = Table(tdata, colWidths=colwidths, repeatRows=1)
table.setStyle(TableStyle(tstyledata))
@@ -376,33 +382,94 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 15 * mm))
tstyledata = [
('SPAN', (1, 0), (-1, 0)),
('ALIGN', (2, 1), (-1, -1), 'RIGHT'),
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
('LEFTPADDING', (0, 0), (0, -1), 0),
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
('FONTSIZE', (0, 0), (-1, -1), 8),
('FONTNAME', (0, 0), (-1, -1), 'OpenSans'),
]
tdata = [('', pgettext('invoice', 'Included taxes'), '', '', ''),
('', pgettext('invoice', 'Tax rate'),
pgettext('invoice', 'Net value'), pgettext('invoice', 'Gross value'), pgettext('invoice', 'Tax'))]
thead = [
pgettext('invoice', 'Tax rate'),
pgettext('invoice', 'Net value'),
pgettext('invoice', 'Gross value'),
pgettext('invoice', 'Tax'),
''
]
tdata = [thead]
for rate, gross in grossvalue_map.items():
for idx, gross in grossvalue_map.items():
rate, name = idx
if rate == 0:
continue
tax = taxvalue_map[rate]
tdata.append((
'',
localize(rate) + " %",
localize((gross - tax)) + " " + self.invoice.event.currency,
tax = taxvalue_map[idx]
tdata.append([
localize(rate) + " % " + name,
localize(gross - tax) + " " + self.invoice.event.currency,
localize(gross) + " " + self.invoice.event.currency,
localize(tax) + " " + self.invoice.event.currency,
''
])
def fmt(val):
try:
return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display)
except ValueError:
return localize(val) + ' ' + self.invoice.foreign_currency_display
if len(tdata) > 1:
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
table.setStyle(TableStyle(tstyledata))
story.append(KeepTogether([
Paragraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table
]))
if self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
tdata = [thead]
for idx, gross in grossvalue_map.items():
rate, name = idx
if rate == 0:
continue
tax = taxvalue_map[idx]
gross = round_decimal(gross * self.invoice.foreign_currency_rate)
tax = round_decimal(tax * self.invoice.foreign_currency_rate)
net = gross - tax
tdata.append([
localize(rate) + " % " + name,
fmt(net), fmt(gross), fmt(tax), ''
])
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
table.setStyle(TableStyle(tstyledata))
story.append(KeepTogether([
Spacer(1, height=2 * mm),
Paragraph(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the European Central Bank on '
'{date}, this corresponds to:'
).format(rate=localize(self.invoice.foreign_currency_rate),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")),
self.stylesheet['Fineprint']
),
Spacer(1, height=3 * mm),
table
]))
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
story.append(Spacer(1, 5 * mm))
story.append(Paragraph(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the European Central Bank on '
'{date}, the invoice total corresponds to {total}.'
).format(rate=localize(self.invoice.foreign_currency_rate),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
total=fmt(total)),
self.stylesheet['Fineprint']
))
if len(tdata) > 2:
colwidths = [a * doc.width for a in (.45, .10, .15, .15, .15)]
table = Table(tdata, colWidths=colwidths, repeatRows=2)
table.setStyle(TableStyle(tstyledata))
story.append(table)
return story

View File

@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-07-16 13:33
from __future__ import unicode_literals
import django.db.models.deletion
import django_countries.fields
import i18nfield.fields
from django.db import migrations, models
from i18nfield.strings import LazyI18nString
def tax_rate_converter(app, schema_editor):
EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
Item = app.get_model('pretixbase', 'Item')
TaxRule = app.get_model('pretixbase', 'TaxRule')
Order = app.get_model('pretixbase', 'Order')
OrderPosition = app.get_model('pretixbase', 'OrderPosition')
InvoiceLine = app.get_model('pretixbase', 'InvoiceLine')
n = LazyI18nString({
'en': 'VAT',
'de': 'MwSt.',
'de-informal': 'MwSt.'
})
for i in Item.objects.select_related('event').exclude(tax_rate=0):
try:
i.tax_rule = i.event.tax_rules.get(rate=i.tax_rate)
except TaxRule.DoesNotExist:
tr = i.event.tax_rules.create(rate=i.tax_rate, name=n)
i.tax_rule = tr
i.save()
for o in Order.objects.select_related('event').exclude(payment_fee_tax_rate=0):
try:
o.payment_fee_tax_rule = o.event.tax_rules.get(rate=o.payment_fee_tax_rate)
except TaxRule.DoesNotExist:
tr = o.event.tax_rules.create(rate=o.payment_fee_tax_rate, name=n)
o.tax_rule = tr
o.save()
for op in OrderPosition.objects.select_related('order', 'order__event').exclude(tax_rate=0):
try:
op.tax_rule = op.order.event.tax_rules.get(rate=op.tax_rate)
except TaxRule.DoesNotExist:
tr = op.order.event.tax_rules.create(rate=op.tax_rate, name=n)
op.tax_rule = tr
op.save()
for il in InvoiceLine.objects.select_related('invoice', 'invoice__event').exclude(tax_rate=0):
try:
il.tax_name = il.invoice.event.tax_rules.get(rate=op.tax_rate).name
except TaxRule.DoesNotExist:
tr = il.invoice.event.tax_rules.create(rate=op.tax_rate, name=n)
il.tax_name = tr.name
il.save()
for setting in EventSettingsStore.objects.filter(key='tax_rate_default'):
try:
tr = i.event.tax_rules.get(rate=setting.value)
except TaxRule.DoesNotExist:
tr = i.event.tax_rules.create(rate=setting.value, name=n)
setting.value = tr.pk
setting.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0072_order_download_reminder_sent'),
]
operations = [
migrations.CreateModel(
name='TaxRule',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', i18nfield.fields.I18nCharField(help_text='Should be short, e.g. "VAT"', max_length=190,
verbose_name='Name')),
('rate', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Tax rate')),
('price_includes_tax', models.BooleanField(default=True,
verbose_name='The configured product prices includes the '
'tax amount')),
('eu_reverse_charge',
models.BooleanField(default=False, help_text='Not recommended. Most events will NOT be '
'qualified for reverse charge since the place of '
'taxation is the location of the event. This option '
'only enables reverse charge for business customers who '
'entered a valid EU VAT ID. Only enable this option '
'after consulting a tax counsel. No warranty given for '
'correct tax calculation.',
verbose_name='Use EU reverse charge taxation')),
('home_country', models.CharField(blank=True,
choices=[('AT', 'Austria'), ('BE', 'Belgium'), ('BG', 'Bulgaria'),
('HR', 'Croatia'), ('CY', 'Cyprus'),
('CZ', 'Czech Republic'), ('DK', 'Denmark'),
('EE', 'Estonia'), ('FI', 'Finland'), ('FR', 'France'),
('DE', 'Germany'), ('GR', 'Greece'), ('HU', 'Hungary'),
('IE', 'Ireland'), ('IT', 'Italy'), ('LV', 'Latvia'),
('LT', 'Lithuania'), ('LU', 'Luxembourg'), ('MT', 'Malta'),
('NL', 'Netherlands'), ('PL', 'Poland'), ('PT', 'Portugal'),
('RO', 'Romania'), ('SK', 'Slovakia'), ('SI', 'Slovenia'),
('ES', 'Spain'), ('SE', 'Sweden'), ('UJ', 'United Kingdom')],
help_text='Your country. Only relevant for EU reverse charge.',
max_length=2, verbose_name='Merchant country')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tax_rules',
to='pretixbase.Event')),
],
),
migrations.AddField(
model_name='item',
name='tax_rule',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
to='pretixbase.TaxRule', verbose_name='Sales tax'),
),
migrations.AddField(
model_name='order',
name='payment_fee_tax_rule',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
to='pretixbase.TaxRule'),
),
migrations.AddField(
model_name='orderposition',
name='tax_rule',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
to='pretixbase.TaxRule'),
),
migrations.RunPython(
tax_rate_converter, migrations.RunPython.noop
),
migrations.RemoveField(
model_name='item',
name='tax_rate',
),
migrations.AddField(
model_name='invoiceaddress',
name='vat_id_validated',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='invoiceaddress',
name='vat_id',
field=models.CharField(blank=True, help_text='Only for business customers within the EU.', max_length=255, verbose_name='VAT ID'),
),
migrations.AlterField(
model_name='taxrule',
name='home_country',
field=django_countries.fields.CountryField(blank=True, help_text='Your country of residence. This is the country the EU reverse charge rule will not apply in, if configured above.', max_length=2, verbose_name='Merchant country'),
),
migrations.AddField(
model_name='cartposition',
name='includes_tax',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='invoiceline',
name='tax_name',
field=models.CharField(default='', max_length=190),
preserve_default=False,
),
migrations.AlterField(
model_name='taxrule',
name='eu_reverse_charge',
field=models.BooleanField(default=False, help_text='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 that do not customers 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.', verbose_name='Use EU reverse charge taxation rules'),
),
migrations.AddField(
model_name='invoice',
name='foreign_currency_display',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AddField(
model_name='invoice',
name='foreign_currency_rate',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True),
),
migrations.AddField(
model_name='invoice',
name='foreign_currency_rate_date',
field=models.DateField(blank=True, null=True),
),
]

View File

@@ -19,5 +19,6 @@ from .orders import (
generate_secret,
)
from .organizer import Organizer, Organizer_SettingsStore, Team, TeamInvite
from .tax import TaxRule
from .vouchers import Voucher
from .waitinglist import WaitingListEntry

View File

@@ -304,6 +304,13 @@ class Event(EventMixin, LoggedModel):
self.is_public = other.is_public
self.save()
tax_map = {}
for t in other.tax_rules.all():
tax_map[t.pk] = t
t.pk = None
t.event = self
t.save()
category_map = {}
for c in ItemCategory.objects.filter(event=other):
category_map[c.pk] = c
@@ -322,6 +329,8 @@ class Event(EventMixin, LoggedModel):
i.picture.save(i.picture.name, i.picture)
if i.category_id:
i.category = category_map[i.category_id]
if i.tax_rule_id:
i.tax_rule = tax_map[i.tax_rule_id]
i.save()
for v in vars:
variation_map[v.pk] = v
@@ -371,7 +380,18 @@ class Event(EventMixin, LoggedModel):
)
newname = default_storage.save(fname, fi)
s.value = 'file://' + newname
s.save()
s.save()
elif s.key == 'tax_rate_default':
try:
if int(s.value) in tax_map:
s.value = tax_map.get(int(s.value)).pk
s.save()
else:
s.delete()
except ValueError:
s.delete()
else:
s.save()
event_copy_data.send(sender=self, other=other)

View File

@@ -53,6 +53,12 @@ class Invoice(models.Model):
:type payment_provider_text: str
:param footer_text: A footer text, displayed smaller and centered on every page
:type footer_text: str
:param foreign_currency_display: A different currency that taxes should also be displayed in.
:type foreign_currency_display: str
:param foreign_currency_rate: The rate of a forein currency that the taxes should be displayed in.
:type foreign_currency_rate: Decimal
:param foreign_currency_rate_date: The date of the forein currency exchange rates.
:type foreign_currency_rate_date: date
:param file: The filename of the rendered invoice
:type file: File
"""
@@ -71,6 +77,9 @@ class Invoice(models.Model):
additional_text = models.TextField(blank=True)
payment_provider_text = models.TextField(blank=True)
footer_text = models.TextField(blank=True)
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
foreign_currency_rate = models.DecimalField(decimal_places=4, max_digits=10, null=True, blank=True)
foreign_currency_rate_date = models.DateField(null=True, blank=True)
file = models.FileField(null=True, blank=True, upload_to=invoice_filename)
@staticmethod
@@ -155,12 +164,15 @@ class InvoiceLine(models.Model):
:type tax_value: decimal.Decimal
:param tax_rate: The applied tax rate in percent
:type tax_rate: decimal.Decimal
:param tax_name: The name of the applied tax rate
:type tax_name: str
"""
invoice = models.ForeignKey('Invoice', related_name='lines')
description = models.TextField()
gross_value = models.DecimalField(max_digits=10, decimal_places=2)
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
tax_name = models.CharField(max_length=190)
@property
def net_value(self):

View File

@@ -13,8 +13,8 @@ from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from pretix.base.models.tax import TaxedPrice
from .event import Event, SubEvent
@@ -202,10 +202,11 @@ class Item(LoggedModel):
"additional donations for your event. This is currently not supported for products that are "
"bought as an add-on to other products.")
)
tax_rate = models.DecimalField(
verbose_name=_("Taxes included in percent"),
max_digits=7, decimal_places=2,
default=Decimal('0.00')
tax_rule = models.ForeignKey(
'TaxRule',
verbose_name=_('Sales tax'),
on_delete=models.PROTECT,
null=True, blank=True
)
admission = models.BooleanField(
verbose_name=_("Is an admission ticket"),
@@ -286,10 +287,12 @@ class Item(LoggedModel):
if self.event:
self.event.get_cache().clear()
@property
def default_price_net(self):
tax_value = round_decimal(self.default_price * (1 - 100 / (100 + self.tax_rate)))
return self.default_price - tax_value
def tax(self, price=None, base_price_is='auto'):
price = price if price is not None else self.default_price
if not self.tax_rule:
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
return self.tax_rule.tax(price, base_price_is=base_price_is)
def is_available(self, now_dt: datetime=None) -> bool:
"""
@@ -396,10 +399,11 @@ class ItemVariation(models.Model):
def price(self):
return self.default_price if self.default_price is not None else self.item.default_price
@property
def net_price(self):
tax_value = round_decimal(self.price * (1 - 100 / (100 + self.item.tax_rate)))
return self.price - tax_value
def tax(self, price=None):
price = price or self.price
if not self.item.tax_rule:
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
return self.item.tax_rule.tax(price)
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)

View File

@@ -8,8 +8,6 @@ from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.models.event import SubEvent
class LogEntry(models.Model):
"""
@@ -52,7 +50,7 @@ class LogEntry(models.Model):
@cached_property
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
if self.content_type.model_class() is Event:
return ''
@@ -131,6 +129,16 @@ class LogEntry(models.Model):
}),
'val': escape(co.question),
}
elif isinstance(co, TaxRule):
a_text = _('Tax rule {val}')
a_map = {
'href': reverse('control:event.settings.tax.edit', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'rule': co.id
}),
'val': escape(co.name),
}
if a_text and a_map:
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)

View File

@@ -25,7 +25,6 @@ from pretix.base.i18n import language
from pretix.base.models import User
from pretix.base.reldate import RelativeDateWrapper
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -162,6 +161,11 @@ class Order(LoggedModel):
decimal_places=2, max_digits=10,
default=0, verbose_name=_("Payment method fee tax")
)
payment_fee_tax_rule = models.ForeignKey(
'TaxRule',
on_delete=models.PROTECT,
null=True, blank=True
)
payment_info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
@@ -229,12 +233,26 @@ class Order(LoggedModel):
Calculates the taxes on the payment fees and sets the parameters payment_fee_tax_rate
and payment_fee_tax_value accordingly.
"""
self.payment_fee_tax_rate = self.event.settings.get('tax_rate_default')
if self.payment_fee_tax_rate:
self.payment_fee_tax_value = round_decimal(
self.payment_fee * (1 - 100 / (100 + self.payment_fee_tax_rate)))
if self.event.settings.tax_rate_default:
tr = self.event.settings.tax_rate_default
tax = tr.tax(self.payment_fee, base_price_is='gross')
rate, tax = tax.rate, tax.tax
try:
ia = self.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if not tr.tax_applicable(ia):
rate = 0
tax = 0
self.payment_fee_tax_rate = rate
self.payment_fee_tax_value = tax
self.payment_fee_tax_rule = tr
else:
self.payment_fee_tax_rate = Decimal('0.00')
self.payment_fee_tax_value = Decimal('0.00')
self.payment_fee_tax_rule = None
@property
def payment_fee_net(self):
@@ -671,6 +689,15 @@ class OrderPosition(AbstractPosition):
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
)
tax_rule = models.ForeignKey(
'TaxRule',
on_delete=models.PROTECT,
null=True, blank=True
)
tax_value = models.DecimalField(
max_digits=10, decimal_places=2,
verbose_name=_('Tax value')
)
tax_value = models.DecimalField(
max_digits=10, decimal_places=2,
verbose_name=_('Tax value')
@@ -730,11 +757,22 @@ class OrderPosition(AbstractPosition):
)
def _calculate_tax(self):
self.tax_rate = self.item.tax_rate
if self.tax_rate:
self.tax_value = round_decimal(self.price * (1 - 100 / (100 + self.item.tax_rate)))
self.tax_rule = self.item.tax_rule
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if self.tax_rule:
if self.tax_rule.tax_applicable(ia):
tax = self.tax_rule.tax(self.price, base_price_is='gross')
self.tax_rate = tax.rate
self.tax_value = tax.tax
else:
self.tax_value = Decimal('0.00')
self.tax_rate = Decimal('0.00')
else:
self.tax_value = Decimal('0.00')
self.tax_rate = Decimal('0.00')
def save(self, *args, **kwargs):
if self.tax_rate is None:
@@ -775,6 +813,9 @@ class CartPosition(AbstractPosition):
verbose_name=_("Expiration date"),
db_index=True
)
includes_tax = models.BooleanField(
default=True
)
class Meta:
verbose_name = _("Cart position")
@@ -787,19 +828,23 @@ class CartPosition(AbstractPosition):
@property
def tax_rate(self):
return self.item.tax_rate
if self.includes_tax:
return self.item.tax(self.price, base_price_is='gross').rate
else:
return Decimal('0.00')
@property
def tax_value(self):
if not self.tax_rate:
if self.includes_tax:
return self.item.tax(self.price, base_price_is='gross').tax
else:
return Decimal('0.00')
return round_decimal(self.price * (1 - 100 / (100 + self.item.tax_rate)))
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address')
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
street = models.TextField(verbose_name=_('Address'), blank=False)
@@ -809,6 +854,7 @@ class InvoiceAddress(models.Model):
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'))
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
help_text=_('Only for business customers within the EU.'))
vat_id_validated = models.BooleanField(default=False)
def cachedticket_name(instance, filename: str) -> str:

View File

@@ -0,0 +1,174 @@
from decimal import Decimal
from django.db import models
from django.utils.formats import localize
from django.utils.translation import ugettext_lazy as _
from django_countries.fields import CountryField
from i18nfield.fields import I18nCharField
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
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))
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', 'GB'
}
EU_CURRENCIES = {
'BG': 'BGN',
'GB': 'GBP',
'HR': 'HRK',
'CZ': 'CZK',
'DK': 'DKK',
'HU': 'HUF',
'PL': 'PLN',
'RO': 'RON',
'SE': 'SEK'
}
class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules')
name = I18nCharField(
verbose_name=_('Name'),
help_text=_('Should be short, e.g. "VAT"'),
max_length=190,
)
rate = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name=_("Tax rate")
)
price_includes_tax = models.BooleanField(
verbose_name=_("The configured product prices include the tax amount"),
default=True,
)
eu_reverse_charge = models.BooleanField(
verbose_name=_("Use EU reverse charge taxation rules"),
default=False,
help_text=_("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 that do not "
"customers 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 = CountryField(
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.'),
)
@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 ValueError(_('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'))
return str(s)
def tax(self, base_price, base_price_is='auto'):
if self.rate == Decimal('0.00'):
return TaxedPrice(
net=base_price, gross=base_price, tax=Decimal('0.00'),
rate=self.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 = base_price
net = gross - round_decimal(base_price * (1 - 100 / (100 + self.rate)))
elif base_price_is == 'net':
net = base_price
gross = round_decimal(net * (1 + self.rate / 100))
else:
raise ValueError('Unknown base price type: {}'.format(base_price_is))
return TaxedPrice(
net=net, gross=gross, tax=gross - net,
rate=self.rate, name=self.name
)
def is_reverse_charge(self, invoice_address):
if not self.eu_reverse_charge:
return False
if not invoice_address or not invoice_address.country:
return False
if str(invoice_address.country) not in EU_COUNTRIES:
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 tax_applicable(self, invoice_address):
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 str(invoice_address.country) not in EU_COUNTRIES:
# 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

View File

@@ -11,9 +11,10 @@ from django.utils.translation import pgettext_lazy, ugettext as _
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice
from pretix.base.services.async import ProfiledTask
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.pricing import get_price
@@ -68,7 +69,7 @@ error_messages = {
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent'))
'addon_to', 'subevent', 'includes_tax'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent'))
@@ -78,7 +79,7 @@ class CartManager:
AddOperation: 30
}
def __init__(self, event: Event, cart_id: str):
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None):
self.event = event
self.cart_id = cart_id
self.now_dt = now()
@@ -89,6 +90,7 @@ class CartManager:
self._subevents_cache = {}
self._variations_cache = {}
self._expiry = None
self.invoice_address = invoice_address
@property
def positions(self):
@@ -213,8 +215,12 @@ class CartManager:
def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal],
subevent: Optional[SubEvent]):
return get_price(item, variation, voucher, custom_price, subevent, self.event.settings.display_net_prices)
subevent: Optional[SubEvent], cp_is_net: bool=None):
return get_price(
item, variation, voucher, custom_price, subevent,
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
invoice_address=self.invoice_address
)
def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
@@ -222,7 +228,12 @@ class CartManager:
).prefetch_related('item__quotas', 'variation__quotas')
err = None
for cp in expired:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent)
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent)
quotas = list(cp.quotas)
if not quotas:
@@ -296,7 +307,7 @@ class CartManager:
price = self._get_price(item, variation, voucher, i.get('price'), subevent)
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent
addon_to=False, subevent=subevent, includes_tax=bool(price.rate)
)
self._check_item_constraints(op)
operations.append(op)
@@ -395,13 +406,13 @@ class CartManager:
quota_diff[quota] += 1
if price_included[cp.pk].get(item.category_id):
price = Decimal('0.00')
price = TAXED_ZERO
else:
price = self._get_price(item, variation, None, None, cp.subevent)
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate)
)
self._check_item_constraints(op)
operations.append(op)
@@ -557,15 +568,14 @@ class CartManager:
for k in range(available_count):
new_cart_positions.append(CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price, expires=self._expiry,
cart_id=self.cart_id, voucher=op.voucher,
addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax
))
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price
op.position.price = op.price.gross
op.position.save()
elif available_count == 0:
op.position.delete()
@@ -591,8 +601,34 @@ class CartManager:
raise CartError(err)
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
positions = CartPosition.objects.filter(
cart_id=cart_id, event=event
).select_related('item', 'item__tax_rule')
totaldiff = Decimal('0.00')
for pos in positions:
if not pos.item.tax_rule:
continue
charge_tax = pos.item.tax_rule.tax_applicable(invoice_address)
if pos.includes_tax and not charge_tax:
price = pos.item.tax(pos.price, base_price_is='gross').net
totaldiff += price - pos.price
pos.price = price
pos.includes_tax = False
pos.save(update_fields=['price', 'includes_tax'])
elif charge_tax and not pos.includes_tax:
price = pos.item.tax(pos.price, base_price_is='net').gross
totaldiff += price - pos.price
pos.price = price
pos.includes_tax = True
pos.save(update_fields=['price', 'includes_tax'])
return totaldiff
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None) -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -602,9 +638,17 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
"""
with language(locale):
event = Event.objects.get(id=event)
ia = False
if invoice_address:
try:
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist:
pass
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
@@ -655,7 +699,8 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en') -> None:
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None) -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
@@ -664,9 +709,16 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
"""
with language(locale):
event = Event.objects.get(id=event)
ia = False
if invoice_address:
try:
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist:
pass
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
cm.set_addons(addons)
cm.commit()
except LockTimeoutException:

View File

@@ -1,19 +1,33 @@
import copy
from decimal import Decimal
import json
import logging
import urllib.error
from datetime import date, timedelta
from decimal import ROUND_HALF_UP, Decimal
import vat_moss.exchange_rates
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.db.models import Count
from django.dispatch import receiver
from django.utils import timezone
from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.async import TransactionAwareTask
from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import periodic_task
from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction
logger = logging.getLogger(__name__)
@transaction.atomic
def build_invoice(invoice: Invoice) -> Invoice:
@@ -33,6 +47,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
try:
ia = invoice.order.invoice_address
addr_template = pgettext("invoice", """{i.company}
{i.name}
{i.street}
@@ -44,7 +59,31 @@ def build_invoice(invoice: Invoice) -> Invoice:
).strip()
if invoice.order.invoice_address.vat_id:
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % invoice.order.invoice_address.vat_id
cc = str(invoice.order.invoice_address.country)
if cc in EU_CURRENCIES and EU_CURRENCIES[cc] != invoice.event.currency:
invoice.foreign_currency_display = EU_CURRENCIES[cc]
if settings.FETCH_ECB_RATES:
gs = GlobalSettingsObject()
rates_date = gs.settings.get('ecb_rates_date', as_type=date)
rates_dict = gs.settings.get('ecb_rates_dict', as_type=dict)
convert = (
rates_date and rates_dict and
rates_date > (now() - timedelta(days=7)).date() and
invoice.event.currency in rates_dict and
invoice.foreign_currency_display in rates_dict
)
if convert:
invoice.foreign_currency_rate = (
Decimal(rates_dict[invoice.foreign_currency_display])
/ Decimal(rates_dict[invoice.event.currency])
).quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rates_date
except InvoiceAddress.DoesNotExist:
ia = None
invoice.invoice_to = ""
invoice.file = None
@@ -52,10 +91,13 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.lines.all().delete()
positions = list(
invoice.order.positions.select_related('addon_to', 'item', 'variation').annotate(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'variation').annotate(
addon_c=Count('addons')
)
)
reverse_charge = False
positions.sort(key=lambda p: p.sort_key)
for p in positions:
if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c:
@@ -69,15 +111,29 @@ def build_invoice(invoice: Invoice) -> Invoice:
InvoiceLine.objects.create(
invoice=invoice, description=desc,
gross_value=p.price, tax_value=p.tax_value,
tax_rate=p.tax_rate
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
)
if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value:
reverse_charge = True
if reverse_charge:
if invoice.additional_text:
invoice.additional_text += "<br /><br />"
invoice.additional_text += pgettext(
"invoice",
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
"rests with the service recipient."
)
invoice.save()
if invoice.order.payment_fee:
InvoiceLine.objects.create(
invoice=invoice,
description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)),
gross_value=invoice.order.payment_fee, tax_value=invoice.order.payment_fee_tax_value,
tax_rate=invoice.order.payment_fee_tax_rate
tax_rate=invoice.order.payment_fee_tax_rate,
tax_name=invoice.order.payment_fee_tax_rule.name if invoice.order.payment_fee_tax_rule else ''
)
return invoice
@@ -200,3 +256,20 @@ def build_preview_invoice_pdf(event):
tax_rate=19
)
return event.invoice_renderer.generate(invoice)
@receiver(signal=periodic_task)
def fetch_ecb_rates(sender, **kwargs):
if not settings.FETCH_ECB_RATES:
return
gs = GlobalSettingsObject()
if gs.settings.ecb_rates_date == now().strftime("%Y-%m-%d"):
return
try:
date, rates = vat_moss.exchange_rates.fetch()
gs.settings.ecb_rates_date = date
gs.settings.ecb_rates_dict = json.dumps(rates, cls=DjangoJSONEncoder)
except urllib.error.URLError:
logger.exception('Could not retrieve rates from ECB')

View File

@@ -24,6 +24,7 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import CachedTicket, InvoiceAddress
from pretix.base.models.tax import TaxedPrice
from pretix.base.payment import BasePaymentProvider
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.async import ProfiledTask
@@ -236,7 +237,7 @@ def _check_date(event: Event, now_dt: datetime):
raise OrderError(error_messages['ended'])
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]):
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None):
err = None
errargs = None
_check_date(event, now_dt)
@@ -293,7 +294,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
continue
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to)
addon_to=cp.addon_to, invoice_address=address)
if price is False or len(quotas) == 0:
err = err or error_messages['unavailable']
@@ -306,9 +307,10 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.delete()
continue
if price != cp.price and not (cp.item.free_price and cp.price > price):
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
positions[i] = cp
cp.price = price
cp.price = price.gross
cp.includes_tax = bool(price.rate)
cp.save()
err = err or error_messages['price_changed']
continue
@@ -389,20 +391,17 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
payment_provider=payment_provider.identifier,
meta_info=json.dumps(meta_info or {}),
)
if address:
if address.order is not None:
address.pk = None
address.order = order
address.save()
order._calculate_tax() # Might have changed due to new invoice address
order.save()
OrderPosition.transform_cart_positions(positions, order)
if address is not None:
try:
addr = InvoiceAddress.objects.get(
pk=address
)
if addr.order is not None:
addr.pk = None
addr.order = order
addr.save()
except InvoiceAddress.DoesNotExist:
pass
order.log_action('pretix.event.order.placed')
order_placed.send(event, order=order)
@@ -417,6 +416,13 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if not pprov:
raise OrderError(error_messages['internal'])
addr = None
if address is not None:
try:
addr = InvoiceAddress.objects.get(pk=address)
except InvoiceAddress.DoesNotExist:
pass
with event.lock() as now_dt:
positions = list(CartPosition.objects.filter(
id__in=position_ids).select_related('item', 'variation', 'subevent'))
@@ -424,9 +430,9 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions)
_check_positions(event, now_dt, positions, address=addr)
order = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=address, meta_info=meta_info)
locale=locale, address=addr, meta_info=meta_info)
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
if not order.invoices.exists():
@@ -597,7 +603,8 @@ class OrderChangeManager:
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent)
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
invoice_address=self._invoice_address)
if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid'])
@@ -607,16 +614,17 @@ class OrderChangeManager:
if not new_quotas:
raise OrderError(self.error_messages['quota_missing'])
if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'):
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff = price - position.price
self._totaldiff += price.gross - position.price
self._quotadiff.update(new_quotas)
self._quotadiff.subtract(position.quotas)
self._operations.append(self.ItemOperation(position, item, variation, price))
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent)
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid'])
@@ -626,24 +634,48 @@ class OrderChangeManager:
if not new_quotas:
raise OrderError(self.error_messages['quota_missing'])
if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'):
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff = price - position.price
self._totaldiff += price.gross - position.price
self._quotadiff.update(new_quotas)
self._quotadiff.subtract(position.quotas)
self._operations.append(self.SubeventOperation(position, subevent, price))
def change_price(self, position: OrderPosition, price: Decimal):
self._totaldiff = price - position.price
price = position.item.tax(price)
if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'):
self._totaldiff += price.gross - position.price
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._operations.append(self.PriceOperation(position, price))
def recalculate_taxes(self):
positions = self.order.positions.select_related('item', 'item__tax_rule')
ia = self._invoice_address
for pos in positions:
if not pos.item.tax_rule:
continue
if not pos.price:
continue
charge_tax = pos.item.tax_rule.tax_applicable(ia)
if pos.tax_value and not charge_tax:
net_price = pos.price - pos.tax_value
price = TaxedPrice(gross=net_price, net=net_price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
if price.gross != pos.price:
self._totaldiff += price.gross - pos.price
self._operations.append(self.PriceOperation(pos, price))
elif charge_tax and not pos.tax_value:
price = pos.item.tax(pos.price, base_price_is='net')
if price.gross != pos.price:
self._totaldiff += price.gross - pos.price
self._operations.append(self.PriceOperation(pos, price))
def cancel(self, position: OrderPosition):
self._totaldiff = -position.price
self._totaldiff += -position.price
self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position))
@@ -653,7 +685,13 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None):
if price is None:
price = get_price(item, variation, subevent=subevent)
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
if item.tax_rule.tax_applicable(self._invoice_address):
price = item.tax(price, base_price_is='gross')
else:
price = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
if price is None:
raise OrderError(self.error_messages['product_invalid'])
if not addon_to and item.category and item.category.is_addon:
@@ -669,10 +707,10 @@ class OrderChangeManager:
if not new_quotas:
raise OrderError(self.error_messages['quota_missing'])
if self.order.event.settings.invoice_include_free or price != Decimal('0.00'):
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff = price
self._totaldiff += price.gross
self._quotadiff.update(new_quotas)
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent))
@@ -712,12 +750,14 @@ class OrderChangeManager:
'new_variation': op.variation.pk if op.variation else None,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.price
'new_price': op.price.gross
})
op.position.item = op.item
op.position.variation = op.variation
op.position.price = op.price
op.position._calculate_tax()
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.item.tax_rule
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, data={
@@ -726,11 +766,13 @@ class OrderChangeManager:
'old_subevent': op.position.subevent.pk,
'new_subevent': op.subevent.pk,
'old_price': op.position.price,
'new_price': op.price
'new_price': op.price.gross
})
op.position.subevent = op.subevent
op.position.price = op.price
op.position._calculate_tax()
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.position.item.tax_rule
op.position.save()
elif isinstance(op, self.PriceOperation):
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
@@ -738,10 +780,12 @@ class OrderChangeManager:
'positionid': op.position.positionid,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.price
'new_price': op.price.gross
})
op.position.price = op.price
op.position._calculate_tax()
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.position.item.tax_rule
op.position.save()
elif isinstance(op, self.CancelOperation):
for opa in op.position.addons.all():
@@ -765,7 +809,8 @@ class OrderChangeManager:
elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price, order=self.order,
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent
)
nextposid += 1
@@ -774,7 +819,7 @@ class OrderChangeManager:
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price,
'price': op.price.gross,
'positionid': pos.positionid,
'subevent': op.subevent.pk if op.subevent else None,
})
@@ -802,6 +847,13 @@ class OrderChangeManager:
if cancels == self.order.positions.count():
raise OrderError(self.error_messages['complete_cancel'])
@property
def _invoice_address(self):
try:
return self.order.invoice_address
except InvoiceAddress.DoesNotExist:
return None
def _notify_user(self):
with language(self.order.locale):
try:

View File

@@ -1,21 +1,21 @@
from decimal import Decimal
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, Item, ItemAddOn, ItemVariation, Voucher,
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
def get_price(item: Item, variation: ItemVariation = None,
voucher: Voucher = None, custom_price: Decimal = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
addon_to: AbstractPosition = None):
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
if iao.price_included:
return Decimal('0.00')
return TAXED_ZERO
except ItemAddOn.DoesNotExist:
pass
@@ -32,13 +32,31 @@ def get_price(item: Item, variation: ItemVariation = None,
if voucher:
price = voucher.calculate_price(price)
if item.tax_rule:
tax_rule = item.tax_rule
else:
tax_rule = TaxRule(
name='',
rate=Decimal('0.00'),
price_includes_tax=True,
eu_reverse_charge=False,
)
price = tax_rule.tax(price)
if item.free_price and custom_price is not None and custom_price != "":
if not isinstance(custom_price, Decimal):
custom_price = Decimal(str(custom_price).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
if custom_price_is_net:
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
price = max(custom_price, price)
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net')
else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross')
if invoice_address and not tax_rule.tax_applicable(invoice_address):
price.tax = Decimal('0.00')
price.rate = Decimal('0.00')
price.gross = price.net
price.name = ''
return price

View File

@@ -1,4 +1,3 @@
import decimal
import json
from datetime import datetime
@@ -10,6 +9,7 @@ from hierarkey.models import GlobalSettingsBase, Hierarkey
from i18nfield.strings import LazyI18nString
from typing import Any
from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper
DEFAULTS = {
@@ -102,8 +102,8 @@ DEFAULTS = {
'type': bool
},
'tax_rate_default': {
'default': '0.00',
'type': decimal.Decimal
'default': None,
'type': TaxRule
},
'invoice_generate': {
'default': 'False',

View File

@@ -19,11 +19,11 @@ class AsyncAction:
error_url = None
known_errortypes = []
def do(self, *args):
def do(self, *args, **kwargs):
if not isinstance(self.task, app.Task):
raise TypeError('Method has no task attached')
res = self.task.apply_async(args=args)
res = self.task.apply_async(args=args, kwargs=kwargs)
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
data = self._return_ajax_result(res)

View File

@@ -9,7 +9,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pytz import common_timezones, timezone
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.models import Event, Organizer
from pretix.base.models import Event, Organizer, TaxRule
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.control.forms import ExtFileField, SlugWidget
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -58,6 +58,13 @@ class EventWizardBasicsForm(I18nModelForm):
choices=settings.LANGUAGES,
label=_("Default language"),
)
tax_rate = forms.DecimalField(
label=_("Sales tax rate"),
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
"here in percent. If you have a more complicated tax situation, you can add more tax rates and "
"detailled configuration later."),
required=False
)
class Meta:
model = Event
@@ -375,10 +382,12 @@ class PaymentSettingsForm(SettingsForm):
"configured above."),
required=False
)
tax_rate_default = forms.DecimalField(
label=_('Tax rate for payment fees'),
help_text=_("The tax rate that applies for additional fees you configured for single payment methods "
"(in percent)."),
tax_rate_default = forms.ModelChoiceField(
queryset=TaxRule.objects.none(),
label=_('Tax rule for payment fees'),
required=False,
help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This "
"will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.")
)
def clean(self):
@@ -392,6 +401,10 @@ class PaymentSettingsForm(SettingsForm):
)
return cleaned_data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['tax_rate_default'].queryset = self.obj.tax_rules.all()
class ProviderForm(SettingsForm):
"""
@@ -777,3 +790,9 @@ class CommentForm(I18nModelForm):
'class': 'helper-width-100',
}),
}
class TaxRuleForm(I18nModelForm):
class Meta:
model = TaxRule
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country']

View File

@@ -138,6 +138,8 @@ class ItemCreateForm(I18nModelForm):
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
self.fields['tax_rule'].empty_label = _('No taxation')
self.fields['copy_from'] = forms.ModelChoiceField(
label=_("Copy product information"),
queryset=self.event.items.all(),
@@ -250,7 +252,7 @@ class ItemCreateForm(I18nModelForm):
'category',
'admission',
'default_price',
'tax_rate',
'tax_rule',
'allow_cancel'
]
@@ -259,6 +261,7 @@ class ItemUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
class Meta:
model = Item
@@ -272,7 +275,7 @@ class ItemUpdateForm(I18nModelForm):
'picture',
'default_price',
'free_price',
'tax_rate',
'tax_rule',
'available_from',
'available_until',
'require_voucher',

View File

@@ -7,7 +7,9 @@ from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.models import Item, ItemAddOn, Order, OrderPosition
from pretix.base.models import (
InvoiceAddress, Item, ItemAddOn, Order, OrderPosition,
)
from pretix.base.models.event import SubEvent
from pretix.base.services.pricing import get_price
@@ -66,6 +68,22 @@ class SubEventChoiceField(forms.ModelChoiceField):
p, self.instance.order.event.currency)
class OtherOperationsForm(forms.Form):
recalculate_taxes = forms.BooleanField(
label=_('Re-calculate taxes'),
required=False,
help_text=_(
'This operation re-checks if taxes should be paid to the items due to e.g. configured reverse charge rules '
'and changes the prices and tax values accordingly. This is useful e.g. after an invoice address change. '
'Use with care and only if you need to. Note that rounding differences might occur in this procedure.'
)
)
def __init__(self, *args, **kwargs):
kwargs.pop('order')
super().__init__(*args, **kwargs)
class OrderPositionAddForm(forms.Form):
do = forms.BooleanField(
label=_('Add a new product to the order'),
@@ -83,7 +101,7 @@ class OrderPositionAddForm(forms.Form):
required=False,
max_digits=10, decimal_places=2,
label=_('Gross price'),
help_text=_("Keep empty for the product's default price")
help_text=_("Including taxes, if any. Keep empty for the product's default price")
)
subevent = forms.ModelChoiceField(
SubEvent.objects.none(),
@@ -95,6 +113,12 @@ class OrderPositionAddForm(forms.Form):
def __init__(self, *args, **kwargs):
order = kwargs.pop('order')
super().__init__(*args, **kwargs)
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
choices = []
for i in order.event.items.prefetch_related('variations').all():
pname = str(i.name)
@@ -103,12 +127,12 @@ class OrderPositionAddForm(forms.Form):
variations = list(i.variations.all())
if variations:
for v in variations:
p = get_price(i, v, invoice_address=ia)
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s (%s %s)' % (pname, v.value, localize(v.price),
order.event.currency)))
'%s %s (%s %s)' % (pname, v.value, p, order.event.currency)))
else:
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(i.default_price),
order.event.currency)))
p = get_price(i, invoice_address=ia)
choices.append((str(i.pk), '%s (%s %s)' % (pname, p, order.event.currency)))
self.fields['itemvar'].choices = choices
if ItemAddOn.objects.filter(base_item__event=order.event).exists():
self.fields['addon_to'].queryset = order.positions.filter(addon_to__isnull=True).select_related(
@@ -150,6 +174,12 @@ class OrderPositionChangeForm(forms.Form):
def __init__(self, *args, **kwargs):
instance = kwargs.pop('instance')
initial = kwargs.get('initial', {})
try:
ia = instance.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
if instance:
try:
if instance.variation:
@@ -159,7 +189,10 @@ class OrderPositionChangeForm(forms.Form):
except Item.DoesNotExist:
pass
initial['price'] = instance.price
if instance.item.tax_rule and not instance.item.tax_rule.price_includes_tax:
initial['price'] = instance.price - instance.tax_value
else:
initial['price'] = instance.price
initial['subevent'] = instance.subevent
kwargs['initial'] = initial
@@ -169,20 +202,24 @@ class OrderPositionChangeForm(forms.Form):
self.fields['subevent'].queryset = instance.order.event.subevents.all()
else:
del self.fields['subevent']
choices = []
for i in instance.order.event.items.prefetch_related('variations').all():
pname = str(i.name)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
variations = list(i.variations.all())
if variations:
for v in variations:
p = get_price(i, v, voucher=instance.voucher, subevent=instance.subevent)
p = get_price(i, v, voucher=instance.voucher, subevent=instance.subevent,
invoice_address=ia)
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s (%s %s)' % (pname, v.value, localize(p),
instance.order.event.currency)))
else:
p = get_price(i, None, voucher=instance.voucher, subevent=instance.subevent)
p = get_price(i, None, voucher=instance.voucher, subevent=instance.subevent,
invoice_address=ia)
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(p),
instance.order.event.currency)))
self.fields['itemvar'].choices = choices

View File

@@ -146,6 +146,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.question.added': _('The question has been added.'),
'pretix.event.question.deleted': _('The question has been deleted.'),
'pretix.event.question.changed': _('The question has been modified.'),
'pretix.event.taxrule.added': _('The tax rule has been added.'),
'pretix.event.taxrule.deleted': _('The tax rule has been deleted.'),
'pretix.event.taxrule.changed': _('The tax rule has been changed.'),
'pretix.event.settings': _('The event settings have been changed.'),
'pretix.event.tickets.settings': _('The ticket download settings have been changed.'),
'pretix.event.plugins.enabled': _('A plugin has been enabled.'),

View File

@@ -10,10 +10,10 @@
{% bootstrap_field form.invoice_address_asked layout="horizontal" %}
{% bootstrap_field form.invoice_address_required layout="horizontal" %}
{% bootstrap_field form.invoice_name_required layout="horizontal" %}
{% bootstrap_field form.invoice_generate layout="horizontal" %}
{% bootstrap_field form.invoice_address_vatid layout="horizontal" %}
{% bootstrap_field form.invoice_numbers_consecutive layout="horizontal" %}
{% bootstrap_field form.invoice_numbers_prefix layout="horizontal" %}
{% bootstrap_field form.invoice_generate layout="horizontal" %}
{% bootstrap_field form.invoice_renderer layout="horizontal" %}
{% bootstrap_field form.invoice_language layout="horizontal" %}
{% bootstrap_field form.invoice_include_free layout="horizontal" %}

View File

@@ -36,6 +36,11 @@
{% trans "Email" %}
</a>
</li>
<li {% if "event.settings.tax" in url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.settings.tax' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "Tax rules" %}
</a>
</li>
<li {% if "event.settings.invoice" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.settings.invoice' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "Invoicing" %}

View File

@@ -0,0 +1,26 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete tax rule" %}{% endblock %}
{% block inside %}
<legend>{% trans "Delete tax rule" %}</legend>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if possible %}
<p>{% blocktrans %}Are you sure you want to delete the tax rule <strong>{{ taxrule }}</strong>?{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans %}You cannot delete a tax rule that is in use for a product or has been in use for any existing orders.{% endblocktrans %}</p>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.settings.tax" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn
btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if possible %}
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
{% endif %}
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% if rule %}
{% blocktrans with name=rule.name %}Tax rule: {{ name }}{% endblocktrans %}
{% else %}
{% trans "Tax rule" %}
{% endif %}
{% endblock %}
{% block inside %}
{% if rule %}
<legend>{% blocktrans with name=rule.name %}Tax rule: {{ name }}{% endblocktrans %}</legend>
{% else %}
<legend>{% trans "Tax rule" %}</legend>
{% endif %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.rate layout="horizontal" %}
<legend>{% trans "Advanced settings" %}</legend>
<div class="alert alert-warning">
<span class="fa fa-w 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
of taxes in your ticket shop. If in doubt, please contact a lawyer or tax consultant.
{% endblocktrans %}
<div class="clearfix"></div>
</div>
{% bootstrap_field form.price_includes_tax layout="horizontal" %}
{% bootstrap_field form.eu_reverse_charge layout="horizontal" %}
{% bootstrap_field form.home_country layout="horizontal" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% block title %}{% trans "Tax rules" %}{% endblock %}
{% block inside %}
<legend>{% trans "Tax rules" %}</legend>
{% if taxrules|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any tax rules yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for tr in taxrules %}
<tr>
<td>
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.name }}
</a></strong>
</td>
<td>{{ tr.rate }} %</td>
<td class="text-right">
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -33,6 +33,7 @@
{% bootstrap_field form.date_to layout="horizontal" %}
{% bootstrap_field form.location layout="horizontal" %}
{% bootstrap_field form.currency layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Display settings" %}</legend>

View File

@@ -31,7 +31,7 @@
<fieldset>
<legend>{% trans "Price settings" %}</legend>
{% bootstrap_field form.default_price layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
{% bootstrap_field form.tax_rule layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">

View File

@@ -18,7 +18,7 @@
<fieldset>
<legend>{% trans "Price settings" %}</legend>
{% bootstrap_field form.default_price layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
{% bootstrap_field form.tax_rule layout="horizontal" %}
{% bootstrap_field form.free_price layout="horizontal" %}
</fieldset>
<fieldset>

View File

@@ -103,8 +103,18 @@
{% if position.form.operation.value == "price" %}checked="checked"{% endif %}>
{% trans "Change price to" %}
{% bootstrap_field position.form.price layout='inline' %}
{% if request.event.settings.display_net_prices %}
<em>{% trans "Enter a gross price including taxes." %}</em>
{% if position.apply_tax %}
{% if position.item.tax_rule and not position.item.tax_rule.price_includes_tax %}
{% blocktrans trimmed with rate=position.item.tax_rule.rate name=position.item.tax_rule.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}
{% elif position.item.tax_rule %}
{% blocktrans trimmed with rate=position.item.tax_rule.rate name=position.item.tax_rule.name %}
<strong>incl.</strong> {{ rate }}% {{ name }}
{% endblocktrans %}
{% endif %}
{% else %}
{% trans "no taxes apply" %}
{% endif %}
</label>
</div>
@@ -150,6 +160,24 @@
</div>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
Other operations
</h3>
</div>
<div class="panel-body">
<div class="form-horizontal">
{% bootstrap_form_errors other_form %}
{% if other_form.custom_error %}
<div class="alert alert-danger">
{{ other_form.custom_error }}
</div>
{% endif %}
{% bootstrap_field other_form.recalculate_taxes layout='horizontal' %}
</div>
</div>
</div>
<div class="form-group submit-group">
<a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">

View File

@@ -9,6 +9,7 @@
{% endblocktrans %}
{% endblock %}
{% block content %}
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
<h1>
{% blocktrans trimmed with code=order.code %}
Order details: {{ code }}
@@ -239,16 +240,22 @@
{% if event.settings.display_net_prices %}
<strong>{{ event.currency }} {{ line.net_price|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% endblocktrans %}</small>
<br />
<small>
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% else %}
<strong>{{ event.currency }} {{ line.price|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
<br />
<small>
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% endif %}
</div>
@@ -265,17 +272,21 @@
<strong>{{ event.currency }} {{ order.payment_fee_net|floatformat:2 }}</strong>
{% if order.payment_fee_tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% endblocktrans %}</small>
<small>
{% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% else %}
<strong>{{ event.currency }} {{ items.payment_fee|floatformat:2 }}</strong>
{% if order.payment_fee_tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
<small>
{% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% endif %}
</div>
@@ -358,7 +369,20 @@
<dd>{{ order.invoice_address.country.name|default:order.invoice_address.country_old }}</dd>
{% if request.event.settings.invoice_address_vatid %}
<dt>{% trans "VAT ID" %}</dt>
<dd>{{ order.invoice_address.vat_id }}</dd>
<dd>
{{ order.invoice_address.vat_id }}
{% if order.invoice_address.vat_id_validated %}
<span class="fa fa-check" data-toggle="tooltip" title="{% blocktrans trimmed %}Valid EU VAT ID{% endblocktrans %}"></span>
{% else %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.checkvatid" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% csrf_token %}
<button class="btn btn-default btn-xs">
{% trans "Check" %}
</button>
</form>
{% endif %}
</dd>
{% endif %}
</dl>
</div>

View File

@@ -69,6 +69,10 @@ urlpatterns = [
url(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
url(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
url(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
url(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'),
url(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'),
url(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'),
url(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'),
url(r'^subevents/(?P<subevent>\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'),
url(r'^subevents/(?P<subevent>\d+)/delete$', subevents.SubEventDelete.as_view(),
@@ -131,6 +135,8 @@ urlpatterns = [
url(r'^orders/(?P<code>[0-9A-Z]+)/answer/(?P<answer>[^/]+)/$',
orders.AnswerDownload.as_view(),
name='event.order.download.answer'),
url(r'^orders/(?P<code>[0-9A-Z]+)/checkvatid', orders.OrderCheckVATID.as_view(),
name='event.order.checkvatid'),
url(r'^orders/(?P<code>[0-9A-Z]+)/extend$', orders.OrderExtend.as_view(),
name='event.order.extend'),
url(r'^orders/(?P<code>[0-9A-Z]+)/contact$', orders.OrderContactChange.as_view(),

View File

@@ -9,22 +9,24 @@ from django.core.files import File
from django.core.urlresolvers import reverse
from django.db import transaction
from django.http import (
HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse,
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.utils import translation
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.views.generic import FormView, ListView
from django.utils.translation import ugettext, ugettext_lazy as _
from django.views.generic import DeleteView, FormView, ListView
from django.views.generic.base import TemplateView, View
from django.views.generic.detail import SingleObjectMixin
from i18nfield.strings import LazyI18nString
from pytz import timezone
from pretix.base.models import (
CachedTicket, Event, Item, ItemVariation, LogEntry, Order, RequiredAction,
Voucher,
CachedTicket, Event, Item, ItemVariation, LogEntry, Order, OrderPosition,
RequiredAction, TaxRule, Voucher,
)
from pretix.base.services import tickets
from pretix.base.services.invoices import build_preview_invoice_pdf
@@ -32,13 +34,13 @@ from pretix.base.signals import event_live_issues, register_ticket_outputs
from pretix.control.forms.event import (
CommentForm, DisplaySettingsForm, EventSettingsForm, EventUpdateForm,
InvoiceSettingsForm, MailSettingsForm, PaymentSettingsForm, ProviderForm,
TicketSettingsForm,
TaxRuleForm, TicketSettingsForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.urls import build_absolute_uri
from pretix.presale.style import regenerate_css
from . import UpdateView
from . import CreateView, UpdateView
from ..logdisplay import OVERVIEW_BLACKLIST
@@ -787,3 +789,129 @@ class EventComment(EventPermissionRequiredMixin, View):
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})
class TaxList(EventPermissionRequiredMixin, ListView):
model = TaxRule
context_object_name = 'taxrules'
paginate_by = 30
template_name = 'pretixcontrol/event/tax_index.html'
permission = 'can_change_event_settings'
def get_queryset(self):
return self.request.event.tax_rules.all()
class TaxCreate(EventPermissionRequiredMixin, CreateView):
model = TaxRule
form_class = TaxRuleForm
template_name = 'pretixcontrol/event/tax_edit.html'
permission = 'can_change_event_settings'
context_object_name = 'taxrule'
def get_success_url(self) -> str:
return reverse('control:event.settings.tax', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
def get_initial(self):
return {
'name': LazyI18nString.from_gettext(ugettext('VAT'))
}
@transaction.atomic
def form_valid(self, form):
form.instance.event = self.request.event
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))
return ret
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class TaxUpdate(EventPermissionRequiredMixin, UpdateView):
model = TaxRule
form_class = TaxRuleForm
template_name = 'pretixcontrol/event/tax_edit.html'
permission = 'can_change_event_settings'
context_object_name = 'rule'
def get_object(self, queryset=None) -> TaxRule:
try:
return self.request.event.tax_rules.get(
id=self.kwargs['rule']
)
except TaxRule.DoesNotExist:
raise Http404(_("The requested tax rule does not exist."))
@transaction.atomic
def form_valid(self, form):
messages.success(self.request, _('Your changes have been saved.'))
if form.has_changed():
self.object.log_action(
'pretix.event.taxrule.changed', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
)
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse('control:event.settings.tax', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
return super().form_invalid(form)
class TaxDelete(EventPermissionRequiredMixin, DeleteView):
model = TaxRule
template_name = 'pretixcontrol/event/tax_delete.html'
permission = 'can_change_event_settings'
context_object_name = 'taxrule'
def get_object(self, queryset=None) -> TaxRule:
try:
return self.request.event.tax_rules.get(
id=self.kwargs['rule']
)
except TaxRule.DoesNotExist:
raise Http404(_("The requested tax rule does not exist."))
@transaction.atomic
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
success_url = self.get_success_url()
if self.is_allowed():
self.object.log_action(action='pretix.event.taxrule.deleted', user=request.user)
self.object.delete()
messages.success(self.request, _('The selected tax rule has been deleted.'))
else:
messages.error(self.request, _('The selected tax rule can not be deleted.'))
return redirect(success_url)
def get_success_url(self) -> str:
return reverse('control:event.settings.tax', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
def is_allowed(self) -> bool:
o = self.object
return (
not self.request.event.orders.filter(payment_fee_tax_rule=o).exists()
and not OrderPosition.objects.filter(tax_rule=o, order__event=self.request.event).exists()
and not self.request.event.items.filter(tax_rule=o).exists()
and self.request.event.settings.tax_rate_default != o
)
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['possible'] = self.is_allowed()
return context

View File

@@ -775,6 +775,13 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
'item': self.object.id,
})
def get_initial(self):
initial = super().get_initial()
trs = list(self.request.event.tax_rules.all())
if len(trs) == 1:
initial['tax_rule'] = trs[0]
return initial
@transaction.atomic
def form_valid(self, form):
messages.success(self.request, _('Your changes have been saved.'))

View File

@@ -6,10 +6,11 @@ from django.http import JsonResponse
from django.shortcuts import redirect
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext, ugettext_lazy as _
from django.views import View
from django.views.generic import ListView
from formtools.wizard.views import SessionWizardView
from i18nfield.strings import LazyI18nString
from pretix.base.models import Event, Team
from pretix.control.forms.event import (
@@ -118,6 +119,12 @@ class EventWizard(SessionWizardView):
active=True
)
if basics_data['tax_rate']:
event.settings.tax_rate_default = event.tax_rules.create(
name=LazyI18nString.from_gettext(ugettext('VAT')),
rate=basics_data['tax_rate']
)
logdata = {}
for f in form_list:
logdata.update({

View File

@@ -1,8 +1,10 @@
import logging
import mimetypes
import os
from datetime import timedelta
import pytz
import vat_moss.id
from django.conf import settings
from django.contrib import messages
from django.core.urlresolvers import reverse
@@ -25,6 +27,7 @@ from pretix.base.models import (
generate_position_secret, generate_secret,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.services.export import export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
@@ -42,12 +45,15 @@ from pretix.control.forms.filter import EventOrderFilterForm
from pretix.control.forms.orders import (
CommentForm, ExporterForm, ExtendForm, OrderContactForm, OrderLocaleForm,
OrderMailForm, OrderPositionAddForm, OrderPositionChangeForm,
OtherOperationsForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.safedownload import check_token
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__)
class OrderList(EventPermissionRequiredMixin, ListView):
model = Order
@@ -144,7 +150,7 @@ class OrderDetail(OrderView):
cartpos = queryset.order_by(
'item', 'variation'
).select_related(
'item', 'variation', 'addon_to'
'item', 'variation', 'addon_to', 'tax_rule'
).prefetch_related(
'item__questions', 'answers', 'answers__question', 'checkins'
).order_by('positionid')
@@ -277,6 +283,54 @@ class OrderInvoiceCreate(OrderView):
return HttpResponseNotAllowed(['POST'])
class OrderCheckVATID(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
messages.error(self.request, _('No VAT ID specified.'))
return redirect(self.get_order_url())
else:
if not ia.vat_id:
messages.error(self.request, _('No VAT ID specified.'))
return redirect(self.get_order_url())
if not ia.country:
messages.error(self.request, _('No country specified.'))
return redirect(self.get_order_url())
if str(ia.country) not in EU_COUNTRIES:
messages.error(self.request, _('VAT ID could not be checked since a non-EU country has been '
'specified.'))
return redirect(self.get_order_url())
if ia.vat_id[:2] != str(ia.country):
messages.error(self.request, _('Your VAT ID does not match the selected country.'))
return redirect(self.get_order_url())
try:
result = vat_moss.id.validate(ia.vat_id)
if result:
country_code, normalized_id, company_name = result
ia.vat_id_validated = True
ia.vat_id = normalized_id
ia.save()
except vat_moss.errors.InvalidError:
messages.error(self.request, _('This VAT ID is not valid.'))
except vat_moss.errors.WebServiceUnavailableError:
logger.exception('VAT ID checking failed for country {}'.format(ia.country))
messages.error(self.request, _('The VAT ID could not be checked, as the VAT checking service of '
'the country is currently not available.'))
else:
messages.success(self.request, _('This VAT ID is valid.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs): # NOQA
return HttpResponseNotAllowed(['POST'])
class OrderInvoiceRegenerate(OrderView):
permission = 'can_change_orders'
@@ -458,6 +512,11 @@ class OrderChange(OrderView):
return self._redirect_back()
return super().dispatch(request, *args, **kwargs)
@cached_property
def other_form(self):
return OtherOperationsForm(prefix='other', order=self.order,
data=self.request.POST if self.request.method == "POST" else None)
@cached_property
def add_form(self):
return OrderPositionAddForm(prefix='add', order=self.order,
@@ -469,14 +528,28 @@ class OrderChange(OrderView):
for p in positions:
p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p,
data=self.request.POST if self.request.method == "POST" else None)
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
p.apply_tax = p.item.tax_rule and p.item.tax_rule.tax_applicable(invoice_address=ia)
return positions
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['positions'] = self.positions
ctx['add_form'] = self.add_form
ctx['other_form'] = self.other_form
return ctx
def _process_other(self, ocm):
if not self.other_form.is_valid():
return False
else:
if self.other_form.cleaned_data['recalculate_taxes']:
ocm.recalculate_taxes()
return True
def _process_add(self, ocm):
if not self.add_form.is_valid():
return False
@@ -534,7 +607,7 @@ class OrderChange(OrderView):
def post(self, *args, **kwargs):
ocm = OrderChangeManager(self.order, self.request.user)
form_valid = self._process_add(ocm) and self._process_change(ocm)
form_valid = self._process_add(ocm) and self._process_change(ocm) and self._process_other(ocm)
if not form_valid:
messages.error(self.request, _('An error occured. Please see the details below.'))

View File

@@ -11,7 +11,7 @@ from django.views.generic.base import TemplateResponseMixin
from pretix.base.models import Order
from pretix.base.models.orders import InvoiceAddress
from pretix.base.services.cart import set_cart_addons
from pretix.base.services.cart import set_cart_addons, update_tax_rates
from pretix.base.services.orders import perform_order
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.forms.checkout import (
@@ -80,6 +80,17 @@ class BaseCheckoutFlowStep:
if n:
return n.get_step_url()
@cached_property
def invoice_address(self):
iapk = self.request.session.get('invoice_address_{}'.format(self.request.event.pk))
if not iapk:
return InvoiceAddress()
try:
return InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
def get_checkout_flow(event):
flow = list([step(event) for step in DEFAULT_FLOW])
@@ -163,7 +174,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
quota_cache = {}
item_cache = {}
for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation'
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
):
current_addon_products = {
a.item_id: a.variation_id for a in cartpos.addons.all()
@@ -244,7 +255,8 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
if not is_valid:
return self.get(request, *args, **kwargs)
return self.do(self.request.event.id, data, self.request.session.session_key)
return self.do(self.request.event.id, data, self.request.session.session_key,
invoice_address=self.invoice_address.pk)
class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
@@ -266,21 +278,17 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
initial=initial)
@cached_property
def invoice_address(self):
iapk = self.request.session.get('invoice_address')
if not iapk:
return InvoiceAddress()
try:
return InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
def eu_reverse_charge_relevant(self):
return any([p.item.tax_rule and p.item.tax_rule.eu_reverse_charge
for p in self.positions])
@cached_property
def invoice_form(self):
return InvoiceAddressForm(data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address)
request=self.request,
instance=self.invoice_address,
validate_vat_id=self.eu_reverse_charge_relevant)
def post(self, request):
self.request = request
@@ -294,9 +302,15 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
request.session['email'] = self.contact_form.cleaned_data['email']
if request.event.settings.invoice_address_asked:
addr = self.invoice_form.save()
request.session['invoice_address'] = addr.pk
request.session['invoice_address_{}'.format(request.event.pk)] = addr.pk
request.session['contact_form_data'] = self.contact_form.cleaned_data
update_tax_rates(
event=request.event,
cart_id=request.session.session_key,
invoice_address=self.invoice_form.instance
)
return redirect(self.get_next_url(request))
def is_completed(self, request, warn=False):
@@ -354,6 +368,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
ctx['formgroups'] = self.formdict.items()
ctx['contact_form'] = self.contact_form
ctx['invoice_form'] = self.invoice_form
ctx['reverse_charge_relevant'] = self.eu_reverse_charge_relevant
return ctx
@@ -387,7 +402,9 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
if p['provider'].identifier == request.POST.get('payment', ''):
request.session['payment'] = p['provider'].identifier
resp = p['provider'].checkout_prepare(
request, self.get_cart(payment_fee=p['provider'].calculate_fee(self._total_order_value)))
request,
self.get_cart()
)
if isinstance(resp, str):
return redirect(resp)
elif resp is True:
@@ -476,16 +493,6 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
def payment_provider(self):
return self.request.event.get_payment_providers().get(self.request.session['payment'])
@cached_property
def invoice_address(self):
try:
return InvoiceAddress.objects.get(
pk=self.request.session.get('invoice_address'),
order__isnull=True
)
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
def get(self, request):
self.request = request
if 'async_id' in request.GET and settings.HAS_CELERY:

View File

@@ -1,8 +1,12 @@
import logging
import os
from decimal import Decimal
from itertools import chain
import vat_moss.errors
import vat_moss.id
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db.models import Count, Prefetch, Q
from django.utils.encoding import force_text
@@ -10,13 +14,15 @@ from django.utils.formats import number_format
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.decimal import round_decimal
from pretix.base.models import ItemVariation, Question
from pretix.base.models.orders import InvoiceAddress, OrderPosition
from pretix.base.models.tax import EU_COUNTRIES, TAXED_ZERO
from pretix.base.templatetags.rich_text import rich_text
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.signals import contact_form_fields, question_form_fields
logger = logging.getLogger(__name__)
class ContactForm(forms.Form):
required_css_class = 'required'
@@ -94,6 +100,8 @@ class InvoiceAddressForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.event = event = kwargs.pop('event')
self.request = kwargs.pop('request', None)
self.validate_vat_id = kwargs.pop('validate_vat_id')
super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:
del self.fields['vat_id']
@@ -115,9 +123,35 @@ class InvoiceAddressForm(forms.ModelForm):
if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
raise ValidationError(_('You need to provide either a company name or your name.'))
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
if data.get('vat_id')[:2] != str(data.get('country')):
raise ValidationError(_('Your VAT ID does not match the selected country.'))
try:
result = vat_moss.id.validate(data.get('vat_id'))
if result:
country_code, normalized_id, company_name = result
self.instance.vat_id_validated = True
self.instance.vat_id = normalized_id
except vat_moss.errors.InvalidError:
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
except vat_moss.errors.WebServiceUnavailableError:
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False
if self.request:
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.'))
else:
self.instance.vat_id_validated = False
class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs):
self.position = kwargs.pop('position')
self.event = kwargs.pop('event')
@@ -309,7 +343,6 @@ class AddOnRadioSelect(forms.RadioSelect):
class AddOnVariationField(forms.ChoiceField):
def valid_value(self, value):
text_value = force_text(value)
for k, v, d in self.choices:
@@ -328,39 +361,37 @@ class AddOnsForm(forms.Form):
variation = item_or_variation
item = item_or_variation.item
price = variation.price
price_net = variation.net_price
label = variation.value
else:
item = item_or_variation
price = item.default_price
price_net = item.default_price_net
label = item.name
if override_price:
price = override_price
tax_value = round_decimal(price * (1 - 100 / (100 + item.tax_rate)))
price_net = price - tax_value
if self.price_included:
price = Decimal('0.00')
price = TAXED_ZERO
else:
price = item.tax(price)
if not price:
if not price.gross:
n = '{name}'.format(
name=label
)
elif not item.tax_rate:
elif not price.rate:
n = _('{name} (+ {currency} {price})').format(
name=label, currency=event.currency, price=number_format(price)
name=label, currency=event.currency, price=number_format(price.gross)
)
elif event.settings.display_net_prices:
n = _('{name} (+ {currency} {price} plus {taxes}% taxes)').format(
name=label, currency=event.currency, price=number_format(price_net),
taxes=number_format(item.tax_rate)
n = _('{name} (+ {currency} {price} plus {taxes}% {taxname})').format(
name=label, currency=event.currency, price=number_format(price.net),
taxes=number_format(price.rate), taxname=price.name
)
else:
n = _('{name} (+ {currency} {price} incl. {taxes}% taxes)').format(
name=label, currency=event.currency, price=number_format(price),
taxes=number_format(item.tax_rate)
n = _('{name} (+ {currency} {price} incl. {taxes}% {taxname})').format(
name=label, currency=event.currency, price=number_format(price.gross),
taxes=number_format(price.rate), taxname=price.name
)
if avail[0] < 20:
@@ -406,7 +437,7 @@ class AddOnsForm(forms.Form):
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(hide_without_voucher=False)
).prefetch_related(
).select_related('tax_rule').prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.filter(subevent=subevent)),

View File

@@ -1,6 +1,7 @@
{% load i18n %}
{% load eventurl %}
{% load safelink %}
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
{% for line in cart.positions %}
<div class="row cart-row {% if download and line.item.admission|default:event.settings.ticket_download_nonadm %}has-downloads{% endif %}">
<div class="product">
@@ -121,16 +122,22 @@
{% if event.settings.display_net_prices %}
<strong>{{ event.currency }} {{ line.net_total|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% endblocktrans %}</small>
<br />
<small>
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% else %}
<strong>{{ event.currency }} {{ line.total|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
<br />
<small>
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% endif %}
</div>
@@ -158,18 +165,22 @@
{% if event.settings.display_net_prices %}
<strong>{{ event.currency }} {{ cart.payment_fee_net|floatformat:2 }}</strong>
{% if cart.payment_fee_tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=cart.payment_fee_tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% endblocktrans %}</small>
<br />
<small>
{% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %}
<strong>plus</strong> {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% else %}
<strong>{{ event.currency }} {{ cart.payment_fee|floatformat:2 }}</strong>
{% if cart.payment_fee_tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=cart.payment_fee_tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
<br />
<small>
{% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
{% endif %}
</div>

View File

@@ -233,20 +233,24 @@
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price"
placeholder="0"
min="{{ var.display_price|stringformat:"0.2f" }}"
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
name="price_{{ item.id }}_{{ var.id }}"
step="any" value="{{ var.display_price|stringformat:"0.2f" }}">
step="any"
value="{% if event.settings.display_net_prices %}{{var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
>
</div>
{% elif event.settings.display_net_prices %}
{{ event.currency }} {{ var.display_price.net|floatformat:2 }}
{% else %}
{{ event.currency }} {{ var.display_price|floatformat:2 }}
{{ event.currency }} {{ var.display_price.gross|floatformat:2 }}
{% endif %}
{% if item.tax_rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% if var.display_price.rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% elif item.tax_rate %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
incl. {{ rate }}% taxes
{% elif var.display_price.rate %}
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
incl. {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% endif %}
</div>
@@ -313,20 +317,23 @@
<div class="input-group input-group-price">
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price" placeholder="0"
min="{{ item.display_price|stringformat:"0.2f" }}"
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
name="price_{{ item.id }}"
step="any" value="{{ item.display_price|stringformat:"0.2f" }}">
value="{% if event.settings.display_net_prices %}{{item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
step="any">
</div>
{% elif event.settings.display_net_prices %}
{{ event.currency }} {{ item.display_price.net|floatformat:2 }}
{% else %}
{{ event.currency }} {{ item.display_price|floatformat:2 }}
{{ event.currency }} {{ item.display_price.gross|floatformat:2 }}
{% endif %}
{% if item.tax_rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% if item.display_price.rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% elif item.tax_rate %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
incl. {{ rate }}% taxes
{% elif item.display_price.rate %}
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
incl. {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% endif %}
</div>

View File

@@ -82,20 +82,22 @@
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price"
placeholder="0"
min="{{ var.price|stringformat:"0.2f" }}"
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
name="price_{{ item.id }}_{{ var.id }}"
step="any"
value="{{ var.display_price|stringformat:"0.2f" }}">
value="{% if event.settings.display_net_prices %}{{var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
step="any">
</div>
{% elif event.settings.display_net_prices %}
{{ event.currency }} {{ var.display_price.net|floatformat:2 }}
{% else %}
{{ event.currency }} {{ var.display_price|floatformat:2 }}
{{ event.currency }} {{ var.display_price.gross|floatformat:2 }}
{% endif %}
{% if item.tax_rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
{% if var.display_price.rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
<strong>plus</strong> {{ rate }}% taxes
{% endblocktrans %}</small>
{% elif item.tax_rate %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
{% elif var.display_price.rate %}
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
@@ -145,20 +147,23 @@
<div class="input-group input-group-price">
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price" placeholder="0"
min="{{ item.price|stringformat:"0.2f" }}"
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
name="price_{{ item.id }}"
step="any" value="{{ item.price|stringformat:"0.2f" }}">
value="{% if event.settings.display_net_prices %}{{ item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
step="any">
</div>
{% elif event.settings.display_net_prices %}
{{ event.currency }} {{ item.display_price.net|floatformat:2 }}
{% else %}
{{ event.currency }} {{ item.price|floatformat:2 }}
{{ event.currency }} {{ item.display_price.gross|floatformat:2 }}
{% endif %}
{% if item.tax_rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
<strong>plus</strong> {{ rate }}% taxes
{% if item.display_price.rate and event.settings.display_net_prices %}
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
<strong>plus</strong> {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% elif item.tax_rate %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
incl. {{ rate }}% taxes
{% elif item.display_price.rate %}
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
incl. {{ rate }}% {{ name }}
{% endblocktrans %}</small>
{% endif %}
</div>

View File

@@ -7,8 +7,8 @@ from django.db.models import Sum
from django.utils.functional import cached_property
from django.utils.timezone import now
from pretix.base.decimal import round_decimal
from pretix.base.models import CartPosition, OrderPosition
from pretix.base.models import CartPosition, InvoiceAddress, OrderPosition
from pretix.base.models.tax import TaxRule
from pretix.presale.signals import question_form_fields
@@ -20,7 +20,7 @@ class CartMixin:
"""
return list(get_cart(self.request))
def get_cart(self, answers=False, queryset=None, payment_fee=None, payment_fee_tax_rate=None, downloads=False):
def get_cart(self, answers=False, queryset=None, order=None, downloads=False):
if queryset:
prefetch = []
if answers:
@@ -90,6 +90,7 @@ class CartMixin:
group.total = group.count * group.price
group.net_total = group.count * group.net_price
group.has_questions = answers and k[0] != ""
group.tax_rule = group.item.tax_rule
if answers:
group.cache_answers()
group.additional_answers = pos_additional_fields.get(group.pk)
@@ -99,14 +100,35 @@ class CartMixin:
net_total = sum(p.net_total for p in positions)
tax_total = sum(p.total - p.net_total for p in positions)
payment_fee = payment_fee if payment_fee is not None else self.get_payment_fee(total)
payment_fee_tax_rate = round_decimal(payment_fee_tax_rate
if payment_fee_tax_rate is not None
else self.request.event.settings.tax_rate_default)
payment_fee_tax_value = round_decimal(payment_fee * (1 - 100 / (100 + payment_fee_tax_rate)))
payment_fee_net = payment_fee - payment_fee_tax_value
tax_total += payment_fee_tax_value
net_total += payment_fee_net
if order:
payment_fee = order.payment_fee
tax_total += order.payment_fee_tax_value
payment_fee_net = order.payment_fee - order.payment_fee_tax_value
net_total += payment_fee_net
payment_fee_tax_rule = order.payment_fee_tax_rule
payment_fee_tax_rate = order.payment_fee_tax_rate
else:
payment_fee = self.get_payment_fee(total)
payment_fee_tax_rule = self.request.event.settings.tax_rate_default or TaxRule.zero()
iapk = self.request.session.get('invoice_address_{}'.format(self.request.event.pk))
ia = None
if payment_fee_tax_rule.eu_reverse_charge and iapk:
try:
ia = InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
except InvoiceAddress.DoesNotExist:
pass
if payment_fee_tax_rule.tax_applicable(ia):
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross')
tax_total += payment_fee_tax.tax
net_total += payment_fee_tax.net
payment_fee_net = payment_fee_tax.net
payment_fee_tax_rate = payment_fee_tax.rate
else:
net_total += payment_fee
payment_fee_net = payment_fee
payment_fee_tax_rate = Decimal('0.00')
try:
first_expiry = min(p.expires for p in positions) if positions else now()
@@ -124,6 +146,7 @@ class CartMixin:
'payment_fee': payment_fee,
'payment_fee_net': payment_fee_net,
'payment_fee_tax_rate': payment_fee_tax_rate,
'payment_fee_tax_rule': payment_fee_tax_rule,
'answers': answers,
'minutes_left': minutes_left,
'first_expiry': first_expiry,
@@ -147,7 +170,8 @@ def get_cart(request):
).order_by(
'item', 'variation'
).select_related(
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer'
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
'item__tax_rule'
).prefetch_related(
'item__questions', 'answers'
)

View File

@@ -6,13 +6,14 @@ from django.db.models import Count, Prefetch, Q
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from django.views.generic import TemplateView, View
from pretix.base.decimal import round_decimal
from pretix.base.models import (
CartPosition, ItemVariation, QuestionAnswer, Quota, SubEvent, Voucher,
CartPosition, InvoiceAddress, ItemVariation, QuestionAnswer, Quota,
SubEvent, Voucher,
)
from pretix.base.services.cart import (
CartError, add_items_to_cart, clear_cart, remove_cart_position,
@@ -37,6 +38,17 @@ class CartActionMixin:
def get_error_url(self):
return self.get_next_url()
@cached_property
def invoice_address(self):
iapk = self.request.session.get('invoice_address_{}'.format(self.request.event.pk))
if not iapk:
return InvoiceAddress()
try:
return InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
def _item_from_post_value(self, key, value, voucher=None):
if value.strip() == '' or '_' not in key:
return
@@ -154,7 +166,8 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs):
items = self._items_from_post_data()
if items:
return self.do(self.request.event.id, items, self.request.session.session_key, translation.get_language())
return self.do(self.request.event.id, items, self.request.session.session_key, translation.get_language(),
self.invoice_address.pk)
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
@@ -190,12 +203,12 @@ class RedeemView(EventViewMixin, TemplateView):
items = items.filter(quotas__in=[self.voucher.quota_id])
items = items.filter(vouchq).select_related(
'category', # for re-grouping
'category', 'tax_rule', # for re-grouping
).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=self.request.event.quotas.filter(subevent=self.subevent)),
Prefetch('variations', to_attr='avail_variations',
Prefetch('variations', to_attr='available_variations',
queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
@@ -217,13 +230,11 @@ class RedeemView(EventViewMixin, TemplateView):
var_price_override = {}
for item in items:
item.available_variations = list(item.variations.filter(active=True, quotas__isnull=False).distinct())
if self.voucher.item_id and self.voucher.variation_id:
item.available_variations = [v for v in item.available_variations if v.pk == self.voucher.variation_id]
item.order_max = item.max_per_order or int(self.request.event.settings.max_items_per_order)
item.has_variations = item.variations.exists()
if not item.has_variations:
item._remove = not bool(item._subevent_quotas)
if self.voucher.allow_ignore_quota or self.voucher.block_quota:
@@ -231,32 +242,32 @@ class RedeemView(EventViewMixin, TemplateView):
else:
item.cached_availability = item.check_quotas(subevent=self.subevent, _cache=quota_cache)
item.price = item_price_override.get(item.pk, item.default_price)
item.price = self.voucher.calculate_price(item.price)
if self.request.event.settings.display_net_prices:
item.price -= round_decimal(item.price * (1 - 100 / (100 + item.tax_rate)))
price = item_price_override.get(item.pk, item.default_price)
price = self.voucher.calculate_price(price)
item.display_price = item.tax(price)
else:
item._remove = False
for var in item.avail_variations:
for var in item.available_variations:
if self.voucher.allow_ignore_quota or self.voucher.block_quota:
var.cached_availability = (Quota.AVAILABILITY_OK, 1)
else:
var.cached_availability = list(var.check_quotas(subevent=self.subevent, _cache=quota_cache))
var.display_price = var_price_override.get(var.pk, var.price)
var.display_price = self.voucher.calculate_price(var.display_price)
if self.request.event.settings.display_net_prices:
var.display_price -= round_decimal(var.display_price * (1 - 100 / (100 + item.tax_rate)))
price = var_price_override.get(var.pk, var.price)
price = self.voucher.calculate_price(price)
var.display_price = item.tax(price)
item.available_variations = [
v for v in item.avail_variations if v._subevent_quotas
v for v in item.available_variations if v._subevent_quotas
]
if self.voucher.variation_id:
item.available_variations = [v for v in item.available_variations
if v.pk == self.voucher.variation_id]
if len(item.available_variations) > 0:
item.min_price = min([v.display_price for v in item.avail_variations])
item.max_price = max([v.display_price for v in item.avail_variations])
item.min_price = min([v.display_price.net if self.request.event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.max_price = max([v.display_price.net if self.request.event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
items = [item for item in items
if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove]

View File

@@ -17,7 +17,6 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView
from pretix.base.decimal import round_decimal
from pretix.base.models import ItemVariation
from pretix.base.models.event import SubEvent
from pretix.multidomain.urlreverse import eventreverse
@@ -53,7 +52,7 @@ def get_grouped_items(event, subevent=None):
& Q(hide_without_voucher=False)
& ~Q(category__is_addon=True)
).select_related(
'category', # for re-grouping
'category', 'tax_rule', # for re-grouping
).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
@@ -88,17 +87,9 @@ def get_grouped_items(event, subevent=None):
item.order_max = min(item.cached_availability[1]
if item.cached_availability[1] is not None else sys.maxsize,
max_per_order)
item.price = item.default_price
price = item.default_price
item.display_price = item.tax(item_price_override.get(item.pk, price))
if event.settings.display_net_prices:
if item_price_override.get(item.pk):
_p = item_price_override.get(item.pk)
tax_value = round_decimal(_p * (1 - 100 / (100 + item.tax_rate)))
item.display_price = _p - tax_value
else:
item.display_price = item.default_price_net
else:
item.display_price = item_price_override.get(item.pk, item.price)
display_add_to_cart = display_add_to_cart or item.order_max > 0
else:
for var in item.available_variations:
@@ -107,15 +98,7 @@ def get_grouped_items(event, subevent=None):
if var.cached_availability[1] is not None else sys.maxsize,
max_per_order)
if event.settings.display_net_prices:
if var_price_override.get(var.pk):
_p = var_price_override.get(var.pk)
tax_value = round_decimal(_p * (1 - 100 / (100 + item.tax_rate)))
var.display_price = _p - tax_value
else:
var.display_price = var.net_price
else:
var.display_price = var_price_override.get(var.pk, var.price)
var.display_price = var.tax(var_price_override.get(var.pk, var.price))
display_add_to_cart = display_add_to_cart or var.order_max > 0
@@ -123,8 +106,10 @@ def get_grouped_items(event, subevent=None):
v for v in item.available_variations if v._subevent_quotas
]
if len(item.available_variations) > 0:
item.min_price = min([v.display_price for v in item.available_variations])
item.max_price = max([v.display_price for v in item.available_variations])
item.min_price = min([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.max_price = max([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item._remove = not bool(item.available_variations)
items = [item for item in items

View File

@@ -97,8 +97,8 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView):
ctx['download_buttons'] = self.download_buttons
ctx['cart'] = self.get_cart(
answers=True, downloads=ctx['can_download'],
queryset=self.order.positions.all(),
payment_fee=self.order.payment_fee, payment_fee_tax_rate=self.order.payment_fee_tax_rate
queryset=self.order.positions.select_related('tax_rule'),
order=self.order
)
ctx['can_download_multi'] = any([b['multi'] for b in self.download_buttons]) and (
self.request.event.settings.ticket_download_nonadm or
@@ -412,7 +412,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template
def invoice_form(self):
return InvoiceAddressForm(data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address)
instance=self.invoice_address, validate_vat_id=False)
def post(self, request, *args, **kwargs):
failed = not self.save() or not self.invoice_form.is_valid()

View File

@@ -94,6 +94,8 @@ CSRF_TRUSTED_ORIGINS = [urlparse(SITE_URL).hostname]
PRETIX_PLUGINS_DEFAULT = config.get('pretix', 'plugins_default',
fallback='pretix.plugins.sendmail,pretix.plugins.statistics,pretix.plugins.checkinlists')
FETCH_ECB_RATES = config.getboolean('pretix', 'ecb_rates', fallback=True)
DEFAULT_CURRENCY = config.get('pretix', 'currency', fallback='EUR')
CURRENCIES = list(currencies)

View File

@@ -27,7 +27,7 @@ $(function () {
e.preventDefault();
});
$("div.collapsed").removeClass("collapsed").addClass("collapse");
$(".has-error").each(function () {
$(".has-error, .alert-danger").each(function () {
$(this).closest("div.panel-collapse").collapse("show");
});