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:
Raphael Michel
2025-10-30 11:49:31 +01:00
committed by GitHub
parent cdeb1e86bd
commit 3e972eddbf
37 changed files with 1923 additions and 319 deletions

View File

@@ -32,6 +32,7 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import copy
import warnings
from collections import defaultdict
from datetime import datetime, timedelta
from decimal import Decimal
@@ -50,10 +51,11 @@ from django_scopes import scopes_disabled
from pretix.base.i18n import get_language_without_region
from pretix.base.middleware import get_supported_language
from pretix.base.models import (
CartPosition, Customer, InvoiceAddress, ItemAddOn, Question,
CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question,
QuestionAnswer, QuestionOption, TaxRule,
)
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.helpers.cookies import set_cookie_without_samesite
from pretix.multidomain.urlreverse import eventreverse
@@ -147,6 +149,30 @@ class CartMixin:
'question': value.label
})
if order:
fees = order.fees.all()
elif lcp:
try:
fees = get_fees(
event=self.request.event,
request=self.request,
invoice_address=self.invoice_address,
payments=payments if payments is not None else self.cart_session.get('payments', []),
positions=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])
total = sum([c.price for c in lcp]) + sum([f.value for f in fees])
net_total = sum(p.price - p.tax_value for p in lcp) + sum([f.net_value for f in fees])
tax_total = sum(p.tax_value for p in lcp) + sum([f.tax_value for f in fees])
# Group items of the same variation
# We do this by list manipulations instead of a GROUP BY query, as
# Django is unable to join related models in a .values() query
@@ -177,7 +203,7 @@ class CartMixin:
pos.subevent_id,
pos.item_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.seat_id or 0),
pos.valid_from,
@@ -204,29 +230,6 @@ class CartMixin:
group.additional_answers = pos_additional_fields.get(group.pk)
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:
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)
@@ -255,20 +258,28 @@ class CartMixin:
'max_expiry_extend': max_expiry_extend,
'is_ordered': bool(order),
'itemcount': sum(c.count for c in positions if not c.addon_to),
'current_selected_payments': [p for p in self.current_selected_payments(total) if p.get('multi_use_supported')]
'current_selected_payments': [
p for p in self.current_selected_payments(positions, fees, self.invoice_address)
if p.get('multi_use_supported')
]
}
def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False):
def current_selected_payments(self, positions, fees, invoice_address, *, warn=False):
raw_payments = copy.deepcopy(self.cart_session.get('payments', []))
fees = [f for f in fees if f.fee_type != OrderFee.FEE_TYPE_PAYMENT] # we re-compute these here
apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments = []
total_remaining = total
payments_assigned = Decimal("0.00")
for p in raw_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']):
if warn:
messages.warning(
self.request,
@@ -279,7 +290,7 @@ class CartMixin:
self._remove_payment(p['id'])
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']))
@@ -288,12 +299,36 @@ class CartMixin:
self._remove_payment(p['id'])
continue
if not total_includes_payment_fees:
fee = pprov.calculate_fee(to_pay)
total_remaining += fee
to_pay += fee
else:
fee = Decimal('0.00')
payment_fee = pprov.calculate_fee(to_pay)
if payment_fee:
if self.request.event.settings.tax_rule_payment == "default":
payment_fee_tax_rule = self.request.event.cached_default_tax_rule or TaxRule.zero()
else:
payment_fee_tax_rule = TaxRule.zero()
try:
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
except TaxRule.SaleNotAllowed:
# Replicate behavior from elsewhere, will fail later at the order stage
payment_fee = Decimal("0.00")
payment_fee_tax = TaxRule.zero().tax(payment_fee)
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(self.request.event.settings.tax_rounding, self.request.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']))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
@@ -301,8 +336,8 @@ class CartMixin:
p['payment_amount'] = to_pay
p['provider_name'] = pprov.public_name
p['pprov'] = pprov
p['fee'] = fee
total_remaining -= to_pay
p['fee'] = payment_fee
payments_assigned += to_pay
payments.append(p)
return payments
@@ -373,6 +408,21 @@ def get_cart(request):
def get_cart_total(request):
"""
Use the following pattern instead::
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])
"""
warnings.warn('get_cart_total is deprecated and will be removed in a future release',
DeprecationWarning)
from pretix.presale.views.cart import get_or_create_cart_id
if not hasattr(request, '_cart_total_cache'):
@@ -409,13 +459,14 @@ def get_cart_is_free(request):
cs = cart_session(request)
pos = get_cart(request)
ia = get_cart_invoice_address(request)
total = get_cart_total(request)
try:
fees = get_fees(request.event, request, total, ia, cs.get('payments', []), pos)
fees = get_fees(event=request.event, request=request, invoice_address=ia,
payments=cs.get('payments', []), positions=pos)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
fees = []
request._cart_free_cache = total + sum(f.value for f in fees) == Decimal('0.00')
request._cart_free_cache = sum(p.price for p in pos) + sum(f.value for f in fees) == Decimal('0.00')
return request._cart_free_cache

View File

@@ -1552,6 +1552,7 @@ class OrderChangeMixin:
def post(self, request, *args, **kwargs):
was_paid = self.order.status == Order.STATUS_PAID
original_total = self.order.total
ocm = OrderChangeManager(
self.order,
notify=True,
@@ -1603,7 +1604,8 @@ class OrderChangeMixin:
except OrderError as e:
messages.error(self.request, str(e))
else:
if self.order.pending_sum < Decimal('0.00') and ocm._totaldiff < Decimal('0.00'):
totaldiff = self.order.total - original_total
if self.order.pending_sum < Decimal('0.00') and totaldiff < Decimal('0.00'):
auto_refund = (
not self.request.event.settings.cancel_allow_user_paid_require_approval
and self.request.event.settings.cancel_allow_user_paid_refund_as_giftcard != "manually"
@@ -1631,7 +1633,7 @@ class OrderChangeMixin:
messages.info(self.request, _('You did not make any changes.'))
return redirect(self.get_self_url())
else:
new_pending_sum = self.order.pending_sum + ocm._totaldiff
new_pending_sum = self.order.pending_sum + ocm.guess_totaldiff()
can_auto_refund = False
if new_pending_sum < Decimal('0.00'):
proposals = self.order.propose_auto_refunds(Decimal('-1.00') * new_pending_sum)
@@ -1639,7 +1641,7 @@ class OrderChangeMixin:
return render(request, self.confirm_template_name, {
'operations': ocm._operations,
'totaldiff': ocm._totaldiff,
'totaldiff': ocm.guess_totaldiff(),
'order': self.order,
'payment_refund_sum': self.order.payment_refund_sum,
'new_pending_sum': new_pending_sum,
@@ -1651,16 +1653,17 @@ class OrderChangeMixin:
def _validate_total_diff(self, ocm):
pr = self.get_price_requirement()
if ocm._totaldiff < Decimal('0.00') and pr == 'gte':
totaldiff = ocm.guess_totaldiff()
if totaldiff < Decimal('0.00') and pr == 'gte':
raise OrderError(_('You may not change your order in a way that reduces the total price.'))
if ocm._totaldiff <= Decimal('0.00') and pr == 'gt':
if totaldiff <= Decimal('0.00') and pr == 'gt':
raise OrderError(_('You may only change your order in a way that increases the total price.'))
if ocm._totaldiff != Decimal('0.00') and pr == 'eq':
if totaldiff != Decimal('0.00') and pr == 'eq':
raise OrderError(_('You may not change your order in a way that changes the total price.'))
if ocm._totaldiff < Decimal('0.00') and self.order.total + ocm._totaldiff < self.order.payment_refund_sum and pr == 'gte_paid':
if totaldiff < Decimal('0.00') and self.order.total + totaldiff < self.order.payment_refund_sum and pr == 'gte_paid':
raise OrderError(_('You may not change your order in a way that would require a refund.'))
if ocm._totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PAID:
if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PAID:
self.order.set_expires(
now(),
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
@@ -1669,7 +1672,7 @@ class OrderChangeMixin:
raise OrderError(_('You may not change your order in a way that increases the total price since '
'payments are no longer being accepted for this event.'))
if ocm._totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING:
if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING:
for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING):
if not p.payment_provider.abort_pending_allowed:
raise OrderError(_('You may not change your order in a way that requires additional payment while '