mirror of
https://github.com/pretix/pretix.git
synced 2025-12-29 18:02:26 +00:00
Compare commits
24 Commits
recovery-c
...
order-roun
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea8ab5b5b | ||
|
|
821bff0e60 | ||
|
|
58aa9b1d17 | ||
|
|
3602831586 | ||
|
|
cf96d4ed31 | ||
|
|
b49207c88a | ||
|
|
f43115d28c | ||
|
|
81eaf79e5b | ||
|
|
c48fd7d3e1 | ||
|
|
ca48803d34 | ||
|
|
8cd36ca48c | ||
|
|
900540f032 | ||
|
|
cb2815a7f0 | ||
|
|
0cc2155aa5 | ||
|
|
5adbf5bb3d | ||
|
|
b0f82d9041 | ||
|
|
acc77baeac | ||
|
|
e37724ab8b | ||
|
|
00734f8972 | ||
|
|
0fd2c60fa0 | ||
|
|
ea454ac302 | ||
|
|
16157fbd07 | ||
|
|
e30e738e96 | ||
|
|
3735b6b5a9 |
@@ -41,6 +41,7 @@ expires datetime The order will
|
||||
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
||||
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
|
||||
total money (string) Total value of this order
|
||||
tax_rounding_mode string Tax rounding mode, see :ref:`algorithms-rounding`
|
||||
comment string Internal comment on this order
|
||||
api_meta object Meta data for that order. Only available through API, no guarantees
|
||||
on the content structure. You can use this to save references to your system.
|
||||
@@ -144,12 +145,16 @@ plugin_data object Additional data
|
||||
The ``tax_code`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2025.2
|
||||
|
||||
The ``plugin_data`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2025.6
|
||||
|
||||
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
|
||||
|
||||
|
||||
.. versionchanged:: 2025.8
|
||||
|
||||
The ``tax_rounding_mode`` attribute has been added.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -358,6 +363,7 @@ List of all orders
|
||||
"payment_provider": "banktransfer",
|
||||
"fees": [],
|
||||
"total": "23.00",
|
||||
"tax_rounding_mode": "line",
|
||||
"comment": "",
|
||||
"custom_followup_at": null,
|
||||
"checkin_attention": false,
|
||||
@@ -601,6 +607,7 @@ Fetching individual orders
|
||||
"payment_provider": "banktransfer",
|
||||
"fees": [],
|
||||
"total": "23.00",
|
||||
"tax_rounding_mode": "line",
|
||||
"comment": "",
|
||||
"api_meta": {},
|
||||
"custom_followup_at": null,
|
||||
@@ -1009,6 +1016,7 @@ Creating orders
|
||||
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
|
||||
charge will be created), this is just informative in case you *handled the payment already*.
|
||||
* ``payment_date`` (optional) – Date and time of the completion of the payment.
|
||||
* ``tax_rounding_mode`` (optional)
|
||||
* ``comment`` (optional)
|
||||
* ``custom_followup_at`` (optional)
|
||||
* ``checkin_attention`` (optional)
|
||||
|
||||
@@ -178,3 +178,124 @@ Flowchart
|
||||
---------
|
||||
|
||||
.. image:: /images/cart_pricing.png
|
||||
|
||||
|
||||
.. _`algorithms-rounding`:
|
||||
|
||||
Rounding of taxes
|
||||
-----------------
|
||||
|
||||
pretix internally always stores taxes on a per-line level, like this:
|
||||
|
||||
========== ========== =========== ======= =============
|
||||
Product Tax rate Net price Tax Gross price
|
||||
========== ========== =========== ======= =============
|
||||
Ticket A 19 % 84.03 15.97 100.00
|
||||
Ticket B 19 % 84.03 15.97 100.00
|
||||
Ticket C 19 % 84.03 15.97 100.00
|
||||
Ticket D 19 % 84.03 15.97 100.00
|
||||
Ticket E 19 % 84.03 15.97 100.00
|
||||
Sum 420.15 79.85 500.00
|
||||
========== ========== =========== ======= =============
|
||||
|
||||
Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line.
|
||||
|
||||
The line-based computation has a few significant advantages:
|
||||
|
||||
- We can report both net and gross prices for every individual ticket.
|
||||
|
||||
- We can report both net and gross prices for every filter imaginable, such as the gross sum of all sales of Ticket A
|
||||
or the net sum of all sales for a specific date in an event series. All numbers will be exact.
|
||||
|
||||
- When splitting the order into two, both net price and gross price are split without any changes in rounding.
|
||||
|
||||
The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15)
|
||||
and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98
|
||||
(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation
|
||||
to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that
|
||||
does not allow the computation as created by pretix.
|
||||
|
||||
However, calculating the tax rate from the net total has significant disadvantages:
|
||||
|
||||
- It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to
|
||||
consumers, they will be confused when they only need to pay €499.98 for 5 tickets.
|
||||
|
||||
- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there
|
||||
is no two-decimal net price that would be computed to a gross price of €99.99.
|
||||
|
||||
- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the
|
||||
original order. Therefore, additional payments or refunds of very small amounts might be necessary.
|
||||
|
||||
To allow organizers to make their own choices on this matter, pretix provides the following options:
|
||||
|
||||
Compute taxes for every line individually
|
||||
"""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Algorithm identifier: ``line``
|
||||
|
||||
This is our original algorithm where the tax value is rounded for every line individually.
|
||||
|
||||
**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below).
|
||||
For the example above:
|
||||
|
||||
========== ========== =========== ======= =============
|
||||
Product Tax rate Net price Tax Gross price
|
||||
========== ========== =========== ======= =============
|
||||
Ticket A 19 % 84.03 15.97 100.00
|
||||
Ticket B 19 % 84.03 15.97 100.00
|
||||
Ticket C 19 % 84.03 15.97 100.00
|
||||
Ticket D 19 % 84.03 15.97 100.00
|
||||
Ticket E 19 % 84.03 15.97 100.00
|
||||
Sum 420.15 79.85 500.00
|
||||
========== ========== =========== ======= =============
|
||||
|
||||
|
||||
Compute taxes based on net total
|
||||
""""""""""""""""""""""""""""""""
|
||||
|
||||
Algorithm identifier: ``sum_by_net``
|
||||
|
||||
In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within
|
||||
our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01).
|
||||
The net price of the tickets always stay the same.
|
||||
|
||||
**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.**
|
||||
|
||||
The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways.
|
||||
For the example above, the customer expects to pay 5 times 500.00, but they are are in fact charged 499.98:
|
||||
|
||||
========== ========== =========== ============================== ==============================
|
||||
Product Tax rate Net price Tax Gross price
|
||||
========== ========== =========== ============================== ==============================
|
||||
Ticket A 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
|
||||
Ticket B 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
|
||||
Ticket C 19 % 84.03 15.97 100.00
|
||||
Ticket D 19 % 84.03 15.97 100.00
|
||||
Ticket E 19 % 84.03 15.97 100.00
|
||||
Sum 420.15 78.83 499.98
|
||||
========== ========== =========== ============================== ==============================
|
||||
|
||||
Compute taxes based on net total with stable gross prices
|
||||
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Algorithm identifier: ``sum_by_net_keep_gross``
|
||||
|
||||
In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices
|
||||
of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting
|
||||
gross prices stay the same.
|
||||
|
||||
**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.**
|
||||
|
||||
The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different.
|
||||
Full computation for the example above:
|
||||
|
||||
========== ========== ============================= ============================== =============
|
||||
Product Tax rate Net price Tax Gross price
|
||||
========== ========== ============================= ============================== =============
|
||||
Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
|
||||
Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
|
||||
Ticket C 19 % 84.03 15.97 100.00
|
||||
Ticket D 19 % 84.03 15.97 100.00
|
||||
Ticket E 19 % 84.03 15.97 100.00
|
||||
Sum 420.17 79.83 500.00
|
||||
========== ========== ============================= ============================== =============
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 49 KiB |
@@ -23,6 +23,7 @@ partition "For every cart position" {
|
||||
--> "Store as line_price (gross), tax_rate"
|
||||
}
|
||||
--> "Apply discount engine"
|
||||
--> "Apply tax rounding"
|
||||
--> "Store as price (gross)"
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -828,6 +828,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_eu_currencies',
|
||||
'invoice_logo_image',
|
||||
'invoice_renderer_highlight_order_code',
|
||||
'tax_rounding',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_unpaid_keep',
|
||||
|
||||
@@ -52,9 +52,10 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
|
||||
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
|
||||
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
|
||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
@@ -64,10 +65,13 @@ from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
|
||||
apply_discounts, apply_rounding, get_line_price, get_listed_price,
|
||||
is_included_for_free,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
|
||||
)
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
@@ -833,14 +837,15 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
list_serializer_class = OrderListSerializer
|
||||
fields = (
|
||||
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data',
|
||||
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
|
||||
'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
|
||||
'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
|
||||
'plugin_data',
|
||||
)
|
||||
read_only_fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
|
||||
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date'
|
||||
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
|
||||
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -1155,6 +1160,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
queryset=SalesChannel.objects.none(),
|
||||
required=False,
|
||||
)
|
||||
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
||||
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -1170,7 +1176,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta')
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
@@ -1693,7 +1699,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
f.save()
|
||||
|
||||
order.total += sum([f.value for f in fees])
|
||||
rounding_mode = validated_data.get("tax_rounding_mode")
|
||||
if not rounding_mode:
|
||||
if isinstance(self.context.get("auth"), Device):
|
||||
# Safety fallback to avoid differences in tax reporting
|
||||
brand = self.context.get("auth").software_brand or ""
|
||||
if "pretixPOS" in brand or "pretixKIOSK" in brand:
|
||||
rounding_mode = "line"
|
||||
if not rounding_mode:
|
||||
rounding_mode = self.context["event"].settings.tax_rounding
|
||||
changed = apply_rounding(
|
||||
rounding_mode,
|
||||
self.context["event"].currency,
|
||||
[*pos_map.values(), *fees]
|
||||
)
|
||||
for line in changed:
|
||||
if isinstance(line, OrderPosition):
|
||||
line.save(update_fields=[
|
||||
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
elif isinstance(line, OrderFee):
|
||||
line.save(update_fields=[
|
||||
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
|
||||
order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees])
|
||||
if simulate:
|
||||
order.fees = fees
|
||||
order.positions = pos_map.values()
|
||||
|
||||
@@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['auth'] = self.request.auth
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -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", "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
|
||||
),
|
||||
),
|
||||
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"),
|
||||
@@ -2258,7 +2284,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.
|
||||
@@ -2308,6 +2334,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"),
|
||||
@@ -2336,6 +2365,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')
|
||||
@@ -2384,17 +2416,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)
|
||||
@@ -2429,6 +2467,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):
|
||||
"""
|
||||
@@ -2508,6 +2564,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)
|
||||
@@ -2680,7 +2739,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
|
||||
|
||||
@@ -3013,6 +3079,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')
|
||||
@@ -3030,6 +3099,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
|
||||
)
|
||||
@@ -3059,14 +3131,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
|
||||
@@ -3077,6 +3154,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):
|
||||
"""
|
||||
@@ -3117,6 +3202,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,
|
||||
)
|
||||
@@ -3157,9 +3249,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):
|
||||
|
||||
@@ -71,7 +71,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__)
|
||||
@@ -1147,12 +1147,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:
|
||||
|
||||
@@ -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
|
||||
@@ -923,10 +923,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.
|
||||
@@ -949,10 +950,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:
|
||||
@@ -963,40 +965,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
|
||||
@@ -1014,10 +1029,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(
|
||||
@@ -1035,6 +1053,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=event.settings.tax_rounding,
|
||||
)
|
||||
if customer:
|
||||
order.email_known_to_work = customer.is_verified
|
||||
@@ -1049,12 +1068,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
|
||||
@@ -1634,7 +1647,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 = []
|
||||
@@ -1751,7 +1764,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
|
||||
@@ -1796,29 +1809,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:
|
||||
@@ -1884,7 +1897,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])
|
||||
@@ -2180,8 +2193,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(
|
||||
@@ -2189,7 +2202,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()
|
||||
@@ -2216,7 +2229,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)
|
||||
@@ -2236,11 +2249,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
|
||||
@@ -2248,7 +2261,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,
|
||||
@@ -2377,10 +2390,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)
|
||||
@@ -2647,14 +2665,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()
|
||||
@@ -2667,9 +2689,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)
|
||||
@@ -2723,9 +2763,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
|
||||
@@ -2753,14 +2796,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:
|
||||
@@ -2770,23 +2831,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:
|
||||
@@ -2905,6 +2949,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
|
||||
@@ -2920,8 +2971,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():
|
||||
@@ -2929,6 +2978,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()
|
||||
@@ -2940,9 +2990,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()
|
||||
@@ -3160,6 +3211,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(
|
||||
@@ -3176,19 +3228,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()
|
||||
@@ -3215,7 +3294,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,
|
||||
|
||||
@@ -68,7 +68,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.base.services.placeholders import FormPlaceholderMixin
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
|
||||
PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings,
|
||||
)
|
||||
from pretix.base.validators import multimail_validate
|
||||
from pretix.control.forms import (
|
||||
@@ -541,7 +541,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
'show_date_to',
|
||||
'show_times',
|
||||
'show_items_outside_presale_period',
|
||||
'display_net_prices',
|
||||
'hide_prices_from_attendees',
|
||||
'presale_start_show_date',
|
||||
'locales',
|
||||
@@ -799,6 +798,80 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
return value
|
||||
|
||||
|
||||
class DisplayNetPricesBooleanSelect(forms.RadioSelect):
|
||||
def __init__(self, attrs=None):
|
||||
choices = (
|
||||
("false", format_html(
|
||||
'{} <br><span class="text-muted">{}</span>',
|
||||
_("Prices including tax"),
|
||||
_("Recommended if you sell tickets at least partly to consumers.")
|
||||
)),
|
||||
("true", format_html(
|
||||
'{} <br><span class="text-muted">{}</span>',
|
||||
_("Prices excluding tax"),
|
||||
_("Recommended only if you sell tickets primarily to business customers.")
|
||||
)),
|
||||
)
|
||||
super().__init__(attrs, choices)
|
||||
|
||||
def format_value(self, value):
|
||||
try:
|
||||
return {
|
||||
True: "true",
|
||||
False: "false",
|
||||
"true": "true",
|
||||
"false": "false",
|
||||
}[value]
|
||||
except KeyError:
|
||||
return "unknown"
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
value = data.get(name)
|
||||
return {
|
||||
True: True,
|
||||
"True": True,
|
||||
"False": False,
|
||||
False: False,
|
||||
"true": True,
|
||||
"false": False,
|
||||
}.get(value)
|
||||
|
||||
|
||||
class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
auto_fields = [
|
||||
'display_net_prices',
|
||||
'tax_rounding',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["display_net_prices"].label = _("Prices shown to customer")
|
||||
self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect()
|
||||
help_text = {
|
||||
"line": _(
|
||||
"Recommended when e-invoicing is not required. Each product will be sold with the advertised "
|
||||
"net and gross price. However, in orders of more than one product, the total tax amount "
|
||||
"can differ from when it would be computed from the order total."
|
||||
),
|
||||
"sum_by_net": _(
|
||||
"Recommended for e-invoicing when you primarily sell to business customers and "
|
||||
"show prices to customers excluding tax. "
|
||||
"The gross price of some products may be changed to ensure correct rounding, while the net "
|
||||
"prices will be kept as configured. This may cause the actual payment amount to differ."
|
||||
),
|
||||
"sum_by_net_keep_gross": _(
|
||||
"Recommended for e-invoicing when you primarily sell to consumers. "
|
||||
"The gross or net price of some products may be changed automatically to ensure correct "
|
||||
"rounding of the order total. The system attempts to keep gross prices as configured whenever "
|
||||
"possible. Gross prices may still change if they are impossible to derive from a rounded net price."
|
||||
),
|
||||
}
|
||||
self.fields["tax_rounding"].choices = (
|
||||
(k, format_html('{}<br><span class="text-muted">{}</span>', v, help_text.get(k, "")))
|
||||
for k, v in ROUNDING_MODES
|
||||
)
|
||||
|
||||
|
||||
class ProviderForm(SettingsForm):
|
||||
"""
|
||||
This is a SettingsForm, but if fields are set to required=True, validation
|
||||
@@ -1526,7 +1599,10 @@ class TaxRuleLineForm(I18nForm):
|
||||
rate = forms.DecimalField(
|
||||
label=_('Deviating tax rate'),
|
||||
max_digits=10, decimal_places=2,
|
||||
required=False
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={
|
||||
'placeholder': _('Deviating tax rate'),
|
||||
})
|
||||
)
|
||||
invoice_text = I18nFormField(
|
||||
label=_('Text on invoice'),
|
||||
|
||||
@@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest):
|
||||
'active': url.url_name == 'event.settings.mail',
|
||||
},
|
||||
{
|
||||
'label': _('Tax rules'),
|
||||
'label': _('Taxes'),
|
||||
'url': reverse('control:event.settings.tax', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
|
||||
@@ -243,7 +243,6 @@
|
||||
{% bootstrap_field sform.show_times layout="control" %}
|
||||
<h4>{% trans "Product list" %}</h4>
|
||||
{% bootstrap_field sform.show_quota_left layout="control" %}
|
||||
{% bootstrap_field sform.display_net_prices layout="control" %}
|
||||
{% bootstrap_field sform.show_variations_expanded layout="control" %}
|
||||
{% bootstrap_field sform.hide_sold_out layout="control" %}
|
||||
|
||||
|
||||
120
src/pretix/control/templates/pretixcontrol/event/tax.html
Normal file
120
src/pretix/control/templates/pretixcontrol/event/tax.html
Normal file
@@ -0,0 +1,120 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Taxes" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Taxes" %}</h1>
|
||||
{% bootstrap_form_errors form layout="control" %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Tax rules" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Tax rules define different taxation scenarios that can then be assigned to the individual products.
|
||||
Each tax rule contains a default tax rate and can optionally contain additional rules that depend
|
||||
on the customer's country and type.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% if taxrules|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any tax rules yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Default" %}</th>
|
||||
<th>{% trans "Usage" %}</th>
|
||||
<th>{% trans "Rate" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tr in taxrules %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a
|
||||
href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
|
||||
{{ tr.internal_name|default:tr.name }}
|
||||
</a></strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if tr.default %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Default" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<form class="form-inline" method="post"
|
||||
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-sm">
|
||||
{% trans "Make default" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% blocktrans trimmed count count=tr.c_items %}
|
||||
{{ count }} product
|
||||
{% plural %}
|
||||
{{ count }} products
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
<td>
|
||||
{% if tr.price_includes_tax %}
|
||||
{% blocktrans with rate=tr.rate %}incl. {{ rate }} %{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with rate=tr.rate %}excl. {{ rate }} %{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% if tr.has_custom_rules %}
|
||||
<br><small>{% trans "with custom rules" %}</small>
|
||||
{% elif tr.eu_reverse_charge %}
|
||||
<br><small>{% trans "reverse charge enabled" %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
|
||||
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
|
||||
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Tax settings" %}</legend>
|
||||
{% bootstrap_field form.tax_rounding layout="control" %}
|
||||
{% bootstrap_field form.display_net_prices layout="control" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,77 +0,0 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Tax rules" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Tax rules" %}</h1>
|
||||
{% if taxrules|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any tax rules yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
|
||||
</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Default" %}</th>
|
||||
<th>{% trans "Rate" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tr in taxrules %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
|
||||
{{ tr.internal_name|default:tr.name }}
|
||||
</a></strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if tr.default %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Default" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<form class="form-inline" method="post"
|
||||
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-sm">
|
||||
{% trans "Make default" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if tr.price_includes_tax %}
|
||||
{% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with rate=tr.rate%}excl. {{ rate }} %{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% if tr.eu_reverse_charge %}
|
||||
({% trans "reverse charge enabled" %})
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -681,6 +681,16 @@
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if django_settings.DEBUG %}
|
||||
<br/>
|
||||
<small class="admin-only">
|
||||
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 class="clearfix"></div>
|
||||
</div>
|
||||
@@ -721,6 +731,16 @@
|
||||
</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if django_settings.DEBUG %}
|
||||
<br/>
|
||||
<small class="admin-only">
|
||||
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 class="clearfix"></div>
|
||||
</div>
|
||||
@@ -749,6 +769,10 @@
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
<strong>{{ items.total|money:event.currency }}</strong>
|
||||
<br/>
|
||||
<small class="admin-only">
|
||||
tax_rounding_mode = {{ order.tax_rounding_mode }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
|
||||
@@ -56,8 +56,26 @@
|
||||
<td>{{ t.get_tax_code_display }}</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.full_tax_value|money:request.event.currency }}</td>
|
||||
<td class="text-right flip">{{ t.full_price|money:request.event.currency }}</td>
|
||||
<td class="text-right flip">
|
||||
{{ 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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -290,7 +290,7 @@ urlpatterns = [
|
||||
re_path(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
|
||||
re_path(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
|
||||
re_path(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
|
||||
re_path(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'),
|
||||
re_path(r'^settings/tax/$', event.TaxSettings.as_view(), name='event.settings.tax'),
|
||||
re_path(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'),
|
||||
re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'),
|
||||
re_path(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
|
||||
|
||||
@@ -54,7 +54,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django.db.models import Count, ProtectedError
|
||||
from django.forms import inlineformset_factory
|
||||
from django.http import (
|
||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
|
||||
@@ -90,7 +90,7 @@ from pretix.control.forms.event import (
|
||||
EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm,
|
||||
EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm,
|
||||
MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm,
|
||||
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
|
||||
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, TaxSettingsForm,
|
||||
TicketSettingsForm, WidgetCodeForm,
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
@@ -648,6 +648,25 @@ class PaymentSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
return context
|
||||
|
||||
|
||||
class TaxSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
template_name = 'pretixcontrol/event/tax.html'
|
||||
form_class = TaxSettingsForm
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:event.settings.tax', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
def get_context_data(self, *args, **kwargs) -> dict:
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['taxrules'] = self.request.event.tax_rules.annotate(
|
||||
c_items=Count("item")
|
||||
).all()
|
||||
return context
|
||||
|
||||
|
||||
class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
model = Event
|
||||
form_class = InvoiceSettingsForm
|
||||
@@ -1263,16 +1282,6 @@ class EventComment(EventPermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class TaxList(EventSettingsViewMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
model = TaxRule
|
||||
context_object_name = 'taxrules'
|
||||
template_name = 'pretixcontrol/event/tax_index.html'
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.tax_rules.all()
|
||||
|
||||
|
||||
class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView):
|
||||
model = TaxRule
|
||||
form_class = TaxRuleForm
|
||||
|
||||
@@ -632,7 +632,9 @@ class OrderTransactions(OrderView):
|
||||
ctx['sums'] = self.order.transactions.aggregate(
|
||||
sum_count=Sum('count'),
|
||||
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_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')),
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ from pretix.plugins.paypal2.payment import (
|
||||
PaypalMethod, PaypalMethod as Paypal, PaypalWallet,
|
||||
)
|
||||
from pretix.plugins.paypal.models import ReferencedPayPalObject
|
||||
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
|
||||
|
||||
logger = logging.getLogger('pretix.plugins.paypal2')
|
||||
@@ -147,7 +147,7 @@ class XHRView(View):
|
||||
|
||||
cart_total = order.pending_sum + fee
|
||||
else:
|
||||
cart_total = get_cart_total(request)
|
||||
cart = get_cart(request)
|
||||
cart_payments = cart_session(request).get('payments', [])
|
||||
multi_use_cart_payments = [p for p in cart_payments if p.get('multi_use_supported')]
|
||||
simulated_payments = multi_use_cart_payments + [{
|
||||
@@ -159,12 +159,13 @@ class XHRView(View):
|
||||
}]
|
||||
|
||||
try:
|
||||
for fee in get_fees(request.event, request, cart_total, None, simulated_payments, get_cart(request)):
|
||||
cart_total += fee.value
|
||||
fees = get_fees(event=request.event, request=request, invoice_address=None,
|
||||
payments=simulated_payments, positions=cart)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
# ignore for now, will fail on order creation
|
||||
pass
|
||||
fees = []
|
||||
|
||||
cart_total = sum([c.price for c in cart]) + sum([f.value for f in fees])
|
||||
total_remaining = cart_total
|
||||
for p in multi_use_cart_payments:
|
||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
||||
|
||||
@@ -91,9 +91,7 @@ from pretix.presale.signals import (
|
||||
question_form_fields_overrides,
|
||||
)
|
||||
from pretix.presale.utils import customer_login
|
||||
from pretix.presale.views import (
|
||||
CartMixin, get_cart, get_cart_is_free, get_cart_total,
|
||||
)
|
||||
from pretix.presale.views import CartMixin, get_cart, get_cart_is_free
|
||||
from pretix.presale.views.cart import (
|
||||
_items_from_post_data, cart_session, create_empty_cart_id,
|
||||
get_or_create_cart_id,
|
||||
@@ -1262,18 +1260,16 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
@cached_property
|
||||
def _total_order_value(self):
|
||||
cart = get_cart(self.request)
|
||||
total = get_cart_total(self.request)
|
||||
try:
|
||||
total += sum([
|
||||
f.value for f in get_fees(
|
||||
self.request.event, self.request, total, self.invoice_address,
|
||||
[p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')],
|
||||
cart,
|
||||
)
|
||||
])
|
||||
fees = get_fees(
|
||||
event=self.request.event, request=self.request, invoice_address=self.invoice_address,
|
||||
payments=[p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')],
|
||||
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 Decimal(total)
|
||||
|
||||
@cached_property
|
||||
@@ -1399,7 +1395,13 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['current_payments'] = [p for p in self.current_selected_payments(self._total_order_value) if p.get('multi_use_supported')]
|
||||
ctx['cart'] = self.get_cart()
|
||||
ctx['current_payments'] = [
|
||||
p for p in self.current_selected_payments(
|
||||
ctx['cart']['raw'], ctx['cart']['fees'], ctx['cart']['invoice_address'],
|
||||
)
|
||||
if p.get('multi_use_supported')
|
||||
]
|
||||
ctx['remaining'] = self._total_order_value - sum(p['payment_amount'] for p in ctx['current_payments']) + sum(p['fee'] for p in ctx['current_payments'])
|
||||
ctx['providers'] = self.provider_forms
|
||||
ctx['show_fees'] = any(p['fee'] for p in self.provider_forms)
|
||||
@@ -1412,7 +1414,6 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
ctx['selected'] = self.single_use_payment['provider']
|
||||
else:
|
||||
ctx['selected'] = ''
|
||||
ctx['cart'] = self.get_cart()
|
||||
return ctx
|
||||
|
||||
def _is_allowed(self, prov, request):
|
||||
@@ -1425,14 +1426,20 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
return False
|
||||
|
||||
cart = get_cart(self.request)
|
||||
total = get_cart_total(self.request)
|
||||
try:
|
||||
total += sum([f.value for f in get_fees(self.request.event, self.request, total, self.invoice_address,
|
||||
self.cart_session.get('payments', []), cart)])
|
||||
fees = get_fees(
|
||||
event=self.request.event,
|
||||
request=self.request,
|
||||
invoice_address=self.invoice_address,
|
||||
payments=self.cart_session.get('payments', []),
|
||||
positions=cart
|
||||
)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
# ignore for now, will fail on order creation
|
||||
pass
|
||||
selected = self.current_selected_payments(total, warn=warn, total_includes_payment_fees=True)
|
||||
fees = []
|
||||
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
|
||||
|
||||
selected = self.current_selected_payments(cart, fees, self.invoice_address, warn=warn)
|
||||
if sum(p['payment_amount'] for p in selected) != total:
|
||||
if warn:
|
||||
messages.error(request, _('Please select a payment method to proceed.'))
|
||||
@@ -1516,7 +1523,11 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['cart'] = self.get_cart(answers=True)
|
||||
|
||||
selected_payments = self.current_selected_payments(ctx['cart']['total'], total_includes_payment_fees=True)
|
||||
selected_payments = self.current_selected_payments(
|
||||
ctx['cart']['raw'],
|
||||
ctx['cart']['fees'],
|
||||
ctx['cart']['invoice_address'],
|
||||
)
|
||||
ctx['payments'] = []
|
||||
for p in selected_payments:
|
||||
if p['provider'] == 'free':
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1525,6 +1525,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,
|
||||
@@ -1576,7 +1577,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"
|
||||
@@ -1604,7 +1606,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)
|
||||
@@ -1612,7 +1614,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,
|
||||
@@ -1624,16 +1626,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))
|
||||
@@ -1642,7 +1645,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 '
|
||||
|
||||
@@ -420,6 +420,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
|
||||
}
|
||||
],
|
||||
'total': '21.75',
|
||||
'tax_rounding_mode': 'line',
|
||||
'comment': '',
|
||||
'api_meta': {},
|
||||
"custom_followup_at": None,
|
||||
@@ -3134,3 +3135,79 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota,
|
||||
m = organizer.reusable_media.get(identifier=i)
|
||||
assert m.linked_orderposition == o.positions.first()
|
||||
assert m.type == "barcode"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_rounding_mode(token_client, organizer, event, item, quota, question, taxrule):
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res["tax_rounding_mode"] = "sum_by_net"
|
||||
res['fees'][0]['_split_taxes_like_products'] = True
|
||||
res['fees'][0]['value'] = Decimal("100.00")
|
||||
res['positions'] = [
|
||||
{
|
||||
"item": item.pk,
|
||||
"price": "100.00",
|
||||
}
|
||||
] * 4
|
||||
|
||||
for simulate in (True, False):
|
||||
res["simulate"] = simulate
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "499.98"
|
||||
assert resp.data["positions"][0]["price"] == "99.99"
|
||||
assert resp.data["positions"][-1]["price"] == "100.00"
|
||||
|
||||
res["tax_rounding_mode"] = "sum_by_net_keep_gross"
|
||||
for simulate in (True, False):
|
||||
res["simulate"] = simulate
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "500.00"
|
||||
assert resp.data["positions"][0]["tax_value"] == "15.96"
|
||||
assert resp.data["positions"][-1]["tax_value"] == "15.97"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_rounding_default_pretixpos_fallback(device, device_client, organizer, event, item, quota, question, taxrule):
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['fees'][0]['_split_taxes_like_products'] = True
|
||||
res['fees'][0]['value'] = Decimal("100.00")
|
||||
res['positions'] = [
|
||||
{
|
||||
"item": item.pk,
|
||||
"price": "100.00",
|
||||
}
|
||||
] * 4
|
||||
|
||||
event.settings.tax_rounding = "sum_by_net"
|
||||
|
||||
resp = device_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "499.98"
|
||||
assert resp.data["positions"][0]["price"] == "99.99"
|
||||
assert resp.data["positions"][-1]["price"] == "100.00"
|
||||
|
||||
device.software_brand = "pretixPOS Android"
|
||||
device.save()
|
||||
resp = device_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "500.00"
|
||||
assert resp.data["positions"][0]["price"] == "100.00"
|
||||
assert resp.data["positions"][-1]["price"] == "100.00"
|
||||
|
||||
@@ -306,6 +306,7 @@ TEST_ORDER_RES = {
|
||||
"url": "http://example.com/dummy/dummy/order/FOO/k24fiuwvu8kxz3y1/",
|
||||
"payment_provider": "banktransfer",
|
||||
"total": "23.00",
|
||||
"tax_rounding_mode": "line",
|
||||
"comment": "",
|
||||
"api_meta": {},
|
||||
"custom_followup_at": None,
|
||||
|
||||
@@ -1769,6 +1769,63 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value
|
||||
assert self.order.total == self.op1.price + self.op2.price
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_price_with_rounding_change_impossible(self):
|
||||
# Order starts with 2*100€ tickets, but rounding corrects it to 199€. Then, the user tries to force both prices
|
||||
# to 100€. No luck.
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.tax_rounding_mode = "sum_by_net"
|
||||
self.order.save()
|
||||
self.op1.price = Decimal("100.00")
|
||||
self.op1._calculate_tax(tax_rule=self.tr19)
|
||||
self.op1.save()
|
||||
self.op2.price = Decimal("100.00")
|
||||
self.op2._calculate_tax(tax_rule=self.tr19)
|
||||
self.op2.save()
|
||||
self.order.refresh_from_db()
|
||||
self.ocm.regenerate_secret(self.op1)
|
||||
self.ocm.commit() # Force re-rounding
|
||||
self.order.refresh_from_db()
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
assert self.order.total == Decimal("199.99")
|
||||
|
||||
self.ocm.change_price(self.op1, Decimal('100.00'))
|
||||
self.ocm.change_price(self.op2, Decimal('100.00'))
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
self.op2.refresh_from_db()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal("199.99")
|
||||
assert self.op1.price == Decimal('99.99')
|
||||
assert self.op2.price == Decimal('100.00')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_price_with_rounding_change_autocorrected(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.tax_rounding_mode = "sum_by_net"
|
||||
self.order.save()
|
||||
self.op1.price = Decimal("0.00")
|
||||
self.op1._calculate_tax(tax_rule=self.tr19)
|
||||
self.op1.save()
|
||||
self.op2.price = Decimal("100.00")
|
||||
self.op2._calculate_tax(tax_rule=self.tr19)
|
||||
self.op2.save()
|
||||
self.order.refresh_from_db()
|
||||
self.ocm.regenerate_secret(self.op1)
|
||||
self.ocm.commit() # Force re-rounding
|
||||
self.order.refresh_from_db()
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
assert self.order.total == Decimal("100.00")
|
||||
|
||||
self.ocm.change_price(self.op1, Decimal('100.00'))
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
self.op2.refresh_from_db()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal("199.99")
|
||||
assert self.op1.price == Decimal('99.99')
|
||||
assert self.op2.price == Decimal('100.00')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_price_net_success(self):
|
||||
self.tr7.price_includes_tax = False
|
||||
@@ -2357,6 +2414,24 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert nop.price == Decimal('12.00')
|
||||
assert nop.subevent == se1
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_add_item_with_rounding(self):
|
||||
self.order.tax_rounding_mode = "sum_by_net"
|
||||
self.order.save()
|
||||
self.ocm.add_position(self.ticket, None, None, None)
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.positions.count() == 3
|
||||
op1, op2, op3 = self.order.positions.all()
|
||||
assert op1.price == Decimal("23.01")
|
||||
assert op1.price_includes_rounding_correction == Decimal("0.01")
|
||||
assert op2.price == Decimal("23.01")
|
||||
assert op2.price_includes_rounding_correction == Decimal("0.01")
|
||||
assert op3.price == Decimal("23.00")
|
||||
assert op3.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert self.order.total == Decimal("69.02")
|
||||
assert self.order.transactions.count() == 7
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_reissue_invoice(self):
|
||||
generate_invoice(self.order)
|
||||
@@ -2471,7 +2546,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
def test_recalculate_country_rate(self):
|
||||
prov = self.ocm._get_payment_provider()
|
||||
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')
|
||||
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
||||
@@ -2503,7 +2578,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
def test_recalculate_country_rate_keep_gross(self):
|
||||
prov = self.ocm._get_payment_provider()
|
||||
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')
|
||||
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
||||
@@ -2533,7 +2608,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
def test_recalculate_reverse_charge(self):
|
||||
prov = self.ocm._get_payment_provider()
|
||||
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')
|
||||
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
|
||||
@@ -2764,6 +2839,61 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert p.amount == Decimal('23.00')
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_split_with_rounding_change(self):
|
||||
# Order starts with 2*100€ tickets, but rounding corrects it to 199€. Then, it gets split, so its now 100 + 100
|
||||
# and 1€ is pending. Nasty, but we didn't choose the EN16931 rounding method…
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.tax_rounding_mode = "sum_by_net"
|
||||
self.order.save()
|
||||
self.op1.price = Decimal("100.00")
|
||||
self.op1._calculate_tax(tax_rule=self.tr19)
|
||||
self.op1.save()
|
||||
self.op2.price = Decimal("100.00")
|
||||
self.op2._calculate_tax(tax_rule=self.tr19)
|
||||
self.op2.save()
|
||||
self.order.refresh_from_db()
|
||||
self.ocm.regenerate_secret(self.op1)
|
||||
self.ocm.commit() # Force re-rounding
|
||||
self.order.refresh_from_db()
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
|
||||
assert self.order.total == Decimal("199.99")
|
||||
self.order.payments.create(
|
||||
provider='manual',
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
amount=self.order.total,
|
||||
)
|
||||
|
||||
# Split
|
||||
self.ocm.split(self.op2)
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
self.op2.refresh_from_db()
|
||||
|
||||
# First order
|
||||
assert self.order.total == Decimal('100.00')
|
||||
assert not self.order.fees.exists()
|
||||
assert self.order.pending_sum == Decimal('0.01')
|
||||
assert self.order.status == Order.STATUS_PENDING
|
||||
r = self.order.refunds.last()
|
||||
assert r.provider == 'offsetting'
|
||||
assert r.amount == Decimal('100.00')
|
||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||
|
||||
# New order
|
||||
assert self.op2.order != self.order
|
||||
o2 = self.op2.order
|
||||
assert o2.total == Decimal('100.00')
|
||||
assert o2.status == Order.STATUS_PAID
|
||||
assert o2.positions.count() == 1
|
||||
assert o2.fees.count() == 0
|
||||
assert o2.pending_sum == Decimal('0.00')
|
||||
p = o2.payments.last()
|
||||
assert p.provider == 'offsetting'
|
||||
assert p.amount == Decimal('100.00')
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_split_and_change_higher(self):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
|
||||
247
src/tests/base/test_pricing_rounding.py
Normal file
247
src/tests/base/test_pricing_rounding.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#
|
||||
# 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 InvoiceAddress, OrderPosition, TaxRule
|
||||
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):
|
||||
corrections = [
|
||||
(line.tax_value_includes_rounding_correction, line.price_includes_rounding_correction)
|
||||
for line in sample_lines
|
||||
]
|
||||
changed = apply_rounding(rounding_mode, "EUR", sample_lines)
|
||||
for line, original in zip(sample_lines, corrections):
|
||||
if (line.tax_value_includes_rounding_correction, line.price_includes_rounding_correction) != original:
|
||||
assert line in changed
|
||||
else:
|
||||
assert line not in changed
|
||||
|
||||
if rounding_mode == "line":
|
||||
for line in sample_lines:
|
||||
assert line.price == Decimal("100.00")
|
||||
assert line.tax_value == Decimal("15.97")
|
||||
assert line.tax_rate == Decimal("19.00")
|
||||
assert sum(line.price for line in sample_lines) == Decimal("500.00")
|
||||
assert sum(line.tax_value for line in sample_lines) == Decimal("79.85")
|
||||
elif rounding_mode == "sum_by_net":
|
||||
for line in sample_lines:
|
||||
# gross price may vary
|
||||
assert line.price - line.tax_value == Decimal("84.03")
|
||||
assert line.tax_rate == Decimal("19.00")
|
||||
assert sum(line.price for line in sample_lines) == Decimal("499.98")
|
||||
assert sum(line.tax_value for line in sample_lines) == Decimal("79.83")
|
||||
assert sum(line.price - line.tax_value for line in sample_lines) == Decimal("420.15")
|
||||
elif rounding_mode == "sum_by_net_keep_gross":
|
||||
for line in sample_lines:
|
||||
assert line.price == Decimal("100.00")
|
||||
# net price may vary
|
||||
assert line.tax_rate == Decimal("19.00")
|
||||
assert sum(line.price for line in sample_lines) == Decimal("500.00")
|
||||
assert sum(line.tax_value for line in sample_lines) == Decimal("79.83")
|
||||
assert sum(line.price - line.tax_value for line in sample_lines) == Decimal("420.17")
|
||||
|
||||
|
||||
@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_net_keep_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_net_keep_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.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert l.tax_value == Decimal("15.97")
|
||||
assert l.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
assert l.tax_rate == Decimal("19.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_revert_net_keep_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_net_keep_gross", "EUR", [l])
|
||||
assert l.price == Decimal("100.00")
|
||||
assert l.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert l.tax_value == Decimal("15.97")
|
||||
assert l.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
assert l.tax_rate == Decimal("19.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("rounding_mode", [
|
||||
"sum_by_net",
|
||||
"sum_by_net_keep_gross",
|
||||
])
|
||||
def test_rounding_of_impossible_gross_price(rounding_mode):
|
||||
l = OrderPosition(
|
||||
price=Decimal("23.00"),
|
||||
)
|
||||
l._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress())
|
||||
apply_rounding(rounding_mode, "EUR", [l])
|
||||
assert l.price == Decimal("23.01")
|
||||
assert l.price_includes_rounding_correction == Decimal("0.01")
|
||||
assert l.tax_value == Decimal("1.51")
|
||||
assert l.tax_value_includes_rounding_correction == Decimal("0.01")
|
||||
assert l.tax_rate == Decimal("7.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_round_down():
|
||||
lines = [OrderPosition(
|
||||
price=Decimal("100.00"),
|
||||
tax_value=Decimal("15.97"),
|
||||
tax_rate=Decimal("19.00"),
|
||||
tax_code="S",
|
||||
) for _ in range(5)]
|
||||
assert sum(l.price for l in lines) == Decimal("500.00")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("79.85")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("420.15")
|
||||
|
||||
apply_rounding("sum_by_net", "EUR", lines)
|
||||
assert sum(l.price for l in lines) == Decimal("499.98")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("79.83")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("420.15")
|
||||
|
||||
apply_rounding("sum_by_net_keep_gross", "EUR", lines)
|
||||
assert sum(l.price for l in lines) == Decimal("500.00")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("79.83")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("420.17")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_round_up():
|
||||
lines = [OrderPosition(
|
||||
price=Decimal("99.98"),
|
||||
tax_value=Decimal("15.96"),
|
||||
tax_rate=Decimal("19.00"),
|
||||
tax_code="S",
|
||||
) for _ in range(5)]
|
||||
assert sum(l.price for l in lines) == Decimal("499.90")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("79.80")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("420.10")
|
||||
|
||||
apply_rounding("sum_by_net", "EUR", lines)
|
||||
assert sum(l.price for l in lines) == Decimal("499.92")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("79.82")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("420.10")
|
||||
|
||||
apply_rounding("sum_by_net_keep_gross", "EUR", lines)
|
||||
assert sum(l.price for l in lines) == Decimal("499.90")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("79.82")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("420.08")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_round_currency_without_decimals():
|
||||
lines = [OrderPosition(
|
||||
price=Decimal("9998.00"),
|
||||
tax_value=Decimal("1596.00"),
|
||||
tax_rate=Decimal("19.00"),
|
||||
tax_code="S",
|
||||
) for _ in range(5)]
|
||||
assert sum(l.price for l in lines) == Decimal("49990.00")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("7980.00")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00")
|
||||
|
||||
apply_rounding("sum_by_net", "JPY", lines)
|
||||
assert sum(l.price for l in lines) == Decimal("49992.00")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("7982.00")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00")
|
||||
|
||||
apply_rounding("sum_by_net_keep_gross", "JPY", lines)
|
||||
assert sum(l.price for l in lines) == Decimal("49990.00")
|
||||
assert sum(l.tax_value for l in lines) == Decimal("7982.00")
|
||||
assert sum(l.price - l.tax_value for l in lines) == Decimal("42008.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("rounding_mode", [
|
||||
"sum_by_net",
|
||||
"sum_by_net_keep_gross",
|
||||
])
|
||||
def test_do_not_touch_free(rounding_mode):
|
||||
l1 = OrderPosition(
|
||||
price=Decimal("0.00"),
|
||||
)
|
||||
l1._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress())
|
||||
l2 = OrderPosition(
|
||||
price=Decimal("23.00"),
|
||||
)
|
||||
l2._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress())
|
||||
apply_rounding(rounding_mode, "EUR", [l1, l2])
|
||||
assert l2.price == Decimal("23.01")
|
||||
assert l2.price_includes_rounding_correction == Decimal("0.01")
|
||||
assert l2.tax_value == Decimal("1.51")
|
||||
assert l2.tax_value_includes_rounding_correction == Decimal("0.01")
|
||||
assert l2.tax_rate == Decimal("7.00")
|
||||
assert l1.price == Decimal("0.00")
|
||||
assert l1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert l1.tax_value == Decimal("0.00")
|
||||
assert l1.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
@@ -60,6 +60,19 @@ def test_from_gross_price(event):
|
||||
assert tp.rate == Decimal('10.00')
|
||||
assert tp.code == 'S/standard'
|
||||
|
||||
tr = TaxRule(
|
||||
event=event,
|
||||
rate=Decimal('19.00'),
|
||||
code=None,
|
||||
price_includes_tax=True,
|
||||
)
|
||||
tp = tr.tax(Decimal('99.99'))
|
||||
assert tp.gross == Decimal('99.99')
|
||||
assert tp.net == Decimal('84.03')
|
||||
assert tp.tax == Decimal('15.96')
|
||||
assert tp.rate == Decimal('19.00')
|
||||
assert tp.code is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_from_net_price(event):
|
||||
@@ -978,7 +991,7 @@ def test_split_fees(event):
|
||||
op2 = OrderPosition(price=Decimal("10.70"), item=item)
|
||||
op2._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress())
|
||||
of1 = OrderFee(value=Decimal("5.00"), fee_type=OrderFee.FEE_TYPE_SHIPPING)
|
||||
of1._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress())
|
||||
of1._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress(), event=event)
|
||||
|
||||
# Example of a 10% service fee
|
||||
assert split_fee_for_taxes([op1, op2], Decimal("2.26"), event) == [
|
||||
|
||||
@@ -77,7 +77,7 @@ class BaseCheckoutTestCase:
|
||||
plugins='pretix.plugins.stripe,pretix.plugins.banktransfer,tests.testdummy',
|
||||
live=True
|
||||
)
|
||||
self.tr19 = self.event.tax_rules.create(rate=19)
|
||||
self.tr19 = self.event.tax_rules.create(rate=19, default=True)
|
||||
self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0)
|
||||
self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5)
|
||||
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
|
||||
@@ -501,6 +501,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
||||
assert cr1.price == Decimal('23.00')
|
||||
|
||||
def test_custom_tax_rules_blocked_on_fee(self):
|
||||
self.tr19.default = False
|
||||
self.tr19.save()
|
||||
self.tr7 = self.event.tax_rules.create(rate=7, default=True)
|
||||
self.tr7.custom_rules = json.dumps([
|
||||
{'country': 'AT', 'address_type': 'business_vat_id', 'action': 'reverse'},
|
||||
@@ -2352,6 +2354,252 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
||||
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
def test_rounding_sum_by_net(self):
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.ticket.default_price = Decimal("100.00")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 2
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"199.99" in response.content
|
||||
assert b"200.00" not in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('199.99')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('199.99')
|
||||
assert o.total == Decimal("199.99")
|
||||
op1, op2 = o.positions.all()
|
||||
assert op1.price == Decimal("99.99")
|
||||
assert op1.price_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op1.tax_value == Decimal("15.96")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op2.price == Decimal("100.00")
|
||||
assert op2.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.tax_value == Decimal("15.97")
|
||||
assert op2.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_rounding_sum_by_net_keep_gross(self):
|
||||
self.event.settings.tax_rounding = "sum_by_net_keep_gross"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.ticket.default_price = Decimal("100.00")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 2
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"199.99" not in response.content
|
||||
assert b"200.00" in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('200.00')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') + Decimal('84.04')
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('200.00')
|
||||
assert o.total == Decimal("200.00")
|
||||
op1, op2 = o.positions.all()
|
||||
assert op1.price == Decimal("100.00")
|
||||
assert op1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op1.tax_value == Decimal("15.96")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op2.price == Decimal("100.00")
|
||||
assert op2.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.tax_value == Decimal("15.97")
|
||||
assert op2.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_rounding_line(self):
|
||||
self.event.settings.tax_rounding = "line"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.ticket.default_price = Decimal("100.00")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 2
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"199.99" not in response.content
|
||||
assert b"200.00" in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('200.00')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('200.00')
|
||||
assert o.total == Decimal("200.00")
|
||||
op1, op2 = o.positions.all()
|
||||
assert op1.price == Decimal("100.00")
|
||||
assert op1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op1.tax_value == Decimal("15.97")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.price == Decimal("100.00")
|
||||
assert op2.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.tax_value == Decimal("15.97")
|
||||
assert op2.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_rounding_sum_by_net_with_payment_fee(self):
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.tax_rule_payment = "default"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.event.settings.set('payment_banktransfer__fee_abs', Decimal("100.00"))
|
||||
self.ticket.default_price = Decimal("100.00")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 1
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"100.00" in response.content
|
||||
assert b"99.99" not in response.content
|
||||
assert b"199.99" not in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('199.99')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('199.99')
|
||||
assert o.total == Decimal("199.99")
|
||||
op1 = o.positions.get()
|
||||
of1 = o.fees.get()
|
||||
assert op1.price == Decimal("99.99")
|
||||
assert op1.price_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op1.tax_value == Decimal("15.96")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("-0.01")
|
||||
assert of1.price == Decimal("100.00")
|
||||
assert of1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert of1.tax_value == Decimal("15.97")
|
||||
assert of1.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_rounding_sum_by_net_with_payment_fee_that_makes_card_insufficient(self):
|
||||
# Our built-in gift card payment does not actually support setting a payment fee, but we still want to
|
||||
# test the core behavior in case a gift-card plugin does
|
||||
gc = self.orga.issued_gift_cards.create(currency="EUR")
|
||||
gc.transactions.create(value=199.96, acceptor=self.orga)
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.event.settings.set('payment_giftcard__fee_abs', "99.98")
|
||||
self.event.settings.set('payment_giftcard__fee_reverse_calc', False)
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.tax_rule_payment = "default"
|
||||
self.ticket.default_price = Decimal("99.98")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 1
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"99.98" in response.content
|
||||
assert b"99.99" not in response.content
|
||||
assert b"199.97" not in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'giftcard',
|
||||
'payment_giftcard-code': gc.secret
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('199.97')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.02') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1, p2 = o.payments.all()
|
||||
assert p1.amount == Decimal('199.96')
|
||||
assert p2.amount == Decimal('0.01')
|
||||
assert o.total == Decimal("199.97")
|
||||
op1 = o.positions.get()
|
||||
of1 = o.fees.get()
|
||||
assert op1.price == Decimal("99.99")
|
||||
assert op1.price_includes_rounding_correction == Decimal("0.01")
|
||||
assert op1.tax_value == Decimal("15.97")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("0.01")
|
||||
assert of1.price == Decimal("99.98")
|
||||
assert of1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert of1.tax_value == Decimal("15.96")
|
||||
assert of1.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_subevent(self):
|
||||
self.event.has_subevents = True
|
||||
self.event.save()
|
||||
|
||||
@@ -1294,6 +1294,56 @@ class OrdersTest(BaseOrdersTest):
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CREATED
|
||||
|
||||
def test_change_paymentmethod_with_rounding_change(self):
|
||||
tr19 = self.event.tax_rules.create(
|
||||
name='VAT',
|
||||
rate=Decimal('19.00'),
|
||||
default=True
|
||||
)
|
||||
self.ticket.tax_rule = tr19
|
||||
self.ticket.save()
|
||||
self.ticket_pos.price = Decimal("100.00")
|
||||
self.ticket_pos.tax_rule = tr19
|
||||
self.ticket_pos._calculate_tax()
|
||||
self.ticket_pos.save()
|
||||
self.order.total = Decimal("100.00")
|
||||
self.order.tax_rounding_mode = "sum_by_net"
|
||||
self.order.save()
|
||||
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.event.settings.set('payment_testdummy__enabled', True)
|
||||
self.event.settings.set('payment_testdummy__fee_reverse_calc', False)
|
||||
self.event.settings.set('payment_testdummy__fee_abs', '100.00')
|
||||
|
||||
response = self.client.get(
|
||||
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
|
||||
)
|
||||
assert 'Test dummy' in response.content.decode()
|
||||
assert '+ €100.00' in response.content.decode()
|
||||
response = self.client.post(
|
||||
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
|
||||
{
|
||||
'payment': 'testdummy'
|
||||
}, follow=True
|
||||
)
|
||||
assert 'Total: €199.99' in response.content.decode()
|
||||
self.order.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert self.order.payments.last().provider == 'testdummy'
|
||||
fee = self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).last()
|
||||
assert fee.value == Decimal('100.00')
|
||||
assert fee.tax_value == Decimal('15.97')
|
||||
self.ticket_pos.refresh_from_db()
|
||||
assert self.ticket_pos.price == Decimal("99.99")
|
||||
assert self.ticket_pos.price_includes_rounding_correction == Decimal("-0.01")
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal('199.99')
|
||||
p = self.order.payments.last()
|
||||
assert p.provider == 'testdummy'
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CREATED
|
||||
assert p.amount == Decimal('199.99')
|
||||
|
||||
def test_change_paymentmethod_to_same(self):
|
||||
with scopes_disabled():
|
||||
p_old = self.order.payments.create(
|
||||
|
||||
@@ -35,7 +35,7 @@ class DummyPaymentProvider(BasePaymentProvider):
|
||||
abort_pending_allowed = False
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
||||
pass
|
||||
return True
|
||||
|
||||
def checkout_confirm_render(self, request) -> str:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user