diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst
index 2894d16c6e..f863a6df45 100644
--- a/doc/api/resources/orders.rst
+++ b/doc/api/resources/orders.rst
@@ -41,6 +41,7 @@ expires datetime The order will
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
total money (string) Total value of this order
+tax_rounding_mode string Tax rounding mode, see :ref:`algorithms-rounding`
comment string Internal comment on this order
api_meta object Meta data for that order. Only available through API, no guarantees
on the content structure. You can use this to save references to your system.
@@ -151,6 +152,10 @@ plugin_data object Additional data
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
+.. versionchanged:: 2025.10
+
+ The ``tax_rounding_mode`` attribute has been added.
+
.. _order-position-resource:
Order position resource
@@ -358,6 +363,7 @@ List of all orders
"payment_provider": "banktransfer",
"fees": [],
"total": "23.00",
+ "tax_rounding_mode": "line",
"comment": "",
"custom_followup_at": null,
"checkin_attention": false,
@@ -602,6 +608,7 @@ Fetching individual orders
"payment_provider": "banktransfer",
"fees": [],
"total": "23.00",
+ "tax_rounding_mode": "line",
"comment": "",
"api_meta": {},
"custom_followup_at": null,
@@ -1011,6 +1018,7 @@ Creating orders
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
charge will be created), this is just informative in case you *handled the payment already*.
* ``payment_date`` (optional) – Date and time of the completion of the payment.
+ * ``tax_rounding_mode`` (optional)
* ``comment`` (optional)
* ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional)
diff --git a/doc/development/algorithms/pricing.rst b/doc/development/algorithms/pricing.rst
index 1b88633894..aa1c7769fc 100644
--- a/doc/development/algorithms/pricing.rst
+++ b/doc/development/algorithms/pricing.rst
@@ -178,3 +178,124 @@ Flowchart
---------
.. image:: /images/cart_pricing.png
+
+
+.. _`algorithms-rounding`:
+
+Rounding of taxes
+-----------------
+
+pretix internally always stores taxes on a per-line level, like this:
+
+ ========== ========== =========== ======= =============
+ Product Tax rate Net price Tax Gross price
+ ========== ========== =========== ======= =============
+ Ticket A 19 % 84.03 15.97 100.00
+ Ticket B 19 % 84.03 15.97 100.00
+ Ticket C 19 % 84.03 15.97 100.00
+ Ticket D 19 % 84.03 15.97 100.00
+ Ticket E 19 % 84.03 15.97 100.00
+ Sum 420.15 79.85 500.00
+ ========== ========== =========== ======= =============
+
+Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line.
+
+The line-based computation has a few significant advantages:
+
+- We can report both net and gross prices for every individual ticket.
+
+- We can report both net and gross prices for every filter imaginable, such as the gross sum of all sales of Ticket A
+ or the net sum of all sales for a specific date in an event series. All numbers will be exact.
+
+- When splitting the order into two, both net price and gross price are split without any changes in rounding.
+
+The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15)
+and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98
+(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation
+to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that
+does not allow the computation as created by pretix.
+
+However, calculating the tax rate from the net total has significant disadvantages:
+
+- It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to
+ consumers, they will be confused when they only need to pay €499.98 for 5 tickets.
+
+- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there
+ is no two-decimal net price that would be computed to a gross price of €99.99.
+
+- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the
+ original order. Therefore, additional payments or refunds of very small amounts might be necessary.
+
+To allow organizers to make their own choices on this matter, pretix provides the following options:
+
+Compute taxes for every line individually
+"""""""""""""""""""""""""""""""""""""""""
+
+Algorithm identifier: ``line``
+
+This is our original algorithm where the tax value is rounded for every line individually.
+
+**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below).
+For the example above:
+
+ ========== ========== =========== ======= =============
+ Product Tax rate Net price Tax Gross price
+ ========== ========== =========== ======= =============
+ Ticket A 19 % 84.03 15.97 100.00
+ Ticket B 19 % 84.03 15.97 100.00
+ Ticket C 19 % 84.03 15.97 100.00
+ Ticket D 19 % 84.03 15.97 100.00
+ Ticket E 19 % 84.03 15.97 100.00
+ Sum 420.15 79.85 500.00
+ ========== ========== =========== ======= =============
+
+
+Compute taxes based on net total
+""""""""""""""""""""""""""""""""
+
+Algorithm identifier: ``sum_by_net``
+
+In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within
+our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01).
+The net price of the tickets always stay the same.
+
+**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.**
+
+The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways.
+For the example above, the customer expects to pay 5 times 100.00, but they are are in fact charged 499.98:
+
+ ========== ========== =========== ============================== ==============================
+ Product Tax rate Net price Tax Gross price
+ ========== ========== =========== ============================== ==============================
+ Ticket A 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
+ Ticket B 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
+ Ticket C 19 % 84.03 15.97 100.00
+ Ticket D 19 % 84.03 15.97 100.00
+ Ticket E 19 % 84.03 15.97 100.00
+ Sum 420.15 78.83 499.98
+ ========== ========== =========== ============================== ==============================
+
+Compute taxes based on net total with stable gross prices
+"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
+
+Algorithm identifier: ``sum_by_net_keep_gross``
+
+In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices
+of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting
+gross prices stay the same.
+
+**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.**
+
+The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different.
+Full computation for the example above:
+
+ ========== ========== ============================= ============================== =============
+ Product Tax rate Net price Tax Gross price
+ ========== ========== ============================= ============================== =============
+ Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
+ Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
+ Ticket C 19 % 84.03 15.97 100.00
+ Ticket D 19 % 84.03 15.97 100.00
+ Ticket E 19 % 84.03 15.97 100.00
+ Sum 420.17 79.83 500.00
+ ========== ========== ============================= ============================== =============
diff --git a/doc/images/cart_pricing.png b/doc/images/cart_pricing.png
index 4b1f3a7138..b2d667eeac 100644
Binary files a/doc/images/cart_pricing.png and b/doc/images/cart_pricing.png differ
diff --git a/doc/images/cart_pricing.puml b/doc/images/cart_pricing.puml
index ae945e0dd8..47c1c49ffb 100644
--- a/doc/images/cart_pricing.puml
+++ b/doc/images/cart_pricing.puml
@@ -23,6 +23,7 @@ partition "For every cart position" {
--> "Store as line_price (gross), tax_rate"
}
--> "Apply discount engine"
+--> "Apply tax rounding"
--> "Store as price (gross)"
@enduml
diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index 680d1bd5d2..b233077e85 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -829,6 +829,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_eu_currencies',
'invoice_logo_image',
'invoice_renderer_highlight_order_code',
+ 'tax_rounding',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep',
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index ab3ff47bf0..808768fc2b 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -52,9 +52,10 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import (
- CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
- ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
- ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
+ CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
+ InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
+ QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
+ Voucher,
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -64,10 +65,13 @@ from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
from pretix.base.services.pricing import (
- apply_discounts, get_line_price, get_listed_price, is_included_for_free,
+ apply_discounts, apply_rounding, get_line_price, get_listed_price,
+ is_included_for_free,
)
from pretix.base.services.quotas import QuotaAvailability
-from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
+from pretix.base.settings import (
+ COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
+)
from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -848,14 +852,15 @@ class OrderSerializer(I18nAwareModelSerializer):
list_serializer_class = OrderListSerializer
fields = (
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
- 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
- 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
- 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data',
+ 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
+ 'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
+ 'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
+ 'plugin_data',
)
read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
- 'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
- 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date'
+ 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
+ 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
)
def __init__(self, *args, **kwargs):
@@ -1174,6 +1179,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
queryset=SalesChannel.objects.none(),
required=False,
)
+ tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
def __init__(self, *args, **kwargs):
@@ -1190,7 +1196,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
- 'require_approval', 'valid_if_pending', 'expires', 'api_meta')
+ 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
def validate_payment_provider(self, pp):
if pp is None:
@@ -1716,7 +1722,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
f.save()
- order.total += sum([f.value for f in fees])
+ rounding_mode = validated_data.get("tax_rounding_mode")
+ if not rounding_mode:
+ if isinstance(self.context.get("auth"), Device):
+ # Safety fallback to avoid differences in tax reporting
+ brand = self.context.get("auth").software_brand or ""
+ if "pretixPOS" in brand or "pretixKIOSK" in brand:
+ rounding_mode = "line"
+ if not rounding_mode:
+ rounding_mode = self.context["event"].settings.tax_rounding
+ changed = apply_rounding(
+ rounding_mode,
+ self.context["event"].currency,
+ [*pos_map.values(), *fees]
+ )
+ for line in changed:
+ if isinstance(line, OrderPosition):
+ line.save(update_fields=[
+ "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+ elif isinstance(line, OrderFee):
+ line.save(update_fields=[
+ "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+
+ order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees])
if simulate:
order.fees = fees
order.positions = pos_map.values()
diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py
index f818192aec..51d463047a 100644
--- a/src/pretix/api/views/order.py
+++ b/src/pretix/api/views/order.py
@@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
+ ctx['auth'] = self.request.auth
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx
diff --git a/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py b/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py
new file mode 100644
index 0000000000..548e84225e
--- /dev/null
+++ b/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py
@@ -0,0 +1,81 @@
+# Generated by Django 4.2.17 on 2025-04-20 13:58
+
+from decimal import Decimal
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pretixbase", "0292_giftcard_customer"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="cartposition",
+ name="price_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="cartposition",
+ name="tax_code",
+ field=models.CharField(max_length=190, null=True),
+ ),
+ migrations.AddField(
+ model_name="cartposition",
+ name="tax_value_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="orderfee",
+ name="tax_value_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="orderfee",
+ name="value_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="orderposition",
+ name="price_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="orderposition",
+ name="tax_value_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="transaction",
+ name="price_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="transaction",
+ name="tax_value_includes_rounding_correction",
+ field=models.DecimalField(
+ decimal_places=2, default=Decimal("0.00"), max_digits=13
+ ),
+ ),
+ migrations.AddField(
+ model_name="order",
+ name="tax_rounding_mode",
+ field=models.CharField(default="line", max_length=100),
+ ),
+ ]
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index adcb69bcce..f8201f071a 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -81,7 +81,7 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import Customer, User
from pretix.base.reldate import RelativeDateWrapper
-from pretix.base.settings import PERSON_NAME_SCHEMES
+from pretix.base.settings import PERSON_NAME_SCHEMES, ROUNDING_MODES
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
from pretix.base.timemachine import time_machine_now
@@ -324,6 +324,11 @@ class Order(LockModel, LoggedModel):
# Invoice needs to be re-issued when the order is paid again
default=False,
)
+ tax_rounding_mode = models.CharField(
+ max_length=100,
+ choices=ROUNDING_MODES,
+ default="line",
+ )
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
@@ -1259,7 +1264,8 @@ class Order(LockModel, LoggedModel):
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = []
for k in keys:
- positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k
+ (positionid, itemid, variationid, subeventid, price, price_includes_rounding_correction, taxrate,
+ taxruleid, taxvalue, taxvalue_includes_rounding_correction, feetype, internaltype, taxcode) = k
d = target_transaction_count[k] - current_transaction_count[k]
if d:
create.append(Transaction(
@@ -1272,9 +1278,11 @@ class Order(LockModel, LoggedModel):
variation_id=variationid,
subevent_id=subeventid,
price=price,
+ price_includes_rounding_correction=price_includes_rounding_correction,
tax_rate=taxrate,
tax_rule_id=taxruleid,
tax_value=taxvalue,
+ tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
tax_code=taxcode,
fee_type=feetype,
internal_type=internaltype,
@@ -1449,7 +1457,22 @@ class QuestionAnswer(models.Model):
super().delete(**kwargs)
-class AbstractPosition(models.Model):
+class RoundingCorrectionMixin:
+
+ @property
+ def gross_price_before_rounding(self):
+ return self.price - self.price_includes_rounding_correction
+
+ @property
+ def tax_value_before_rounding(self):
+ return self.tax_value - self.tax_value_includes_rounding_correction
+
+ @property
+ def net_price_before_rounding(self):
+ return self.gross_price_before_rounding - self.tax_value_before_rounding
+
+
+class AbstractPosition(RoundingCorrectionMixin, models.Model):
"""
A position can either be one line of an order or an item placed in a cart.
@@ -1499,6 +1522,9 @@ class AbstractPosition(models.Model):
decimal_places=2, max_digits=13,
verbose_name=_("Price")
)
+ price_includes_rounding_correction = models.DecimalField(
+ max_digits=13, decimal_places=2, default=Decimal("0.00")
+ )
attendee_name_cached = models.CharField(
max_length=255,
verbose_name=_("Attendee name"),
@@ -2272,7 +2298,7 @@ class ActivePositionManager(ScopedManager(organizer='order__event__organizer')._
return super().get_queryset().filter(canceled=False)
-class OrderFee(models.Model):
+class OrderFee(RoundingCorrectionMixin, models.Model):
"""
An OrderFee object represents a fee that is added to the order total independently of
the actual positions. This might for example be a payment or a shipping fee.
@@ -2322,6 +2348,9 @@ class OrderFee(models.Model):
decimal_places=2, max_digits=13,
verbose_name=_("Value")
)
+ value_includes_rounding_correction = models.DecimalField(
+ max_digits=13, decimal_places=2, default=Decimal("0.00")
+ )
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
@@ -2350,6 +2379,9 @@ class OrderFee(models.Model):
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
+ tax_value_includes_rounding_correction = models.DecimalField(
+ max_digits=13, decimal_places=2, default=Decimal("0.00")
+ )
canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer')
@@ -2398,17 +2430,23 @@ class OrderFee(models.Model):
self.fee_type, self.value
)
- def _calculate_tax(self, tax_rule=None, invoice_address=None):
+ def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None):
if tax_rule:
self.tax_rule = tax_rule
- try:
- ia = invoice_address or self.order.invoice_address
- except InvoiceAddress.DoesNotExist:
+ if invoice_address:
+ ia = invoice_address
+ elif hasattr(self, "order"):
+ try:
+ ia = self.order.invoice_address
+ except InvoiceAddress.DoesNotExist:
+ ia = None
+ else:
ia = None
- if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
- self.tax_rule = self.order.event.cached_default_tax_rule
+ event = event or self.order.event
+ if not self.tax_rule and self.fee_type == "payment" and event.settings.tax_rule_payment == "default":
+ self.tax_rule = event.cached_default_tax_rule
if self.tax_rule:
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
@@ -2443,6 +2481,24 @@ class OrderFee(models.Model):
self.order.touch()
super().delete(**kwargs)
+ # For historical reasons, OrderFee has "value", but OrderPosition has "price". These properties
+ # help using them the same way.
+ @property
+ def price(self):
+ return self.value
+
+ @price.setter
+ def price(self, value):
+ self.value = value
+
+ @property
+ def price_includes_rounding_correction(self):
+ return self.value_includes_rounding_correction
+
+ @price_includes_rounding_correction.setter
+ def price_includes_rounding_correction(self, value):
+ self.value_includes_rounding_correction = value
+
class OrderPosition(AbstractPosition):
"""
@@ -2522,6 +2578,9 @@ class OrderPosition(AbstractPosition):
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
+ tax_value_includes_rounding_correction = models.DecimalField(
+ max_digits=13, decimal_places=2, default=Decimal("0.00"),
+ )
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
@@ -2694,7 +2753,14 @@ class OrderPosition(AbstractPosition):
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
else:
setattr(op, f.name, getattr(cartpos, f.name))
- op._calculate_tax()
+
+ op.tax_value = cartpos.tax_value
+ op.tax_value_includes_rounding_correction = cartpos.tax_value_includes_rounding_correction
+ op.tax_rate = cartpos.tax_rate
+ op.tax_code = cartpos.tax_code
+ op.tax_rule = cartpos.item.tax_rule
+ # todo: is removing this safe? op._calculate_tax()
+
if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
@@ -3027,6 +3093,9 @@ class Transaction(models.Model):
decimal_places=2, max_digits=13,
verbose_name=_("Price")
)
+ price_includes_rounding_correction = models.DecimalField(
+ max_digits=13, decimal_places=2, default=Decimal("0.00")
+ )
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
@@ -3044,6 +3113,9 @@ class Transaction(models.Model):
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
+ tax_value_includes_rounding_correction = models.DecimalField(
+ max_digits=13, decimal_places=2, default=Decimal("0.00")
+ )
fee_type = models.CharField(
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
)
@@ -3073,14 +3145,19 @@ class Transaction(models.Model):
@staticmethod
def key(obj):
if isinstance(obj, Transaction):
- return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
- obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
+ return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
+ obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
+ obj.tax_value, obj.tax_value_includes_rounding_correction, obj.fee_type,
+ obj.internal_type, obj.tax_code)
elif isinstance(obj, OrderPosition):
- return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
- obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code)
+ return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
+ obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
+ obj.tax_value, obj.tax_value_includes_rounding_correction, None,
+ None, obj.tax_code)
elif isinstance(obj, OrderFee):
- return (None, None, None, None, obj.value, obj.tax_rate,
- obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
+ return (None, None, None, None, obj.value, obj.value_includes_rounding_correction,
+ obj.tax_rate, obj.tax_rule_id, obj.tax_value, obj.tax_value_includes_rounding_correction,
+ obj.fee_type, obj.internal_type, obj.tax_code)
raise ValueError('invalid state') # noqa
@property
@@ -3091,6 +3168,14 @@ class Transaction(models.Model):
def full_tax_value(self):
return self.tax_value * self.count
+ @property
+ def full_price_includes_rounding_correction(self):
+ return self.price_includes_rounding_correction * self.count
+
+ @property
+ def full_tax_value_includes_rounding_correction(self):
+ return self.tax_value_includes_rounding_correction * self.count
+
class CartPosition(AbstractPosition):
"""
@@ -3131,6 +3216,13 @@ class CartPosition(AbstractPosition):
max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate')
)
+ tax_code = models.CharField(
+ max_length=190,
+ null=True, blank=True,
+ )
+ tax_value_includes_rounding_correction = models.DecimalField(
+ max_digits=13, decimal_places=2, default=Decimal("0.00")
+ )
listed_price = models.DecimalField(
decimal_places=2, max_digits=13, null=True,
)
@@ -3171,9 +3263,15 @@ class CartPosition(AbstractPosition):
@property
def tax_value(self):
- net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
+ price = self.gross_price_before_rounding
+ net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))),
self.event.currency)
- return self.price - net
+ return self.gross_price_before_rounding - net + self.tax_value_includes_rounding_correction
+
+ @tax_value.setter
+ def tax_value(self, value):
+ # ignore, tax value is always computed on the fly
+ pass
@cached_property
def sort_key(self):
diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py
index 72f127770d..61808a6fb5 100644
--- a/src/pretix/base/payment.py
+++ b/src/pretix/base/payment.py
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import build_absolute_uri
-from pretix.presale.views import get_cart, get_cart_total
+from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -1149,12 +1149,16 @@ class FreeOrderProvider(BasePaymentProvider):
from .services.cart import get_fees
cart = get_cart(request)
- total = get_cart_total(request)
+
try:
- total += sum([f.value for f in get_fees(self.event, request, total, None, None, cart)])
+ fees = get_fees(event=request.event, request=request,
+ invoice_address=None,
+ payments=None, positions=cart)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
- pass
+ fees = []
+ total = sum([c.price for c in cart]) + sum([f.value for f in fees])
+
return total == 0
def order_change_allowed(self, order: Order) -> bool:
diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py
index 89d0d82b6f..694ea35987 100644
--- a/src/pretix/base/services/cancelevent.py
+++ b/src/pretix/base/services/cancelevent.py
@@ -350,7 +350,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
ocm.add_fee(f)
if dry_run:
- refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00"))
+ refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff_guesstimate), Decimal("0.00"))
else:
ocm.commit()
refund_amount = payment_refund_sum - o.total
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index efffda3744..66c7c47bd3 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -66,8 +66,8 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, lock_objects
from pretix.base.services.pricing import (
- apply_discounts, get_line_price, get_listed_price, get_price,
- is_included_for_free,
+ apply_discounts, apply_rounding, get_line_price, get_listed_price,
+ get_price, is_included_for_free,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask
@@ -1430,11 +1430,12 @@ class CartManager:
)
for cp, (new_price, discount) in zip(positions, discount_results):
- if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
- diff += new_price - cp.price
+ if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
+ diff += new_price - cp.gross_price_before_rounding
cp.price = new_price
+ cp.price_includes_rounding_correction = Decimal("0.00")
cp.discount = discount
- cp.save(update_fields=['price', 'discount'])
+ cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
return diff
@@ -1493,30 +1494,53 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data)
-def get_fees(event, request, total, invoice_address, payments, positions):
+def get_fees(event, request, _total_ignored_=None, invoice_address=None, payments=None, positions=None):
+ """
+ Return all fees that would be created for the current cart. Also implicitly applies rounding on the order
+ positions. A recommended usage pattern to compute the total looks like this::
+
+ cart = get_cart(request)
+ fees = get_fees(
+ event=request.event,
+ request=request,
+ invoice_address=cached_invoice_address(request),
+ payments=None,
+ positions=cart,
+ )
+ total = sum([c.price for c in cart]) + sum([f.value for f in fees])
+ """
if payments and not isinstance(payments, list):
raise TypeError("payments must now be a list")
+ if positions is None:
+ raise TypeError("Must pass positions, parameter is only optional for backwards-compat reasons")
fees = []
+ total = sum([c.gross_price_before_rounding for c in positions])
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
- total=total, positions=positions, payment_requests=payments):
+ positions=positions, total=total, payment_requests=payments):
if resp:
fees += resp
- total = total + sum(f.value for f in fees)
+ for fee in fees:
+ fee._calculate_tax(invoice_address=invoice_address, event=event)
+ if fee.tax_rule and not fee.tax_rule.pk:
+ fee.tax_rule = None # TODO: deprecate
+
+ apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
+ total = sum([c.price for c in positions]) + sum([f.value for f in fees])
if total != 0 and payments:
- total_remaining = total
+ payments_assigned = Decimal("0.00")
for p in payments:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
- if p.get('min_value') and total_remaining < Decimal(p['min_value']):
+ if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
continue
- to_pay = total_remaining
+ to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
@@ -1525,28 +1549,32 @@ def get_fees(event, request, total, invoice_address, payments, positions):
continue
payment_fee = pprov.calculate_fee(to_pay)
- total_remaining += payment_fee
- to_pay += payment_fee
-
- if p.get('max_value') and to_pay > Decimal(p['max_value']):
- to_pay = min(to_pay, Decimal(p['max_value']))
-
- total_remaining -= to_pay
-
if payment_fee:
if event.settings.tax_rule_payment == "default":
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
else:
payment_fee_tax_rule = TaxRule.zero()
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
- fees.append(OrderFee(
+ pf = OrderFee(
fee_type=OrderFee.FEE_TYPE_PAYMENT,
value=payment_fee,
tax_rate=payment_fee_tax.rate,
tax_value=payment_fee_tax.tax,
tax_code=payment_fee_tax.code,
tax_rule=payment_fee_tax_rule
- ))
+ )
+ fees.append(pf)
+
+ # Re-apply rounding as grand total has changed
+ apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
+ total = sum([c.price for c in positions]) + sum([f.value for f in fees])
+
+ # Re-calculate to_pay as grand total has changed
+ to_pay = max(total - payments_assigned, Decimal("0.00"))
+ if p.get('max_value') and to_pay > Decimal(p['max_value']):
+ to_pay = min(to_pay, Decimal(p['max_value']))
+
+ payments_assigned += to_pay
return fees
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index bfe817d0fc..b37e584736 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -95,7 +95,7 @@ from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order,
)
from pretix.base.services.pricing import (
- apply_discounts, get_listed_price, get_price,
+ apply_discounts, apply_rounding, get_listed_price, get_price,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
@@ -947,10 +947,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
]
)
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
- if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
+ if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
cp.price = new_price
+ cp.price_includes_rounding_correction = Decimal("0.00")
cp.discount = discount
- cp.save(update_fields=['price', 'discount'])
+ cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
# After applying discounts, add-on positions might still have a reference to the *old* version of the
# parent position, which can screw up ordering later since the system sees inconsistent data.
@@ -973,10 +974,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
raise OrderError(err)
-def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
- meta_info: dict, event: Event, require_approval=False):
+def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
+ meta_info: dict, event: Event, require_approval=False):
fees = []
- total = sum([c.price for c in positions])
+ # Pre-rounding, pre-fee total is used for fee calculation
+ total = sum([c.gross_price_before_rounding for c in positions])
gift_cards = [] # for backwards compatibility
for p in payment_requests:
@@ -987,40 +989,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
if resp:
fees += resp
- total += sum(f.value for f in fees)
- total_remaining = total
+ for fee in fees:
+ fee._calculate_tax(invoice_address=address, event=event)
+ if fee.tax_rule and not fee.tax_rule.pk:
+ fee.tax_rule = None # TODO: deprecate
+
+ # Apply rounding to get final total in case no payment fees will be added
+ apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
+ total = sum([c.price for c in positions]) + sum([f.value for f in fees])
+
+ payments_assigned = Decimal("0.00")
for p in payment_requests:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
- if p.get('min_value') and total_remaining < Decimal(p['min_value']):
+ if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
p['payment_amount'] = Decimal('0.00')
continue
- to_pay = total_remaining
+ to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payment_fee = p['pprov'].calculate_fee(to_pay)
- total_remaining += payment_fee
- to_pay += payment_fee
-
- if p.get('max_value') and to_pay > Decimal(p['max_value']):
- to_pay = min(to_pay, Decimal(p['max_value']))
-
- total_remaining -= to_pay
-
- p['payment_amount'] = to_pay
if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=p['pprov'].identifier)
+ pf._calculate_tax(invoice_address=address, event=event)
fees.append(pf)
p['fee'] = pf
- if total_remaining != Decimal('0.00') and not require_approval:
+ # Re-apply rounding as grand total has changed
+ apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
+ total = sum([c.price for c in positions]) + sum([f.value for f in fees])
+
+ # Re-calculate to_pay as grand total has changed
+ to_pay = max(total - payments_assigned, Decimal("0.00"))
+
+ if p.get('max_value') and to_pay > Decimal(p['max_value']):
+ to_pay = min(to_pay, Decimal(p['max_value']))
+
+ payments_assigned += to_pay
+ p['payment_amount'] = to_pay
+
+ if total != payments_assigned and not require_approval:
raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees
@@ -1029,7 +1044,7 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
- customer=None, valid_if_pending=False, api_meta: dict=None):
+ customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
payments = []
try:
@@ -1038,10 +1053,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
raise OrderError(e.message)
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
+
+ # Final calculation of fees, also performs final rounding
try:
- fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
+ fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
+
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
order = Order(
@@ -1059,6 +1077,7 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
sales_channel=sales_channel,
customer=customer,
valid_if_pending=valid_if_pending,
+ tax_rounding_mode=tax_rounding_mode or event.settings.tax_rounding,
)
if customer:
order.email_known_to_work = customer.is_verified
@@ -1073,12 +1092,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
for fee in fees:
fee.order = order
- try:
- fee._calculate_tax()
- except TaxRule.SaleNotAllowed:
- raise OrderError(error_messages['country_blocked'])
- if fee.tax_rule and not fee.tax_rule.pk:
- fee.tax_rule = None # TODO: deprecate
fee.save()
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
@@ -1167,7 +1180,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
- shown_total=None, customer=None, api_meta: dict=None):
+ shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']:
@@ -1273,6 +1286,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
customer=customer,
valid_if_pending=valid_if_pending,
api_meta=api_meta,
+ tax_rounding_mode=tax_rounding_mode,
)
try:
@@ -1664,7 +1678,7 @@ class OrderChangeManager:
self.split_order = None
self.reissue_invoice = reissue_invoice
self._committed = False
- self._totaldiff = 0
+ self._totaldiff_guesstimate = 0
self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = []
@@ -1781,7 +1795,7 @@ class OrderChangeManager:
if position.issued_gift_cards.exists():
raise OrderError(self.error_messages['gift_card_change'])
- self._totaldiff += price.gross - position.price
+ self._totaldiff_guesstimate += price.gross - position.gross_price_before_rounding
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
@@ -1826,29 +1840,29 @@ class OrderChangeManager:
else:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code)
- self._totaldiff += new_tax.gross - pos.price
+ self._totaldiff_guesstimate += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee):
- self._totaldiff -= fee.value
+ self._totaldiff_guesstimate -= fee.value
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
self._invoice_dirty = True
def add_fee(self, fee: OrderFee):
- self._totaldiff += fee.value
+ self._totaldiff_guesstimate += fee.value
self._invoice_dirty = True
self._operations.append(self.AddFeeOperation(fee, fee.value))
def change_fee(self, fee: OrderFee, value: Decimal):
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address,
force_fixed_gross_price=True)
- self._totaldiff += value.gross - fee.value
+ self._totaldiff_guesstimate += value.gross - fee.value
self._invoice_dirty = True
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
def cancel(self, position: OrderPosition):
- self._totaldiff -= position.price
+ self._totaldiff_guesstimate -= position.price
self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position, -position.price))
if position.seat:
@@ -1914,7 +1928,7 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
- self._totaldiff += price.gross
+ self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
@@ -2210,8 +2224,8 @@ class OrderChangeManager:
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise OrderError(self.error_messages['quota'].format(name=quota.name))
- def _check_paid_price_change(self):
- if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
+ def _check_paid_price_change(self, totaldiff):
+ if self.order.status == Order.STATUS_PAID and totaldiff > 0:
if self.order.pending_sum > Decimal('0.00'):
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
@@ -2219,7 +2233,7 @@ class OrderChangeManager:
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save()
- elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
+ elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff < 0:
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
self.order.status = Order.STATUS_PAID
self.order.save()
@@ -2246,7 +2260,7 @@ class OrderChangeManager:
user=self.user,
auth=self.auth
)
- elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
+ elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff > 0:
if self.open_payment:
try:
self.open_payment.payment_provider.cancel_payment(self.open_payment)
@@ -2266,11 +2280,11 @@ class OrderChangeManager:
auth=self.auth,
)
- def _check_paid_to_free(self):
- if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
+ def _check_paid_to_free(self, totaldiff):
+ if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"):
raise OrderError(error_messages['currency_XXX'])
- if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
+ if self.order.total == 0 and (totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
if not self.order.fees.exists() and not self.order.positions.exists():
# The order is completely empty now, so we cancel it.
self.order.status = Order.STATUS_CANCELED
@@ -2278,7 +2292,7 @@ class OrderChangeManager:
order_canceled.send(self.order.event, order=self.order)
elif self.order.status != Order.STATUS_CANCELED:
# if the order becomes free, mark it paid using the 'free' provider
- # this could happen if positions have been made cheaper or removed (_totaldiff < 0)
+ # this could happen if positions have been made cheaper or removed (totaldiff < 0)
# or positions got split off to a new order (split_order with positive total)
p = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
@@ -2407,10 +2421,15 @@ class OrderChangeManager:
'new_price': op.price.gross
})
position.price = op.price.gross
+ position.price_includes_rounding_correction = Decimal("0.00")
position.tax_rate = op.price.rate
position.tax_value = op.price.tax
+ position.tax_value_includes_rounding_correction = Decimal("0.00")
position.tax_code = op.price.code
- position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code'])
+ position.save(update_fields=[
+ 'price', 'price_includes_rounding_correction', 'tax_rate', 'tax_value',
+ 'tax_value_includes_rounding_correction', 'tax_code'
+ ])
elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition):
position = position_cache.setdefault(op.position.pk, op.position)
@@ -2677,14 +2696,18 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist:
pass
- split_order.total = sum([p.price for p in split_positions if not p.canceled])
-
+ fees = []
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
- split_order.total += new_fee.value
new_fee.save()
+ fees.append(new_fee)
+
+ changed_by_rounding = set(apply_rounding(
+ self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
+ ))
+ split_order.total = sum([p.price for p in split_positions if not p.canceled])
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider()
@@ -2697,9 +2720,27 @@ class OrderChangeManager:
fee._calculate_tax()
if payment_fee != 0:
fee.save()
+ fees.append(fee)
elif fee.pk:
+ if fee in fees:
+ fees.remove(fee)
fee.delete()
- split_order.total += fee.value
+
+ changed_by_rounding |= set(apply_rounding(
+ self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
+ ))
+ split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
+
+ for l in changed_by_rounding:
+ if isinstance(l, OrderPosition):
+ l.save(update_fields=[
+ "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+ elif isinstance(l, OrderFee):
+ l.save(update_fields=[
+ "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+ split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
@@ -2759,9 +2800,12 @@ class OrderChangeManager:
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum
- def _recalculate_total_and_payment_fee(self):
- total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
+ def _recalculate_rounding_total_and_payment_fee(self):
+ positions = list(self.order.positions.all())
+ fees = list(self.order.fees.all())
+ total = sum([p.price for p in positions]) + sum([f.value for f in fees])
payment_fee = Decimal('0.00')
+ fee_changed = False
if self.open_payment:
current_fee = Decimal('0.00')
fee = None
@@ -2789,14 +2833,32 @@ class OrderChangeManager:
fee.value = payment_fee
fee._calculate_tax()
fee.save()
+ fee_changed = True
if not self.open_payment.fee:
self.open_payment.fee = fee
self.open_payment.save(update_fields=['fee'])
elif fee and not fee.canceled:
fee.delete()
+ fee_changed = True
- self.order.total = total + payment_fee
+ if fee_changed:
+ fees = list(self.order.fees.all())
+
+ changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
+ for l in changed:
+ if isinstance(l, OrderPosition):
+ l.save(update_fields=[
+ "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+ elif isinstance(l, OrderFee):
+ l.save(update_fields=[
+ "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+ total = sum([p.price for p in positions]) + sum([f.value for f in fees])
+
+ self.order.total = total
self.order.save()
+ return total
def _check_order_size(self):
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
@@ -2806,23 +2868,6 @@ class OrderChangeManager:
}
)
- def _payment_fee_diff(self):
- total = self.order.total + self._totaldiff
- if self.open_payment:
- current_fee = Decimal('0.00')
- if self.open_payment and self.open_payment.fee:
- current_fee = self.open_payment.fee.value
- total -= current_fee
-
- # Do not change payment fees of paid orders
- payment_fee = Decimal('0.00')
- if self.order.pending_sum - current_fee != 0:
- prov = self.open_payment.payment_provider
- if prov:
- payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
-
- self._totaldiff += payment_fee - current_fee
-
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
if self.reissue_invoice and self._invoice_dirty:
@@ -2953,6 +2998,13 @@ class OrderChangeManager:
shared_lock_objects=[self.event]
)
+ def guess_totaldiff(self):
+ """
+ Return the estimated difference of ``order.total`` based on the currently queued operations. This is only
+ a guess since it does not account for (a) tax rounding or (b) payment fee changes.
+ """
+ return self._totaldiff_guesstimate
+
def commit(self, check_quotas=True):
if self._committed:
# an order change can only be committed once
@@ -2968,8 +3020,6 @@ class OrderChangeManager:
# so it's dangerous to keep the cache around.
self.order._prefetched_objects_cache = {}
- # finally, incorporate difference in payment fees
- self._payment_fee_diff()
self._check_order_size()
with transaction.atomic():
@@ -2977,6 +3027,7 @@ class OrderChangeManager:
if locked_instance.last_modified != self.order.last_modified:
raise OrderError(error_messages['race_condition'])
+ original_total = self.order.total
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
if check_quotas:
self._check_quotas()
@@ -2988,9 +3039,10 @@ class OrderChangeManager:
self._perform_operations()
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
- self._recalculate_total_and_payment_fee()
- self._check_paid_price_change()
- self._check_paid_to_free()
+ new_total = self._recalculate_rounding_total_and_payment_fee()
+ totaldiff = new_total - original_total
+ self._check_paid_price_change(totaldiff)
+ self._check_paid_to_free(totaldiff)
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
self._reissue_invoice()
self._clear_tickets_cache()
@@ -3209,6 +3261,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
raise Exception('change_payment_provider should only be called in atomic transaction!')
oldtotal = order.total
+ already_paid = order.payment_refund_sum
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED))
open_fees = list(
@@ -3225,19 +3278,46 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order)
old_fee = fee.value
+ positions = list(order.positions.all())
+ fees = list(order.fees.all())
+ rounding_changed = set(apply_rounding(
+ order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
+ ))
+ total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
+ pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
+
new_fee = payment_provider.calculate_fee(
- order.pending_sum - old_fee if amount is None else amount
+ pending_sum_without_fee if amount is None else amount
)
if new_fee:
fee.value = new_fee
fee.internal_type = payment_provider.identifier
fee._calculate_tax()
+ if fee in fees:
+ fees.remove(fee)
+ # "Update instance in the fees array
+ fees.append(fee)
fee.save()
else:
+ if fee in fees:
+ fees.remove(fee)
if fee.pk:
fee.delete()
fee = None
+ rounding_changed |= set(apply_rounding(
+ order.tax_rounding_mode, order.event.currency, [*positions, *fees]
+ ))
+ for l in rounding_changed:
+ if isinstance(l, OrderPosition):
+ l.save(update_fields=[
+ "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+ elif isinstance(l, OrderFee):
+ l.save(update_fields=[
+ "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
+ ])
+
open_payment = None
if new_payment:
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
@@ -3264,7 +3344,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
},
)
- order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
+ order.total = sum(c.price for c in positions) + sum(f.value for f in fees)
order.save(update_fields=['total'])
if not new_payment:
diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py
index 5ac9dbea4c..6f03a91394 100644
--- a/src/pretix/base/services/pricing.py
+++ b/src/pretix/base/services/pricing.py
@@ -23,15 +23,17 @@ import re
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
-from typing import List, Optional, Tuple, Union
+from itertools import groupby
+from typing import List, Literal, Optional, Tuple, Union
from django import forms
+from django.conf import settings
from django.db.models import Q
from pretix.base.decimal import round_decimal
from pretix.base.models import (
- AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
- SalesChannel, Voucher,
+ AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
+ ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
@@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
- tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice:
+ tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal,
+ is_bundled=False) -> TaxedPrice:
if not tax_rule:
tax_rule = TaxRule(
name='',
@@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
else:
- price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
+ price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross',
+ override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
else:
@@ -164,7 +168,7 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
- collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
+ collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
"""
Applies any dynamic discounts to a cart
@@ -203,3 +207,121 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
new_prices.update(result)
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
+
+
+def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
+ lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
+ """
+ Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
+ and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and
+ ``tax_value_includes_rounding_correction`` attributes.
+
+ When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line.
+
+ When rounding mode is set to ``"sum_by_net_keep_gross"``, the tax values of the individual lines will be adjusted
+ such that the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant.
+
+ When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
+ adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
+
+ :param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
+ :param currency: Currency that will be used to determine rounding precision
+ :param lines: List of order/cart contents
+ :return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
+ """
+
+ def _key(line):
+ return (line.tax_rate, line.tax_code)
+
+ places = settings.CURRENCY_PLACES.get(currency, 2)
+ minimum_unit = Decimal('1') / 10 ** places
+ changed = []
+
+ if rounding_mode == "sum_by_net":
+ for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
+ lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
+
+ # Compute the net and gross total of the line-based computation method
+ net_total = sum(l.net_price_before_rounding for l in lines)
+ gross_total = sum(l.gross_price_before_rounding for l in lines)
+
+ # Compute the gross total we need to achieve based on the net total
+ target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency)
+
+ # Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same)
+ # until the values align
+ diff = target_gross_total - gross_total
+ diff_sgn = -1 if diff < 0 else 1
+ for l in lines:
+ if diff:
+ apply_diff = diff_sgn * minimum_unit
+ l.price = l.gross_price_before_rounding + apply_diff
+ l.price_includes_rounding_correction = apply_diff
+ l.tax_value = l.tax_value_before_rounding + apply_diff
+ l.tax_value_includes_rounding_correction = apply_diff
+ diff -= apply_diff
+ changed.append(l)
+ elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
+ l.price = l.gross_price_before_rounding
+ l.price_includes_rounding_correction = Decimal("0.00")
+ l.tax_value = l.tax_value_before_rounding
+ l.tax_value_includes_rounding_correction = Decimal("0.00")
+ changed.append(l)
+
+ elif rounding_mode == "sum_by_net_keep_gross":
+ for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
+ lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
+
+ # Compute the net and gross total of the line-based computation method
+ net_total = sum(l.net_price_before_rounding for l in lines)
+ gross_total = sum(l.gross_price_before_rounding for l in lines)
+
+ # Compute the net total that would yield the correct gross total (if possible)
+ target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency)
+
+ # Compute the gross total that would be computed from that net total – this will be different than
+ # gross_total when there is no possible net value for the gross total
+ # e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98
+ target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency)
+
+ diff_gross = target_gross_total - gross_total
+ diff_net = target_net_total - net_total
+ diff_gross_sgn = -1 if diff_gross < 0 else 1
+ diff_net_sgn = -1 if diff_net < 0 else 1
+ for l in lines:
+ if diff_gross:
+ apply_diff = diff_gross_sgn * minimum_unit
+ l.price = l.gross_price_before_rounding + apply_diff
+ l.price_includes_rounding_correction = apply_diff
+ l.tax_value = l.tax_value_before_rounding + apply_diff
+ l.tax_value_includes_rounding_correction = apply_diff
+ changed.append(l)
+ diff_gross -= apply_diff
+ elif diff_net:
+ apply_diff = diff_net_sgn * minimum_unit
+ l.price = l.gross_price_before_rounding
+ l.price_includes_rounding_correction = Decimal("0.00")
+ l.tax_value = l.tax_value_before_rounding - apply_diff
+ l.tax_value_includes_rounding_correction = -apply_diff
+ changed.append(l)
+ diff_net -= apply_diff
+ elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
+ l.price = l.gross_price_before_rounding
+ l.price_includes_rounding_correction = Decimal("0.00")
+ l.tax_value = l.tax_value_before_rounding
+ l.tax_value_includes_rounding_correction = Decimal("0.00")
+ changed.append(l)
+
+ elif rounding_mode == "line":
+ for l in lines:
+ if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
+ l.price = l.gross_price_before_rounding
+ l.price_includes_rounding_correction = Decimal("0.00")
+ l.tax_value = l.tax_value_before_rounding
+ l.tax_value_includes_rounding_correction = Decimal("0.00")
+ changed.append(l)
+
+ else:
+ raise ValueError("Unknown rounding_mode")
+
+ return changed
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index 3bf4ead9e7..60e510b286 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -77,6 +77,13 @@ from pretix.control.forms import (
)
from pretix.helpers.countries import CachedCountries
+ROUNDING_MODES = (
+ ('line', _('Compute taxes for every line individually')),
+ ('sum_by_net', _('Compute taxes based on net total')),
+ ('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
+ # We could also have sum_by_gross, but we're not aware of any use-cases for it
+)
+
def country_choice_kwargs():
allcountries = list(CachedCountries())
@@ -324,7 +331,7 @@ DEFAULTS = {
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
- label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
+ label=_("Show net prices instead of gross prices in the product list"),
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
"paid."),
@@ -465,6 +472,25 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
)
},
+ 'tax_rounding': {
+ 'default': 'line',
+ 'type': str,
+ 'form_class': forms.ChoiceField,
+ 'serializer_class': serializers.ChoiceField,
+ 'form_kwargs': dict(
+ label=_("Rounding of taxes"),
+ widget=forms.RadioSelect,
+ choices=ROUNDING_MODES,
+ help_text=_(
+ "Note that if you transfer your sales data from pretix to an external system for tax reporting, you "
+ "need to make sure to account for possible rounding differences if your external system rounds "
+ "differently than pretix."
+ )
+ ),
+ 'serializer_kwargs': dict(
+ choices=ROUNDING_MODES,
+ ),
+ },
'invoice_address_asked': {
'default': 'True',
'type': bool,
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
index 2c67d74b6c..f73d4bd4b1 100644
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -68,7 +68,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
- PERSON_NAME_TITLE_GROUPS, validate_event_settings,
+ PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings,
)
from pretix.base.validators import multimail_validate
from pretix.control.forms import (
@@ -541,7 +541,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'show_date_to',
'show_times',
'show_items_outside_presale_period',
- 'display_net_prices',
'hide_prices_from_attendees',
'presale_start_show_date',
'locales',
@@ -799,6 +798,80 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
return value
+class DisplayNetPricesBooleanSelect(forms.RadioSelect):
+ def __init__(self, attrs=None):
+ choices = (
+ ("false", format_html(
+ '{}
{}',
+ _("Prices including tax"),
+ _("Recommended if you sell tickets at least partly to consumers.")
+ )),
+ ("true", format_html(
+ '{}
{}',
+ _("Prices excluding tax"),
+ _("Recommended only if you sell tickets primarily to business customers.")
+ )),
+ )
+ super().__init__(attrs, choices)
+
+ def format_value(self, value):
+ try:
+ return {
+ True: "true",
+ False: "false",
+ "true": "true",
+ "false": "false",
+ }[value]
+ except KeyError:
+ return "unknown"
+
+ def value_from_datadict(self, data, files, name):
+ value = data.get(name)
+ return {
+ True: True,
+ "True": True,
+ "False": False,
+ False: False,
+ "true": True,
+ "false": False,
+ }.get(value)
+
+
+class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
+ auto_fields = [
+ 'display_net_prices',
+ 'tax_rounding',
+ ]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields["display_net_prices"].label = _("Prices shown to customer")
+ self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect()
+ help_text = {
+ "line": _(
+ "Recommended when e-invoicing is not required. Each product will be sold with the advertised "
+ "net and gross price. However, in orders of more than one product, the total tax amount "
+ "can differ from when it would be computed from the order total."
+ ),
+ "sum_by_net": _(
+ "Recommended for e-invoicing when you primarily sell to business customers and "
+ "show prices to customers excluding tax. "
+ "The gross price of some products may be changed to ensure correct rounding, while the net "
+ "prices will be kept as configured. This may cause the actual payment amount to differ."
+ ),
+ "sum_by_net_keep_gross": _(
+ "Recommended for e-invoicing when you primarily sell to consumers. "
+ "The gross or net price of some products may be changed automatically to ensure correct "
+ "rounding of the order total. The system attempts to keep gross prices as configured whenever "
+ "possible. Gross prices may still change if they are impossible to derive from a rounded net price."
+ ),
+ }
+ self.fields["tax_rounding"].choices = (
+ (k, format_html('{}
{}', v, help_text.get(k, "")))
+ for k, v in ROUNDING_MODES
+ )
+
+
class ProviderForm(SettingsForm):
"""
This is a SettingsForm, but if fields are set to required=True, validation
@@ -1527,7 +1600,10 @@ class TaxRuleLineForm(I18nForm):
rate = forms.DecimalField(
label=_('Deviating tax rate'),
max_digits=10, decimal_places=2,
- required=False
+ required=False,
+ widget=forms.NumberInput(attrs={
+ 'placeholder': _('Deviating tax rate'),
+ })
)
invoice_text = I18nFormField(
label=_('Text on invoice'),
diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py
index 99bdd86544..c3a0cbb8f9 100644
--- a/src/pretix/control/navigation.py
+++ b/src/pretix/control/navigation.py
@@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest):
'active': url.url_name == 'event.settings.mail',
},
{
- 'label': _('Tax rules'),
+ 'label': _('Taxes'),
'url': reverse('control:event.settings.tax', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html
index 3a90aef7ca..8d9a46f732 100644
--- a/src/pretix/control/templates/pretixcontrol/event/settings.html
+++ b/src/pretix/control/templates/pretixcontrol/event/settings.html
@@ -243,7 +243,6 @@
{% bootstrap_field sform.show_times layout="control" %}
- {% blocktrans trimmed %} - You haven't created any tax rules yet. - {% endblocktrans %} -
- - {% trans "Create a new tax rule" %} -- {% trans "Create a new tax rule" %} - -
-| {% trans "Name" %} | -{% trans "Default" %} | -{% trans "Rate" %} | -- |
|---|---|---|---|
| - - {{ tr.internal_name|default:tr.name }} - - | -- {% if tr.default %} - - - {% trans "Default" %} - - {% else %} - - {% endif %} - | -- {% if tr.price_includes_tax %} - {% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %} - {% else %} - {% blocktrans with rate=tr.rate%}excl. {{ rate }} %{% endblocktrans %} - {% endif %} - {% if tr.eu_reverse_charge %} - ({% trans "reverse charge enabled" %}) - {% endif %} - | -- - - | -