diff --git a/doc/admin/config.rst b/doc/admin/config.rst
index 00fe3b7873..8b9f4f5286 100644
--- a/doc/admin/config.rst
+++ b/doc/admin/config.rst
@@ -60,6 +60,11 @@ Example::
``password_reset``
Enables or disables password reset. Defaults to ``on``.
+``ecb_rates``
+ By default, pretix periodically downloads a XML file from the European Central Bank to retrieve exchange rates
+ that are used to print tax amounts in the customer currency on invoices for some currencies. Set to ``off`` to
+ disable this feature. Defaults to ``on``.
+
Locale settings
---------------
diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst
index 10427113ed..7be1b885b8 100644
--- a/doc/api/resources/index.rst
+++ b/doc/api/resources/index.rst
@@ -7,6 +7,7 @@ Resources and endpoints
organizers
events
subevents
+ taxrules
categories
items
questions
diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst
index 0c88257a31..4389bbdeb4 100644
--- a/doc/api/resources/invoices.rst
+++ b/doc/api/resources/invoices.rst
@@ -29,9 +29,18 @@ payment_provider_text string Text to be prin
footer_text string Text to be printed in the page footer area
lines list of objects The actual invoice contents
├ description string Text representing the invoice line (e.g. product name)
-├ gross_value money (string) Price including VAT
-├ tax_value money (string) VAT amount
-└ tax_rate decimal (string) Used VAT rate
+├ gross_value money (string) Price including taxes
+├ tax_value money (string) Tax amount included
+├ tax_name string Name of used tax rate (e.g. "VAT")
+└ tax_rate decimal (string) Used tax rate
+foreign_currency_display string If the invoice should also show the total and tax
+ amount in a different currency, this contains the
+ currency code (``null`` otherwise).
+foreign_currency_rate decimal (string) If ``foreign_currency_rate`` is set and the system
+ knows the exchange rate to the event currency at
+ invoicing time, it is stored here.
+foreign_currency_rate_date date If ``foreign_currency_rate`` is set, this signifies the
+ date at which the currency rate was obtained.
===================================== ========================== =======================================================
@@ -42,6 +51,12 @@ lines list of objects The actual invo
number.
+.. versionchanged:: 1.7
+
+ The attributes ``lines.tax_name``, ``foreign_currency_display``, ``foreign_currency_rate``, and
+ ``foreign_currency_rate_date`` have been added.
+
+
Endpoints
---------
@@ -88,9 +103,13 @@ Endpoints
"description": "Budget Ticket",
"gross_value": "23.00",
"tax_value": "0.00",
+ "tax_name": "VAT",
"tax_rate": "0.00"
}
- ]
+ ],
+ "foreign_currency_display": "PLN",
+ "foreign_currency_rate": "4.2408",
+ "foreign_currency_rate_date": "2017-07-24"
}
]
}
@@ -147,9 +166,13 @@ Endpoints
"description": "Budget Ticket",
"gross_value": "23.00",
"tax_value": "0.00",
+ "tax_name": "VAT",
"tax_rate": "0.00"
}
- ]
+ ],
+ "foreign_currency_display": "PLN",
+ "foreign_currency_rate": "4.2408",
+ "foreign_currency_rate_date": "2017-07-24"
}
:param organizer: The ``slug`` field of the organizer to fetch
diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst
index 3737c68e3f..1e115c19ed 100644
--- a/doc/api/resources/items.rst
+++ b/doc/api/resources/items.rst
@@ -27,6 +27,7 @@ free_price boolean If ``True``, cu
lower than the price defined by ``default_price`` or
otherwise).
tax_rate decimal (string) The VAT rate to be applied for this item.
+tax_rule integer The internal ID of the applied tax rule (or ``null``).
admission boolean ``True`` for items that grant admission to the event
(such as primary tickets) and ``False`` for others
(such as add-ons or merchandise).
@@ -70,6 +71,10 @@ addons list of objects Definition of a
└ position integer An integer, used for sorting
===================================== ========================== =======================================================
+.. versionchanged:: 1.7
+
+ The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility.
+
Endpoints
---------
@@ -108,6 +113,7 @@ Endpoints
"description": null,
"free_price": false,
"tax_rate": "0.00",
+ "tax_rule": 1,
"admission": false,
"position": 0,
"picture": null,
@@ -188,6 +194,7 @@ Endpoints
"description": null,
"free_price": false,
"tax_rate": "0.00",
+ "tax_rule": 1,
"admission": false,
"position": 0,
"picture": null,
diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst
index 9c823b125b..dc533f755d 100644
--- a/doc/api/resources/orders.rst
+++ b/doc/api/resources/orders.rst
@@ -27,19 +27,26 @@ expires datetime The order will
payment_date date Date of payment receival
payment_provider string Payment provider used for this order
payment_fee money (string) Payment fee included in this order's total
-payment_fee_tax_rate decimal (string) VAT rate applied to the payment fee
-payment_fee_tax_value money (string) VAT value included in the payment fee
+payment_fee_tax_rate decimal (string) Tax rate applied to the payment fee
+payment_fee_tax_value money (string) Tax value included in the payment fee
+payment_fee_tax_rule integer The ID of the used tax rule (or ``null``)
total money (string) Total value of this order
comment string Internal comment on this order
invoice_address object Invoice address information (can be ``null``)
├ last_modified datetime Last modification date of the address
├ company string Customer company name
+├ is_business boolean Business or individual customers (always ``False``
+ for orders created before pretix 1.7, do not rely on
+ it).
├ name string Customer name
├ street string Customer street
├ zipcode string Customer ZIP code
├ city string Customer city
├ country string Customer country
-└ vat_id string Customer VAT ID
+├ vat_id string Customer VAT ID
+└ vat_id_validated string ``True``, if the VAT ID has been validated against the
+ EU VAT service and validation was successful. This only
+ happens in rare cases.
position list of objects List of order positions (see below)
downloads list of objects List of ticket download options for order-wise ticket
downloading. This might be a multi-page PDF or a ZIP
@@ -56,6 +63,11 @@ downloads list of objects List of ticket
The ``invoice_address.country`` attribute contains a two-letter country code for all new orders. For old orders,
a custom text might still be returned.
+.. versionchanged:: 1.7
+
+ The attributes ``payment_fee_tax_rule``, ``invoice_address.vat_id_validated`` and ``invoice_address.is_business``
+ have been added.
+
Order position resource
-----------------------
@@ -76,6 +88,7 @@ attendee_email string Specified atten
voucher integer Internal ID of the voucher used for this position (or ``null``)
tax_rate decimal (string) VAT rate applied for this position
tax_value money (string) VAT included in this position
+tax_rule integer The ID of the used tax rule (or ``null``)
secret string Secret code printed on the tickets for validation
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
@@ -90,6 +103,10 @@ answers list of objects Answers to user
└ options list of integers Internal IDs of selected option(s)s (only for choice types)
===================================== ========================== =======================================================
+.. versionchanged:: 1.7
+
+ The attribute ``tax_rule`` has been added.
+
Order endpoints
---------------
@@ -132,17 +149,20 @@ Order endpoints
"payment_fee": "0.00",
"payment_fee_tax_rate": "0.00",
"payment_fee_tax_value": "0.00",
+ "payment_fee_tax_rule": null,
"total": "23.00",
"comment": "",
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
+ "is_business": True,
"company": "Sample company",
"name": "John Doe",
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
"country": "Testikistan",
- "vat_id": "EU123456789"
+ "vat_id": "EU123456789",
+ "vat_id_validated": False
},
"positions": [
{
@@ -157,6 +177,7 @@ Order endpoints
"voucher": null,
"tax_rate": "0.00",
"tax_value": "0.00",
+ "tax_rule": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
"subevent": null,
@@ -236,17 +257,20 @@ Order endpoints
"payment_fee": "0.00",
"payment_fee_tax_rate": "0.00",
"payment_fee_tax_value": "0.00",
+ "payment_fee_tax_rule": null,
"total": "23.00",
"comment": "",
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"company": "Sample company",
+ "is_business": True,
"name": "John Doe",
"street": "Test street 12",
"zipcode": "12345",
"city": "Testington",
"country": "Testikistan",
- "vat_id": "EU123456789"
+ "vat_id": "EU123456789",
+ "vat_id_validated": False
},
"positions": [
{
@@ -260,6 +284,7 @@ Order endpoints
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",
+ "tax_rule": null,
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
@@ -379,6 +404,7 @@ Order position endpoints
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",
+ "tax_rule": null,
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
@@ -457,6 +483,7 @@ Order position endpoints
"attendee_email": null,
"voucher": null,
"tax_rate": "0.00",
+ "tax_rule": null,
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null,
diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst
new file mode 100644
index 0000000000..c5b5a9f868
--- /dev/null
+++ b/doc/api/resources/taxrules.rst
@@ -0,0 +1,109 @@
+Tax rules
+=========
+
+Resource description
+--------------------
+
+Tax rules specify how tax should be calculated for specific products.
+
+.. rst-class:: rest-resource-table
+
+===================================== ========================== =======================================================
+Field Type Description
+===================================== ========================== =======================================================
+id integer Internal ID of the tax rule
+name multi-lingual string The tax rules' name
+rate decimal (string) Tax rate in percent
+price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
+ the specified product price
+eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
+home_country string Merchant country (required for reverse charge), can be
+ ``null`` or empty string
+===================================== ========================== =======================================================
+
+.. versionchanged:: 1.7
+
+ This resource has been added.
+
+
+Endpoints
+---------
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/taxrules/
+
+ Returns a list of all tax rules configured for an event.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/taxrules/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: text/javascript
+
+ {
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 1,
+ "name": {"en": "VAT"},
+ "rate": "19.00",
+ "price_includes_tax": true,
+ "eu_reverse_charge": false,
+ "home_country": "DE"
+ }
+ ]
+ }
+
+ :query page: The page number in case of a multi-page result set, default is 1
+ :param organizer: The ``slug`` field of a valid organizer
+ :param event: The ``slug`` field of the event to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/taxrules/(id)/
+
+ Returns information on one tax rule, identified by its ID.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/taxrules/1/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: text/javascript
+
+ {
+ "id": 1,
+ "name": {"en": "VAT"},
+ "rate": "19.00",
+ "price_includes_tax": true,
+ "eu_reverse_charge": false,
+ "home_country": "DE"
+ }
+
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :param event: The ``slug`` field of the event to fetch
+ :param id: The ``slug`` field of the sub-event to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
diff --git a/doc/conf.py b/doc/conf.py
index 87d8ea8732..419f4fb505 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -42,7 +42,6 @@ django.setup()
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.doctest',
- 'sphinx.ext.todo',
'sphinx.ext.coverage',
'sphinxcontrib.httpdomain',
'sphinxcontrib.images',
diff --git a/doc/development/index.rst b/doc/development/index.rst
index ca27b175f5..e9ebcd172d 100644
--- a/doc/development/index.rst
+++ b/doc/development/index.rst
@@ -10,6 +10,3 @@ Developer documentation
implementation/index
api/index
structure
-
-.. TODO::
- Document settings objects, ItemVariation objects, form fields.
diff --git a/doc/user/events/create.rst b/doc/user/events/create.rst
index 7a7efe7eff..3dc8ddef22 100644
--- a/doc/user/events/create.rst
+++ b/doc/user/events/create.rst
@@ -62,6 +62,11 @@ Location
Event currency
This is the currency all prices and payments in your shop will be handled in.
+Sales tax rate
+ If you need to pay a form of sales tax (also known as VAT in many countries) on your products, you can set a tax rate
+ in percent here that will be used as a default later. After creating your event, you can also create multiple tax
+ rates or fine-tune the tax settings.
+
Default language
If you selected multiple supported languages in the previous step, you can now decide which one should be
displayed by default.
diff --git a/doc/user/index.rst b/doc/user/index.rst
index a1ae3a4452..b405c61cf7 100644
--- a/doc/user/index.rst
+++ b/doc/user/index.rst
@@ -10,3 +10,4 @@ wanting to use pretix to sell tickets.
organizers/index
payments/index
events/create
+ events/taxes
diff --git a/src/make_testdata.py b/src/make_testdata.py
index 66af4b02f5..917f9d96c0 100644
--- a/src/make_testdata.py
+++ b/src/make_testdata.py
@@ -47,14 +47,15 @@ question = Question.objects.create(
event=event, question='Age',
type=Question.TYPE_NUMBER, required=False
)
+tr19 = event.tax_rules.create(rate=19)
item_ticket = Item.objects.create(
event=event, category=cat_tickets, name='Ticket',
- default_price=23, tax_rate=19, admission=True
+ default_price=23, tax_rule=tr19, admission=True
)
item_ticket.questions.add(question)
item_shirt = Item.objects.create(
event=event, category=cat_merch, name='T-Shirt',
- default_price=15, tax_rate=19
+ default_price=15, tax_rule=tr19
)
var_s = ItemVariation.objects.create(item=item_shirt, value='S')
var_m = ItemVariation.objects.create(item=item_shirt, value='M')
diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index 0678de0471..08c846e492 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -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')
diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py
index b74d51b7b0..3cd4ba6100 100644
--- a/src/pretix/api/serializers/item.py
+++ b/src/pretix/api/serializers/item.py
@@ -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',
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index 530198d6c9..71a297d820 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -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')
diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py
index 56d9c39545..4ad6833e04 100644
--- a/src/pretix/api/urls.py
+++ b/src/pretix/api/urls.py
@@ -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
diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py
index c0879c7fb1..11a1ac06bf 100644
--- a/src/pretix/api/views/event.py
+++ b/src/pretix/api/views/event.py
@@ -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()
diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py
index 8a001663a3..ea77201680 100644
--- a/src/pretix/api/views/item.py
+++ b/src/pretix/api/views/item.py
@@ -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):
diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py
index d9d7652b0c..2b6a18c8d6 100644
--- a/src/pretix/base/__init__.py
+++ b/src/pretix/base/__init__.py
@@ -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
diff --git a/src/pretix/base/exporters/json.py b/src/pretix/base/exporters/json.py
index 066cc9a452..cd504d6257 100644
--- a/src/pretix/base/exporters/json.py
+++ b/src/pretix/base/exporters/json.py
@@ -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': [
{
diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py
index 09f5643d1c..96fcf19987 100644
--- a/src/pretix/base/invoice.py
+++ b/src/pretix/base/invoice.py
@@ -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
diff --git a/src/pretix/base/migrations/0073_auto_20170716_1333.py b/src/pretix/base/migrations/0073_auto_20170716_1333.py
new file mode 100644
index 0000000000..d8a4aaf0d4
--- /dev/null
+++ b/src/pretix/base/migrations/0073_auto_20170716_1333.py
@@ -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),
+ ),
+ ]
diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py
index 2da46e9d97..6e6c39c003 100644
--- a/src/pretix/base/models/__init__.py
+++ b/src/pretix/base/models/__init__.py
@@ -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
diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py
index e1fa30f44b..18583ba4d6 100644
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -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)
diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py
index eb015a3661..1284c307b8 100644
--- a/src/pretix/base/models/invoices.py
+++ b/src/pretix/base/models/invoices.py
@@ -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):
diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py
index 31899a3f30..0af75ce3fa 100644
--- a/src/pretix/base/models/items.py
+++ b/src/pretix/base/models/items.py
@@ -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)
diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py
index c8d45569e5..9501a370dc 100644
--- a/src/pretix/base/models/log.py
+++ b/src/pretix/base/models/log.py
@@ -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'] = '{val}'.format_map(a_map)
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index e88a85135b..d913bb3e31 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -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:
diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py
new file mode 100644
index 0000000000..4caa357655
--- /dev/null
+++ b/src/pretix/base/models/tax.py
@@ -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
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index 41694060ea..a91d50eb4f 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -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:
diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py
index 6b7a1de7e2..de4416e727 100644
--- a/src/pretix/base/services/invoices.py
+++ b/src/pretix/base/services/invoices.py
@@ -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', '
')
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 += "
"
+ 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')
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index a4e1df9333..18e42b212b 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -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:
diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py
index 4dc1a23ceb..ec7a7117d6 100644
--- a/src/pretix/base/services/pricing.py
+++ b/src/pretix/base/services/pricing.py
@@ -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
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index 5437c4ec34..20d8bca077 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -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',
diff --git a/src/pretix/base/views/async.py b/src/pretix/base/views/async.py
index 6e9c702b1a..e9e32602d6 100644
--- a/src/pretix/base/views/async.py
+++ b/src/pretix/base/views/async.py
@@ -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)
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
index e3201a7907..549d10c57f 100644
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -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']
diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py
index 978e35ac60..6f42799b25 100644
--- a/src/pretix/control/forms/item.py
+++ b/src/pretix/control/forms/item.py
@@ -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',
diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py
index d90d561211..8970f9ccf5 100644
--- a/src/pretix/control/forms/orders.py
+++ b/src/pretix/control/forms/orders.py
@@ -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
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 590a7d0496..ed97afe3a8 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -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.'),
diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html
index c7f0cd2a7d..41495d8eba 100644
--- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html
+++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html
@@ -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" %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/settings_base.html b/src/pretix/control/templates/pretixcontrol/event/settings_base.html
index fe76187577..72c4094bcb 100644
--- a/src/pretix/control/templates/pretixcontrol/event/settings_base.html
+++ b/src/pretix/control/templates/pretixcontrol/event/settings_base.html
@@ -36,6 +36,11 @@
{% trans "Email" %}
+
+ {% blocktrans trimmed %} + You haven't created any tax rules yet. + {% endblocktrans %} +
+ + {% trans "Create a new tax rule" %} ++ {% trans "Create a new tax rule" %} + +
+| {% trans "Name" %} | +{% trans "Rate" %} | ++ |
|---|---|---|
| + + {{ tr.name }} + + | +{{ tr.rate }} % | ++ + + | +