forked from CGM_Public/pretix_original
Allow to round taxes on order-level (#5019)
* Allow to round taxes on order-level * Rename get_cart_total * Persist rounding mode with order * Add general docs * Order creation API * Update fee algorithm * Rounding on payment method change * Round when splitting order * Fix failing tests * Add settings page * Add tests * Replace algorithm * Add test case for currency rounding * Improve order change * Update flowchart * Update discount logic (more hypothetical, we don't store rounding on cart positions atm) * Rename internal method * Fix typo * Update help text * Apply suggestions from code review Co-authored-by: luelista <weller@rami.io> * Order rounding refactor (#5571) * Add RoundingCorrectionMixin providing before-rounding-values as properties * Use gross_price_before_rounding in more places * Update doc/development/algorithms/pricing.rst Co-authored-by: Martin Gross <gross@rami.io> * Allow to override on perform_order * Rebase migration * Fix event cancellation --------- Co-authored-by: luelista <weller@rami.io> Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user