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" %} +
  • + + {% trans "Tax rules" %} + +
  • {% trans "Invoicing" %} diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_delete.html b/src/pretix/control/templates/pretixcontrol/event/tax_delete.html new file mode 100644 index 0000000000..4dd4bd0f9b --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/tax_delete.html @@ -0,0 +1,26 @@ +{% extends "pretixcontrol/event/settings_base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Delete tax rule" %}{% endblock %} +{% block inside %} + {% trans "Delete tax rule" %} +
    + {% csrf_token %} + {% if possible %} +

    {% blocktrans %}Are you sure you want to delete the tax rule {{ taxrule }}?{% endblocktrans %}

    + {% else %} +

    {% blocktrans %}You cannot delete a tax rule that is in use for a product or has been in use for any existing orders.{% endblocktrans %}

    + {% endif %} +
    + + {% trans "Cancel" %} + + {% if possible %} + + {% endif %} +
    +
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html new file mode 100644 index 0000000000..1417ec20c2 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html @@ -0,0 +1,41 @@ +{% extends "pretixcontrol/event/settings_base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% if rule %} + {% blocktrans with name=rule.name %}Tax rule: {{ name }}{% endblocktrans %} + {% else %} + {% trans "Tax rule" %} + {% endif %} +{% endblock %} +{% block inside %} + {% if rule %} + {% blocktrans with name=rule.name %}Tax rule: {{ name }}{% endblocktrans %} + {% else %} + {% trans "Tax rule" %} + {% endif %} +
    + {% csrf_token %} + {% bootstrap_form_errors form %} + {% bootstrap_field form.name layout="horizontal" %} + {% bootstrap_field form.rate layout="horizontal" %} + {% trans "Advanced settings" %} +
    + + {% blocktrans trimmed with docs="https://docs.pretix.eu/en/latest/user/events/taxes.html" %} + These settings are intended for advanced users. See the documentation + for more information. Note that we are not responsible for the correct handling + of taxes in your ticket shop. If in doubt, please contact a lawyer or tax consultant. + {% endblocktrans %} +
    +
    + {% bootstrap_field form.price_includes_tax layout="horizontal" %} + {% bootstrap_field form.eu_reverse_charge layout="horizontal" %} + {% bootstrap_field form.home_country layout="horizontal" %} +
    + +
    +
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_index.html b/src/pretix/control/templates/pretixcontrol/event/tax_index.html new file mode 100644 index 0000000000..88ff2355db --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/tax_index.html @@ -0,0 +1,51 @@ +{% extends "pretixcontrol/event/settings_base.html" %} +{% load i18n %} +{% block title %}{% trans "Tax rules" %}{% endblock %} +{% block inside %} + {% trans "Tax rules" %} + {% if taxrules|length == 0 %} +
    +

    + {% blocktrans trimmed %} + You haven't created any tax rules yet. + {% endblocktrans %} +

    + + {% trans "Create a new tax rule" %} +
    + {% else %} +

    + {% trans "Create a new tax rule" %} + +

    +
    + + + + + + + + + + {% for tr in taxrules %} + + + + + + {% endfor %} + +
    {% trans "Name" %}{% trans "Rate" %}
    + + {{ tr.name }} + + {{ tr.rate }} % + + +
    +
    + {% endif %} + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/events/create_basics.html b/src/pretix/control/templates/pretixcontrol/events/create_basics.html index bc643c4601..679049276a 100644 --- a/src/pretix/control/templates/pretixcontrol/events/create_basics.html +++ b/src/pretix/control/templates/pretixcontrol/events/create_basics.html @@ -33,6 +33,7 @@ {% bootstrap_field form.date_to layout="horizontal" %} {% bootstrap_field form.location layout="horizontal" %} {% bootstrap_field form.currency layout="horizontal" %} + {% bootstrap_field form.tax_rate layout="horizontal" %}
    {% trans "Display settings" %} diff --git a/src/pretix/control/templates/pretixcontrol/item/create.html b/src/pretix/control/templates/pretixcontrol/item/create.html index 419b18ba6d..c8ea6cd485 100644 --- a/src/pretix/control/templates/pretixcontrol/item/create.html +++ b/src/pretix/control/templates/pretixcontrol/item/create.html @@ -31,7 +31,7 @@
    {% trans "Price settings" %} {% bootstrap_field form.default_price layout="horizontal" %} - {% bootstrap_field form.tax_rate layout="horizontal" %} + {% bootstrap_field form.tax_rule layout="horizontal" %}
    @@ -150,6 +160,24 @@ +
    +
    +

    + Other operations +

    +
    +
    +
    + {% bootstrap_form_errors other_form %} + {% if other_form.custom_error %} +
    + {{ other_form.custom_error }} +
    + {% endif %} + {% bootstrap_field other_form.recalculate_taxes layout='horizontal' %} +
    +
    +
    @@ -265,17 +272,21 @@ {{ event.currency }} {{ order.payment_fee_net|floatformat:2 }} {% if order.payment_fee_tax_rate %}
    - {% blocktrans trimmed with rate=order.payment_fee_tax_rate %} - plus {{ rate }}% taxes - {% endblocktrans %} + + {% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %} + plus {{ rate }}% {{ taxname }} + {% endblocktrans %} + {% endif %} {% else %} {{ event.currency }} {{ items.payment_fee|floatformat:2 }} {% if order.payment_fee_tax_rate %}
    - {% blocktrans trimmed with rate=order.payment_fee_tax_rate %} - incl. {{ rate }}% taxes - {% endblocktrans %} + + {% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %} + incl. {{ rate }}% {{ taxname }} + {% endblocktrans %} + {% endif %} {% endif %} @@ -358,7 +369,20 @@
    {{ order.invoice_address.country.name|default:order.invoice_address.country_old }}
    {% if request.event.settings.invoice_address_vatid %}
    {% trans "VAT ID" %}
    -
    {{ order.invoice_address.vat_id }}
    +
    + {{ order.invoice_address.vat_id }} + {% if order.invoice_address.vat_id_validated %} + + {% else %} +
    + {% csrf_token %} + +
    + {% endif %} +
    {% endif %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 6134fc3c8b..7290448aaf 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -69,6 +69,10 @@ urlpatterns = [ url(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'), url(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'), url(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'), + url(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'), + url(r'^settings/tax/(?P\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'), + url(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'), + url(r'^settings/tax/(?P\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'), url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'), url(r'^subevents/(?P\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'), url(r'^subevents/(?P\d+)/delete$', subevents.SubEventDelete.as_view(), @@ -131,6 +135,8 @@ urlpatterns = [ url(r'^orders/(?P[0-9A-Z]+)/answer/(?P[^/]+)/$', orders.AnswerDownload.as_view(), name='event.order.download.answer'), + url(r'^orders/(?P[0-9A-Z]+)/checkvatid', orders.OrderCheckVATID.as_view(), + name='event.order.checkvatid'), url(r'^orders/(?P[0-9A-Z]+)/extend$', orders.OrderExtend.as_view(), name='event.order.extend'), url(r'^orders/(?P[0-9A-Z]+)/contact$', orders.OrderContactChange.as_view(), diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index e030bf77ee..9bdf315add 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -9,22 +9,24 @@ from django.core.files import File from django.core.urlresolvers import reverse from django.db import transaction from django.http import ( - HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse, + Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, + JsonResponse, ) from django.shortcuts import get_object_or_404, redirect from django.utils import translation from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.timezone import now -from django.utils.translation import ugettext_lazy as _ -from django.views.generic import FormView, ListView +from django.utils.translation import ugettext, ugettext_lazy as _ +from django.views.generic import DeleteView, FormView, ListView from django.views.generic.base import TemplateView, View from django.views.generic.detail import SingleObjectMixin +from i18nfield.strings import LazyI18nString from pytz import timezone from pretix.base.models import ( - CachedTicket, Event, Item, ItemVariation, LogEntry, Order, RequiredAction, - Voucher, + CachedTicket, Event, Item, ItemVariation, LogEntry, Order, OrderPosition, + RequiredAction, TaxRule, Voucher, ) from pretix.base.services import tickets from pretix.base.services.invoices import build_preview_invoice_pdf @@ -32,13 +34,13 @@ from pretix.base.signals import event_live_issues, register_ticket_outputs from pretix.control.forms.event import ( CommentForm, DisplaySettingsForm, EventSettingsForm, EventUpdateForm, InvoiceSettingsForm, MailSettingsForm, PaymentSettingsForm, ProviderForm, - TicketSettingsForm, + TaxRuleForm, TicketSettingsForm, ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.urls import build_absolute_uri from pretix.presale.style import regenerate_css -from . import UpdateView +from . import CreateView, UpdateView from ..logdisplay import OVERVIEW_BLACKLIST @@ -787,3 +789,129 @@ class EventComment(EventPermissionRequiredMixin, View): 'organizer': self.request.event.organizer.slug, 'event': self.request.event.slug }) + + +class TaxList(EventPermissionRequiredMixin, ListView): + model = TaxRule + context_object_name = 'taxrules' + paginate_by = 30 + template_name = 'pretixcontrol/event/tax_index.html' + permission = 'can_change_event_settings' + + def get_queryset(self): + return self.request.event.tax_rules.all() + + +class TaxCreate(EventPermissionRequiredMixin, CreateView): + model = TaxRule + form_class = TaxRuleForm + template_name = 'pretixcontrol/event/tax_edit.html' + permission = 'can_change_event_settings' + context_object_name = 'taxrule' + + def get_success_url(self) -> str: + return reverse('control:event.settings.tax', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + def get_initial(self): + return { + 'name': LazyI18nString.from_gettext(ugettext('VAT')) + } + + @transaction.atomic + def form_valid(self, form): + form.instance.event = self.request.event + messages.success(self.request, _('The new tax rule has been created.')) + ret = super().form_valid(form) + form.instance.log_action('pretix.event.taxrule.added', user=self.request.user, data=dict(form.cleaned_data)) + return ret + + def form_invalid(self, form): + messages.error(self.request, _('We could not save your changes. See below for details.')) + return super().form_invalid(form) + + +class TaxUpdate(EventPermissionRequiredMixin, UpdateView): + model = TaxRule + form_class = TaxRuleForm + template_name = 'pretixcontrol/event/tax_edit.html' + permission = 'can_change_event_settings' + context_object_name = 'rule' + + def get_object(self, queryset=None) -> TaxRule: + try: + return self.request.event.tax_rules.get( + id=self.kwargs['rule'] + ) + except TaxRule.DoesNotExist: + raise Http404(_("The requested tax rule does not exist.")) + + @transaction.atomic + def form_valid(self, form): + messages.success(self.request, _('Your changes have been saved.')) + if form.has_changed(): + self.object.log_action( + 'pretix.event.taxrule.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + return super().form_valid(form) + + def get_success_url(self) -> str: + return reverse('control:event.settings.tax', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + def form_invalid(self, form): + messages.error(self.request, _('We could not save your changes. See below for details.')) + return super().form_invalid(form) + + +class TaxDelete(EventPermissionRequiredMixin, DeleteView): + model = TaxRule + template_name = 'pretixcontrol/event/tax_delete.html' + permission = 'can_change_event_settings' + context_object_name = 'taxrule' + + def get_object(self, queryset=None) -> TaxRule: + try: + return self.request.event.tax_rules.get( + id=self.kwargs['rule'] + ) + except TaxRule.DoesNotExist: + raise Http404(_("The requested tax rule does not exist.")) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + success_url = self.get_success_url() + if self.is_allowed(): + self.object.log_action(action='pretix.event.taxrule.deleted', user=request.user) + self.object.delete() + messages.success(self.request, _('The selected tax rule has been deleted.')) + else: + messages.error(self.request, _('The selected tax rule can not be deleted.')) + return redirect(success_url) + + def get_success_url(self) -> str: + return reverse('control:event.settings.tax', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + def is_allowed(self) -> bool: + o = self.object + return ( + not self.request.event.orders.filter(payment_fee_tax_rule=o).exists() + and not OrderPosition.objects.filter(tax_rule=o, order__event=self.request.event).exists() + and not self.request.event.items.filter(tax_rule=o).exists() + and self.request.event.settings.tax_rate_default != o + ) + + def get_context_data(self, *args, **kwargs) -> dict: + context = super().get_context_data(*args, **kwargs) + context['possible'] = self.is_allowed() + return context diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index ca1dfae6d0..9f44154994 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -775,6 +775,13 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView): 'item': self.object.id, }) + def get_initial(self): + initial = super().get_initial() + trs = list(self.request.event.tax_rules.all()) + if len(trs) == 1: + initial['tax_rule'] = trs[0] + return initial + @transaction.atomic def form_valid(self, form): messages.success(self.request, _('Your changes have been saved.')) diff --git a/src/pretix/control/views/main.py b/src/pretix/control/views/main.py index 833806ba30..47db205ddf 100644 --- a/src/pretix/control/views/main.py +++ b/src/pretix/control/views/main.py @@ -6,10 +6,11 @@ from django.http import JsonResponse from django.shortcuts import redirect from django.utils.crypto import get_random_string from django.utils.functional import cached_property -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext, ugettext_lazy as _ from django.views import View from django.views.generic import ListView from formtools.wizard.views import SessionWizardView +from i18nfield.strings import LazyI18nString from pretix.base.models import Event, Team from pretix.control.forms.event import ( @@ -118,6 +119,12 @@ class EventWizard(SessionWizardView): active=True ) + if basics_data['tax_rate']: + event.settings.tax_rate_default = event.tax_rules.create( + name=LazyI18nString.from_gettext(ugettext('VAT')), + rate=basics_data['tax_rate'] + ) + logdata = {} for f in form_list: logdata.update({ diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 062bcc8b42..ed5dee2a2e 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1,8 +1,10 @@ +import logging import mimetypes import os from datetime import timedelta import pytz +import vat_moss.id from django.conf import settings from django.contrib import messages from django.core.urlresolvers import reverse @@ -25,6 +27,7 @@ from pretix.base.models import ( generate_position_secret, generate_secret, ) from pretix.base.models.event import SubEvent +from pretix.base.models.tax import EU_COUNTRIES from pretix.base.services.export import export from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, @@ -42,12 +45,15 @@ from pretix.control.forms.filter import EventOrderFilterForm from pretix.control.forms.orders import ( CommentForm, ExporterForm, ExtendForm, OrderContactForm, OrderLocaleForm, OrderMailForm, OrderPositionAddForm, OrderPositionChangeForm, + OtherOperationsForm, ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.safedownload import check_token from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.signals import question_form_fields +logger = logging.getLogger(__name__) + class OrderList(EventPermissionRequiredMixin, ListView): model = Order @@ -144,7 +150,7 @@ class OrderDetail(OrderView): cartpos = queryset.order_by( 'item', 'variation' ).select_related( - 'item', 'variation', 'addon_to' + 'item', 'variation', 'addon_to', 'tax_rule' ).prefetch_related( 'item__questions', 'answers', 'answers__question', 'checkins' ).order_by('positionid') @@ -277,6 +283,54 @@ class OrderInvoiceCreate(OrderView): return HttpResponseNotAllowed(['POST']) +class OrderCheckVATID(OrderView): + permission = 'can_change_orders' + + def post(self, *args, **kwargs): + try: + ia = self.order.invoice_address + except InvoiceAddress.DoesNotExist: + messages.error(self.request, _('No VAT ID specified.')) + return redirect(self.get_order_url()) + else: + if not ia.vat_id: + messages.error(self.request, _('No VAT ID specified.')) + return redirect(self.get_order_url()) + + if not ia.country: + messages.error(self.request, _('No country specified.')) + return redirect(self.get_order_url()) + + if str(ia.country) not in EU_COUNTRIES: + messages.error(self.request, _('VAT ID could not be checked since a non-EU country has been ' + 'specified.')) + return redirect(self.get_order_url()) + + if ia.vat_id[:2] != str(ia.country): + messages.error(self.request, _('Your VAT ID does not match the selected country.')) + return redirect(self.get_order_url()) + + try: + result = vat_moss.id.validate(ia.vat_id) + if result: + country_code, normalized_id, company_name = result + ia.vat_id_validated = True + ia.vat_id = normalized_id + ia.save() + except vat_moss.errors.InvalidError: + messages.error(self.request, _('This VAT ID is not valid.')) + except vat_moss.errors.WebServiceUnavailableError: + logger.exception('VAT ID checking failed for country {}'.format(ia.country)) + messages.error(self.request, _('The VAT ID could not be checked, as the VAT checking service of ' + 'the country is currently not available.')) + else: + messages.success(self.request, _('This VAT ID is valid.')) + return redirect(self.get_order_url()) + + def get(self, *args, **kwargs): # NOQA + return HttpResponseNotAllowed(['POST']) + + class OrderInvoiceRegenerate(OrderView): permission = 'can_change_orders' @@ -458,6 +512,11 @@ class OrderChange(OrderView): return self._redirect_back() return super().dispatch(request, *args, **kwargs) + @cached_property + def other_form(self): + return OtherOperationsForm(prefix='other', order=self.order, + data=self.request.POST if self.request.method == "POST" else None) + @cached_property def add_form(self): return OrderPositionAddForm(prefix='add', order=self.order, @@ -469,14 +528,28 @@ class OrderChange(OrderView): for p in positions: p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, data=self.request.POST if self.request.method == "POST" else None) + try: + ia = self.order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + p.apply_tax = p.item.tax_rule and p.item.tax_rule.tax_applicable(invoice_address=ia) return positions def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['positions'] = self.positions ctx['add_form'] = self.add_form + ctx['other_form'] = self.other_form return ctx + def _process_other(self, ocm): + if not self.other_form.is_valid(): + return False + else: + if self.other_form.cleaned_data['recalculate_taxes']: + ocm.recalculate_taxes() + return True + def _process_add(self, ocm): if not self.add_form.is_valid(): return False @@ -534,7 +607,7 @@ class OrderChange(OrderView): def post(self, *args, **kwargs): ocm = OrderChangeManager(self.order, self.request.user) - form_valid = self._process_add(ocm) and self._process_change(ocm) + form_valid = self._process_add(ocm) and self._process_change(ocm) and self._process_other(ocm) if not form_valid: messages.error(self.request, _('An error occured. Please see the details below.')) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 382b1b6ee7..736bd492d5 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -11,7 +11,7 @@ from django.views.generic.base import TemplateResponseMixin from pretix.base.models import Order from pretix.base.models.orders import InvoiceAddress -from pretix.base.services.cart import set_cart_addons +from pretix.base.services.cart import set_cart_addons, update_tax_rates from pretix.base.services.orders import perform_order from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.checkout import ( @@ -80,6 +80,17 @@ class BaseCheckoutFlowStep: if n: return n.get_step_url() + @cached_property + def invoice_address(self): + iapk = self.request.session.get('invoice_address_{}'.format(self.request.event.pk)) + if not iapk: + return InvoiceAddress() + + try: + return InvoiceAddress.objects.get(pk=iapk, order__isnull=True) + except InvoiceAddress.DoesNotExist: + return InvoiceAddress() + def get_checkout_flow(event): flow = list([step(event) for step in DEFAULT_FLOW]) @@ -163,7 +174,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): quota_cache = {} item_cache = {} for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related( - 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation' + 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', ): current_addon_products = { a.item_id: a.variation_id for a in cartpos.addons.all() @@ -244,7 +255,8 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): if not is_valid: return self.get(request, *args, **kwargs) - return self.do(self.request.event.id, data, self.request.session.session_key) + return self.do(self.request.event.id, data, self.request.session.session_key, + invoice_address=self.invoice_address.pk) class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): @@ -266,21 +278,17 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): initial=initial) @cached_property - def invoice_address(self): - iapk = self.request.session.get('invoice_address') - if not iapk: - return InvoiceAddress() - - try: - return InvoiceAddress.objects.get(pk=iapk, order__isnull=True) - except InvoiceAddress.DoesNotExist: - return InvoiceAddress() + def eu_reverse_charge_relevant(self): + return any([p.item.tax_rule and p.item.tax_rule.eu_reverse_charge + for p in self.positions]) @cached_property def invoice_form(self): return InvoiceAddressForm(data=self.request.POST if self.request.method == "POST" else None, event=self.request.event, - instance=self.invoice_address) + request=self.request, + instance=self.invoice_address, + validate_vat_id=self.eu_reverse_charge_relevant) def post(self, request): self.request = request @@ -294,9 +302,15 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): request.session['email'] = self.contact_form.cleaned_data['email'] if request.event.settings.invoice_address_asked: addr = self.invoice_form.save() - request.session['invoice_address'] = addr.pk + request.session['invoice_address_{}'.format(request.event.pk)] = addr.pk request.session['contact_form_data'] = self.contact_form.cleaned_data + update_tax_rates( + event=request.event, + cart_id=request.session.session_key, + invoice_address=self.invoice_form.instance + ) + return redirect(self.get_next_url(request)) def is_completed(self, request, warn=False): @@ -354,6 +368,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): ctx['formgroups'] = self.formdict.items() ctx['contact_form'] = self.contact_form ctx['invoice_form'] = self.invoice_form + ctx['reverse_charge_relevant'] = self.eu_reverse_charge_relevant return ctx @@ -387,7 +402,9 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): if p['provider'].identifier == request.POST.get('payment', ''): request.session['payment'] = p['provider'].identifier resp = p['provider'].checkout_prepare( - request, self.get_cart(payment_fee=p['provider'].calculate_fee(self._total_order_value))) + request, + self.get_cart() + ) if isinstance(resp, str): return redirect(resp) elif resp is True: @@ -476,16 +493,6 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): def payment_provider(self): return self.request.event.get_payment_providers().get(self.request.session['payment']) - @cached_property - def invoice_address(self): - try: - return InvoiceAddress.objects.get( - pk=self.request.session.get('invoice_address'), - order__isnull=True - ) - except InvoiceAddress.DoesNotExist: - return InvoiceAddress() - def get(self, request): self.request = request if 'async_id' in request.GET and settings.HAS_CELERY: diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index d02ba90bd6..4b41e37370 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -1,8 +1,12 @@ +import logging import os from decimal import Decimal from itertools import chain +import vat_moss.errors +import vat_moss.id from django import forms +from django.contrib import messages from django.core.exceptions import ValidationError from django.db.models import Count, Prefetch, Q from django.utils.encoding import force_text @@ -10,13 +14,15 @@ from django.utils.formats import number_format from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ -from pretix.base.decimal import round_decimal from pretix.base.models import ItemVariation, Question from pretix.base.models.orders import InvoiceAddress, OrderPosition +from pretix.base.models.tax import EU_COUNTRIES, TAXED_ZERO from pretix.base.templatetags.rich_text import rich_text from pretix.multidomain.urlreverse import eventreverse from pretix.presale.signals import contact_form_fields, question_form_fields +logger = logging.getLogger(__name__) + class ContactForm(forms.Form): required_css_class = 'required' @@ -94,6 +100,8 @@ class InvoiceAddressForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.event = event = kwargs.pop('event') + self.request = kwargs.pop('request', None) + self.validate_vat_id = kwargs.pop('validate_vat_id') super().__init__(*args, **kwargs) if not event.settings.invoice_address_vatid: del self.fields['vat_id'] @@ -115,9 +123,35 @@ class InvoiceAddressForm(forms.ModelForm): if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required: raise ValidationError(_('You need to provide either a company name or your name.')) + if 'vat_id' in self.changed_data or not data.get('vat_id'): + self.instance.vat_id_validated = False + + if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: + pass + elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'): + if data.get('vat_id')[:2] != str(data.get('country')): + raise ValidationError(_('Your VAT ID does not match the selected country.')) + try: + result = vat_moss.id.validate(data.get('vat_id')) + if result: + country_code, normalized_id, company_name = result + self.instance.vat_id_validated = True + self.instance.vat_id = normalized_id + except vat_moss.errors.InvalidError: + raise ValidationError(_('This VAT ID is not valid. Please re-check your input.')) + except vat_moss.errors.WebServiceUnavailableError: + logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) + self.instance.vat_id_validated = False + if self.request: + messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of ' + 'your country is currently not available. We will therefore ' + 'need to charge VAT on your invoice. You can get the tax amount ' + 'back via the VAT reimbursement process.')) + else: + self.instance.vat_id_validated = False + class UploadedFileWidget(forms.ClearableFileInput): - def __init__(self, *args, **kwargs): self.position = kwargs.pop('position') self.event = kwargs.pop('event') @@ -309,7 +343,6 @@ class AddOnRadioSelect(forms.RadioSelect): class AddOnVariationField(forms.ChoiceField): - def valid_value(self, value): text_value = force_text(value) for k, v, d in self.choices: @@ -328,39 +361,37 @@ class AddOnsForm(forms.Form): variation = item_or_variation item = item_or_variation.item price = variation.price - price_net = variation.net_price label = variation.value else: item = item_or_variation price = item.default_price - price_net = item.default_price_net label = item.name if override_price: price = override_price - tax_value = round_decimal(price * (1 - 100 / (100 + item.tax_rate))) - price_net = price - tax_value if self.price_included: - price = Decimal('0.00') + price = TAXED_ZERO + else: + price = item.tax(price) - if not price: + if not price.gross: n = '{name}'.format( name=label ) - elif not item.tax_rate: + elif not price.rate: n = _('{name} (+ {currency} {price})').format( - name=label, currency=event.currency, price=number_format(price) + name=label, currency=event.currency, price=number_format(price.gross) ) elif event.settings.display_net_prices: - n = _('{name} (+ {currency} {price} plus {taxes}% taxes)').format( - name=label, currency=event.currency, price=number_format(price_net), - taxes=number_format(item.tax_rate) + n = _('{name} (+ {currency} {price} plus {taxes}% {taxname})').format( + name=label, currency=event.currency, price=number_format(price.net), + taxes=number_format(price.rate), taxname=price.name ) else: - n = _('{name} (+ {currency} {price} incl. {taxes}% taxes)').format( - name=label, currency=event.currency, price=number_format(price), - taxes=number_format(item.tax_rate) + n = _('{name} (+ {currency} {price} incl. {taxes}% {taxname})').format( + name=label, currency=event.currency, price=number_format(price.gross), + taxes=number_format(price.rate), taxname=price.name ) if avail[0] < 20: @@ -406,7 +437,7 @@ class AddOnsForm(forms.Form): & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) & Q(hide_without_voucher=False) - ).prefetch_related( + ).select_related('tax_rule').prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', queryset=event.quotas.filter(subevent=subevent)), diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index dd4c13d343..75eee47a82 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -1,6 +1,7 @@ {% load i18n %} {% load eventurl %} {% load safelink %} +{% blocktrans asvar s_taxes %}taxes{% endblocktrans %} {% for line in cart.positions %}
    @@ -121,16 +122,22 @@ {% if event.settings.display_net_prices %} {{ event.currency }} {{ line.net_total|floatformat:2 }} {% if line.tax_rate %} -
    {% blocktrans trimmed with rate=line.tax_rate %} - plus {{ rate }}% taxes - {% endblocktrans %} +
    + + {% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %} + plus {{ rate }}% {{ taxname }} + {% endblocktrans %} + {% endif %} {% else %} {{ event.currency }} {{ line.total|floatformat:2 }} {% if line.tax_rate %} -
    {% blocktrans trimmed with rate=line.tax_rate %} - incl. {{ rate }}% taxes - {% endblocktrans %} +
    + + {% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %} + incl. {{ rate }}% {{ taxname }} + {% endblocktrans %} + {% endif %} {% endif %}
    @@ -158,18 +165,22 @@ {% if event.settings.display_net_prices %} {{ event.currency }} {{ cart.payment_fee_net|floatformat:2 }} {% if cart.payment_fee_tax_rate %} -
    - {% blocktrans trimmed with rate=cart.payment_fee_tax_rate %} - plus {{ rate }}% taxes - {% endblocktrans %} +
    + + {% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %} + plus {{ rate }}% {{ taxname }} + {% endblocktrans %} + {% endif %} {% else %} {{ event.currency }} {{ cart.payment_fee|floatformat:2 }} {% if cart.payment_fee_tax_rate %} -
    - {% blocktrans trimmed with rate=cart.payment_fee_tax_rate %} - incl. {{ rate }}% taxes - {% endblocktrans %} +
    + + {% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %} + incl. {{ rate }}% {{ taxname }} + {% endblocktrans %} + {% endif %} {% endif %}
    diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 7aea00b76d..3c640f932c 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -233,20 +233,24 @@ {{ event.currency }} + step="any" + value="{% if event.settings.display_net_prices %}{{var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}" + > + {% elif event.settings.display_net_prices %} + {{ event.currency }} {{ var.display_price.net|floatformat:2 }} {% else %} - {{ event.currency }} {{ var.display_price|floatformat:2 }} + {{ event.currency }} {{ var.display_price.gross|floatformat:2 }} {% endif %} - {% if item.tax_rate and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=item.tax_rate %} - plus {{ rate }}% taxes + {% if var.display_price.rate and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %} + plus {{ rate }}% {{ name }} {% endblocktrans %} - {% elif item.tax_rate %} - {% blocktrans trimmed with rate=item.tax_rate %} - incl. {{ rate }}% taxes + {% elif var.display_price.rate %} + {% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %} + incl. {{ rate }}% {{ name }} {% endblocktrans %} {% endif %} @@ -313,20 +317,23 @@
    {{ event.currency }} + value="{% if event.settings.display_net_prices %}{{item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}" + step="any">
    + {% elif event.settings.display_net_prices %} + {{ event.currency }} {{ item.display_price.net|floatformat:2 }} {% else %} - {{ event.currency }} {{ item.display_price|floatformat:2 }} + {{ event.currency }} {{ item.display_price.gross|floatformat:2 }} {% endif %} - {% if item.tax_rate and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=item.tax_rate %} - plus {{ rate }}% taxes + {% if item.display_price.rate and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %} + plus {{ rate }}% {{ name }} {% endblocktrans %} - {% elif item.tax_rate %} - {% blocktrans trimmed with rate=item.tax_rate %} - incl. {{ rate }}% taxes + {% elif item.display_price.rate %} + {% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %} + incl. {{ rate }}% {{ name }} {% endblocktrans %} {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/voucher.html b/src/pretix/presale/templates/pretixpresale/event/voucher.html index 05a36c88b7..cdecbeb12f 100644 --- a/src/pretix/presale/templates/pretixpresale/event/voucher.html +++ b/src/pretix/presale/templates/pretixpresale/event/voucher.html @@ -82,20 +82,22 @@ {{ event.currency }} + value="{% if event.settings.display_net_prices %}{{var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}" + step="any"> + {% elif event.settings.display_net_prices %} + {{ event.currency }} {{ var.display_price.net|floatformat:2 }} {% else %} - {{ event.currency }} {{ var.display_price|floatformat:2 }} + {{ event.currency }} {{ var.display_price.gross|floatformat:2 }} {% endif %} - {% if item.tax_rate and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=item.tax_rate %} + {% if var.display_price.rate and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %} plus {{ rate }}% taxes {% endblocktrans %} - {% elif item.tax_rate %} - {% blocktrans trimmed with rate=item.tax_rate %} + {% elif var.display_price.rate %} + {% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %} incl. {{ rate }}% taxes {% endblocktrans %} {% endif %} @@ -145,20 +147,23 @@
    {{ event.currency }} + value="{% if event.settings.display_net_prices %}{{ item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}" + step="any">
    + {% elif event.settings.display_net_prices %} + {{ event.currency }} {{ item.display_price.net|floatformat:2 }} {% else %} - {{ event.currency }} {{ item.price|floatformat:2 }} + {{ event.currency }} {{ item.display_price.gross|floatformat:2 }} {% endif %} - {% if item.tax_rate and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=item.tax_rate %} - plus {{ rate }}% taxes + {% if item.display_price.rate and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %} + plus {{ rate }}% {{ name }} {% endblocktrans %} - {% elif item.tax_rate %} - {% blocktrans trimmed with rate=item.tax_rate %} - incl. {{ rate }}% taxes + {% elif item.display_price.rate %} + {% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %} + incl. {{ rate }}% {{ name }} {% endblocktrans %} {% endif %} diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index ff67d0bf20..be16bcccc8 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -7,8 +7,8 @@ from django.db.models import Sum from django.utils.functional import cached_property from django.utils.timezone import now -from pretix.base.decimal import round_decimal -from pretix.base.models import CartPosition, OrderPosition +from pretix.base.models import CartPosition, InvoiceAddress, OrderPosition +from pretix.base.models.tax import TaxRule from pretix.presale.signals import question_form_fields @@ -20,7 +20,7 @@ class CartMixin: """ return list(get_cart(self.request)) - def get_cart(self, answers=False, queryset=None, payment_fee=None, payment_fee_tax_rate=None, downloads=False): + def get_cart(self, answers=False, queryset=None, order=None, downloads=False): if queryset: prefetch = [] if answers: @@ -90,6 +90,7 @@ class CartMixin: group.total = group.count * group.price group.net_total = group.count * group.net_price group.has_questions = answers and k[0] != "" + group.tax_rule = group.item.tax_rule if answers: group.cache_answers() group.additional_answers = pos_additional_fields.get(group.pk) @@ -99,14 +100,35 @@ class CartMixin: net_total = sum(p.net_total for p in positions) tax_total = sum(p.total - p.net_total for p in positions) - payment_fee = payment_fee if payment_fee is not None else self.get_payment_fee(total) - payment_fee_tax_rate = round_decimal(payment_fee_tax_rate - if payment_fee_tax_rate is not None - else self.request.event.settings.tax_rate_default) - payment_fee_tax_value = round_decimal(payment_fee * (1 - 100 / (100 + payment_fee_tax_rate))) - payment_fee_net = payment_fee - payment_fee_tax_value - tax_total += payment_fee_tax_value - net_total += payment_fee_net + if order: + payment_fee = order.payment_fee + tax_total += order.payment_fee_tax_value + payment_fee_net = order.payment_fee - order.payment_fee_tax_value + net_total += payment_fee_net + payment_fee_tax_rule = order.payment_fee_tax_rule + payment_fee_tax_rate = order.payment_fee_tax_rate + else: + payment_fee = self.get_payment_fee(total) + payment_fee_tax_rule = self.request.event.settings.tax_rate_default or TaxRule.zero() + + iapk = self.request.session.get('invoice_address_{}'.format(self.request.event.pk)) + ia = None + if payment_fee_tax_rule.eu_reverse_charge and iapk: + try: + ia = InvoiceAddress.objects.get(pk=iapk, order__isnull=True) + except InvoiceAddress.DoesNotExist: + pass + + if payment_fee_tax_rule.tax_applicable(ia): + payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross') + tax_total += payment_fee_tax.tax + net_total += payment_fee_tax.net + payment_fee_net = payment_fee_tax.net + payment_fee_tax_rate = payment_fee_tax.rate + else: + net_total += payment_fee + payment_fee_net = payment_fee + payment_fee_tax_rate = Decimal('0.00') try: first_expiry = min(p.expires for p in positions) if positions else now() @@ -124,6 +146,7 @@ class CartMixin: 'payment_fee': payment_fee, 'payment_fee_net': payment_fee_net, 'payment_fee_tax_rate': payment_fee_tax_rate, + 'payment_fee_tax_rule': payment_fee_tax_rule, 'answers': answers, 'minutes_left': minutes_left, 'first_expiry': first_expiry, @@ -147,7 +170,8 @@ def get_cart(request): ).order_by( 'item', 'variation' ).select_related( - 'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer' + 'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer', + 'item__tax_rule' ).prefetch_related( 'item__questions', 'answers' ) diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 34230e7f12..360d8664bc 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -6,13 +6,14 @@ from django.db.models import Count, Prefetch, Q from django.http import FileResponse, Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.utils import translation +from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import ugettext as _ from django.views.generic import TemplateView, View -from pretix.base.decimal import round_decimal from pretix.base.models import ( - CartPosition, ItemVariation, QuestionAnswer, Quota, SubEvent, Voucher, + CartPosition, InvoiceAddress, ItemVariation, QuestionAnswer, Quota, + SubEvent, Voucher, ) from pretix.base.services.cart import ( CartError, add_items_to_cart, clear_cart, remove_cart_position, @@ -37,6 +38,17 @@ class CartActionMixin: def get_error_url(self): return self.get_next_url() + @cached_property + def invoice_address(self): + iapk = self.request.session.get('invoice_address_{}'.format(self.request.event.pk)) + if not iapk: + return InvoiceAddress() + + try: + return InvoiceAddress.objects.get(pk=iapk, order__isnull=True) + except InvoiceAddress.DoesNotExist: + return InvoiceAddress() + def _item_from_post_value(self, key, value, voucher=None): if value.strip() == '' or '_' not in key: return @@ -154,7 +166,8 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): def post(self, request, *args, **kwargs): items = self._items_from_post_data() if items: - return self.do(self.request.event.id, items, self.request.session.session_key, translation.get_language()) + return self.do(self.request.event.id, items, self.request.session.session_key, translation.get_language(), + self.invoice_address.pk) else: if 'ajax' in self.request.GET or 'ajax' in self.request.POST: return JsonResponse({ @@ -190,12 +203,12 @@ class RedeemView(EventViewMixin, TemplateView): items = items.filter(quotas__in=[self.voucher.quota_id]) items = items.filter(vouchq).select_related( - 'category', # for re-grouping + 'category', 'tax_rule', # for re-grouping ).prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', queryset=self.request.event.quotas.filter(subevent=self.subevent)), - Prefetch('variations', to_attr='avail_variations', + Prefetch('variations', to_attr='available_variations', queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', @@ -217,13 +230,11 @@ class RedeemView(EventViewMixin, TemplateView): var_price_override = {} for item in items: - item.available_variations = list(item.variations.filter(active=True, quotas__isnull=False).distinct()) if self.voucher.item_id and self.voucher.variation_id: item.available_variations = [v for v in item.available_variations if v.pk == self.voucher.variation_id] item.order_max = item.max_per_order or int(self.request.event.settings.max_items_per_order) - item.has_variations = item.variations.exists() if not item.has_variations: item._remove = not bool(item._subevent_quotas) if self.voucher.allow_ignore_quota or self.voucher.block_quota: @@ -231,32 +242,32 @@ class RedeemView(EventViewMixin, TemplateView): else: item.cached_availability = item.check_quotas(subevent=self.subevent, _cache=quota_cache) - item.price = item_price_override.get(item.pk, item.default_price) - item.price = self.voucher.calculate_price(item.price) - if self.request.event.settings.display_net_prices: - item.price -= round_decimal(item.price * (1 - 100 / (100 + item.tax_rate))) + price = item_price_override.get(item.pk, item.default_price) + price = self.voucher.calculate_price(price) + item.display_price = item.tax(price) else: item._remove = False - for var in item.avail_variations: + for var in item.available_variations: if self.voucher.allow_ignore_quota or self.voucher.block_quota: var.cached_availability = (Quota.AVAILABILITY_OK, 1) else: var.cached_availability = list(var.check_quotas(subevent=self.subevent, _cache=quota_cache)) - var.display_price = var_price_override.get(var.pk, var.price) - var.display_price = self.voucher.calculate_price(var.display_price) - if self.request.event.settings.display_net_prices: - var.display_price -= round_decimal(var.display_price * (1 - 100 / (100 + item.tax_rate))) + price = var_price_override.get(var.pk, var.price) + price = self.voucher.calculate_price(price) + var.display_price = item.tax(price) item.available_variations = [ - v for v in item.avail_variations if v._subevent_quotas + v for v in item.available_variations if v._subevent_quotas ] if self.voucher.variation_id: item.available_variations = [v for v in item.available_variations if v.pk == self.voucher.variation_id] if len(item.available_variations) > 0: - item.min_price = min([v.display_price for v in item.avail_variations]) - item.max_price = max([v.display_price for v in item.avail_variations]) + item.min_price = min([v.display_price.net if self.request.event.settings.display_net_prices else + v.display_price.gross for v in item.available_variations]) + item.max_price = max([v.display_price.net if self.request.event.settings.display_net_prices else + v.display_price.gross for v in item.available_variations]) items = [item for item in items if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove] diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 6ca15bec40..e0155fd78a 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -17,7 +17,6 @@ from django.views import View from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView -from pretix.base.decimal import round_decimal from pretix.base.models import ItemVariation from pretix.base.models.event import SubEvent from pretix.multidomain.urlreverse import eventreverse @@ -53,7 +52,7 @@ def get_grouped_items(event, subevent=None): & Q(hide_without_voucher=False) & ~Q(category__is_addon=True) ).select_related( - 'category', # for re-grouping + 'category', 'tax_rule', # for re-grouping ).prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', @@ -88,17 +87,9 @@ def get_grouped_items(event, subevent=None): item.order_max = min(item.cached_availability[1] if item.cached_availability[1] is not None else sys.maxsize, max_per_order) - item.price = item.default_price + price = item.default_price + item.display_price = item.tax(item_price_override.get(item.pk, price)) - if event.settings.display_net_prices: - if item_price_override.get(item.pk): - _p = item_price_override.get(item.pk) - tax_value = round_decimal(_p * (1 - 100 / (100 + item.tax_rate))) - item.display_price = _p - tax_value - else: - item.display_price = item.default_price_net - else: - item.display_price = item_price_override.get(item.pk, item.price) display_add_to_cart = display_add_to_cart or item.order_max > 0 else: for var in item.available_variations: @@ -107,15 +98,7 @@ def get_grouped_items(event, subevent=None): if var.cached_availability[1] is not None else sys.maxsize, max_per_order) - if event.settings.display_net_prices: - if var_price_override.get(var.pk): - _p = var_price_override.get(var.pk) - tax_value = round_decimal(_p * (1 - 100 / (100 + item.tax_rate))) - var.display_price = _p - tax_value - else: - var.display_price = var.net_price - else: - var.display_price = var_price_override.get(var.pk, var.price) + var.display_price = var.tax(var_price_override.get(var.pk, var.price)) display_add_to_cart = display_add_to_cart or var.order_max > 0 @@ -123,8 +106,10 @@ def get_grouped_items(event, subevent=None): v for v in item.available_variations if v._subevent_quotas ] if len(item.available_variations) > 0: - item.min_price = min([v.display_price for v in item.available_variations]) - item.max_price = max([v.display_price for v in item.available_variations]) + item.min_price = min([v.display_price.net if event.settings.display_net_prices else + v.display_price.gross for v in item.available_variations]) + item.max_price = max([v.display_price.net if event.settings.display_net_prices else + v.display_price.gross for v in item.available_variations]) item._remove = not bool(item.available_variations) items = [item for item in items diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 80a29f4e98..a6857ff653 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -97,8 +97,8 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView): ctx['download_buttons'] = self.download_buttons ctx['cart'] = self.get_cart( answers=True, downloads=ctx['can_download'], - queryset=self.order.positions.all(), - payment_fee=self.order.payment_fee, payment_fee_tax_rate=self.order.payment_fee_tax_rate + queryset=self.order.positions.select_related('tax_rule'), + order=self.order ) ctx['can_download_multi'] = any([b['multi'] for b in self.download_buttons]) and ( self.request.event.settings.ticket_download_nonadm or @@ -412,7 +412,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template def invoice_form(self): return InvoiceAddressForm(data=self.request.POST if self.request.method == "POST" else None, event=self.request.event, - instance=self.invoice_address) + instance=self.invoice_address, validate_vat_id=False) def post(self, request, *args, **kwargs): failed = not self.save() or not self.invoice_form.is_valid() diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 7626f39b2c..401265e532 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -94,6 +94,8 @@ CSRF_TRUSTED_ORIGINS = [urlparse(SITE_URL).hostname] PRETIX_PLUGINS_DEFAULT = config.get('pretix', 'plugins_default', fallback='pretix.plugins.sendmail,pretix.plugins.statistics,pretix.plugins.checkinlists') +FETCH_ECB_RATES = config.getboolean('pretix', 'ecb_rates', fallback=True) + DEFAULT_CURRENCY = config.get('pretix', 'currency', fallback='EUR') CURRENCIES = list(currencies) diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index ecd2246530..27c8ec7af7 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -27,7 +27,7 @@ $(function () { e.preventDefault(); }); $("div.collapsed").removeClass("collapsed").addClass("collapse"); - $(".has-error").each(function () { + $(".has-error, .alert-danger").each(function () { $(this).closest("div.panel-collapse").collapse("show"); }); diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 8f9372e687..8253a2030a 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -44,3 +44,4 @@ pycountry django-countries pyuca # for better sorting of country names in django-countries defusedcsv>=1.0.1 +vat_moss==0.11.0 diff --git a/src/setup.py b/src/setup.py index 695b2b9860..da28601682 100644 --- a/src/setup.py +++ b/src/setup.py @@ -105,7 +105,8 @@ setup( 'pycountry', 'django-countries', 'pyuca', - 'defusedcsv' + 'defusedcsv', + 'vat_moss==0.11.0', ], extras_require={ 'dev': [ diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 2bb40146ef..5abd8ea13f 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -53,3 +53,8 @@ def subevent(event): event.save() return event.subevents.create(name="Foobar", date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) + + +@pytest.fixture +def taxrule(event): + return event.tax_rules.create(name="VAT", rate=19) diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 7a5d88869b..f3df7f16f8 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -63,6 +63,7 @@ TEST_ITEM_RES = { "description": None, "free_price": False, "tax_rate": "0.00", + "tax_rule": None, "admission": False, "position": 0, "picture": None, diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 52e3c0eba6..287a4c3aaa 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -54,6 +54,7 @@ TEST_ORDERPOSITION_RES = { "voucher": None, "tax_rate": "0.00", "tax_value": "0.00", + "tax_rule": None, "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": None, "checkins": [], @@ -74,17 +75,20 @@ TEST_ORDER_RES = { "payment_fee": "0.00", "payment_fee_tax_rate": "0.00", "payment_fee_tax_value": "0.00", + "payment_fee_tax_rule": None, "total": "23.00", "comment": "", "invoice_address": { "last_modified": "2017-12-01T10:00:00Z", + "is_business": False, "company": "Sample company", "name": "", "street": "", "zipcode": "", "city": "", "country": "NZ", - "vat_id": "" + "vat_id": "", + "vat_id_validated": False }, "positions": [TEST_ORDERPOSITION_RES], "downloads": [] @@ -268,11 +272,15 @@ TEST_INVOICE_RES = { "additional_text": "", "payment_provider_text": "", "footer_text": "", + "foreign_currency_display": None, + "foreign_currency_rate": None, + "foreign_currency_rate_date": None, "lines": [ { "description": "Budget Ticket", "gross_value": "23.00", "tax_value": "0.00", + "tax_name": "", "tax_rate": "0.00" } ] diff --git a/src/tests/api/test_taxrules.py b/src/tests/api/test_taxrules.py new file mode 100644 index 0000000000..d790c48960 --- /dev/null +++ b/src/tests/api/test_taxrules.py @@ -0,0 +1,28 @@ +import pytest + +TEST_TAXRULE_RES = { + 'name': {'en': 'VAT'}, + 'rate': '19.00', + 'price_includes_tax': True, + 'eu_reverse_charge': False, + 'home_country': '' +} + + +@pytest.mark.django_db +def test_rule_list(token_client, organizer, event, taxrule): + res = dict(TEST_TAXRULE_RES) + res["id"] = taxrule.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/taxrules/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_rule_detail(token_client, organizer, event, taxrule): + res = dict(TEST_TAXRULE_RES) + res["id"] = taxrule.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/taxrules/{}/'.format(organizer.slug, event.slug, + taxrule.pk)) + assert resp.status_code == 200 + assert res == resp.data diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index 902beaf1a1..d1d91c1d67 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -1,8 +1,10 @@ -from datetime import timedelta +import json +from datetime import date, timedelta from decimal import Decimal import pytest -from django.db import DatabaseError +from django.core.serializers.json import DjangoJSONEncoder +from django.db import DatabaseError, transaction from django.utils.timezone import now from django_countries.fields import Country @@ -14,6 +16,8 @@ from pretix.base.services.invoices import ( build_preview_invoice_pdf, generate_cancellation, generate_invoice, invoice_pdf_task, regenerate_invoice, ) +from pretix.base.services.orders import OrderChangeManager +from pretix.base.settings import GlobalSettingsObject @pytest.fixture @@ -23,19 +27,20 @@ def env(): organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer' ) + tr = event.tax_rules.create(rate=Decimal('19.00')) o = Order.objects.create( code='FOO', event=event, email='dummy@dummy.test', status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), total=0, payment_provider='banktransfer', - payment_fee=Decimal('0.25'), payment_fee_tax_rate=0, - payment_fee_tax_value=0, locale='en' + payment_fee=Decimal('0.25'), payment_fee_tax_rate=Decimal('19.00'), + payment_fee_tax_value=Decimal('0.04'), locale='en', payment_fee_tax_rule=tr ) ticket = Item.objects.create(event=event, name='Early-bird ticket', - category=None, default_price=23, + category=None, default_price=23, tax_rule=tr, admission=True) t_shirt = Item.objects.create(event=event, name='T-Shirt', - category=None, default_price=42, + category=None, default_price=42, tax_rule=tr, admission=True) variation = ItemVariation.objects.create(value='M', item=t_shirt) OrderPosition.objects.create( @@ -52,6 +57,22 @@ def env(): price=Decimal("42.00"), positionid=2, ) + gs = GlobalSettingsObject() + gs.settings.ecb_rates_date = date.today() + gs.settings.ecb_rates_dict = json.dumps({ + "USD": "1.1648", + "RON": "4.5638", + "CZK": "26.024", + "BGN": "1.9558", + "HRK": "7.4098", + "EUR": "1.0000", + "NOK": "9.3525", + "HUF": "305.15", + "DKK": "7.4361", + "PLN": "4.2408", + "GBP": "0.89350", + "SEK": "9.5883" + }, cls=DjangoJSONEncoder) return event, o @@ -116,6 +137,59 @@ def test_positions_skip_free(env): assert inv.lines.count() == 2 +@pytest.mark.django_db +def test_reverse_charge_note(env): + event, order = env + + tr = event.tax_rules.first() + tr.eu_reverse_charge = True + tr.home_country = Country('DE') + tr.save() + + event.settings.set('invoice_language', 'en') + InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street', zipcode='12345', city='Warsaw', + country=Country('PL'), vat_id='PL123456780', vat_id_validated=True, order=order, + is_business=True) + + ocm = OrderChangeManager(order, None) + ocm.recalculate_taxes() + ocm.commit() + assert not order.positions.filter(tax_value__gt=0).exists() + + inv = generate_invoice(order) + assert "reverse charge" in inv.additional_text.lower() + assert inv.foreign_currency_display == "PLN" + assert inv.foreign_currency_rate == Decimal("4.2408") + assert inv.foreign_currency_rate_date == date.today() + + +@pytest.mark.django_db +def test_reverse_charge_foreign_currency_data_too_old(env): + event, order = env + gs = GlobalSettingsObject() + gs.settings.ecb_rates_date = date.today() - timedelta(days=14) + + tr = event.tax_rules.first() + tr.eu_reverse_charge = True + tr.home_country = Country('DE') + tr.save() + + event.settings.set('invoice_language', 'en') + InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street', zipcode='12345', city='Warsaw', + country=Country('PL'), vat_id='PL123456780', vat_id_validated=True, order=order, + is_business=True) + + ocm = OrderChangeManager(order, None) + ocm.recalculate_taxes() + ocm.commit() + assert not order.positions.filter(tax_value__gt=0).exists() + + inv = generate_invoice(order) + assert "reverse charge" in inv.additional_text.lower() + assert inv.foreign_currency_rate is None + assert inv.foreign_currency_rate_date is None + + @pytest.mark.django_db def test_positions(env): event, order = env @@ -279,11 +353,12 @@ def test_invoice_number_prefixes(env): # Test database uniqueness check with pytest.raises(DatabaseError): - Invoice.objects.create( - order=order, - event=order.event, - organizer=order.event.organizer, - date=now().date(), - locale='en', - invoice_no='00001', - ) + with transaction.atomic(): + Invoice.objects.create( + order=order, + event=order.event, + organizer=order.event.organizer, + date=now().date(), + locale='en', + invoice_no='00001', + ) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index e5fb7d06a9..45fa541b9f 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -935,6 +935,56 @@ class EventTest(TestCase): self.assertIn('slug', str(context.exception)) + def test_copy(self): + event1 = Event.objects.create( + organizer=self.organizer, name='Download', slug='ab1234', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + is_public=True, + ) + tr7 = event1.tax_rules.create(rate=Decimal('7.00')) + c1 = event1.categories.create(name='Tickets') + c2 = event1.categories.create(name='Workshops') + i1 = event1.items.create(name='Foo', default_price=Decimal('13.00'), tax_rule=tr7, + category=c1) + v1 = i1.variations.create(value='Bar') + i1.addons.create(addon_category=c2) + q1 = event1.quotas.create(name='Quota 1', size=50) + q1.items.add(i1) + q1.variations.add(v1) + que1 = event1.questions.create(question="Age", type="N") + que1.items.add(i1) + event1.settings.foo_setting = 23 + event1.settings.tax_rate_default = tr7 + + event2 = Event.objects.create( + organizer=self.organizer, name='Download', slug='ab1234', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc) + ) + event2.copy_data_from(event1) + + for a in (tr7, c1, c2, i1, q1, que1): + a.refresh_from_db() + assert a.event == event1 + + trnew = event2.tax_rules.first() + assert trnew.rate == tr7.rate + c1new = event2.categories.get(name='Tickets') + c2new = event2.categories.get(name='Workshops') + i1new = event2.items.first() + assert i1new.name == i1.name + assert i1new.category == c1new + assert i1new.tax_rule == trnew + assert i1new.variations.count() == 1 + assert i1new.addons.get(addon_category=c2new) + q1new = event2.quotas.first() + assert q1new.size == q1.size + assert q1new.items.get(pk=i1new.pk) + que1new = event2.questions.first() + assert que1new.type == que1.type + assert que1new.items.get(pk=i1new.pk) + assert event2.settings.foo_setting == '23' + assert event2.settings.tax_rate_default == trnew + class SubEventTest(TestCase): @classmethod diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 021d28ef6b..9ce59a9dd4 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -6,10 +6,11 @@ import pytz from django.core import mail as djmail from django.test import TestCase from django.utils.timezone import make_aware, now +from django_countries.fields import Country from pretix.base.decimal import round_decimal from pretix.base.models import ( - CartPosition, Event, Item, Order, OrderPosition, Organizer, + CartPosition, Event, InvoiceAddress, Item, Order, OrderPosition, Organizer, ) from pretix.base.models.items import SubEventItem from pretix.base.payment import FreeOrderProvider @@ -102,7 +103,8 @@ def test_expiry_last_relative_subevents(event): event.date_from = now() + timedelta(days=5) event.has_subevents = True event.save() - ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + tr7 = event.tax_rules.create(rate=Decimal('17.00')) + ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rule=tr7, default_price=Decimal('23.00'), admission=True) se1 = event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) @@ -196,7 +198,7 @@ class DownloadReminderTests(TestCase): expires=now() + timedelta(days=10), total=Decimal('46.00'), payment_provider='banktransfer' ) - self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=Decimal('23.00'), admission=True) self.op1 = OrderPosition.objects.create( order=self.order, item=self.ticket, variation=None, @@ -233,18 +235,21 @@ class OrderChangeManagerTests(TestCase): def setUp(self): super().setUp() o = Organizer.objects.create(name='Dummy', slug='dummy') - self.event = Event.objects.create(organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer') + self.event = Event.objects.create(organizer=o, name='Dummy', slug='dummy', date_from=now(), + plugins='pretix.plugins.banktransfer') self.order = Order.objects.create( code='FOO', event=self.event, email='dummy@dummy.test', status=Order.STATUS_PENDING, locale='en', datetime=now(), expires=now() + timedelta(days=10), total=Decimal('46.00'), payment_provider='banktransfer' ) - self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + self.tr7 = self.event.tax_rules.create(rate=Decimal('7.00')) + self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00')) + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rule=self.tr7, default_price=Decimal('23.00'), admission=True) - self.ticket2 = Item.objects.create(event=self.event, name='Other ticket', tax_rate=Decimal('7.00'), + self.ticket2 = Item.objects.create(event=self.event, name='Other ticket', tax_rule=self.tr7, default_price=Decimal('23.00'), admission=True) - self.shirt = Item.objects.create(event=self.event, name='T-Shirt', tax_rate=Decimal('19.00'), + self.shirt = Item.objects.create(event=self.event, name='T-Shirt', tax_rule=self.tr19, default_price=Decimal('12.00')) self.op1 = OrderPosition.objects.create( order=self.order, item=self.ticket, variation=None, @@ -260,6 +265,18 @@ class OrderChangeManagerTests(TestCase): self.quota.items.add(self.ticket2) self.quota.items.add(self.shirt) + def _enable_reverse_charge(self): + self.tr7.eu_reverse_charge = True + self.tr7.home_country = Country('DE') + self.tr7.save() + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + return InvoiceAddress.objects.create( + order=self.order, is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + def test_change_subevent_quota_required(self): self.event.has_subevents = True self.event.save() @@ -291,6 +308,48 @@ class OrderChangeManagerTests(TestCase): assert self.op1.price == 12 assert self.order.total == self.op1.price + self.op2.price + def test_change_subevent_reverse_charge(self): + self._enable_reverse_charge() + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + SubEventItem.objects.create(subevent=se2, item=self.ticket, price=10.7) + self.op1.subevent = se1 + self.op1.save() + self.quota.subevent = se2 + self.quota.save() + + self.ocm.change_subevent(self.op1, se2) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.subevent == se2 + assert self.op1.price == Decimal('10.00') + assert self.op1.tax_value == Decimal('0.00') + assert self.order.total == self.op1.price + self.op2.price + + def test_change_subevent_net_price(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + self.tr7.price_includes_tax = False + self.tr7.save() + SubEventItem.objects.create(subevent=se2, item=self.ticket, price=10) + self.op1.subevent = se1 + self.op1.save() + self.quota.subevent = se2 + self.quota.save() + + self.ocm.change_subevent(self.op1, se2) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.subevent == se2 + assert self.op1.price == Decimal('10.70') + assert self.order.total == self.op1.price + self.op2.price + def test_change_subevent_sold_out(self): self.event.has_subevents = True self.event.save() @@ -320,10 +379,35 @@ class OrderChangeManagerTests(TestCase): self.order.refresh_from_db() assert self.op1.item == self.shirt assert self.op1.price == self.shirt.default_price - assert self.op1.tax_rate == self.shirt.tax_rate + assert self.op1.tax_rate == self.shirt.tax_rule.rate assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value assert self.order.total == self.op1.price + self.op2.price + def test_change_item_net_price_success(self): + self.tr19.price_includes_tax = False + self.tr19.save() + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.shirt + assert self.op1.price == Decimal('14.28') + assert self.op1.tax_rate == self.shirt.tax_rule.rate + assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value + assert self.order.total == self.op1.price + self.op2.price + + def test_change_item_reverse_charge(self): + self._enable_reverse_charge() + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.shirt + assert self.op1.price == Decimal('10.08') + assert self.op1.tax_rate == Decimal('0.00') + assert self.op1.tax_value == Decimal('0.00') + assert self.order.total == self.op1.price + self.op2.price + def test_change_price_success(self): self.ocm.change_price(self.op1, Decimal('24.00')) self.ocm.commit() @@ -334,6 +418,18 @@ class OrderChangeManagerTests(TestCase): assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value assert self.order.total == self.op1.price + self.op2.price + def test_change_price_net_success(self): + self.tr7.price_includes_tax = False + self.tr7.save() + self.ocm.change_price(self.op1, Decimal('10.00')) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.ticket + assert self.op1.price == Decimal('10.70') + assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value + assert self.order.total == self.op1.price + self.op2.price + def test_cancel_success(self): self.ocm.cancel(self.op1) self.ocm.commit() @@ -436,7 +532,7 @@ class OrderChangeManagerTests(TestCase): assert self.op2.item == self.ticket def test_payment_fee_calculation(self): - self.event.settings.set('tax_rate_default', Decimal('19.00')) + self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) self.ocm.change_price(self.op1, Decimal('24.00')) @@ -445,7 +541,8 @@ class OrderChangeManagerTests(TestCase): assert self.order.total == Decimal('47.30') assert self.order.payment_fee == prov.calculate_fee(self.order.total) assert self.order.payment_fee_tax_rate == Decimal('19.00') - assert round_decimal(self.order.payment_fee * (1 - 100 / (100 + self.order.payment_fee_tax_rate))) == self.order.payment_fee_tax_value + assert round_decimal(self.order.payment_fee * ( + 1 - 100 / (100 + self.order.payment_fee_tax_rate))) == self.order.payment_fee_tax_value def test_require_pending(self): self.order.status = Order.STATUS_PAID @@ -497,8 +594,37 @@ class OrderChangeManagerTests(TestCase): nop = self.order.positions.last() assert nop.item == self.shirt assert nop.price == self.shirt.default_price - assert nop.tax_rate == self.shirt.tax_rate - assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rate))) == nop.tax_value + assert nop.tax_rate == self.shirt.tax_rule.rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rule.rate))) == nop.tax_value + assert self.order.total == self.op1.price + self.op2.price + nop.price + assert nop.positionid == 3 + + def test_add_item_net_price_success(self): + self.tr19.price_includes_tax = False + self.tr19.save() + self.ocm.add_position(self.shirt, None, None, None) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + nop = self.order.positions.last() + assert nop.item == self.shirt + assert nop.price == Decimal('14.28') + assert nop.tax_rate == self.shirt.tax_rule.rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rule.rate))) == nop.tax_value + assert self.order.total == self.op1.price + self.op2.price + nop.price + assert nop.positionid == 3 + + def test_add_item_reverse_charge(self): + self._enable_reverse_charge() + self.ocm.add_position(self.shirt, None, None, None) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + nop = self.order.positions.last() + assert nop.item == self.shirt + assert nop.price == Decimal('10.08') + assert nop.tax_rate == Decimal('0.00') + assert nop.tax_value == Decimal('0.00') assert self.order.total == self.op1.price + self.op2.price + nop.price assert nop.positionid == 3 @@ -510,8 +636,22 @@ class OrderChangeManagerTests(TestCase): nop = self.order.positions.last() assert nop.item == self.shirt assert nop.price == Decimal('13.00') - assert nop.tax_rate == self.shirt.tax_rate - assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rate))) == nop.tax_value + assert nop.tax_rate == self.shirt.tax_rule.rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rule.rate))) == nop.tax_value + assert self.order.total == self.op1.price + self.op2.price + nop.price + + def test_add_item_custom_price_tax_always_included(self): + self.tr19.price_includes_tax = False + self.tr19.save() + self.ocm.add_position(self.shirt, None, Decimal('11.90'), None) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + nop = self.order.positions.last() + assert nop.item == self.shirt + assert nop.price == Decimal('11.90') + assert nop.tax_rate == self.shirt.tax_rule.rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rule.rate))) == nop.tax_value assert self.order.total == self.op1.price + self.op2.price + nop.price def test_add_item_quota_full(self): @@ -577,3 +717,44 @@ class OrderChangeManagerTests(TestCase): self.ocm.add_position(self.ticket, None, Decimal('0.00')) self.ocm.commit() assert self.order.invoices.count() == 1 + + def test_recalculate_reverse_charge(self): + self.event.settings.set('tax_rate_default', self.tr19.pk) + prov = self.ocm._get_payment_provider() + prov.settings.set('_fee_abs', Decimal('0.30')) + self.ocm._recalculate_total_and_payment_fee() + + assert self.order.total == Decimal('46.30') + assert self.order.payment_fee == prov.calculate_fee(self.order.total) + assert self.order.payment_fee_tax_rate == Decimal('19.00') + assert self.order.payment_fee_tax_value == Decimal('0.05') + + ia = self._enable_reverse_charge() + self.ocm.recalculate_taxes() + self.ocm.commit() + ops = list(self.order.positions.all()) + for op in ops: + assert op.price == Decimal('21.50') + assert op.tax_value == Decimal('0.00') + assert op.tax_rate == Decimal('0.00') + + assert self.order.total == Decimal('43.30') + assert self.order.payment_fee == prov.calculate_fee(self.order.total) + assert self.order.payment_fee_tax_rate == Decimal('0.00') + assert self.order.payment_fee_tax_value == Decimal('0.00') + + ia.vat_id_validated = False + ia.save() + + self.ocm.recalculate_taxes() + self.ocm.commit() + ops = list(self.order.positions.all()) + for op in ops: + assert op.price == Decimal('23.01') # sic. we can't really avoid it. + assert op.tax_value == Decimal('1.51') + assert op.tax_rate == Decimal('7.00') + + assert self.order.total == Decimal('46.32') + assert self.order.payment_fee == prov.calculate_fee(self.order.total) + assert self.order.payment_fee_tax_rate == Decimal('19.00') + assert self.order.payment_fee_tax_value == Decimal('0.05') diff --git a/src/tests/base/test_payment.py b/src/tests/base/test_payment.py index bf047ffbac..89a2d12dfb 100644 --- a/src/tests/base/test_payment.py +++ b/src/tests/base/test_payment.py @@ -108,7 +108,8 @@ def test_availability_date_cart_relative_subevents(event): event.date_from = now() + datetime.timedelta(days=5) event.has_subevents = True event.save() - ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + tr7 = event.tax_rules.create(rate=Decimal('7.00')) + ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rule=tr7, default_price=Decimal('23.00'), admission=True) se1 = event.subevents.create(name="SE1", date_from=now() + datetime.timedelta(days=10)) @@ -138,7 +139,8 @@ def test_availability_date_order_relative_subevents(event): event.date_from = now() + datetime.timedelta(days=5) event.has_subevents = True event.save() - ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + tr7 = event.tax_rules.create(rate=Decimal('7.00')) + ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rule=tr7, default_price=Decimal('23.00'), admission=True) se1 = event.subevents.create(name="SE1", date_from=now() + datetime.timedelta(days=10)) diff --git a/src/tests/base/test_pricing.py b/src/tests/base/test_pricing.py index d6a23c9cb5..f6e4550945 100644 --- a/src/tests/base/test_pricing.py +++ b/src/tests/base/test_pricing.py @@ -2,8 +2,9 @@ from decimal import Decimal import pytest from django.utils.timezone import now +from django_countries.fields import Country -from pretix.base.models import Event, Organizer +from pretix.base.models import Event, InvoiceAddress, Organizer from pretix.base.models.items import SubEventItem, SubEventItemVariation from pretix.base.services.pricing import get_price @@ -42,143 +43,143 @@ def subevent(event): @pytest.mark.django_db def test_base_item_default(item): - assert get_price(item) == Decimal('23.00') + assert get_price(item).gross == Decimal('23.00') @pytest.mark.django_db def test_base_item_subevent_no_entry(item, subevent): - assert get_price(item, subevent=subevent) == Decimal('23.00') + assert get_price(item, subevent=subevent).gross == Decimal('23.00') @pytest.mark.django_db def test_base_item_subevent_no_override(item, subevent): SubEventItem.objects.create(item=item, subevent=subevent, price=None) - assert get_price(item, subevent=subevent) == Decimal('23.00') + assert get_price(item, subevent=subevent).gross == Decimal('23.00') @pytest.mark.django_db def test_base_item_subevent_override(item, subevent): SubEventItem.objects.create(item=item, subevent=subevent, price=Decimal('24.00')) - assert get_price(item, subevent=subevent) == Decimal('24.00') + assert get_price(item, subevent=subevent).gross == Decimal('24.00') @pytest.mark.django_db def test_variation_with_default_item_price(item, variation): - assert get_price(item, variation=variation) == Decimal('23.00') + assert get_price(item, variation=variation).gross == Decimal('23.00') @pytest.mark.django_db def test_variation_with_specific_price(item, variation): variation.default_price = Decimal('24.00') - assert get_price(item, variation=variation) == Decimal('24.00') + assert get_price(item, variation=variation).gross == Decimal('24.00') @pytest.mark.django_db def test_variation_with_default_subevent_and_default_price(item, subevent, variation): SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=None) - assert get_price(item, variation=variation, subevent=subevent) == Decimal('23.00') + assert get_price(item, variation=variation, subevent=subevent).gross == Decimal('23.00') @pytest.mark.django_db def test_variation_with_subevent_and_default_price(item, subevent, variation): SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=Decimal('24.00')) - assert get_price(item, variation=variation, subevent=subevent) == Decimal('24.00') + assert get_price(item, variation=variation, subevent=subevent).gross == Decimal('24.00') @pytest.mark.django_db def test_variation_with_no_subevent_and_specific_price(item, subevent, variation): variation.default_price = Decimal('24.00') - assert get_price(item, variation=variation, subevent=subevent) == Decimal('24.00') + assert get_price(item, variation=variation, subevent=subevent).gross == Decimal('24.00') @pytest.mark.django_db def test_variation_with_default_subevent_and_specific_price(item, subevent, variation): variation.default_price = Decimal('24.00') SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=None) - assert get_price(item, variation=variation, subevent=subevent) == Decimal('24.00') + assert get_price(item, variation=variation, subevent=subevent).gross == Decimal('24.00') @pytest.mark.django_db def test_variation_with_subevent_and_specific_price(item, subevent, variation): variation.default_price = Decimal('24.00') SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=Decimal('26.00')) - assert get_price(item, variation=variation, subevent=subevent) == Decimal('26.00') + assert get_price(item, variation=variation, subevent=subevent).gross == Decimal('26.00') @pytest.mark.django_db def test_voucher_no_override(item, subevent, voucher): - assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('23.00') + assert get_price(item, subevent=subevent, voucher=voucher).gross == Decimal('23.00') @pytest.mark.django_db def test_voucher_set_price(item, subevent, voucher): voucher.price_mode = 'set' voucher.value = Decimal('12.00') - assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('12.00') + assert get_price(item, subevent=subevent, voucher=voucher).gross == Decimal('12.00') @pytest.mark.django_db def test_voucher_subtract(item, subevent, voucher): voucher.price_mode = 'subtract' voucher.value = Decimal('12.00') - assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('11.00') + assert get_price(item, subevent=subevent, voucher=voucher).gross == Decimal('11.00') @pytest.mark.django_db def test_voucher_percent(item, subevent, voucher): voucher.price_mode = 'percent' voucher.value = Decimal('10.00') - assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('20.70') + assert get_price(item, subevent=subevent, voucher=voucher).gross == Decimal('20.70') @pytest.mark.django_db def test_free_price_ignored_if_disabled(item): - assert get_price(item, custom_price=Decimal('42.00')) == Decimal('23.00') + assert get_price(item, custom_price=Decimal('42.00')).gross == Decimal('23.00') @pytest.mark.django_db def test_free_price_ignored_if_lower(item): item.free_price = True - assert get_price(item, custom_price=Decimal('12.00')) == Decimal('23.00') + assert get_price(item, custom_price=Decimal('12.00')).gross == Decimal('23.00') @pytest.mark.django_db def test_free_price_ignored_if_lower_than_voucher(item, voucher): voucher.price_mode = 'set' voucher.value = Decimal('50.00') - assert get_price(item, voucher=voucher, custom_price=Decimal('40.00')) == Decimal('50.00') + assert get_price(item, voucher=voucher, custom_price=Decimal('40.00')).gross == Decimal('50.00') @pytest.mark.django_db def test_free_price_ignored_if_lower_than_subevent(item, subevent): item.free_price = True SubEventItem.objects.create(item=item, subevent=subevent, price=Decimal('50.00')) - assert get_price(item, subevent=subevent, custom_price=Decimal('40.00')) == Decimal('50.00') + assert get_price(item, subevent=subevent, custom_price=Decimal('40.00')).gross == Decimal('50.00') @pytest.mark.django_db def test_free_price_ignored_if_lower_than_variation(item, variation): variation.default_price = Decimal('50.00') item.free_price = True - assert get_price(item, variation=variation, custom_price=Decimal('40.00')) == Decimal('50.00') + assert get_price(item, variation=variation, custom_price=Decimal('40.00')).gross == Decimal('50.00') @pytest.mark.django_db def test_free_price_accepted(item): item.free_price = True - assert get_price(item, custom_price=Decimal('42.00')) == Decimal('42.00') + assert get_price(item, custom_price=Decimal('42.00')).gross == Decimal('42.00') @pytest.mark.django_db def test_free_price_string(item): item.free_price = True - assert get_price(item, custom_price='42,00') == Decimal('42.00') + assert get_price(item, custom_price='42,00').gross == Decimal('42.00') @pytest.mark.django_db def test_free_price_float(item): item.free_price = True - assert get_price(item, custom_price=42.00) == Decimal('42.00') + assert get_price(item, custom_price=42.00).gross == Decimal('42.00') @pytest.mark.django_db @@ -191,5 +192,136 @@ def test_free_price_limit(item): @pytest.mark.django_db def test_free_price_net(item): item.free_price = True - item.tax_rate = 19 - assert get_price(item, custom_price=Decimal('100.00'), custom_price_is_net=True) == Decimal('119.00') + item.tax_rule = item.event.tax_rules.create(rate=Decimal('19.00')) + assert get_price(item, custom_price=Decimal('100.00'), custom_price_is_net=True).gross == Decimal('119.00') + + +@pytest.mark.django_db +def test_tax_included(item): + item.default_price = Decimal('119.00') + item.tax_rule = item.event.tax_rules.create(rate=Decimal('19.00'), price_includes_tax=True) + assert get_price(item).gross == Decimal('119.00') + assert get_price(item).net == Decimal('100.00') + assert get_price(item).tax == Decimal('19.00') + assert get_price(item).rate == Decimal('19.00') + + +@pytest.mark.django_db +def test_tax_none(item): + item.default_price = Decimal('100.00') + assert get_price(item).gross == Decimal('100.00') + assert get_price(item).net == Decimal('100.00') + assert get_price(item).tax == Decimal('0.00') + assert get_price(item).rate == Decimal('0.00') + + +@pytest.mark.django_db +def test_tax_added(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create(rate=Decimal('19.00'), price_includes_tax=False) + assert get_price(item).gross == Decimal('119.00') + assert get_price(item).net == Decimal('100.00') + assert get_price(item).tax == Decimal('19.00') + assert get_price(item).rate == Decimal('19.00') + + +@pytest.mark.django_db +def test_tax_reverse_charge_valid(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + eu_reverse_charge=True, home_country=Country('DE') + ) + ia = InvoiceAddress( + is_business=True, vat_id="EU1234", vat_id_validated=True, + country=Country('BE') + ) + assert item.tax_rule.is_reverse_charge(ia) + assert get_price(item, invoice_address=ia).gross == Decimal('100.00') + + +@pytest.mark.django_db +def test_tax_reverse_charge_disabled(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + eu_reverse_charge=False, home_country=Country('DE') + ) + ia = InvoiceAddress( + is_business=True, vat_id="EU1234", vat_id_validated=True, + country=Country('BE') + ) + assert not item.tax_rule.is_reverse_charge(ia) + assert get_price(item, invoice_address=ia).gross == Decimal('119.00') + + +@pytest.mark.django_db +def test_tax_reverse_charge_no_country(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + eu_reverse_charge=True, home_country=Country('DE') + ) + ia = InvoiceAddress( + is_business=True, vat_id="EU1234", vat_id_validated=True, + ) + assert not item.tax_rule.is_reverse_charge(ia) + assert get_price(item, invoice_address=ia).gross == Decimal('119.00') + + +@pytest.mark.django_db +def test_tax_reverse_charge_non_eu_country(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + eu_reverse_charge=True, home_country=Country('DE') + ) + ia = InvoiceAddress( + country=Country('US') + ) + assert not item.tax_rule.is_reverse_charge(ia) + assert get_price(item, invoice_address=ia).gross == Decimal('100.00') + + +@pytest.mark.django_db +def test_tax_reverse_charge_same_country(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + eu_reverse_charge=True, home_country=Country('DE') + ) + ia = InvoiceAddress( + is_business=True, vat_id="EU1234", vat_id_validated=True, + country=Country('DE') + ) + assert not item.tax_rule.is_reverse_charge(ia) + assert get_price(item, invoice_address=ia).gross == Decimal('119.00') + + +@pytest.mark.django_db +def test_tax_reverse_charge_consumer(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + eu_reverse_charge=True, home_country=Country('DE') + ) + ia = InvoiceAddress( + is_business=False, country=Country('BE') + ) + assert not item.tax_rule.is_reverse_charge(ia) + assert get_price(item, invoice_address=ia).gross == Decimal('119.00') + + +@pytest.mark.django_db +def test_tax_reverse_charge_invalid_vat_id(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + eu_reverse_charge=True, home_country=Country('DE') + ) + ia = InvoiceAddress( + is_business=True, vat_id="EU1234", vat_id_validated=False, + country=Country('BE') + ) + assert not item.tax_rule.is_reverse_charge(ia) + assert get_price(item, invoice_address=ia).gross == Decimal('119.00') diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 14b900f5bd..1e55920494 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -132,35 +132,39 @@ class EventsTest(SoupTest): assert len(doc.select(".btn-primary")) == 0 def test_payment_settings(self): + tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) self.get_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug)) self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { 'payment_banktransfer__enabled': 'true', 'payment_banktransfer__fee_abs': '12.23', 'payment_banktransfer_bank_details_0': 'Test', 'settings-payment_term_days': '2', - 'settings-tax_rate_default': '19.00', + 'settings-tax_rate_default': tr19.pk, }) self.event1.settings.flush() assert self.event1.settings.get('payment_banktransfer__enabled', as_type=bool) assert self.event1.settings.get('payment_banktransfer__fee_abs', as_type=Decimal) == Decimal('12.23') def test_payment_settings_dont_require_fields_of_inactive_providers(self): + tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) doc = self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { - 'settings-tax_rate_default': '19.00', + 'settings-tax_rate_default': tr19.pk, 'settings-payment_term_days': '2' }, follow=True) assert doc.select('.alert-success') def test_payment_settings_require_fields_of_active_providers(self): + tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) doc = self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { 'payment_banktransfer__enabled': 'true', 'payment_banktransfer__fee_abs': '12.23', 'settings-payment_term_days': '2', - 'settings-tax_rate_default': '19.00', + 'settings-tax_rate_default': tr19.pk, }) assert doc.select('.alert-danger') def test_payment_settings_last_date_payment_after_presale_end(self): + tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) self.event1.presale_end = datetime.datetime.now() self.event1.save(update_fields=['presale_end']) doc = self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { @@ -172,13 +176,14 @@ class EventsTest(SoupTest): 'settings-payment_term_last_1': (self.event1.presale_end - datetime.timedelta(1)).strftime('%Y-%m-%d'), 'settings-payment_term_last_2': '0', 'settings-payment_term_last_3': 'date_from', - 'settings-tax_rate_default': '19.00', + 'settings-tax_rate_default': tr19.pk, }) assert doc.select('.alert-danger') self.event1.presale_end = None self.event1.save(update_fields=['presale_end']) def test_payment_settings_relative_date_payment_after_presale_end(self): + tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) self.event1.presale_end = self.event1.date_from - datetime.timedelta(days=5) self.event1.save(update_fields=['presale_end']) doc = self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { @@ -190,7 +195,7 @@ class EventsTest(SoupTest): 'settings-payment_term_last_1': '', 'settings-payment_term_last_2': '10', 'settings-payment_term_last_3': 'date_from', - 'settings-tax_rate_default': '19.00', + 'settings-tax_rate_default': tr19.pk, }) assert doc.select('.alert-danger') self.event1.presale_end = None @@ -268,6 +273,7 @@ class EventsTest(SoupTest): 'basics-location_0': 'Hamburg', 'basics-location_1': 'Hamburg', 'basics-currency': 'EUR', + 'basics-tax_rate': '', 'basics-locale': 'en', 'basics-timezone': 'Europe/Berlin', 'basics-presale_start': '2016-11-01 10:00:00', @@ -292,6 +298,7 @@ class EventsTest(SoupTest): 'basics-location_0': 'Hamburg', 'basics-location_1': 'Hamburg', 'basics-currency': 'EUR', + 'basics-tax_rate': '', 'basics-locale': 'en', 'basics-timezone': 'Europe/Berlin', 'basics-presale_start': '2016-11-01 10:00:00', @@ -323,6 +330,7 @@ class EventsTest(SoupTest): 'basics-location_0': 'Hamburg', 'basics-location_1': 'Hamburg', 'basics-currency': 'EUR', + 'basics-tax_rate': '19.00', 'basics-locale': 'en', 'basics-timezone': 'Europe/Berlin', 'basics-presale_start': '2016-11-01 10:00:00', @@ -352,6 +360,8 @@ class EventsTest(SoupTest): assert ev.presale_start == berlin_tz.localize(datetime.datetime(2016, 11, 1, 10, 0, 0)).astimezone(pytz.utc) assert ev.presale_end == berlin_tz.localize(datetime.datetime(2016, 11, 30, 18, 0, 0)).astimezone(pytz.utc) + assert ev.tax_rules.filter(rate=Decimal('19.00')).exists() + def test_create_event_with_subevents_success(self): doc = self.get_doc('/control/events/add') tabletext = doc.select("form")[0].text @@ -374,6 +384,7 @@ class EventsTest(SoupTest): 'basics-location_0': 'Hamburg', 'basics-location_1': 'Hamburg', 'basics-currency': 'EUR', + 'basics-tax_rate': '', 'basics-locale': 'en', 'basics-timezone': 'Europe/Berlin', 'basics-presale_start': '2016-11-01 10:00:00', @@ -402,6 +413,7 @@ class EventsTest(SoupTest): 'basics-date_to': '', 'basics-location_0': 'Hamburg', 'basics-currency': 'EUR', + 'basics-tax_rate': '', 'basics-locale': 'en', 'basics-timezone': 'UTC', 'basics-presale_start': '', @@ -441,6 +453,7 @@ class EventsTest(SoupTest): 'basics-date_to': '2016-12-30 19:00:00', 'basics-location_0': 'Hamburg', 'basics-currency': 'EUR', + 'basics-tax_rate': '', 'basics-locale': 'en', 'basics-timezone': 'Europe/Berlin', 'basics-presale_start': '2016-11-20 11:00:00', @@ -463,6 +476,7 @@ class EventsTest(SoupTest): 'basics-date_to': '2016-12-30 19:00:00', 'basics-location_0': 'Hamburg', 'basics-currency': '$', + 'basics-tax_rate': '', 'basics-locale': 'en', 'basics-timezone': 'Europe/Berlin', 'basics-presale_start': '2016-11-01 10:00:00', @@ -485,6 +499,7 @@ class EventsTest(SoupTest): 'basics-date_to': '2016-12-30 19:00:00', 'basics-location_0': 'Hamburg', 'basics-currency': 'ASD', + 'basics-tax_rate': '', 'basics-locale': 'en', 'basics-timezone': 'Europe/Berlin', 'basics-presale_start': '2016-11-01 10:00:00', diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 10e22e4978..52bd89d58c 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -1,9 +1,11 @@ from datetime import timedelta from decimal import Decimal +from unittest import mock import pytest from django.core import mail from django.utils.timezone import now +from django_countries.fields import Country from tests.base import SoupTest from pretix.base.models import ( @@ -558,9 +560,11 @@ class OrderChangeTests(SoupTest): datetime=now(), expires=now() + timedelta(days=10), total=Decimal('46.00'), payment_provider='banktransfer' ) - self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + self.tr7 = self.event.tax_rules.create(rate=Decimal('7.00')) + self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00')) + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rule=self.tr7, default_price=Decimal('23.00'), admission=True) - self.shirt = Item.objects.create(event=self.event, name='T-Shirt', tax_rate=Decimal('19.00'), + self.shirt = Item.objects.create(event=self.event, name='T-Shirt', tax_rule=self.tr19, default_price=Decimal('12.00')) self.op1 = OrderPosition.objects.create( order=self.order, item=self.ticket, variation=None, @@ -593,7 +597,7 @@ class OrderChangeTests(SoupTest): self.order.refresh_from_db() assert self.op1.item == self.shirt assert self.op1.price == self.shirt.default_price - assert self.op1.tax_rate == self.shirt.tax_rate + assert self.op1.tax_rate == self.shirt.tax_rule.rate assert self.order.total == self.op1.price + self.op2.price def test_change_subevent_success(self): @@ -678,3 +682,135 @@ class OrderChangeTests(SoupTest): assert self.order.positions.count() == 3 assert self.order.positions.last().item == self.shirt assert self.order.positions.last().price == 14 + + def test_recalculate_reverse_charge(self): + self.tr7.eu_reverse_charge = True + self.tr7.home_country = Country('DE') + self.tr7.save() + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + InvoiceAddress.objects.create( + order=self.order, is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + + self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'other-recalculate_taxes': 'on', + 'add-itemvar'.format(self.op2.pk): str(self.ticket.pk), + 'op-{}-operation'.format(self.op1.pk): '', + 'op-{}-operation'.format(self.op2.pk): '', + 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op2.pk): str(self.op2.price), + 'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op1.pk): str(self.op1.price), + }) + + ops = list(self.order.positions.all()) + for op in ops: + assert op.price == Decimal('21.50') + assert op.tax_value == Decimal('0.00') + assert op.tax_rate == Decimal('0.00') + + +@pytest.mark.django_db +def test_check_vatid(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + ia = InvoiceAddress.objects.create(order=env[2], is_business=True, vat_id='ATU1234567', country=Country('AT')) + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-success' in response.rendered_content + ia.refresh_from_db() + assert ia.vat_id_validated + + +@pytest.mark.django_db +def test_check_vatid_no_entered(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + ia = InvoiceAddress.objects.create(order=env[2], is_business=True, country=Country('AT')) + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-danger' in response.rendered_content + ia.refresh_from_db() + assert not ia.vat_id_validated + + +@pytest.mark.django_db +def test_check_vatid_invalid_country(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + ia = InvoiceAddress.objects.create(order=env[2], is_business=True, vat_id='ATU1234567', country=Country('FR')) + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-danger' in response.rendered_content + ia.refresh_from_db() + assert not ia.vat_id_validated + + +@pytest.mark.django_db +def test_check_vatid_noneu_country(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + ia = InvoiceAddress.objects.create(order=env[2], is_business=True, vat_id='CHU1234567', country=Country('CH')) + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-danger' in response.rendered_content + ia.refresh_from_db() + assert not ia.vat_id_validated + + +@pytest.mark.django_db +def test_check_vatid_no_country(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + ia = InvoiceAddress.objects.create(order=env[2], is_business=True, vat_id='ATU1234567') + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-danger' in response.rendered_content + ia.refresh_from_db() + assert not ia.vat_id_validated + + +@pytest.mark.django_db +def test_check_vatid_no_invoiceaddress(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-danger' in response.rendered_content + + +@pytest.mark.django_db +def test_check_vatid_invalid(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + ia = InvoiceAddress.objects.create(order=env[2], is_business=True, vat_id='ATU1234567', country=Country('AT')) + with mock.patch('vat_moss.id.validate') as mock_validate: + def raiser(*args, **kwargs): + import vat_moss.errors + raise vat_moss.errors.InvalidError('Fail') + + mock_validate.side_effect = raiser + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-danger' in response.rendered_content + ia.refresh_from_db() + assert not ia.vat_id_validated + + +@pytest.mark.django_db +def test_check_vatid_unavailable(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + ia = InvoiceAddress.objects.create(order=env[2], is_business=True, vat_id='ATU1234567', country=Country('AT')) + with mock.patch('vat_moss.id.validate') as mock_validate: + def raiser(*args, **kwargs): + import vat_moss.errors + raise vat_moss.errors.WebServiceUnavailableError('Fail') + + mock_validate.side_effect = raiser + response = client.post('/control/event/dummy/dummy/orders/FOO/checkvatid', {}, follow=True) + assert 'alert-danger' in response.rendered_content + ia.refresh_from_db() + assert not ia.vat_id_validated diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 1b1b8ad20f..4af204d533 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -41,6 +41,10 @@ event_urls = [ "settings/invoice", "settings/invoice/preview", "settings/display", + "settings/tax/", + "settings/tax/add", + "settings/tax/1/", + "settings/tax/1/delete", "items/", "items/add", "items/1/", @@ -81,6 +85,7 @@ event_urls = [ "orders/ABC/contact", "orders/ABC/comment", "orders/ABC/locale", + "orders/ABC/checkvatid", "orders/ABC/", "orders/", "waitinglist/", @@ -162,6 +167,10 @@ event_permission_urls = [ ("can_change_event_settings", "settings/display", 200), ("can_change_event_settings", "settings/invoice", 200), ("can_change_event_settings", "settings/invoice/preview", 200), + ("can_change_event_settings", "settings/tax/", 200), + ("can_change_event_settings", "settings/tax/1/", 404), + ("can_change_event_settings", "settings/tax/add", 200), + ("can_change_event_settings", "settings/tax/1/delete", 404), ("can_change_event_settings", "comment/", 405), # Lists are currently not access-controlled # ("can_change_items", "items/", 200), @@ -197,6 +206,7 @@ event_permission_urls = [ ("can_change_orders", "orders/FOO/extend", 200), ("can_change_orders", "orders/FOO/contact", 200), ("can_change_orders", "orders/FOO/transition", 405), + ("can_change_orders", "orders/FOO/checkvatid", 405), ("can_change_orders", "orders/FOO/resend", 405), ("can_change_orders", "orders/FOO/invoice", 405), ("can_change_orders", "orders/FOO/change", 200), diff --git a/src/tests/control/test_taxrates.py b/src/tests/control/test_taxrates.py new file mode 100644 index 0000000000..decb22aad3 --- /dev/null +++ b/src/tests/control/test_taxrates.py @@ -0,0 +1,115 @@ +import datetime +from decimal import Decimal + +from django.utils.timezone import now +from tests.base import SoupTest, extract_form_fields + +from pretix.base.models import Event, Order, Organizer, Team, User + + +class TaxRateFormTest(SoupTest): + def setUp(self): + super().setUp() + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + self.orga1 = Organizer.objects.create(name='CCC', slug='ccc') + self.orga2 = Organizer.objects.create(name='MRM', slug='mrm') + self.event1 = Event.objects.create( + organizer=self.orga1, name='30C3', slug='30c3', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + ) + t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_change_items=True) + t.members.add(self.user) + t.limit_events.add(self.event1) + self.client.login(email='dummy@dummy.dummy', password='dummy') + + def test_create(self): + doc = self.get_doc('/control/event/%s/%s/settings/tax/add' % (self.orga1.slug, self.event1.slug)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + form_data['name_0'] = 'VAT' + form_data['rate'] = '19.00' + form_data['price_includes_tax'] = 'on' + doc = self.post_doc('/control/event/%s/%s/settings/tax/add' % (self.orga1.slug, self.event1.slug), form_data) + assert doc.select(".alert-success") + self.assertIn("VAT", doc.select("#page-wrapper table")[0].text) + assert self.event1.tax_rules.get( + rate=19, price_includes_tax=True, eu_reverse_charge=False + ) + + def test_update(self): + tr = self.event1.tax_rules.create(rate=19, name="VAT") + doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/' % (self.orga1.slug, self.event1.slug, tr.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + form_data['rate'] = '20.00' + doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/' % (self.orga1.slug, self.event1.slug, tr.id), + form_data) + assert doc.select(".alert-success") + tr.refresh_from_db() + assert tr.rate == Decimal('20.00') + + def test_delete(self): + tr = self.event1.tax_rules.create(rate=19, name="VAT") + doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id), + form_data) + assert doc.select(".alert-success") + self.assertNotIn("VAT", doc.select("#page-wrapper")[0].text) + assert not self.event1.tax_rules.exists() + + def test_delete_item_existing(self): + tr = self.event1.tax_rules.create(rate=19, name="VAT") + self.event1.items.create(name="foo", default_price=12, tax_rule=tr) + doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id), + form_data) + self.assertIn("VAT", doc.select("#page-wrapper")[0].text) + assert self.event1.tax_rules.exists() + + def test_delete_default_rule(self): + tr = self.event1.tax_rules.create(rate=19, name="VAT") + self.event1.settings.tax_rate_default = tr + doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id), + form_data) + self.assertIn("VAT", doc.select("#page-wrapper")[0].text) + assert self.event1.tax_rules.exists() + + def test_delete_order_existing(self): + tr = self.event1.tax_rules.create(rate=19, name="VAT") + self.event1.orders.create( + code='FOO', event=self.event1, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + datetime.timedelta(days=10), + total=14, payment_provider='banktransfer', locale='en', + payment_fee=0.3, + payment_fee_tax_rate=19, + payment_fee_tax_value=0.05, + payment_fee_tax_rule=tr + ) + doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id), + form_data) + self.assertIn("VAT", doc.select("#page-wrapper")[0].text) + assert self.event1.tax_rules.exists() + + def test_delete_orderpos_existing(self): + tr = self.event1.tax_rules.create(rate=19, name="VAT") + i = self.event1.items.create(name="foo", default_price=12) + o = self.event1.orders.create( + code='FOO', event=self.event1, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + datetime.timedelta(days=10), + total=12, payment_provider='banktransfer', locale='en' + ) + o.positions.create( + item=i, price=12, tax_rule=tr, tax_rate=19, tax_value=12 - 12 / 1.19 + ) + doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id), + form_data) + self.assertIn("VAT", doc.select("#page-wrapper")[0].text) + assert self.event1.tax_rules.exists() diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index ac0ecb3c46..8a3e90ed5c 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -6,10 +6,12 @@ from bs4 import BeautifulSoup from django.conf import settings from django.test import TestCase from django.utils.timezone import now +from django_countries.fields import Country +from pretix.base.decimal import round_decimal from pretix.base.models import ( - CartPosition, Event, Item, ItemCategory, ItemVariation, Organizer, - Question, QuestionAnswer, Quota, Voucher, + CartPosition, Event, InvoiceAddress, Item, ItemCategory, ItemVariation, + Organizer, Question, QuestionAnswer, Quota, Voucher, ) from pretix.base.models.items import ( ItemAddOn, SubEventItem, SubEventItemVariation, @@ -26,9 +28,11 @@ class CartTestMixin: date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), live=True ) + self.tr19 = self.event.tax_rules.create(rate=19) self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0) self.quota_shirts = Quota.objects.create(event=self.event, name='Shirts', size=2) - self.shirt = Item.objects.create(event=self.event, name='T-Shirt', category=self.category, default_price=12) + self.shirt = Item.objects.create(event=self.event, name='T-Shirt', category=self.category, default_price=12, + tax_rule=self.tr19) self.quota_shirts.items.add(self.shirt) self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value='Red') self.shirt_blue = ItemVariation.objects.create(item=self.shirt, value='Blue') @@ -36,7 +40,8 @@ class CartTestMixin: self.quota_shirts.variations.add(self.shirt_blue) self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5) self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', - category=self.category, default_price=23) + category=self.category, default_price=23, + tax_rule=self.tr19) self.quota_tickets.items.add(self.ticket) self.quota_all = Quota.objects.create(event=self.event, name='All', size=None) @@ -90,6 +95,35 @@ class CartTest(CartTestMixin, TestCase): self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, 23) + def _set_session(self, key, value): + session = self.client.session + session[key] = value + session.save() + + def _enable_reverse_charge(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + self._set_session('invoice_address_{}'.format(self.event.pk), ia.pk) + return ia + + def test_reverse_charge(self): + self._enable_reverse_charge() + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1' + }, follow=True) + self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), + target_status_code=200) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].price, round_decimal(Decimal('23.00') / Decimal('1.19'))) + def test_subevent_missing(self): self.event.has_subevents = True self.event.save() diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index c9fface515..e8ccb58177 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -2,16 +2,19 @@ import datetime import os from datetime import timedelta from decimal import Decimal +from unittest import mock from bs4 import BeautifulSoup from django.conf import settings from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.utils.timezone import now +from django_countries.fields import Country +from pretix.base.decimal import round_decimal from pretix.base.models import ( - CartPosition, Event, Item, ItemCategory, Order, OrderPosition, Organizer, - Question, Quota, Voucher, + CartPosition, Event, InvoiceAddress, Item, ItemCategory, Order, + OrderPosition, Organizer, Question, Quota, Voucher, ) from pretix.base.models.items import ItemAddOn, ItemVariation, SubEventItem @@ -26,10 +29,12 @@ class CheckoutTestCase(TestCase): plugins='pretix.plugins.stripe,pretix.plugins.banktransfer', live=True ) + self.tr19 = self.event.tax_rules.create(rate=19) self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0) self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5) self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', - category=self.category, default_price=23, admission=True) + category=self.category, default_price=23, admission=True, + tax_rule=self.tr19) self.quota_tickets.items.add(self.ticket) self.event.settings.set('attendee_names_asked', False) self.event.settings.set('payment_banktransfer__enabled', True) @@ -51,6 +56,17 @@ class CheckoutTestCase(TestCase): self.workshopquota.variations.add(self.workshop2a) self.workshopquota.variations.add(self.workshop2b) + def _enable_reverse_charge(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + ia = InvoiceAddress.objects.create( + is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + self._set_session('invoice_address_{}'.format(self.event.pk), ia.pk) + return ia + def test_empty_cart(self): response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), @@ -112,6 +128,218 @@ class CheckoutTestCase(TestCase): self.assertEqual(cr1.answers.filter(question=q2).count(), 1) self.assertFalse(cr2.answers.filter(question=q2).exists()) + def test_reverse_charge(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + self.event.settings.invoice_address_vatid = True + + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AT', + 'vat_id': 'AT123456', + 'email': 'admin@localhost' + }, follow=True) + + cr1.refresh_from_db() + assert cr1.price == round_decimal(Decimal('23.00') / Decimal('1.19')) + + ia = InvoiceAddress.objects.get(pk=self.client.session.get('invoice_address_{}'.format(self.event.pk))) + assert ia.vat_id_validated + + def test_reverse_charge_enable_then_disable(self): + self.test_reverse_charge() + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'individual', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AT', + 'vat_id': '', + 'email': 'admin@localhost' + }, follow=True) + + cr = CartPosition.objects.get(cart_id=self.session_key) + assert cr.price == Decimal('23.00') + + ia = InvoiceAddress.objects.get(pk=self.client.session.get('invoice_address_{}'.format(self.event.pk))) + assert not ia.vat_id_validated + + def test_reverse_charge_invalid_vatid(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + self.event.settings.invoice_address_vatid = True + + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('vat_moss.id.validate') as mock_validate: + def raiser(*args, **kwargs): + import vat_moss.errors + raise vat_moss.errors.InvalidError() + + mock_validate.side_effect = raiser + resp = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AT', + 'vat_id': 'AT123456', + 'email': 'admin@localhost' + }, follow=True) + assert 'alert-danger' in resp.rendered_content + + cr1.refresh_from_db() + assert cr1.price == Decimal('23.00') + + def test_reverse_charge_vatid_non_eu(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('NO') + self.tr19.save() + self.event.settings.invoice_address_vatid = True + + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AU', 'AU123456', 'Foo') + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AU', + 'vat_id': 'AU123456', + 'email': 'admin@localhost' + }, follow=True) + + cr1.refresh_from_db() + assert cr1.price == round_decimal(Decimal('23.00') / Decimal('1.19')) + + ia = InvoiceAddress.objects.get(pk=self.client.session.get('invoice_address_{}'.format(self.event.pk))) + assert not ia.vat_id_validated + + def test_reverse_charge_vatid_same_country(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('AT') + self.tr19.save() + self.event.settings.invoice_address_vatid = True + + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AT', + 'vat_id': 'AT123456', + 'email': 'admin@localhost' + }, follow=True) + + cr1.refresh_from_db() + assert cr1.price == Decimal('23.00') + + ia = InvoiceAddress.objects.get(pk=self.client.session.get('invoice_address_{}'.format(self.event.pk))) + assert ia.vat_id_validated + + def test_reverse_charge_vatid_check_invalid_country(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + self.event.settings.invoice_address_vatid = True + + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + resp = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'FR', + 'vat_id': 'AT123456', + 'email': 'admin@localhost' + }, follow=True) + assert 'alert-danger' in resp.rendered_content + + cr1.refresh_from_db() + assert cr1.price == Decimal('23.00') + + def test_reverse_charge_vatid_check_unavailable(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + self.event.settings.invoice_address_vatid = True + + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('vat_moss.id.validate') as mock_validate: + def raiser(*args, **kwargs): + import vat_moss.errors + raise vat_moss.errors.WebServiceUnavailableError('Fail') + + mock_validate.side_effect = raiser + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AT', + 'vat_id': 'AT123456', + 'email': 'admin@localhost' + }, follow=True) + + cr1.refresh_from_db() + assert cr1.price == Decimal('23.00') + + ia = InvoiceAddress.objects.get(pk=self.client.session.get('invoice_address_{}'.format(self.event.pk))) + assert not ia.vat_id_validated + def test_question_file_upload(self): q1 = Question.objects.create( event=self.event, question='Student ID', type=Question.TYPE_FILE, @@ -426,6 +654,22 @@ class CheckoutTestCase(TestCase): self.assertEqual(len(doc.select(".thank-you")), 1) self.assertEqual(OrderPosition.objects.filter(item=self.workshop1).last().price, 0) + def test_confirm_price_changed_reverse_charge(self): + self._enable_reverse_charge() + self.ticket.default_price = 24 + self.ticket.save() + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".alert-danger")), 1) + cr1 = CartPosition.objects.get(id=cr1.id) + self.assertEqual(cr1.price, round_decimal(Decimal('24.00') / Decimal('1.19'))) + def test_confirm_price_changed(self): self.ticket.default_price = 24 self.ticket.save() @@ -1018,9 +1262,9 @@ class CheckoutTestCase(TestCase): self.workshopquota.size = 1 self.workshopquota.subevent = se self.workshopquota.save() - self.workshop1.tax_rate = 19 + self.workshop1.tax_rule = self.event.tax_rules.get_or_create(rate=Decimal('19.00'), name="VAT")[0] self.workshop1.save() - self.workshop2.tax_rate = 19 + self.workshop2.tax_rule = self.event.tax_rules.get_or_create(rate=Decimal('19.00'), name="VAT")[0] self.workshop2.save() SubEventItem.objects.create(subevent=se, item=self.workshop1, price=42) @@ -1033,8 +1277,8 @@ class CheckoutTestCase(TestCase): response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), target_status_code=200) - assert 'Workshop 1 (+ EUR 35.29 plus 19.00% taxes)' in response.rendered_content - assert 'A (+ EUR 10.08 plus 19.00% taxes)' in response.rendered_content + assert 'Workshop 1 (+ EUR 35.29 plus 19.00% VAT)' in response.rendered_content + assert 'A (+ EUR 10.08 plus 19.00% VAT)' in response.rendered_content def test_confirm_subevent_presale_not_yet(self): self.event.has_subevents = True diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index bb0996fdff..e6b82ebf53 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -215,8 +215,9 @@ class ItemDisplayTest(EventTestMixin, SoupTest): self.event.settings.display_net_prices = True se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + tr = self.event.tax_rules.get_or_create(rate=Decimal('19.00'))[0] item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=15, - tax_rate=19) + tax_rule=tr) q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) q.items.add(item) q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) @@ -463,7 +464,7 @@ class VoucherRedeemItemDisplayTest(EventTestMixin, SoupTest): self.event.settings.display_net_prices = True self.event.has_subevents = True self.event.save() - self.item.tax_rate = 19 + self.item.tax_rule = self.event.tax_rules.get_or_create(rate=Decimal('19.00'))[0] self.item.save() se1 = self.event.subevents.create(name='SE1', date_from=now(), active=True) q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1)