mirror of
https://github.com/pretix/pretix.git
synced 2026-05-07 15:34:02 +00:00
Allow to round taxes on order-level
This commit is contained in:
@@ -829,6 +829,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'invoice_eu_currencies',
|
'invoice_eu_currencies',
|
||||||
'invoice_logo_image',
|
'invoice_logo_image',
|
||||||
'invoice_renderer_highlight_order_code',
|
'invoice_renderer_highlight_order_code',
|
||||||
|
'tax_rounding',
|
||||||
'cancel_allow_user',
|
'cancel_allow_user',
|
||||||
'cancel_allow_user_until',
|
'cancel_allow_user_until',
|
||||||
'cancel_allow_user_unpaid_keep',
|
'cancel_allow_user_unpaid_keep',
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1259,7 +1259,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
|
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
|
||||||
create = []
|
create = []
|
||||||
for k in keys:
|
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]
|
d = target_transaction_count[k] - current_transaction_count[k]
|
||||||
if d:
|
if d:
|
||||||
create.append(Transaction(
|
create.append(Transaction(
|
||||||
@@ -1272,9 +1273,11 @@ class Order(LockModel, LoggedModel):
|
|||||||
variation_id=variationid,
|
variation_id=variationid,
|
||||||
subevent_id=subeventid,
|
subevent_id=subeventid,
|
||||||
price=price,
|
price=price,
|
||||||
|
price_includes_rounding_correction=price_includes_rounding_correction,
|
||||||
tax_rate=taxrate,
|
tax_rate=taxrate,
|
||||||
tax_rule_id=taxruleid,
|
tax_rule_id=taxruleid,
|
||||||
tax_value=taxvalue,
|
tax_value=taxvalue,
|
||||||
|
tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
|
||||||
tax_code=taxcode,
|
tax_code=taxcode,
|
||||||
fee_type=feetype,
|
fee_type=feetype,
|
||||||
internal_type=internaltype,
|
internal_type=internaltype,
|
||||||
@@ -1499,6 +1502,9 @@ class AbstractPosition(models.Model):
|
|||||||
decimal_places=2, max_digits=13,
|
decimal_places=2, max_digits=13,
|
||||||
verbose_name=_("Price")
|
verbose_name=_("Price")
|
||||||
)
|
)
|
||||||
|
price_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
attendee_name_cached = models.CharField(
|
attendee_name_cached = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
verbose_name=_("Attendee name"),
|
verbose_name=_("Attendee name"),
|
||||||
@@ -2301,6 +2307,9 @@ class OrderFee(models.Model):
|
|||||||
decimal_places=2, max_digits=13,
|
decimal_places=2, max_digits=13,
|
||||||
verbose_name=_("Value")
|
verbose_name=_("Value")
|
||||||
)
|
)
|
||||||
|
value_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
Order,
|
Order,
|
||||||
verbose_name=_("Order"),
|
verbose_name=_("Order"),
|
||||||
@@ -2329,6 +2338,9 @@ class OrderFee(models.Model):
|
|||||||
max_digits=13, decimal_places=2,
|
max_digits=13, decimal_places=2,
|
||||||
verbose_name=_('Tax value')
|
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)
|
canceled = models.BooleanField(default=False)
|
||||||
|
|
||||||
all = ScopedManager(organizer='order__event__organizer')
|
all = ScopedManager(organizer='order__event__organizer')
|
||||||
@@ -2377,17 +2389,23 @@ class OrderFee(models.Model):
|
|||||||
self.fee_type, self.value
|
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:
|
if tax_rule:
|
||||||
self.tax_rule = tax_rule
|
self.tax_rule = tax_rule
|
||||||
|
|
||||||
try:
|
if invoice_address:
|
||||||
ia = invoice_address or self.order.invoice_address
|
ia = invoice_address
|
||||||
except InvoiceAddress.DoesNotExist:
|
elif hasattr(self, "order"):
|
||||||
|
try:
|
||||||
|
ia = self.order.invoice_address
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
ia = None
|
||||||
|
else:
|
||||||
ia = None
|
ia = None
|
||||||
|
|
||||||
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
|
event = event or self.order.event
|
||||||
self.tax_rule = self.order.event.cached_default_tax_rule
|
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:
|
if self.tax_rule:
|
||||||
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
|
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()
|
self.order.touch()
|
||||||
super().delete(**kwargs)
|
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):
|
class OrderPosition(AbstractPosition):
|
||||||
"""
|
"""
|
||||||
@@ -2501,6 +2537,9 @@ class OrderPosition(AbstractPosition):
|
|||||||
max_digits=13, decimal_places=2,
|
max_digits=13, decimal_places=2,
|
||||||
verbose_name=_('Tax value')
|
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)
|
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)
|
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])
|
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
||||||
else:
|
else:
|
||||||
setattr(op, f.name, getattr(cartpos, f.name))
|
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:
|
if cartpos.voucher:
|
||||||
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_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,
|
decimal_places=2, max_digits=13,
|
||||||
verbose_name=_("Price")
|
verbose_name=_("Price")
|
||||||
)
|
)
|
||||||
|
price_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
tax_rate = models.DecimalField(
|
tax_rate = models.DecimalField(
|
||||||
max_digits=7, decimal_places=2,
|
max_digits=7, decimal_places=2,
|
||||||
verbose_name=_('Tax rate')
|
verbose_name=_('Tax rate')
|
||||||
@@ -3023,6 +3072,9 @@ class Transaction(models.Model):
|
|||||||
max_digits=13, decimal_places=2,
|
max_digits=13, decimal_places=2,
|
||||||
verbose_name=_('Tax value')
|
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(
|
fee_type = models.CharField(
|
||||||
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
|
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
|
||||||
)
|
)
|
||||||
@@ -3052,14 +3104,19 @@ class Transaction(models.Model):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def key(obj):
|
def key(obj):
|
||||||
if isinstance(obj, Transaction):
|
if isinstance(obj, Transaction):
|
||||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
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):
|
elif isinstance(obj, OrderPosition):
|
||||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||||
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code)
|
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):
|
elif isinstance(obj, OrderFee):
|
||||||
return (None, None, None, None, obj.value, obj.tax_rate,
|
return (None, None, None, None, obj.value, obj.value_includes_rounding_correction,
|
||||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
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
|
raise ValueError('invalid state') # noqa
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -3070,6 +3127,14 @@ class Transaction(models.Model):
|
|||||||
def full_tax_value(self):
|
def full_tax_value(self):
|
||||||
return self.tax_value * self.count
|
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):
|
class CartPosition(AbstractPosition):
|
||||||
"""
|
"""
|
||||||
@@ -3110,6 +3175,13 @@ class CartPosition(AbstractPosition):
|
|||||||
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
||||||
verbose_name=_('Tax rate')
|
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(
|
listed_price = models.DecimalField(
|
||||||
decimal_places=2, max_digits=13, null=True,
|
decimal_places=2, max_digits=13, null=True,
|
||||||
)
|
)
|
||||||
@@ -3150,9 +3222,15 @@ class CartPosition(AbstractPosition):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tax_value(self):
|
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)
|
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
|
@cached_property
|
||||||
def sort_key(self):
|
def sort_key(self):
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ from pretix.base.services.memberships import (
|
|||||||
create_membership, validate_memberships_in_order,
|
create_membership, validate_memberships_in_order,
|
||||||
)
|
)
|
||||||
from pretix.base.services.pricing import (
|
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.quotas import QuotaAvailability
|
||||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
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,
|
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||||
meta_info: dict, event: Event, require_approval=False):
|
meta_info: dict, event: Event, require_approval=False):
|
||||||
fees = []
|
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
|
gift_cards = [] # for backwards compatibility
|
||||||
for p in payment_requests:
|
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):
|
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
|
||||||
if resp:
|
if resp:
|
||||||
fees += 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:
|
for p in payment_requests:
|
||||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||||
# places in the code base:
|
# places in the code base:
|
||||||
# - pretix.base.services.cart.get_fees
|
# - pretix.base.services.cart.get_fees
|
||||||
# - pretix.base.services.orders._get_fees
|
# - pretix.base.services.orders._get_fees
|
||||||
# - pretix.presale.views.CartMixin.current_selected_payments
|
# - 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')
|
p['payment_amount'] = Decimal('0.00')
|
||||||
continue
|
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']):
|
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||||
|
|
||||||
payment_fee = p['pprov'].calculate_fee(to_pay)
|
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:
|
if payment_fee:
|
||||||
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
||||||
internal_type=p['pprov'].identifier)
|
internal_type=p['pprov'].identifier)
|
||||||
|
pf._calculate_tax(invoice_address=address, event=event)
|
||||||
fees.append(pf)
|
fees.append(pf)
|
||||||
p['fee'] = 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."))
|
raise OrderError(_("The selected payment methods do not cover the total balance."))
|
||||||
|
|
||||||
return fees
|
return fees
|
||||||
@@ -1001,10 +1015,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
|||||||
raise OrderError(e.message)
|
raise OrderError(e.message)
|
||||||
|
|
||||||
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
|
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
|
||||||
|
|
||||||
|
# Final calculation of fees, also performs final rounding
|
||||||
try:
|
try:
|
||||||
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
|
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
|
||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
raise OrderError(error_messages['country_blocked'])
|
raise OrderError(error_messages['country_blocked'])
|
||||||
|
|
||||||
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||||
|
|
||||||
order = Order(
|
order = Order(
|
||||||
@@ -1036,12 +1053,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
|||||||
|
|
||||||
for fee in fees:
|
for fee in fees:
|
||||||
fee.order = order
|
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()
|
fee.save()
|
||||||
|
|
||||||
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
|
# 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')
|
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||||
return payment_sum - refund_sum
|
return payment_sum - refund_sum
|
||||||
|
|
||||||
def _recalculate_total_and_payment_fee(self):
|
def _recalculate_rounding_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()])
|
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')
|
payment_fee = Decimal('0.00')
|
||||||
|
fee_changed = False
|
||||||
if self.open_payment:
|
if self.open_payment:
|
||||||
current_fee = Decimal('0.00')
|
current_fee = Decimal('0.00')
|
||||||
fee = None
|
fee = None
|
||||||
@@ -2726,13 +2740,31 @@ class OrderChangeManager:
|
|||||||
fee.value = payment_fee
|
fee.value = payment_fee
|
||||||
fee._calculate_tax()
|
fee._calculate_tax()
|
||||||
fee.save()
|
fee.save()
|
||||||
|
fee_changed = True
|
||||||
if not self.open_payment.fee:
|
if not self.open_payment.fee:
|
||||||
self.open_payment.fee = fee
|
self.open_payment.fee = fee
|
||||||
self.open_payment.save(update_fields=['fee'])
|
self.open_payment.save(update_fields=['fee'])
|
||||||
elif fee and not fee.canceled:
|
elif fee and not fee.canceled:
|
||||||
fee.delete()
|
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()
|
self.order.save()
|
||||||
|
|
||||||
def _check_order_size(self):
|
def _check_order_size(self):
|
||||||
@@ -2913,7 +2945,7 @@ class OrderChangeManager:
|
|||||||
self._perform_operations()
|
self._perform_operations()
|
||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
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_price_change()
|
||||||
self._check_paid_to_free()
|
self._check_paid_to_free()
|
||||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ import re
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
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 import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
|
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
|
||||||
SalesChannel, Voucher,
|
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.models.discount import Discount, PositionInfo
|
from pretix.base.models.discount import Discount, PositionInfo
|
||||||
from pretix.base.models.event import Event, SubEvent
|
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,
|
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:
|
if not tax_rule:
|
||||||
tax_rule = TaxRule(
|
tax_rule = TaxRule(
|
||||||
name='',
|
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,
|
override_tax_code=price.code, invoice_address=invoice_address,
|
||||||
subtract_from_gross=bundled_sum)
|
subtract_from_gross=bundled_sum)
|
||||||
else:
|
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,
|
override_tax_code=price.code, invoice_address=invoice_address,
|
||||||
subtract_from_gross=bundled_sum)
|
subtract_from_gross=bundled_sum)
|
||||||
else:
|
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],
|
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||||
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
|
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
|
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)
|
new_prices.update(result)
|
||||||
|
|
||||||
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
|
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
|
||||||
|
|||||||
@@ -465,6 +465,28 @@ DEFAULTS = {
|
|||||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
|
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': {
|
'invoice_address_asked': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
|
|||||||
@@ -881,6 +881,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
|||||||
'invoice_logo_image',
|
'invoice_logo_image',
|
||||||
'invoice_renderer_highlight_order_code',
|
'invoice_renderer_highlight_order_code',
|
||||||
'invoice_renderer_font',
|
'invoice_renderer_font',
|
||||||
|
'tax_rounding',
|
||||||
]
|
]
|
||||||
|
|
||||||
invoice_generate_sales_channels = forms.MultipleChoiceField(
|
invoice_generate_sales_channels = forms.MultipleChoiceField(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
|
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
|
||||||
{% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %}
|
{% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %}
|
||||||
{% bootstrap_field form.invoice_numbers_counter_length layout="control" %}
|
{% bootstrap_field form.invoice_numbers_counter_length layout="control" %}
|
||||||
|
{% bootstrap_field form.tax_rounding layout="control" %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "Address form" %}</legend>
|
<legend>{% trans "Address form" %}</legend>
|
||||||
|
|||||||
@@ -641,6 +641,16 @@
|
|||||||
</small>
|
</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if django_settings.DEBUG %}
|
||||||
|
<br/>
|
||||||
|
<small>
|
||||||
|
price = {{ line.price|floatformat:2 }}<br>
|
||||||
|
rounding_correction = {{ line.price_includes_rounding_correction|floatformat:2 }}<br>
|
||||||
|
tax_value = {{ line.tax_value|floatformat:2 }}<br>
|
||||||
|
tax_value_rounding_correction = {{ line.tax_value_includes_rounding_correction|floatformat:2 }}<br>
|
||||||
|
voucher_budget_use = {{ line.voucher_budget_use|floatformat:2 }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -681,6 +691,16 @@
|
|||||||
</small>
|
</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if django_settings.DEBUG %}
|
||||||
|
<br/>
|
||||||
|
<small>
|
||||||
|
price = {{ fee.value|floatformat:2 }}<br>
|
||||||
|
rounding_correction = {{ fee.value_includes_rounding_correction|floatformat:2 }}<br>
|
||||||
|
tax_value = {{ fee.tax_value|floatformat:2 }}<br>
|
||||||
|
tax_value_rounding_correction = {{ fee.tax_value_includes_rounding_correction|floatformat:2 }}<br>
|
||||||
|
voucher_budget_use = {{ fee.voucher_budget_use|floatformat:2 }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,8 +56,26 @@
|
|||||||
<td>{{ t.get_tax_code_display }}</td>
|
<td>{{ t.get_tax_code_display }}</td>
|
||||||
<td class="text-right flip">{{ t.count }} ×</td>
|
<td class="text-right flip">{{ t.count }} ×</td>
|
||||||
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
|
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
|
||||||
<td class="text-right flip">{{ t.full_tax_value|money:request.event.currency }}</td>
|
<td class="text-right flip">
|
||||||
<td class="text-right flip">{{ t.full_price|money:request.event.currency }}</td>
|
{{ t.full_tax_value|money:request.event.currency }}
|
||||||
|
{% if t.full_price_includes_rounding_correction %}
|
||||||
|
<br><small>
|
||||||
|
{% blocktrans trimmed with amount=t.full_tax_value_includes_rounding_correction|money:request.event.currency %}
|
||||||
|
incl. {{ amount }} rounding correction
|
||||||
|
{% endblocktrans %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
{{ t.full_price|money:request.event.currency }}
|
||||||
|
{% if t.full_price_includes_rounding_correction %}
|
||||||
|
<br><small>
|
||||||
|
{% blocktrans trimmed with amount=t.full_price_includes_rounding_correction|money:request.event.currency %}
|
||||||
|
incl. {{ amount }} rounding correction
|
||||||
|
{% endblocktrans %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -649,7 +649,9 @@ class OrderTransactions(OrderView):
|
|||||||
ctx['sums'] = self.order.transactions.aggregate(
|
ctx['sums'] = self.order.transactions.aggregate(
|
||||||
sum_count=Sum('count'),
|
sum_count=Sum('count'),
|
||||||
full_price=Sum(F('count') * F('price')),
|
full_price=Sum(F('count') * F('price')),
|
||||||
|
full_price_includes_rounding_correction=Sum(F('count') * F('price_includes_rounding_correction')),
|
||||||
full_tax_value=Sum(F('count') * F('tax_value')),
|
full_tax_value=Sum(F('count') * F('tax_value')),
|
||||||
|
full_tax_value_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')),
|
||||||
)
|
)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from pretix.base.models import (
|
|||||||
QuestionAnswer, QuestionOption, TaxRule,
|
QuestionAnswer, QuestionOption, TaxRule,
|
||||||
)
|
)
|
||||||
from pretix.base.services.cart import get_fees
|
from pretix.base.services.cart import get_fees
|
||||||
|
from pretix.base.services.pricing import apply_rounding
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.helpers.cookies import set_cookie_without_samesite
|
from pretix.helpers.cookies import set_cookie_without_samesite
|
||||||
from pretix.multidomain.urlreverse import eventreverse
|
from pretix.multidomain.urlreverse import eventreverse
|
||||||
@@ -147,6 +148,34 @@ class CartMixin:
|
|||||||
'question': value.label
|
'question': value.label
|
||||||
})
|
})
|
||||||
|
|
||||||
|
total = sum(p.price for p in lcp)
|
||||||
|
|
||||||
|
if order:
|
||||||
|
fees = order.fees.all()
|
||||||
|
elif lcp:
|
||||||
|
try:
|
||||||
|
fees = get_fees(
|
||||||
|
self.request.event, self.request, total, self.invoice_address,
|
||||||
|
payments if payments is not None else self.cart_session.get('payments', []),
|
||||||
|
cartpos
|
||||||
|
)
|
||||||
|
except TaxRule.SaleNotAllowed:
|
||||||
|
# ignore for now, will fail on order creation
|
||||||
|
fees = []
|
||||||
|
else:
|
||||||
|
fees = []
|
||||||
|
|
||||||
|
if not order:
|
||||||
|
apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*lcp, *fees])
|
||||||
|
print([(p.price, p.tax_value, ) for p in lcp])
|
||||||
|
total = sum(p.price for p in lcp)
|
||||||
|
|
||||||
|
net_total = sum(p.price - p.tax_value for p in lcp)
|
||||||
|
tax_total = sum(p.tax_value for p in lcp)
|
||||||
|
total += sum([f.value for f in fees])
|
||||||
|
net_total += sum([f.net_value for f in fees])
|
||||||
|
tax_total += sum([f.tax_value for f in fees])
|
||||||
|
|
||||||
# Group items of the same variation
|
# Group items of the same variation
|
||||||
# We do this by list manipulations instead of a GROUP BY query, as
|
# We do this by list manipulations instead of a GROUP BY query, as
|
||||||
# Django is unable to join related models in a .values() query
|
# Django is unable to join related models in a .values() query
|
||||||
@@ -177,7 +206,7 @@ class CartMixin:
|
|||||||
pos.subevent_id,
|
pos.subevent_id,
|
||||||
pos.item_id,
|
pos.item_id,
|
||||||
pos.variation_id,
|
pos.variation_id,
|
||||||
pos.price,
|
pos.net_price if self.request.event.settings.display_net_prices else pos.price,
|
||||||
(pos.voucher_id or 0),
|
(pos.voucher_id or 0),
|
||||||
(pos.seat_id or 0),
|
(pos.seat_id or 0),
|
||||||
pos.valid_from,
|
pos.valid_from,
|
||||||
@@ -204,29 +233,6 @@ class CartMixin:
|
|||||||
group.additional_answers = pos_additional_fields.get(group.pk)
|
group.additional_answers = pos_additional_fields.get(group.pk)
|
||||||
positions.append(group)
|
positions.append(group)
|
||||||
|
|
||||||
total = sum(p.total for p in positions)
|
|
||||||
net_total = sum(p.net_total for p in positions)
|
|
||||||
tax_total = sum(p.total - p.net_total for p in positions)
|
|
||||||
|
|
||||||
if order:
|
|
||||||
fees = order.fees.all()
|
|
||||||
elif positions:
|
|
||||||
try:
|
|
||||||
fees = get_fees(
|
|
||||||
self.request.event, self.request, total, self.invoice_address,
|
|
||||||
payments if payments is not None else self.cart_session.get('payments', []),
|
|
||||||
cartpos
|
|
||||||
)
|
|
||||||
except TaxRule.SaleNotAllowed:
|
|
||||||
# ignore for now, will fail on order creation
|
|
||||||
fees = []
|
|
||||||
else:
|
|
||||||
fees = []
|
|
||||||
|
|
||||||
total += sum([f.value for f in fees])
|
|
||||||
net_total += sum([f.net_value for f in fees])
|
|
||||||
tax_total += sum([f.tax_value for f in fees])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
first_expiry = min(p.expires for p in positions) if positions else now()
|
first_expiry = min(p.expires for p in positions) if positions else now()
|
||||||
max_expiry_extend = min((p.max_extend for p in positions if p.max_extend), default=None)
|
max_expiry_extend = min((p.max_extend for p in positions if p.max_extend), default=None)
|
||||||
|
|||||||
@@ -2270,7 +2270,7 @@ class OrderChangeManagerTests(TestCase):
|
|||||||
def test_recalculate_country_rate(self):
|
def test_recalculate_country_rate(self):
|
||||||
prov = self.ocm._get_payment_provider()
|
prov = self.ocm._get_payment_provider()
|
||||||
prov.settings.set('_fee_abs', Decimal('0.30'))
|
prov.settings.set('_fee_abs', Decimal('0.30'))
|
||||||
self.ocm._recalculate_total_and_payment_fee()
|
self.ocm._recalculate_rounding_total_and_payment_fee()
|
||||||
|
|
||||||
assert self.order.total == Decimal('46.30')
|
assert self.order.total == Decimal('46.30')
|
||||||
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
||||||
@@ -2302,7 +2302,7 @@ class OrderChangeManagerTests(TestCase):
|
|||||||
def test_recalculate_country_rate_keep_gross(self):
|
def test_recalculate_country_rate_keep_gross(self):
|
||||||
prov = self.ocm._get_payment_provider()
|
prov = self.ocm._get_payment_provider()
|
||||||
prov.settings.set('_fee_abs', Decimal('0.30'))
|
prov.settings.set('_fee_abs', Decimal('0.30'))
|
||||||
self.ocm._recalculate_total_and_payment_fee()
|
self.ocm._recalculate_rounding_total_and_payment_fee()
|
||||||
|
|
||||||
assert self.order.total == Decimal('46.30')
|
assert self.order.total == Decimal('46.30')
|
||||||
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
||||||
@@ -2332,7 +2332,7 @@ class OrderChangeManagerTests(TestCase):
|
|||||||
def test_recalculate_reverse_charge(self):
|
def test_recalculate_reverse_charge(self):
|
||||||
prov = self.ocm._get_payment_provider()
|
prov = self.ocm._get_payment_provider()
|
||||||
prov.settings.set('_fee_abs', Decimal('0.30'))
|
prov.settings.set('_fee_abs', Decimal('0.30'))
|
||||||
self.ocm._recalculate_total_and_payment_fee()
|
self.ocm._recalculate_rounding_total_and_payment_fee()
|
||||||
|
|
||||||
assert self.order.total == Decimal('46.30')
|
assert self.order.total == Decimal('46.30')
|
||||||
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
||||||
|
|||||||
118
src/tests/base/test_pricing_rounding.py
Normal file
118
src/tests/base/test_pricing_rounding.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pretix.base.models import OrderPosition
|
||||||
|
from pretix.base.services.pricing import apply_rounding
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_lines():
|
||||||
|
lines = [OrderPosition(
|
||||||
|
price=Decimal("100.00"),
|
||||||
|
tax_value=Decimal("15.97"),
|
||||||
|
tax_rate=Decimal("19.00"),
|
||||||
|
tax_code="S",
|
||||||
|
) for _ in range(5)]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sample_lines(sample_lines, rounding_mode):
|
||||||
|
apply_rounding(rounding_mode, "EUR", sample_lines)
|
||||||
|
if rounding_mode == "line":
|
||||||
|
for l in sample_lines:
|
||||||
|
assert l.price == Decimal("100.00")
|
||||||
|
assert l.tax_value == Decimal("15.97")
|
||||||
|
assert l.tax_rate == Decimal("19.00")
|
||||||
|
assert sum(l.price for l in sample_lines) == Decimal("500.00")
|
||||||
|
assert sum(l.tax_value for l in sample_lines) == Decimal("79.85")
|
||||||
|
elif rounding_mode == "sum_by_net":
|
||||||
|
for l in sample_lines:
|
||||||
|
# gross price may vary
|
||||||
|
assert l.price - l.tax_value == Decimal("84.03")
|
||||||
|
assert l.tax_rate == Decimal("19.00")
|
||||||
|
assert sum(l.price for l in sample_lines) == Decimal("499.98")
|
||||||
|
assert sum(l.tax_value for l in sample_lines) == Decimal("79.83")
|
||||||
|
elif rounding_mode == "sum_by_gross":
|
||||||
|
for l in sample_lines:
|
||||||
|
assert l.price == Decimal("100.00")
|
||||||
|
# net price may vary
|
||||||
|
assert l.tax_rate == Decimal("19.00")
|
||||||
|
assert sum(l.price for l in sample_lines) == Decimal("500.00")
|
||||||
|
assert sum(l.tax_value for l in sample_lines) == Decimal("79.83")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_simple_case_by_line(sample_lines):
|
||||||
|
_validate_sample_lines(sample_lines, "line")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_simple_case_by_net(sample_lines):
|
||||||
|
_validate_sample_lines(sample_lines, "sum_by_net")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_simple_case_by_gross(sample_lines):
|
||||||
|
_validate_sample_lines(sample_lines, "sum_by_gross")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_simple_case_switch_rounding(sample_lines):
|
||||||
|
_validate_sample_lines(sample_lines, "sum_by_net")
|
||||||
|
_validate_sample_lines(sample_lines, "sum_by_gross")
|
||||||
|
_validate_sample_lines(sample_lines, "line")
|
||||||
|
_validate_sample_lines(sample_lines, "sum_by_net")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_revert_net_rounding_to_single_line(sample_lines):
|
||||||
|
l = OrderPosition(
|
||||||
|
price=Decimal("100.01"),
|
||||||
|
price_includes_rounding_correction=Decimal("0.01"),
|
||||||
|
tax_value=Decimal("15.98"),
|
||||||
|
tax_value_includes_rounding_correction=Decimal("0.01"),
|
||||||
|
tax_rate=Decimal("19.00"),
|
||||||
|
tax_code="S",
|
||||||
|
)
|
||||||
|
apply_rounding("sum_by_net", "EUR", [l])
|
||||||
|
assert l.price == Decimal("100.00")
|
||||||
|
assert l.tax_value == Decimal("15.97")
|
||||||
|
assert l.tax_rate == Decimal("19.00")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_revert_gross_rounding_to_single_line(sample_lines):
|
||||||
|
l = OrderPosition(
|
||||||
|
price=Decimal("100.00"),
|
||||||
|
price_includes_rounding_correction=Decimal("0.00"),
|
||||||
|
tax_value=Decimal("15.96"),
|
||||||
|
tax_value_includes_rounding_correction=Decimal("-0.01"),
|
||||||
|
tax_rate=Decimal("19.00"),
|
||||||
|
tax_code="S",
|
||||||
|
)
|
||||||
|
apply_rounding("sum_by_gross", "EUR", [l])
|
||||||
|
assert l.price == Decimal("100.00")
|
||||||
|
assert l.tax_value == Decimal("15.97")
|
||||||
|
assert l.tax_rate == Decimal("19.00")
|
||||||
Reference in New Issue
Block a user