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

@@ -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)