mirror of
https://github.com/pretix/pretix.git
synced 2026-08-13 11:17:01 +00:00
@@ -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
|
||||
---------------
|
||||
|
||||
@@ -7,6 +7,7 @@ Resources and endpoints
|
||||
organizers
|
||||
events
|
||||
subevents
|
||||
taxrules
|
||||
categories
|
||||
items
|
||||
questions
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -42,7 +42,6 @@ django.setup()
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.doctest',
|
||||
'sphinx.ext.todo',
|
||||
'sphinx.ext.coverage',
|
||||
'sphinxcontrib.httpdomain',
|
||||
'sphinxcontrib.images',
|
||||
|
||||
@@ -10,6 +10,3 @@ Developer documentation
|
||||
implementation/index
|
||||
api/index
|
||||
structure
|
||||
|
||||
.. TODO::
|
||||
Document settings objects, ItemVariation objects, form fields.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -10,3 +10,4 @@ wanting to use pretix to sell tickets.
|
||||
organizers/index
|
||||
payments/index
|
||||
events/create
|
||||
events/taxes
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': [
|
||||
{
|
||||
|
||||
+89
-22
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,8 +8,6 @@ from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.models.event import SubEvent
|
||||
|
||||
|
||||
class LogEntry(models.Model):
|
||||
"""
|
||||
@@ -52,7 +50,7 @@ class LogEntry(models.Model):
|
||||
|
||||
@cached_property
|
||||
def display_object(self):
|
||||
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event
|
||||
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
|
||||
|
||||
if self.content_type.model_class() is Event:
|
||||
return ''
|
||||
@@ -131,6 +129,16 @@ class LogEntry(models.Model):
|
||||
}),
|
||||
'val': escape(co.question),
|
||||
}
|
||||
elif isinstance(co, TaxRule):
|
||||
a_text = _('Tax rule {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.settings.tax.edit', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'rule': co.id
|
||||
}),
|
||||
'val': escape(co.name),
|
||||
}
|
||||
|
||||
if a_text and a_map:
|
||||
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import copy
|
||||
from decimal import Decimal
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
from datetime import date, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import vat_moss.exchange_rates
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
|
||||
from pretix.base.models.tax import EU_CURRENCIES
|
||||
from pretix.base.services.async import TransactionAwareTask
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def build_invoice(invoice: Invoice) -> Invoice:
|
||||
@@ -33,6 +47,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||
|
||||
try:
|
||||
ia = invoice.order.invoice_address
|
||||
addr_template = pgettext("invoice", """{i.company}
|
||||
{i.name}
|
||||
{i.street}
|
||||
@@ -44,7 +59,31 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
).strip()
|
||||
if invoice.order.invoice_address.vat_id:
|
||||
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % invoice.order.invoice_address.vat_id
|
||||
|
||||
cc = str(invoice.order.invoice_address.country)
|
||||
|
||||
if cc in EU_CURRENCIES and EU_CURRENCIES[cc] != invoice.event.currency:
|
||||
invoice.foreign_currency_display = EU_CURRENCIES[cc]
|
||||
|
||||
if settings.FETCH_ECB_RATES:
|
||||
gs = GlobalSettingsObject()
|
||||
rates_date = gs.settings.get('ecb_rates_date', as_type=date)
|
||||
rates_dict = gs.settings.get('ecb_rates_dict', as_type=dict)
|
||||
convert = (
|
||||
rates_date and rates_dict and
|
||||
rates_date > (now() - timedelta(days=7)).date() and
|
||||
invoice.event.currency in rates_dict and
|
||||
invoice.foreign_currency_display in rates_dict
|
||||
)
|
||||
if convert:
|
||||
invoice.foreign_currency_rate = (
|
||||
Decimal(rates_dict[invoice.foreign_currency_display])
|
||||
/ Decimal(rates_dict[invoice.event.currency])
|
||||
).quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rates_date
|
||||
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
invoice.invoice_to = ""
|
||||
|
||||
invoice.file = None
|
||||
@@ -52,10 +91,13 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.lines.all().delete()
|
||||
|
||||
positions = list(
|
||||
invoice.order.positions.select_related('addon_to', 'item', 'variation').annotate(
|
||||
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'variation').annotate(
|
||||
addon_c=Count('addons')
|
||||
)
|
||||
)
|
||||
|
||||
reverse_charge = False
|
||||
|
||||
positions.sort(key=lambda p: p.sort_key)
|
||||
for p in positions:
|
||||
if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c:
|
||||
@@ -69,15 +111,29 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=desc,
|
||||
gross_value=p.price, tax_value=p.tax_value,
|
||||
tax_rate=p.tax_rate
|
||||
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
|
||||
)
|
||||
|
||||
if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value:
|
||||
reverse_charge = True
|
||||
|
||||
if reverse_charge:
|
||||
if invoice.additional_text:
|
||||
invoice.additional_text += "<br /><br />"
|
||||
invoice.additional_text += pgettext(
|
||||
"invoice",
|
||||
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
|
||||
"rests with the service recipient."
|
||||
)
|
||||
invoice.save()
|
||||
|
||||
if invoice.order.payment_fee:
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice,
|
||||
description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)),
|
||||
gross_value=invoice.order.payment_fee, tax_value=invoice.order.payment_fee_tax_value,
|
||||
tax_rate=invoice.order.payment_fee_tax_rate
|
||||
tax_rate=invoice.order.payment_fee_tax_rate,
|
||||
tax_name=invoice.order.payment_fee_tax_rule.name if invoice.order.payment_fee_tax_rule else ''
|
||||
)
|
||||
|
||||
return invoice
|
||||
@@ -200,3 +256,20 @@ def build_preview_invoice_pdf(event):
|
||||
tax_rate=19
|
||||
)
|
||||
return event.invoice_renderer.generate(invoice)
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def fetch_ecb_rates(sender, **kwargs):
|
||||
if not settings.FETCH_ECB_RATES:
|
||||
return
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
if gs.settings.ecb_rates_date == now().strftime("%Y-%m-%d"):
|
||||
return
|
||||
|
||||
try:
|
||||
date, rates = vat_moss.exchange_rates.fetch()
|
||||
gs.settings.ecb_rates_date = date
|
||||
gs.settings.ecb_rates_dict = json.dumps(rates, cls=DjangoJSONEncoder)
|
||||
except urllib.error.URLError:
|
||||
logger.exception('Could not retrieve rates from ECB')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
{% trans "Email" %}
|
||||
</a>
|
||||
</li>
|
||||
<li {% if "event.settings.tax" in url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.settings.tax' organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
{% trans "Tax rules" %}
|
||||
</a>
|
||||
</li>
|
||||
<li {% if "event.settings.invoice" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.settings.invoice' organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
{% trans "Invoicing" %}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete tax rule" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<legend>{% trans "Delete tax rule" %}</legend>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if possible %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the tax rule <strong>{{ taxrule }}</strong>?{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p>{% blocktrans %}You cannot delete a tax rule that is in use for a product or has been in use for any existing orders.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.settings.tax" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn
|
||||
btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
{% if possible %}
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}
|
||||
{% if rule %}
|
||||
{% blocktrans with name=rule.name %}Tax rule: {{ name }}{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Tax rule" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
{% if rule %}
|
||||
<legend>{% blocktrans with name=rule.name %}Tax rule: {{ name }}{% endblocktrans %}</legend>
|
||||
{% else %}
|
||||
<legend>{% trans "Tax rule" %}</legend>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.rate layout="horizontal" %}
|
||||
<legend>{% trans "Advanced settings" %}</legend>
|
||||
<div class="alert alert-warning">
|
||||
<span class="fa fa-w fa-legal fa-4x pull-left"></span>
|
||||
{% blocktrans trimmed with docs="https://docs.pretix.eu/en/latest/user/events/taxes.html" %}
|
||||
These settings are intended for advanced users. See the <a href="{{ docs }}">documentation</a>
|
||||
for more information. Note that we are not responsible for the correct handling
|
||||
of taxes in your ticket shop. If in doubt, please contact a lawyer or tax consultant.
|
||||
{% endblocktrans %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% bootstrap_field form.price_includes_tax layout="horizontal" %}
|
||||
{% bootstrap_field form.eu_reverse_charge layout="horizontal" %}
|
||||
{% bootstrap_field form.home_country layout="horizontal" %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Tax rules" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<legend>{% trans "Tax rules" %}</legend>
|
||||
{% if taxrules|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any tax rules yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
|
||||
</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Rate" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tr in taxrules %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
|
||||
{{ tr.name }}
|
||||
</a></strong>
|
||||
</td>
|
||||
<td>{{ tr.rate }} %</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -33,6 +33,7 @@
|
||||
{% bootstrap_field form.date_to layout="horizontal" %}
|
||||
{% bootstrap_field form.location layout="horizontal" %}
|
||||
{% bootstrap_field form.currency layout="horizontal" %}
|
||||
{% bootstrap_field form.tax_rate layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Display settings" %}</legend>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Price settings" %}</legend>
|
||||
{% bootstrap_field form.default_price layout="horizontal" %}
|
||||
{% bootstrap_field form.tax_rate layout="horizontal" %}
|
||||
{% bootstrap_field form.tax_rule layout="horizontal" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Price settings" %}</legend>
|
||||
{% bootstrap_field form.default_price layout="horizontal" %}
|
||||
{% bootstrap_field form.tax_rate layout="horizontal" %}
|
||||
{% bootstrap_field form.tax_rule layout="horizontal" %}
|
||||
{% bootstrap_field form.free_price layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -103,8 +103,18 @@
|
||||
{% if position.form.operation.value == "price" %}checked="checked"{% endif %}>
|
||||
{% trans "Change price to" %}
|
||||
{% bootstrap_field position.form.price layout='inline' %}
|
||||
{% if request.event.settings.display_net_prices %}
|
||||
<em>{% trans "Enter a gross price including taxes." %}</em>
|
||||
{% if position.apply_tax %}
|
||||
{% if position.item.tax_rule and not position.item.tax_rule.price_includes_tax %}
|
||||
{% blocktrans trimmed with rate=position.item.tax_rule.rate name=position.item.tax_rule.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}
|
||||
{% elif position.item.tax_rule %}
|
||||
{% blocktrans trimmed with rate=position.item.tax_rule.rate name=position.item.tax_rule.name %}
|
||||
<strong>incl.</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% trans "no taxes apply" %}
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
@@ -150,6 +160,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
Other operations
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-horizontal">
|
||||
{% bootstrap_form_errors other_form %}
|
||||
{% if other_form.custom_error %}
|
||||
<div class="alert alert-danger">
|
||||
{{ other_form.custom_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_field other_form.recalculate_taxes layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<a class="btn btn-default btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
|
||||
<h1>
|
||||
{% blocktrans trimmed with code=order.code %}
|
||||
Order details: {{ code }}
|
||||
@@ -239,16 +240,22 @@
|
||||
{% if event.settings.display_net_prices %}
|
||||
<strong>{{ event.currency }} {{ line.net_price|floatformat:2 }}</strong>
|
||||
{% if line.tax_rate %}
|
||||
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<br />
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<strong>{{ event.currency }} {{ line.price|floatformat:2 }}</strong>
|
||||
{% if line.tax_rate %}
|
||||
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<br />
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -265,17 +272,21 @@
|
||||
<strong>{{ event.currency }} {{ order.payment_fee_net|floatformat:2 }}</strong>
|
||||
{% if order.payment_fee_tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<strong>{{ event.currency }} {{ items.payment_fee|floatformat:2 }}</strong>
|
||||
{% if order.payment_fee_tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -358,7 +369,20 @@
|
||||
<dd>{{ order.invoice_address.country.name|default:order.invoice_address.country_old }}</dd>
|
||||
{% if request.event.settings.invoice_address_vatid %}
|
||||
<dt>{% trans "VAT ID" %}</dt>
|
||||
<dd>{{ order.invoice_address.vat_id }}</dd>
|
||||
<dd>
|
||||
{{ order.invoice_address.vat_id }}
|
||||
{% if order.invoice_address.vat_id_validated %}
|
||||
<span class="fa fa-check" data-toggle="tooltip" title="{% blocktrans trimmed %}Valid EU VAT ID{% endblocktrans %}"></span>
|
||||
{% else %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.checkvatid" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs">
|
||||
{% trans "Check" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,10 @@ urlpatterns = [
|
||||
url(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
|
||||
url(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
|
||||
url(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
|
||||
url(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'),
|
||||
url(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'),
|
||||
url(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'),
|
||||
url(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
|
||||
url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'),
|
||||
url(r'^subevents/(?P<subevent>\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'),
|
||||
url(r'^subevents/(?P<subevent>\d+)/delete$', subevents.SubEventDelete.as_view(),
|
||||
@@ -131,6 +135,8 @@ urlpatterns = [
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/answer/(?P<answer>[^/]+)/$',
|
||||
orders.AnswerDownload.as_view(),
|
||||
name='event.order.download.answer'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/checkvatid', orders.OrderCheckVATID.as_view(),
|
||||
name='event.order.checkvatid'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/extend$', orders.OrderExtend.as_view(),
|
||||
name='event.order.extend'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/contact$', orders.OrderContactChange.as_view(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load safelink %}
|
||||
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
|
||||
{% for line in cart.positions %}
|
||||
<div class="row cart-row {% if download and line.item.admission|default:event.settings.ticket_download_nonadm %}has-downloads{% endif %}">
|
||||
<div class="product">
|
||||
@@ -121,16 +122,22 @@
|
||||
{% if event.settings.display_net_prices %}
|
||||
<strong>{{ event.currency }} {{ line.net_total|floatformat:2 }}</strong>
|
||||
{% if line.tax_rate %}
|
||||
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<br />
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<strong>{{ event.currency }} {{ line.total|floatformat:2 }}</strong>
|
||||
{% if line.tax_rate %}
|
||||
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<br />
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -158,18 +165,22 @@
|
||||
{% if event.settings.display_net_prices %}
|
||||
<strong>{{ event.currency }} {{ cart.payment_fee_net|floatformat:2 }}</strong>
|
||||
{% if cart.payment_fee_tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=cart.payment_fee_tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<br />
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<strong>{{ event.currency }} {{ cart.payment_fee|floatformat:2 }}</strong>
|
||||
{% if cart.payment_fee_tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=cart.payment_fee_tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
<br />
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -233,20 +233,24 @@
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price"
|
||||
placeholder="0"
|
||||
min="{{ var.display_price|stringformat:"0.2f" }}"
|
||||
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
name="price_{{ item.id }}_{{ var.id }}"
|
||||
step="any" value="{{ var.display_price|stringformat:"0.2f" }}">
|
||||
step="any"
|
||||
value="{% if event.settings.display_net_prices %}{{var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
>
|
||||
</div>
|
||||
{% elif event.settings.display_net_prices %}
|
||||
{{ event.currency }} {{ var.display_price.net|floatformat:2 }}
|
||||
{% else %}
|
||||
{{ event.currency }} {{ var.display_price|floatformat:2 }}
|
||||
{{ event.currency }} {{ var.display_price.gross|floatformat:2 }}
|
||||
{% endif %}
|
||||
{% if item.tax_rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% if var.display_price.rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% elif item.tax_rate %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% elif var.display_price.rate %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
|
||||
incl. {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -313,20 +317,23 @@
|
||||
<div class="input-group input-group-price">
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price" placeholder="0"
|
||||
min="{{ item.display_price|stringformat:"0.2f" }}"
|
||||
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
name="price_{{ item.id }}"
|
||||
step="any" value="{{ item.display_price|stringformat:"0.2f" }}">
|
||||
value="{% if event.settings.display_net_prices %}{{item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
step="any">
|
||||
</div>
|
||||
{% elif event.settings.display_net_prices %}
|
||||
{{ event.currency }} {{ item.display_price.net|floatformat:2 }}
|
||||
{% else %}
|
||||
{{ event.currency }} {{ item.display_price|floatformat:2 }}
|
||||
{{ event.currency }} {{ item.display_price.gross|floatformat:2 }}
|
||||
{% endif %}
|
||||
{% if item.tax_rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% if item.display_price.rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% elif item.tax_rate %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% elif item.display_price.rate %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
|
||||
incl. {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -82,20 +82,22 @@
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price"
|
||||
placeholder="0"
|
||||
min="{{ var.price|stringformat:"0.2f" }}"
|
||||
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
name="price_{{ item.id }}_{{ var.id }}"
|
||||
step="any"
|
||||
value="{{ var.display_price|stringformat:"0.2f" }}">
|
||||
value="{% if event.settings.display_net_prices %}{{var.display_price.net|stringformat:"0.2f" }}{% else %}{{ var.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
step="any">
|
||||
</div>
|
||||
{% elif event.settings.display_net_prices %}
|
||||
{{ event.currency }} {{ var.display_price.net|floatformat:2 }}
|
||||
{% else %}
|
||||
{{ event.currency }} {{ var.display_price|floatformat:2 }}
|
||||
{{ event.currency }} {{ var.display_price.gross|floatformat:2 }}
|
||||
{% endif %}
|
||||
{% if item.tax_rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
{% if var.display_price.rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
{% elif item.tax_rate %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
{% elif var.display_price.rate %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate name=var.display_price.name %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
@@ -145,20 +147,23 @@
|
||||
<div class="input-group input-group-price">
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price" placeholder="0"
|
||||
min="{{ item.price|stringformat:"0.2f" }}"
|
||||
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
name="price_{{ item.id }}"
|
||||
step="any" value="{{ item.price|stringformat:"0.2f" }}">
|
||||
value="{% if event.settings.display_net_prices %}{{ item.display_price.net|stringformat:"0.2f" }}{% else %}{{ item.display_price.gross|stringformat:"0.2f" }}{% endif %}"
|
||||
step="any">
|
||||
</div>
|
||||
{% elif event.settings.display_net_prices %}
|
||||
{{ event.currency }} {{ item.display_price.net|floatformat:2 }}
|
||||
{% else %}
|
||||
{{ event.currency }} {{ item.price|floatformat:2 }}
|
||||
{{ event.currency }} {{ item.display_price.gross|floatformat:2 }}
|
||||
{% endif %}
|
||||
{% if item.tax_rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% if item.display_price.rate and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% elif item.tax_rate %}
|
||||
<small>{% blocktrans trimmed with rate=item.tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% elif item.display_price.rate %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate name=item.display_price.name %}
|
||||
incl. {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -105,7 +105,8 @@ setup(
|
||||
'pycountry',
|
||||
'django-countries',
|
||||
'pyuca',
|
||||
'defusedcsv'
|
||||
'defusedcsv',
|
||||
'vat_moss==0.11.0',
|
||||
],
|
||||
extras_require={
|
||||
'dev': [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+195
-14
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
+158
-26
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user