mirror of
https://github.com/pretix/pretix.git
synced 2026-05-07 15:34:02 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 '
|
||||
|
||||
Reference in New Issue
Block a user