diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index f7351a805d..b9f90d716d 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/base/migrations/0285_cartposition_price_includes_rounding_correction_and_more.py b/src/pretix/base/migrations/0285_cartposition_price_includes_rounding_correction_and_more.py new file mode 100644 index 0000000000..fda9c90c84 --- /dev/null +++ b/src/pretix/base/migrations/0285_cartposition_price_includes_rounding_correction_and_more.py @@ -0,0 +1,76 @@ +# 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", "0284_ordersyncresult_ordersyncqueue"), + ] + + 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 + ), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 96f1b53268..4ff5d0a14b 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1259,7 +1259,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 +1273,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, @@ -1499,6 +1502,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"), @@ -2301,6 +2307,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"), @@ -2329,6 +2338,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') @@ -2377,17 +2389,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) @@ -2422,6 +2440,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): """ @@ -2501,6 +2537,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) @@ -2673,7 +2712,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 @@ -3006,6 +3052,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') @@ -3023,6 +3072,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 ) @@ -3052,14 +3104,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 @@ -3070,6 +3127,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): """ @@ -3110,6 +3175,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, ) @@ -3150,9 +3222,15 @@ class CartPosition(AbstractPosition): @property def tax_value(self): - net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))), + price = self.price - self.price_includes_rounding_correction + net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))), self.event.currency) - return self.price - net + return self.price - self.price_includes_rounding_correction - 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/services/orders.py b/src/pretix/base/services/orders.py index 200b8a9f24..e46a2b648b 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -93,7 +93,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 @@ -939,7 +939,8 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti def _get_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.price - c.price_includes_rounding_correction for c in positions]) gift_cards = [] # for backwards compatibility for p in payment_requests: @@ -950,40 +951,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 @@ -1001,10 +1015,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) 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( @@ -1036,12 +1053,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 @@ -2696,9 +2707,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 @@ -2726,13 +2740,31 @@ 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()) + + print("round", positions, fees) + changed = apply_rounding(self.order.event.settings.tax_rounding, 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() def _check_order_size(self): @@ -2913,7 +2945,7 @@ class OrderChangeManager: self._perform_operations() except TaxRule.SaleNotAllowed: raise OrderError(self.error_messages['tax_rule_country_blocked']) - self._recalculate_total_and_payment_fee() + self._recalculate_rounding_total_and_payment_fee() self._check_paid_price_change() self._check_paid_to_free() if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index ac6b1a13e8..f6f45a9474 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,102 @@ 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_gross", "sum_by_net"], currency: str, + lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list: + """ + Given a 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_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_gross"``, or ``"sum_by_net"``. + :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.price - l.price_includes_rounding_correction))) + net_total = sum( + l.price - l.price_includes_rounding_correction - l.tax_value + l.tax_value_includes_rounding_correction + for l in lines + ) + gross_total = sum(l.price - l.price_includes_rounding_correction for l in lines) + target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency) + + 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.price - l.price_includes_rounding_correction + apply_diff + l.price_includes_rounding_correction = diff_sgn * minimum_unit + l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction + 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.price - l.price_includes_rounding_correction + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction + l.tax_value_includes_rounding_correction = Decimal("0.00") + changed.append(l) + + elif rounding_mode == "sum_by_gross": + for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key): + lines = list(sorted(lines, key=lambda l: -(l.price - l.price_includes_rounding_correction))) + net_total = sum( + l.price - l.price_includes_rounding_correction - l.tax_value + l.tax_value_includes_rounding_correction + for l in lines + ) + gross_total = sum(l.price - l.price_includes_rounding_correction for l in lines) + target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency) + + diff = target_net_total - net_total + diff_sgn = -1 if diff < 0 else 1 + for l in lines: + if diff: + apply_diff = diff_sgn * minimum_unit + l.price = l.price - l.price_includes_rounding_correction + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction - 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.price - l.price_includes_rounding_correction + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction + 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.price - l.price_includes_rounding_correction + l.price_includes_rounding_correction = Decimal("0.00") + l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction + 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 550d747ebd..1bb929a542 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -465,6 +465,28 @@ 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=( + ('line', _('Rounding every line individually')), + ('sum_by_net', _('Rounding by order total, keeping net prices stable')), + ('sum_by_gross', _('Rounding by order total, keeping gross prices stable')), + ), + ), + 'serializer_kwargs': dict( + choices=( + ('line', _('Rounding every line individually')), + ('sum_by_net', _('Rounding by order total, keeping net prices stable')), + ('sum_by_gross', _('Rounding by order total, keeping gross prices stable')), + ), + ), + }, 'invoice_address_asked': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 427447ab35..ab42d57df7 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -881,6 +881,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_logo_image', 'invoice_renderer_highlight_order_code', 'invoice_renderer_font', + 'tax_rounding', ] invoice_generate_sales_channels = forms.MultipleChoiceField( diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index a3f8b89965..74bd1a9650 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -21,6 +21,7 @@ {% bootstrap_field form.invoice_numbers_prefix layout="control" %} {% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %} {% bootstrap_field form.invoice_numbers_counter_length layout="control" %} + {% bootstrap_field form.tax_rounding layout="control" %}