mirror of
https://github.com/pretix/pretix.git
synced 2026-06-22 03:06:16 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e5c7867b | |||
| 7010752bb0 | |||
| 7d8bcd4b10 | |||
| 2121566ae5 | |||
| 9f6216e6f1 | |||
| c824663946 | |||
| 29906c6288 | |||
| 3380bd3e82 | |||
| 6ae8e7cbb6 | |||
| 23c2d9266e | |||
| ba155faaa3 | |||
| fd177fa89f | |||
| 0b051c1400 | |||
| af8d0f0b65 | |||
| 1b7bba195c | |||
| f056f77dc0 | |||
| ee4e7f618f | |||
| cd450f1780 | |||
| fc876978b2 | |||
| d8efaa47f7 | |||
| f0c3514588 | |||
| e1ad4d9dba | |||
| 3ab587883e | |||
| b02e1a1515 | |||
| 41780add40 | |||
| b07a61e4f1 | |||
| dead2a9bed | |||
| 94389c3913 | |||
| 3e972eddbf | |||
| cdeb1e86bd | |||
| 9a69b76880 | |||
| 7d5df2b69e | |||
| d203eee5ab |
@@ -421,3 +421,94 @@ Annulment of a check-in
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested nonce does not exist.
|
||||
|
||||
|
||||
Check-in history
|
||||
----------------
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the check-in
|
||||
successful boolean Whether the check-in was successful
|
||||
error_reason string Category of reason why the check-in was unsuccessful. Currently
|
||||
``"canceled"``, ``"invalid"``, ``"unpaid"`` ``"product"``,
|
||||
``"rules"``, ``"revoked"``, ``"incomplete"``, ``"already_redeemed"``,
|
||||
``"ambiguous"``, ``"error"``, ``"blocked"``, ``"unapproved"``,
|
||||
``"invalid_time"``, ``"annulled"`` or ``null``
|
||||
error_explanation string Additional, human-readable reason for the check-in to be unsuccessful (or ``null``)
|
||||
position integer Internal ID of the order position (or ``null`` for unknown scans)
|
||||
datetime datetime Logical time when the check-in happened
|
||||
created datetime Time when the check-in appeared on the server
|
||||
list integer Internal ID of the check-in list
|
||||
auto_checked_in boolean Whether the check-in was performed by the system automatically
|
||||
gate integer Internal ID of the gate (or ``null``)
|
||||
device integer Internal ID of the device (or ``null``)
|
||||
device_id integer Organizer-internal ID of the device (or ``null``)
|
||||
type string Type of check-in, currently ``"entry"`` or ``"exit"``
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkins/
|
||||
|
||||
Returns a list of all check-in events within a given event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkins/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"successful": true,
|
||||
"error_reason": null,
|
||||
"error_explanation": null,
|
||||
"position": 1234,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"created": "2017-12-25T12:45:23Z",
|
||||
"list": 2,
|
||||
"auto_checked_in": false,
|
||||
"gate": null,
|
||||
"device": null,
|
||||
"device_id": null,
|
||||
"type": "entry",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query datetime created_since: Only return check-ins that have been created since the given date (inclusive).
|
||||
:query datetime created_before: Only return check-ins that have been created before the given date (exclusive).
|
||||
:query datetime datetime_since: Only return check-ins that have happened since the given date (inclusive).
|
||||
:query datetime datetime_before: Only return check-ins that have happened before the given date (exclusive).
|
||||
:query boolean successful: Only return check-ins that have (not) been successful.
|
||||
:query boolean error_reason: Only return check-ins with a specific error reason.
|
||||
:query integer list: Only return check-ins from a specific list.
|
||||
:query string type: Only return check-ins of a specific type.
|
||||
:query integer gate: Only return check-ins from a specific gate.
|
||||
:query integer device: Only return check-ins from a specific device.
|
||||
:query boolean auto_checked_in: Only return check-ins that are (not) auto-checked in.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``,
|
||||
and ``id``.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
@@ -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.
|
||||
@@ -151,6 +152,10 @@ plugin_data object Additional data
|
||||
|
||||
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 2025.10
|
||||
|
||||
The ``tax_rounding_mode`` attribute has been added.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
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,
|
||||
@@ -602,6 +608,7 @@ Fetching individual orders
|
||||
"payment_provider": "banktransfer",
|
||||
"fees": [],
|
||||
"total": "23.00",
|
||||
"tax_rounding_mode": "line",
|
||||
"comment": "",
|
||||
"api_meta": {},
|
||||
"custom_followup_at": null,
|
||||
@@ -1011,6 +1018,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 100.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
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ dependencies = [
|
||||
"celery==5.5.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=44.0.0",
|
||||
"css-inline==0.17.*",
|
||||
"css-inline==0.18.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"Django[argon2]==4.2.*,>=4.2.24",
|
||||
"django-bootstrap3==25.2",
|
||||
@@ -91,7 +91,7 @@ dependencies = [
|
||||
"redis==6.4.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.42.*",
|
||||
"sentry-sdk==2.43.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# 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/>.
|
||||
#
|
||||
__version__ = "2025.9.0"
|
||||
__version__ = "2025.10.0.dev0"
|
||||
|
||||
@@ -829,6 +829,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
|
||||
@@ -325,7 +329,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class InlineCheckinSerializer(I18nAwareModelSerializer):
|
||||
device_id = serializers.SlugRelatedField(
|
||||
source='device',
|
||||
slug_field='device_id',
|
||||
@@ -337,6 +341,21 @@ class CheckinSerializer(I18nAwareModelSerializer):
|
||||
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type')
|
||||
|
||||
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
device_id = serializers.SlugRelatedField(
|
||||
source='device',
|
||||
slug_field='device_id',
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = (
|
||||
'id', 'successful', 'error_reason', 'error_explanation', 'position', 'datetime', 'list', 'created',
|
||||
'auto_checked_in', 'gate', 'device', 'device_id', 'type'
|
||||
)
|
||||
|
||||
|
||||
class PrintLogSerializer(serializers.ModelSerializer):
|
||||
device_id = serializers.SlugRelatedField(
|
||||
source='device',
|
||||
@@ -560,7 +579,7 @@ class OrderPositionPluginDataField(serializers.Field):
|
||||
|
||||
|
||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
checkins = CheckinSerializer(many=True, read_only=True)
|
||||
checkins = InlineCheckinSerializer(many=True, read_only=True)
|
||||
print_logs = PrintLogSerializer(many=True, read_only=True)
|
||||
answers = AnswerSerializer(many=True)
|
||||
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||
@@ -833,14 +852,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):
|
||||
@@ -1159,6 +1179,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):
|
||||
@@ -1175,7 +1196,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:
|
||||
@@ -1701,7 +1722,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()
|
||||
|
||||
@@ -92,6 +92,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
|
||||
event_router.register(r'seats', event.SeatViewSet)
|
||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
event_router.register(r'checkins', checkin.CheckinViewSet)
|
||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||
event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet)
|
||||
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
|
||||
|
||||
@@ -56,7 +56,8 @@ from pretix.api.serializers.checkin import (
|
||||
)
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
|
||||
CheckinListOrderPositionSerializer, CheckinSerializer,
|
||||
FailedCheckinSerializer,
|
||||
)
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
@@ -96,6 +97,16 @@ with scopes_disabled():
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
|
||||
class CheckinFilter(FilterSet):
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
|
||||
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
|
||||
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ['successful', 'error_reason', 'list', 'type', 'gate', 'device', 'auto_checked_in']
|
||||
|
||||
|
||||
class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CheckinListSerializer
|
||||
@@ -1080,3 +1091,25 @@ class CheckinRPCAnnulView(views.APIView):
|
||||
checkin_annulled.send(ci.position.order.event, checkin=ci)
|
||||
|
||||
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = CheckinSerializer
|
||||
queryset = Checkin.all.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
filterset_class = CheckinFilter
|
||||
ordering = ('created', 'id')
|
||||
ordering_fields = ('created', 'datetime', 'id',)
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Checkin.all.filter().select_related(
|
||||
"position",
|
||||
"device",
|
||||
)
|
||||
return qs
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -800,7 +800,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
|
||||
identifier=serializer.instance.identifier,
|
||||
)
|
||||
inst.log_action(
|
||||
'pretix.sales_channel.changed',
|
||||
'pretix.saleschannel.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
|
||||
@@ -19,15 +19,20 @@
|
||||
# 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/>.
|
||||
#
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from functools import cached_property
|
||||
from typing import Optional
|
||||
|
||||
import jsonschema
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.signals import PluginAwareRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
|
||||
if a_map:
|
||||
@@ -105,12 +110,38 @@ They are annotated with their ``action_type`` and the defining ``plugin``.
|
||||
log_entry_types = LogEntryTypeRegistry()
|
||||
|
||||
|
||||
def prepare_schema(schema):
|
||||
def handle_properties(t):
|
||||
return {"shred_properties": [k for k, v in t["properties"].items() if v["shred"]]}
|
||||
|
||||
def walk_tree(schema):
|
||||
if type(schema) is dict:
|
||||
new_keys = {}
|
||||
for k, v in schema.items():
|
||||
if k == "properties":
|
||||
new_keys = handle_properties(schema)
|
||||
walk_tree(v)
|
||||
if schema.get("type") == "object" and "additionalProperties" not in new_keys:
|
||||
new_keys["additionalProperties"] = False
|
||||
schema.update(new_keys)
|
||||
elif type(schema) is list:
|
||||
for v in schema:
|
||||
walk_tree(v)
|
||||
|
||||
walk_tree(schema)
|
||||
return schema
|
||||
|
||||
|
||||
class LogEntryType:
|
||||
"""
|
||||
Base class for a type of LogEntry, identified by its action_type.
|
||||
"""
|
||||
|
||||
data_schema = None # {"type": "object", "properties": []}
|
||||
|
||||
def __init__(self, action_type=None, plain=None):
|
||||
if self.data_schema:
|
||||
print(self.__class__.__name__, "has schema", self._prepared_schema)
|
||||
if action_type:
|
||||
self.action_type = action_type
|
||||
if plain:
|
||||
@@ -147,12 +178,37 @@ class LogEntryType:
|
||||
|
||||
object_link_wrapper = '{val}'
|
||||
|
||||
def validate_data(self, parsed_data):
|
||||
if not self._prepared_schema:
|
||||
return
|
||||
try:
|
||||
jsonschema.validate(parsed_data, self._prepared_schema)
|
||||
except jsonschema.exceptions.ValidationError as ex:
|
||||
logger.warning("%s schema validation failed: %s %s", type(self).__name__, ex.json_path, ex.message)
|
||||
raise
|
||||
|
||||
@cached_property
|
||||
def _prepared_schema(self):
|
||||
if self.data_schema:
|
||||
return prepare_schema(self.data_schema)
|
||||
|
||||
def shred_pii(self, logentry):
|
||||
"""
|
||||
To be used for shredding personally identified information contained in the data field of a LogEntry of this
|
||||
type.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
if self._prepared_schema:
|
||||
def shred_fun(validator, value, instance, schema):
|
||||
for key in value:
|
||||
instance[key] = "##########"
|
||||
|
||||
v = jsonschema.validators.extend(jsonschema.validators.Draft202012Validator,
|
||||
validators={"shred_properties": shred_fun})
|
||||
data = logentry.parsed_data
|
||||
jsonschema.validate(data, self._prepared_schema, v)
|
||||
logentry.data = json.dumps(data)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NoOpShredderMixin:
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Generated by Django 4.2.17 on 2025-04-20 13:58
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0292_giftcard_customer"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="cartposition",
|
||||
name="price_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cartposition",
|
||||
name="tax_code",
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cartposition",
|
||||
name="tax_value_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="orderfee",
|
||||
name="tax_value_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="orderfee",
|
||||
name="value_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="orderposition",
|
||||
name="price_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="orderposition",
|
||||
name="tax_value_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="transaction",
|
||||
name="price_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="transaction",
|
||||
name="tax_value_includes_rounding_correction",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="order",
|
||||
name="tax_rounding_mode",
|
||||
field=models.CharField(default="line", max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -80,6 +80,7 @@ class LoggingMixin:
|
||||
from pretix.api.models import OAuthAccessToken, OAuthApplication
|
||||
from pretix.api.webhooks import notify_webhooks
|
||||
|
||||
from ..logentrytype_registry import log_entry_types
|
||||
from ..services.notifications import notify
|
||||
from .devices import Device
|
||||
from .event import Event
|
||||
@@ -124,7 +125,13 @@ class LoggingMixin:
|
||||
if (sensitivekey in k) and v:
|
||||
data[k] = "********"
|
||||
|
||||
type, meta = log_entry_types.get(action_type=action)
|
||||
if not type:
|
||||
raise TypeError("Undefined log entry type '%s'" % action)
|
||||
|
||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True)
|
||||
|
||||
type.validate_data(json.loads(logentry.data))
|
||||
elif data:
|
||||
raise TypeError("You should only supply dictionaries as log data.")
|
||||
if save:
|
||||
|
||||
@@ -81,7 +81,7 @@ from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Customer, User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, ROUNDING_MODES
|
||||
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
|
||||
@@ -324,6 +324,11 @@ class Order(LockModel, LoggedModel):
|
||||
# Invoice needs to be re-issued when the order is paid again
|
||||
default=False,
|
||||
)
|
||||
tax_rounding_mode = models.CharField(
|
||||
max_length=100,
|
||||
choices=ROUNDING_MODES,
|
||||
default="line",
|
||||
)
|
||||
|
||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
||||
|
||||
@@ -1259,7 +1264,8 @@ class Order(LockModel, LoggedModel):
|
||||
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
|
||||
create = []
|
||||
for k in keys:
|
||||
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k
|
||||
(positionid, itemid, variationid, subeventid, price, price_includes_rounding_correction, taxrate,
|
||||
taxruleid, taxvalue, taxvalue_includes_rounding_correction, feetype, internaltype, taxcode) = k
|
||||
d = target_transaction_count[k] - current_transaction_count[k]
|
||||
if d:
|
||||
create.append(Transaction(
|
||||
@@ -1272,9 +1278,11 @@ class Order(LockModel, LoggedModel):
|
||||
variation_id=variationid,
|
||||
subevent_id=subeventid,
|
||||
price=price,
|
||||
price_includes_rounding_correction=price_includes_rounding_correction,
|
||||
tax_rate=taxrate,
|
||||
tax_rule_id=taxruleid,
|
||||
tax_value=taxvalue,
|
||||
tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
|
||||
tax_code=taxcode,
|
||||
fee_type=feetype,
|
||||
internal_type=internaltype,
|
||||
@@ -1449,7 +1457,22 @@ class QuestionAnswer(models.Model):
|
||||
super().delete(**kwargs)
|
||||
|
||||
|
||||
class AbstractPosition(models.Model):
|
||||
class RoundingCorrectionMixin:
|
||||
|
||||
@property
|
||||
def gross_price_before_rounding(self):
|
||||
return self.price - self.price_includes_rounding_correction
|
||||
|
||||
@property
|
||||
def tax_value_before_rounding(self):
|
||||
return self.tax_value - self.tax_value_includes_rounding_correction
|
||||
|
||||
@property
|
||||
def net_price_before_rounding(self):
|
||||
return self.gross_price_before_rounding - self.tax_value_before_rounding
|
||||
|
||||
|
||||
class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
||||
"""
|
||||
A position can either be one line of an order or an item placed in a cart.
|
||||
|
||||
@@ -1499,6 +1522,9 @@ class AbstractPosition(models.Model):
|
||||
decimal_places=2, max_digits=13,
|
||||
verbose_name=_("Price")
|
||||
)
|
||||
price_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
attendee_name_cached = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Attendee name"),
|
||||
@@ -2272,7 +2298,7 @@ class ActivePositionManager(ScopedManager(organizer='order__event__organizer')._
|
||||
return super().get_queryset().filter(canceled=False)
|
||||
|
||||
|
||||
class OrderFee(models.Model):
|
||||
class OrderFee(RoundingCorrectionMixin, models.Model):
|
||||
"""
|
||||
An OrderFee object represents a fee that is added to the order total independently of
|
||||
the actual positions. This might for example be a payment or a shipping fee.
|
||||
@@ -2322,6 +2348,9 @@ class OrderFee(models.Model):
|
||||
decimal_places=2, max_digits=13,
|
||||
verbose_name=_("Value")
|
||||
)
|
||||
value_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
@@ -2350,6 +2379,9 @@ class OrderFee(models.Model):
|
||||
max_digits=13, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
tax_value_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
@@ -2398,17 +2430,23 @@ class OrderFee(models.Model):
|
||||
self.fee_type, self.value
|
||||
)
|
||||
|
||||
def _calculate_tax(self, tax_rule=None, invoice_address=None):
|
||||
def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None):
|
||||
if tax_rule:
|
||||
self.tax_rule = tax_rule
|
||||
|
||||
try:
|
||||
ia = invoice_address or self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
if invoice_address:
|
||||
ia = invoice_address
|
||||
elif hasattr(self, "order"):
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
else:
|
||||
ia = None
|
||||
|
||||
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
|
||||
self.tax_rule = self.order.event.cached_default_tax_rule
|
||||
event = event or self.order.event
|
||||
if not self.tax_rule and self.fee_type == "payment" and event.settings.tax_rule_payment == "default":
|
||||
self.tax_rule = event.cached_default_tax_rule
|
||||
|
||||
if self.tax_rule:
|
||||
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
|
||||
@@ -2443,6 +2481,24 @@ class OrderFee(models.Model):
|
||||
self.order.touch()
|
||||
super().delete(**kwargs)
|
||||
|
||||
# For historical reasons, OrderFee has "value", but OrderPosition has "price". These properties
|
||||
# help using them the same way.
|
||||
@property
|
||||
def price(self):
|
||||
return self.value
|
||||
|
||||
@price.setter
|
||||
def price(self, value):
|
||||
self.value = value
|
||||
|
||||
@property
|
||||
def price_includes_rounding_correction(self):
|
||||
return self.value_includes_rounding_correction
|
||||
|
||||
@price_includes_rounding_correction.setter
|
||||
def price_includes_rounding_correction(self, value):
|
||||
self.value_includes_rounding_correction = value
|
||||
|
||||
|
||||
class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -2522,6 +2578,9 @@ class OrderPosition(AbstractPosition):
|
||||
max_digits=13, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
tax_value_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00"),
|
||||
)
|
||||
|
||||
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
|
||||
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
|
||||
@@ -2694,7 +2753,14 @@ class OrderPosition(AbstractPosition):
|
||||
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
||||
else:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
op._calculate_tax()
|
||||
|
||||
op.tax_value = cartpos.tax_value
|
||||
op.tax_value_includes_rounding_correction = cartpos.tax_value_includes_rounding_correction
|
||||
op.tax_rate = cartpos.tax_rate
|
||||
op.tax_code = cartpos.tax_code
|
||||
op.tax_rule = cartpos.item.tax_rule
|
||||
# todo: is removing this safe? op._calculate_tax()
|
||||
|
||||
if cartpos.voucher:
|
||||
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
|
||||
|
||||
@@ -3027,6 +3093,9 @@ class Transaction(models.Model):
|
||||
decimal_places=2, max_digits=13,
|
||||
verbose_name=_("Price")
|
||||
)
|
||||
price_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
verbose_name=_('Tax rate')
|
||||
@@ -3044,6 +3113,9 @@ class Transaction(models.Model):
|
||||
max_digits=13, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
tax_value_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
fee_type = models.CharField(
|
||||
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
|
||||
)
|
||||
@@ -3073,14 +3145,19 @@ class Transaction(models.Model):
|
||||
@staticmethod
|
||||
def key(obj):
|
||||
if isinstance(obj, Transaction):
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||
obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
|
||||
obj.tax_value, obj.tax_value_includes_rounding_correction, obj.fee_type,
|
||||
obj.internal_type, obj.tax_code)
|
||||
elif isinstance(obj, OrderPosition):
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code)
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||
obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
|
||||
obj.tax_value, obj.tax_value_includes_rounding_correction, None,
|
||||
None, obj.tax_code)
|
||||
elif isinstance(obj, OrderFee):
|
||||
return (None, None, None, None, obj.value, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
||||
return (None, None, None, None, obj.value, obj.value_includes_rounding_correction,
|
||||
obj.tax_rate, obj.tax_rule_id, obj.tax_value, obj.tax_value_includes_rounding_correction,
|
||||
obj.fee_type, obj.internal_type, obj.tax_code)
|
||||
raise ValueError('invalid state') # noqa
|
||||
|
||||
@property
|
||||
@@ -3091,6 +3168,14 @@ class Transaction(models.Model):
|
||||
def full_tax_value(self):
|
||||
return self.tax_value * self.count
|
||||
|
||||
@property
|
||||
def full_price_includes_rounding_correction(self):
|
||||
return self.price_includes_rounding_correction * self.count
|
||||
|
||||
@property
|
||||
def full_tax_value_includes_rounding_correction(self):
|
||||
return self.tax_value_includes_rounding_correction * self.count
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -3131,6 +3216,13 @@ class CartPosition(AbstractPosition):
|
||||
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_code = models.CharField(
|
||||
max_length=190,
|
||||
null=True, blank=True,
|
||||
)
|
||||
tax_value_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
listed_price = models.DecimalField(
|
||||
decimal_places=2, max_digits=13, null=True,
|
||||
)
|
||||
@@ -3171,9 +3263,15 @@ class CartPosition(AbstractPosition):
|
||||
|
||||
@property
|
||||
def tax_value(self):
|
||||
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
|
||||
price = self.gross_price_before_rounding
|
||||
net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))),
|
||||
self.event.currency)
|
||||
return self.price - net
|
||||
return self.gross_price_before_rounding - net + self.tax_value_includes_rounding_correction
|
||||
|
||||
@tax_value.setter
|
||||
def tax_value(self, value):
|
||||
# ignore, tax value is always computed on the fly
|
||||
pass
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
|
||||
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.views import get_cart, get_cart_total
|
||||
from pretix.presale.views import get_cart
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1149,12 +1149,16 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
from .services.cart import get_fees
|
||||
|
||||
cart = get_cart(request)
|
||||
total = get_cart_total(request)
|
||||
|
||||
try:
|
||||
total += sum([f.value for f in get_fees(self.event, request, total, None, None, cart)])
|
||||
fees = get_fees(event=request.event, request=request,
|
||||
invoice_address=None,
|
||||
payments=None, positions=cart)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
# ignore for now, will fail on order creation
|
||||
pass
|
||||
fees = []
|
||||
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
|
||||
|
||||
return total == 0
|
||||
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
@@ -1373,7 +1377,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
execute_payment_needs_user = False
|
||||
verbose_name = _("Gift card")
|
||||
payment_form_class = GiftCardPaymentForm
|
||||
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
|
||||
payment_form_template_name = 'pretixpresale/giftcard/checkout.html'
|
||||
|
||||
@cached_property
|
||||
def customer_gift_cards(self):
|
||||
@@ -1500,7 +1504,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
|
||||
|
||||
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({
|
||||
return get_template('pretixpresale/giftcard/checkout_confirm.html').render({
|
||||
'info_data': info_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
ocm.add_fee(f)
|
||||
|
||||
if dry_run:
|
||||
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00"))
|
||||
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff_guesstimate), Decimal("0.00"))
|
||||
else:
|
||||
ocm.commit()
|
||||
refund_amount = payment_refund_sum - o.total
|
||||
|
||||
@@ -66,8 +66,8 @@ from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
from pretix.base.services.locking import LockTimeoutException, lock_objects
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_line_price, get_listed_price, get_price,
|
||||
is_included_for_free,
|
||||
apply_discounts, apply_rounding, get_line_price, get_listed_price,
|
||||
get_price, is_included_for_free,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
@@ -1430,11 +1430,12 @@ class CartManager:
|
||||
)
|
||||
|
||||
for cp, (new_price, discount) in zip(positions, discount_results):
|
||||
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
diff += new_price - cp.price
|
||||
if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
diff += new_price - cp.gross_price_before_rounding
|
||||
cp.price = new_price
|
||||
cp.price_includes_rounding_correction = Decimal("0.00")
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
|
||||
|
||||
return diff
|
||||
|
||||
@@ -1493,30 +1494,53 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
|
||||
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data)
|
||||
|
||||
|
||||
def get_fees(event, request, total, invoice_address, payments, positions):
|
||||
def get_fees(event, request, _total_ignored_=None, invoice_address=None, payments=None, positions=None):
|
||||
"""
|
||||
Return all fees that would be created for the current cart. Also implicitly applies rounding on the order
|
||||
positions. A recommended usage pattern to compute the total looks like this::
|
||||
|
||||
cart = get_cart(request)
|
||||
fees = get_fees(
|
||||
event=request.event,
|
||||
request=request,
|
||||
invoice_address=cached_invoice_address(request),
|
||||
payments=None,
|
||||
positions=cart,
|
||||
)
|
||||
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
|
||||
"""
|
||||
if payments and not isinstance(payments, list):
|
||||
raise TypeError("payments must now be a list")
|
||||
if positions is None:
|
||||
raise TypeError("Must pass positions, parameter is only optional for backwards-compat reasons")
|
||||
|
||||
fees = []
|
||||
total = sum([c.gross_price_before_rounding for c in positions])
|
||||
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
|
||||
total=total, positions=positions, payment_requests=payments):
|
||||
positions=positions, total=total, payment_requests=payments):
|
||||
if resp:
|
||||
fees += resp
|
||||
|
||||
total = total + sum(f.value for f in fees)
|
||||
for fee in fees:
|
||||
fee._calculate_tax(invoice_address=invoice_address, event=event)
|
||||
if fee.tax_rule and not fee.tax_rule.pk:
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
|
||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||
|
||||
if total != 0 and payments:
|
||||
total_remaining = total
|
||||
payments_assigned = Decimal("0.00")
|
||||
for p in payments:
|
||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||
# places in the code base:
|
||||
# - pretix.base.services.cart.get_fees
|
||||
# - pretix.base.services.orders._get_fees
|
||||
# - pretix.presale.views.CartMixin.current_selected_payments
|
||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
||||
if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
|
||||
continue
|
||||
|
||||
to_pay = total_remaining
|
||||
to_pay = max(total - payments_assigned, Decimal("0.00"))
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
@@ -1525,28 +1549,32 @@ def get_fees(event, request, total, invoice_address, payments, positions):
|
||||
continue
|
||||
|
||||
payment_fee = pprov.calculate_fee(to_pay)
|
||||
total_remaining += payment_fee
|
||||
to_pay += payment_fee
|
||||
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
total_remaining -= to_pay
|
||||
|
||||
if payment_fee:
|
||||
if event.settings.tax_rule_payment == "default":
|
||||
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
|
||||
else:
|
||||
payment_fee_tax_rule = TaxRule.zero()
|
||||
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
|
||||
fees.append(OrderFee(
|
||||
pf = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
||||
value=payment_fee,
|
||||
tax_rate=payment_fee_tax.rate,
|
||||
tax_value=payment_fee_tax.tax,
|
||||
tax_code=payment_fee_tax.code,
|
||||
tax_rule=payment_fee_tax_rule
|
||||
))
|
||||
)
|
||||
fees.append(pf)
|
||||
|
||||
# Re-apply rounding as grand total has changed
|
||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||
|
||||
# Re-calculate to_pay as grand total has changed
|
||||
to_pay = max(total - payments_assigned, Decimal("0.00"))
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
payments_assigned += to_pay
|
||||
|
||||
return fees
|
||||
|
||||
|
||||
@@ -258,9 +258,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
if resp:
|
||||
desc += "<br/>" + resp
|
||||
|
||||
for answ in p.answers.all():
|
||||
if not answ.question.print_on_invoice:
|
||||
continue
|
||||
answers_qs = p.answers.filter(
|
||||
question__print_on_invoice=True
|
||||
).select_related(
|
||||
'question'
|
||||
).order_by(
|
||||
'question__position',
|
||||
'question__id'
|
||||
)
|
||||
for answ in answers_qs:
|
||||
desc += "<br />{}{} {}".format(
|
||||
answ.question.question,
|
||||
"" if str(answ.question.question).endswith("?") else ":",
|
||||
|
||||
@@ -95,7 +95,7 @@ from pretix.base.services.memberships import (
|
||||
create_membership, validate_memberships_in_order,
|
||||
)
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_listed_price, get_price,
|
||||
apply_discounts, apply_rounding, get_listed_price, get_price,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
@@ -947,10 +947,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
||||
]
|
||||
)
|
||||
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
|
||||
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
cp.price = new_price
|
||||
cp.price_includes_rounding_correction = Decimal("0.00")
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
|
||||
|
||||
# After applying discounts, add-on positions might still have a reference to the *old* version of the
|
||||
# parent position, which can screw up ordering later since the system sees inconsistent data.
|
||||
@@ -973,10 +974,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
||||
raise OrderError(err)
|
||||
|
||||
|
||||
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||
meta_info: dict, event: Event, require_approval=False):
|
||||
def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
|
||||
meta_info: dict, event: Event, require_approval=False):
|
||||
fees = []
|
||||
total = sum([c.price for c in positions])
|
||||
# Pre-rounding, pre-fee total is used for fee calculation
|
||||
total = sum([c.gross_price_before_rounding for c in positions])
|
||||
|
||||
gift_cards = [] # for backwards compatibility
|
||||
for p in payment_requests:
|
||||
@@ -987,40 +989,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
|
||||
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
|
||||
if resp:
|
||||
fees += resp
|
||||
total += sum(f.value for f in fees)
|
||||
|
||||
total_remaining = total
|
||||
for fee in fees:
|
||||
fee._calculate_tax(invoice_address=address, event=event)
|
||||
if fee.tax_rule and not fee.tax_rule.pk:
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
|
||||
# Apply rounding to get final total in case no payment fees will be added
|
||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||
|
||||
payments_assigned = Decimal("0.00")
|
||||
for p in payment_requests:
|
||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||
# places in the code base:
|
||||
# - pretix.base.services.cart.get_fees
|
||||
# - pretix.base.services.orders._get_fees
|
||||
# - pretix.presale.views.CartMixin.current_selected_payments
|
||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
||||
if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
|
||||
p['payment_amount'] = Decimal('0.00')
|
||||
continue
|
||||
|
||||
to_pay = total_remaining
|
||||
to_pay = max(total - payments_assigned, Decimal("0.00"))
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
payment_fee = p['pprov'].calculate_fee(to_pay)
|
||||
total_remaining += payment_fee
|
||||
to_pay += payment_fee
|
||||
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
total_remaining -= to_pay
|
||||
|
||||
p['payment_amount'] = to_pay
|
||||
if payment_fee:
|
||||
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
|
||||
internal_type=p['pprov'].identifier)
|
||||
pf._calculate_tax(invoice_address=address, event=event)
|
||||
fees.append(pf)
|
||||
p['fee'] = pf
|
||||
|
||||
if total_remaining != Decimal('0.00') and not require_approval:
|
||||
# Re-apply rounding as grand total has changed
|
||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||
|
||||
# Re-calculate to_pay as grand total has changed
|
||||
to_pay = max(total - payments_assigned, Decimal("0.00"))
|
||||
|
||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||
|
||||
payments_assigned += to_pay
|
||||
p['payment_amount'] = to_pay
|
||||
|
||||
if total != payments_assigned and not require_approval:
|
||||
raise OrderError(_("The selected payment methods do not cover the total balance."))
|
||||
|
||||
return fees
|
||||
@@ -1029,7 +1044,7 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
|
||||
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
|
||||
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
|
||||
customer=None, valid_if_pending=False, api_meta: dict=None):
|
||||
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
|
||||
payments = []
|
||||
|
||||
try:
|
||||
@@ -1038,10 +1053,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
||||
raise OrderError(e.message)
|
||||
|
||||
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
|
||||
|
||||
# Final calculation of fees, also performs final rounding
|
||||
try:
|
||||
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
|
||||
fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(error_messages['country_blocked'])
|
||||
|
||||
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||
|
||||
order = Order(
|
||||
@@ -1059,6 +1077,7 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
||||
sales_channel=sales_channel,
|
||||
customer=customer,
|
||||
valid_if_pending=valid_if_pending,
|
||||
tax_rounding_mode=tax_rounding_mode or event.settings.tax_rounding,
|
||||
)
|
||||
if customer:
|
||||
order.email_known_to_work = customer.is_verified
|
||||
@@ -1073,12 +1092,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
||||
|
||||
for fee in fees:
|
||||
fee.order = order
|
||||
try:
|
||||
fee._calculate_tax()
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(error_messages['country_blocked'])
|
||||
if fee.tax_rule and not fee.tax_rule.pk:
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
fee.save()
|
||||
|
||||
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
|
||||
@@ -1167,7 +1180,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
|
||||
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
|
||||
shown_total=None, customer=None, api_meta: dict=None):
|
||||
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
|
||||
for p in payment_requests:
|
||||
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
|
||||
if not p['pprov']:
|
||||
@@ -1273,6 +1286,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
||||
customer=customer,
|
||||
valid_if_pending=valid_if_pending,
|
||||
api_meta=api_meta,
|
||||
tax_rounding_mode=tax_rounding_mode,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1664,7 +1678,7 @@ class OrderChangeManager:
|
||||
self.split_order = None
|
||||
self.reissue_invoice = reissue_invoice
|
||||
self._committed = False
|
||||
self._totaldiff = 0
|
||||
self._totaldiff_guesstimate = 0
|
||||
self._quotadiff = Counter()
|
||||
self._seatdiff = Counter()
|
||||
self._operations = []
|
||||
@@ -1781,7 +1795,7 @@ class OrderChangeManager:
|
||||
if position.issued_gift_cards.exists():
|
||||
raise OrderError(self.error_messages['gift_card_change'])
|
||||
|
||||
self._totaldiff += price.gross - position.price
|
||||
self._totaldiff_guesstimate += price.gross - position.gross_price_before_rounding
|
||||
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
@@ -1826,29 +1840,29 @@ class OrderChangeManager:
|
||||
else:
|
||||
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
|
||||
override_tax_rate=new_rate, override_tax_code=new_code)
|
||||
self._totaldiff += new_tax.gross - pos.price
|
||||
self._totaldiff_guesstimate += new_tax.gross - pos.price
|
||||
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
|
||||
self._invoice_dirty = True
|
||||
|
||||
def cancel_fee(self, fee: OrderFee):
|
||||
self._totaldiff -= fee.value
|
||||
self._totaldiff_guesstimate -= fee.value
|
||||
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
|
||||
self._invoice_dirty = True
|
||||
|
||||
def add_fee(self, fee: OrderFee):
|
||||
self._totaldiff += fee.value
|
||||
self._totaldiff_guesstimate += fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.AddFeeOperation(fee, fee.value))
|
||||
|
||||
def change_fee(self, fee: OrderFee, value: Decimal):
|
||||
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address,
|
||||
force_fixed_gross_price=True)
|
||||
self._totaldiff += value.gross - fee.value
|
||||
self._totaldiff_guesstimate += value.gross - fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
|
||||
|
||||
def cancel(self, position: OrderPosition):
|
||||
self._totaldiff -= position.price
|
||||
self._totaldiff_guesstimate -= position.price
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.CancelOperation(position, -position.price))
|
||||
if position.seat:
|
||||
@@ -1914,7 +1928,7 @@ class OrderChangeManager:
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._totaldiff += price.gross
|
||||
self._totaldiff_guesstimate += price.gross
|
||||
self._quotadiff.update(new_quotas)
|
||||
if seat:
|
||||
self._seatdiff.update([seat])
|
||||
@@ -2210,8 +2224,8 @@ class OrderChangeManager:
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
|
||||
raise OrderError(self.error_messages['quota'].format(name=quota.name))
|
||||
|
||||
def _check_paid_price_change(self):
|
||||
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
|
||||
def _check_paid_price_change(self, totaldiff):
|
||||
if self.order.status == Order.STATUS_PAID and totaldiff > 0:
|
||||
if self.order.pending_sum > Decimal('0.00'):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.set_expires(
|
||||
@@ -2219,7 +2233,7 @@ class OrderChangeManager:
|
||||
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
self.order.save()
|
||||
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
|
||||
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff < 0:
|
||||
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
@@ -2246,7 +2260,7 @@ class OrderChangeManager:
|
||||
user=self.user,
|
||||
auth=self.auth
|
||||
)
|
||||
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
|
||||
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff > 0:
|
||||
if self.open_payment:
|
||||
try:
|
||||
self.open_payment.payment_provider.cancel_payment(self.open_payment)
|
||||
@@ -2266,11 +2280,11 @@ class OrderChangeManager:
|
||||
auth=self.auth,
|
||||
)
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
|
||||
def _check_paid_to_free(self, totaldiff):
|
||||
if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"):
|
||||
raise OrderError(error_messages['currency_XXX'])
|
||||
|
||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
||||
if self.order.total == 0 and (totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
||||
if not self.order.fees.exists() and not self.order.positions.exists():
|
||||
# The order is completely empty now, so we cancel it.
|
||||
self.order.status = Order.STATUS_CANCELED
|
||||
@@ -2278,7 +2292,7 @@ class OrderChangeManager:
|
||||
order_canceled.send(self.order.event, order=self.order)
|
||||
elif self.order.status != Order.STATUS_CANCELED:
|
||||
# if the order becomes free, mark it paid using the 'free' provider
|
||||
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
|
||||
# this could happen if positions have been made cheaper or removed (totaldiff < 0)
|
||||
# or positions got split off to a new order (split_order with positive total)
|
||||
p = self.order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
@@ -2407,10 +2421,15 @@ class OrderChangeManager:
|
||||
'new_price': op.price.gross
|
||||
})
|
||||
position.price = op.price.gross
|
||||
position.price_includes_rounding_correction = Decimal("0.00")
|
||||
position.tax_rate = op.price.rate
|
||||
position.tax_value = op.price.tax
|
||||
position.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||
position.tax_code = op.price.code
|
||||
position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code'])
|
||||
position.save(update_fields=[
|
||||
'price', 'price_includes_rounding_correction', 'tax_rate', 'tax_value',
|
||||
'tax_value_includes_rounding_correction', 'tax_code'
|
||||
])
|
||||
elif isinstance(op, self.TaxRuleOperation):
|
||||
if isinstance(op.position, OrderPosition):
|
||||
position = position_cache.setdefault(op.position.pk, op.position)
|
||||
@@ -2677,14 +2696,18 @@ class OrderChangeManager:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
||||
|
||||
fees = []
|
||||
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
|
||||
new_fee = modelcopy(fee)
|
||||
new_fee.pk = None
|
||||
new_fee.order = split_order
|
||||
split_order.total += new_fee.value
|
||||
new_fee.save()
|
||||
fees.append(new_fee)
|
||||
|
||||
changed_by_rounding = set(apply_rounding(
|
||||
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
|
||||
))
|
||||
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
||||
|
||||
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
||||
pp = self._get_payment_provider()
|
||||
@@ -2697,9 +2720,27 @@ class OrderChangeManager:
|
||||
fee._calculate_tax()
|
||||
if payment_fee != 0:
|
||||
fee.save()
|
||||
fees.append(fee)
|
||||
elif fee.pk:
|
||||
if fee in fees:
|
||||
fees.remove(fee)
|
||||
fee.delete()
|
||||
split_order.total += fee.value
|
||||
|
||||
changed_by_rounding |= set(apply_rounding(
|
||||
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
|
||||
))
|
||||
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
|
||||
|
||||
for l in changed_by_rounding:
|
||||
if isinstance(l, OrderPosition):
|
||||
l.save(update_fields=[
|
||||
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
elif isinstance(l, OrderFee):
|
||||
l.save(update_fields=[
|
||||
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
|
||||
|
||||
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
||||
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
|
||||
@@ -2759,9 +2800,12 @@ class OrderChangeManager:
|
||||
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||
return payment_sum - refund_sum
|
||||
|
||||
def _recalculate_total_and_payment_fee(self):
|
||||
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
||||
def _recalculate_rounding_total_and_payment_fee(self):
|
||||
positions = list(self.order.positions.all())
|
||||
fees = list(self.order.fees.all())
|
||||
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
|
||||
payment_fee = Decimal('0.00')
|
||||
fee_changed = False
|
||||
if self.open_payment:
|
||||
current_fee = Decimal('0.00')
|
||||
fee = None
|
||||
@@ -2789,14 +2833,32 @@ class OrderChangeManager:
|
||||
fee.value = payment_fee
|
||||
fee._calculate_tax()
|
||||
fee.save()
|
||||
fee_changed = True
|
||||
if not self.open_payment.fee:
|
||||
self.open_payment.fee = fee
|
||||
self.open_payment.save(update_fields=['fee'])
|
||||
elif fee and not fee.canceled:
|
||||
fee.delete()
|
||||
fee_changed = True
|
||||
|
||||
self.order.total = total + payment_fee
|
||||
if fee_changed:
|
||||
fees = list(self.order.fees.all())
|
||||
|
||||
changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
|
||||
for l in changed:
|
||||
if isinstance(l, OrderPosition):
|
||||
l.save(update_fields=[
|
||||
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
elif isinstance(l, OrderFee):
|
||||
l.save(update_fields=[
|
||||
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
|
||||
|
||||
self.order.total = total
|
||||
self.order.save()
|
||||
return total
|
||||
|
||||
def _check_order_size(self):
|
||||
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
|
||||
@@ -2806,23 +2868,6 @@ class OrderChangeManager:
|
||||
}
|
||||
)
|
||||
|
||||
def _payment_fee_diff(self):
|
||||
total = self.order.total + self._totaldiff
|
||||
if self.open_payment:
|
||||
current_fee = Decimal('0.00')
|
||||
if self.open_payment and self.open_payment.fee:
|
||||
current_fee = self.open_payment.fee.value
|
||||
total -= current_fee
|
||||
|
||||
# Do not change payment fees of paid orders
|
||||
payment_fee = Decimal('0.00')
|
||||
if self.order.pending_sum - current_fee != 0:
|
||||
prov = self.open_payment.payment_provider
|
||||
if prov:
|
||||
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
|
||||
|
||||
self._totaldiff += payment_fee - current_fee
|
||||
|
||||
def _reissue_invoice(self):
|
||||
i = self.order.invoices.filter(is_cancellation=False).last()
|
||||
if self.reissue_invoice and self._invoice_dirty:
|
||||
@@ -2953,6 +2998,13 @@ class OrderChangeManager:
|
||||
shared_lock_objects=[self.event]
|
||||
)
|
||||
|
||||
def guess_totaldiff(self):
|
||||
"""
|
||||
Return the estimated difference of ``order.total`` based on the currently queued operations. This is only
|
||||
a guess since it does not account for (a) tax rounding or (b) payment fee changes.
|
||||
"""
|
||||
return self._totaldiff_guesstimate
|
||||
|
||||
def commit(self, check_quotas=True):
|
||||
if self._committed:
|
||||
# an order change can only be committed once
|
||||
@@ -2968,8 +3020,6 @@ class OrderChangeManager:
|
||||
# so it's dangerous to keep the cache around.
|
||||
self.order._prefetched_objects_cache = {}
|
||||
|
||||
# finally, incorporate difference in payment fees
|
||||
self._payment_fee_diff()
|
||||
self._check_order_size()
|
||||
|
||||
with transaction.atomic():
|
||||
@@ -2977,6 +3027,7 @@ class OrderChangeManager:
|
||||
if locked_instance.last_modified != self.order.last_modified:
|
||||
raise OrderError(error_messages['race_condition'])
|
||||
|
||||
original_total = self.order.total
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
if check_quotas:
|
||||
self._check_quotas()
|
||||
@@ -2988,9 +3039,10 @@ class OrderChangeManager:
|
||||
self._perform_operations()
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
self._recalculate_total_and_payment_fee()
|
||||
self._check_paid_price_change()
|
||||
self._check_paid_to_free()
|
||||
new_total = self._recalculate_rounding_total_and_payment_fee()
|
||||
totaldiff = new_total - original_total
|
||||
self._check_paid_price_change(totaldiff)
|
||||
self._check_paid_to_free(totaldiff)
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
@@ -3209,6 +3261,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
raise Exception('change_payment_provider should only be called in atomic transaction!')
|
||||
|
||||
oldtotal = order.total
|
||||
already_paid = order.payment_refund_sum
|
||||
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
OrderPayment.PAYMENT_STATE_REFUNDED))
|
||||
open_fees = list(
|
||||
@@ -3225,19 +3278,46 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order)
|
||||
old_fee = fee.value
|
||||
|
||||
positions = list(order.positions.all())
|
||||
fees = list(order.fees.all())
|
||||
rounding_changed = set(apply_rounding(
|
||||
order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
|
||||
))
|
||||
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
|
||||
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
|
||||
|
||||
new_fee = payment_provider.calculate_fee(
|
||||
order.pending_sum - old_fee if amount is None else amount
|
||||
pending_sum_without_fee if amount is None else amount
|
||||
)
|
||||
if new_fee:
|
||||
fee.value = new_fee
|
||||
fee.internal_type = payment_provider.identifier
|
||||
fee._calculate_tax()
|
||||
if fee in fees:
|
||||
fees.remove(fee)
|
||||
# "Update instance in the fees array
|
||||
fees.append(fee)
|
||||
fee.save()
|
||||
else:
|
||||
if fee in fees:
|
||||
fees.remove(fee)
|
||||
if fee.pk:
|
||||
fee.delete()
|
||||
fee = None
|
||||
|
||||
rounding_changed |= set(apply_rounding(
|
||||
order.tax_rounding_mode, order.event.currency, [*positions, *fees]
|
||||
))
|
||||
for l in rounding_changed:
|
||||
if isinstance(l, OrderPosition):
|
||||
l.save(update_fields=[
|
||||
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
elif isinstance(l, OrderFee):
|
||||
l.save(update_fields=[
|
||||
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||
])
|
||||
|
||||
open_payment = None
|
||||
if new_payment:
|
||||
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
|
||||
@@ -3264,7 +3344,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
},
|
||||
)
|
||||
|
||||
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
|
||||
order.total = sum(c.price for c in positions) + sum(f.value for f in fees)
|
||||
order.save(update_fields=['total'])
|
||||
|
||||
if not new_payment:
|
||||
|
||||
@@ -23,15 +23,17 @@ import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from itertools import groupby
|
||||
from typing import List, Literal, Optional, Tuple, Union
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
|
||||
SalesChannel, Voucher,
|
||||
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
|
||||
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
|
||||
)
|
||||
from pretix.base.models.discount import Discount, PositionInfo
|
||||
from pretix.base.models.event import Event, SubEvent
|
||||
@@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
|
||||
|
||||
|
||||
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
|
||||
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice:
|
||||
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal,
|
||||
is_bundled=False) -> TaxedPrice:
|
||||
if not tax_rule:
|
||||
tax_rule = TaxRule(
|
||||
name='',
|
||||
@@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
||||
override_tax_code=price.code, invoice_address=invoice_address,
|
||||
subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
|
||||
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross',
|
||||
override_tax_rate=price.rate,
|
||||
override_tax_code=price.code, invoice_address=invoice_address,
|
||||
subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
@@ -164,7 +168,7 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
||||
|
||||
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
|
||||
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
||||
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
||||
"""
|
||||
Applies any dynamic discounts to a cart
|
||||
|
||||
@@ -203,3 +207,121 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
new_prices.update(result)
|
||||
|
||||
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
|
||||
|
||||
|
||||
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
|
||||
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
|
||||
"""
|
||||
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
|
||||
and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and
|
||||
``tax_value_includes_rounding_correction`` attributes.
|
||||
|
||||
When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line.
|
||||
|
||||
When rounding mode is set to ``"sum_by_net_keep_gross"``, the tax values of the individual lines will be adjusted
|
||||
such that the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant.
|
||||
|
||||
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
|
||||
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
|
||||
|
||||
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
|
||||
:param currency: Currency that will be used to determine rounding precision
|
||||
:param lines: List of order/cart contents
|
||||
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
|
||||
"""
|
||||
|
||||
def _key(line):
|
||||
return (line.tax_rate, line.tax_code)
|
||||
|
||||
places = settings.CURRENCY_PLACES.get(currency, 2)
|
||||
minimum_unit = Decimal('1') / 10 ** places
|
||||
changed = []
|
||||
|
||||
if rounding_mode == "sum_by_net":
|
||||
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
|
||||
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
|
||||
|
||||
# Compute the net and gross total of the line-based computation method
|
||||
net_total = sum(l.net_price_before_rounding for l in lines)
|
||||
gross_total = sum(l.gross_price_before_rounding for l in lines)
|
||||
|
||||
# Compute the gross total we need to achieve based on the net total
|
||||
target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency)
|
||||
|
||||
# Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same)
|
||||
# until the values align
|
||||
diff = target_gross_total - gross_total
|
||||
diff_sgn = -1 if diff < 0 else 1
|
||||
for l in lines:
|
||||
if diff:
|
||||
apply_diff = diff_sgn * minimum_unit
|
||||
l.price = l.gross_price_before_rounding + apply_diff
|
||||
l.price_includes_rounding_correction = apply_diff
|
||||
l.tax_value = l.tax_value_before_rounding + apply_diff
|
||||
l.tax_value_includes_rounding_correction = apply_diff
|
||||
diff -= apply_diff
|
||||
changed.append(l)
|
||||
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
|
||||
l.price = l.gross_price_before_rounding
|
||||
l.price_includes_rounding_correction = Decimal("0.00")
|
||||
l.tax_value = l.tax_value_before_rounding
|
||||
l.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||
changed.append(l)
|
||||
|
||||
elif rounding_mode == "sum_by_net_keep_gross":
|
||||
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
|
||||
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
|
||||
|
||||
# Compute the net and gross total of the line-based computation method
|
||||
net_total = sum(l.net_price_before_rounding for l in lines)
|
||||
gross_total = sum(l.gross_price_before_rounding for l in lines)
|
||||
|
||||
# Compute the net total that would yield the correct gross total (if possible)
|
||||
target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency)
|
||||
|
||||
# Compute the gross total that would be computed from that net total – this will be different than
|
||||
# gross_total when there is no possible net value for the gross total
|
||||
# e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98
|
||||
target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency)
|
||||
|
||||
diff_gross = target_gross_total - gross_total
|
||||
diff_net = target_net_total - net_total
|
||||
diff_gross_sgn = -1 if diff_gross < 0 else 1
|
||||
diff_net_sgn = -1 if diff_net < 0 else 1
|
||||
for l in lines:
|
||||
if diff_gross:
|
||||
apply_diff = diff_gross_sgn * minimum_unit
|
||||
l.price = l.gross_price_before_rounding + apply_diff
|
||||
l.price_includes_rounding_correction = apply_diff
|
||||
l.tax_value = l.tax_value_before_rounding + apply_diff
|
||||
l.tax_value_includes_rounding_correction = apply_diff
|
||||
changed.append(l)
|
||||
diff_gross -= apply_diff
|
||||
elif diff_net:
|
||||
apply_diff = diff_net_sgn * minimum_unit
|
||||
l.price = l.gross_price_before_rounding
|
||||
l.price_includes_rounding_correction = Decimal("0.00")
|
||||
l.tax_value = l.tax_value_before_rounding - apply_diff
|
||||
l.tax_value_includes_rounding_correction = -apply_diff
|
||||
changed.append(l)
|
||||
diff_net -= apply_diff
|
||||
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
|
||||
l.price = l.gross_price_before_rounding
|
||||
l.price_includes_rounding_correction = Decimal("0.00")
|
||||
l.tax_value = l.tax_value_before_rounding
|
||||
l.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||
changed.append(l)
|
||||
|
||||
elif rounding_mode == "line":
|
||||
for l in lines:
|
||||
if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
|
||||
l.price = l.gross_price_before_rounding
|
||||
l.price_includes_rounding_correction = Decimal("0.00")
|
||||
l.tax_value = l.tax_value_before_rounding
|
||||
l.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||
changed.append(l)
|
||||
|
||||
else:
|
||||
raise ValueError("Unknown rounding_mode")
|
||||
|
||||
return changed
|
||||
|
||||
@@ -77,6 +77,13 @@ from pretix.control.forms import (
|
||||
)
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
|
||||
ROUNDING_MODES = (
|
||||
('line', _('Compute taxes for every line individually')),
|
||||
('sum_by_net', _('Compute taxes based on net total')),
|
||||
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
|
||||
# We could also have sum_by_gross, but we're not aware of any use-cases for it
|
||||
)
|
||||
|
||||
|
||||
def country_choice_kwargs():
|
||||
allcountries = list(CachedCountries())
|
||||
@@ -324,7 +331,7 @@ DEFAULTS = {
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
|
||||
label=_("Show net prices instead of gross prices in the product list"),
|
||||
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
|
||||
"paid."),
|
||||
|
||||
@@ -465,6 +472,25 @@ DEFAULTS = {
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
|
||||
)
|
||||
},
|
||||
'tax_rounding': {
|
||||
'default': 'line',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Rounding of taxes"),
|
||||
widget=forms.RadioSelect,
|
||||
choices=ROUNDING_MODES,
|
||||
help_text=_(
|
||||
"Note that if you transfer your sales data from pretix to an external system for tax reporting, you "
|
||||
"need to make sure to account for possible rounding differences if your external system rounds "
|
||||
"differently than pretix."
|
||||
)
|
||||
),
|
||||
'serializer_kwargs': dict(
|
||||
choices=ROUNDING_MODES,
|
||||
),
|
||||
},
|
||||
'invoice_address_asked': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
|
||||
@@ -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
|
||||
@@ -1527,7 +1600,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'),
|
||||
|
||||
@@ -503,7 +503,6 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
|
||||
'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'),
|
||||
'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'),
|
||||
'pretix.event.order.expired': _('The order has been marked as expired.'),
|
||||
'pretix.event.order.paid': _('The order has been marked as paid.'),
|
||||
'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'),
|
||||
'pretix.event.order.refunded': _('The order has been refunded.'),
|
||||
'pretix.event.order.reactivated': _('The order has been reactivated.'),
|
||||
@@ -535,7 +534,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
|
||||
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
|
||||
'toggled.'),
|
||||
'pretix.event.order.checkin_text': _('The order\'s check-in text has been changed.'),
|
||||
'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
|
||||
'pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
|
||||
'unpaid has been toggled.'),
|
||||
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
|
||||
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
|
||||
@@ -575,6 +574,21 @@ class CoreOrderLogEntryType(OrderLogEntryType):
|
||||
pass
|
||||
|
||||
|
||||
@log_entry_types.new()
|
||||
class OrderPaidLogEntryType(CoreOrderLogEntryType):
|
||||
action_type = 'pretix.event.order.paid'
|
||||
plain = _('The order has been marked as paid.')
|
||||
data_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {"type": ["null", "string"], "shred": False, },
|
||||
"info": {"type": ["null", "string", "object"], "shred": True, },
|
||||
"date": {"type": ["null", "string"], "shred": False, },
|
||||
"force": {"type": "boolean", "shred": False, },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@log_entry_types.new_from_dict({
|
||||
'pretix.voucher.added': _('The voucher has been created.'),
|
||||
'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'),
|
||||
@@ -585,13 +599,63 @@ class CoreOrderLogEntryType(OrderLogEntryType):
|
||||
'pretix.voucher.added.waitinglist': _('The voucher has been assigned to {email} through the waiting list.'),
|
||||
})
|
||||
class CoreVoucherLogEntryType(VoucherLogEntryType):
|
||||
pass
|
||||
data_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {"type": ["null", "number"], "shred": False, },
|
||||
"variation": {"type": ["null", "number"], "shred": False, },
|
||||
"tag": {"type": "string", "shred": False,},
|
||||
"block_quota": {"type": "boolean", "shred": False, },
|
||||
"valid_until": {"type": ["null", "string"], "shred": False, },
|
||||
"min_usages": {"type": "number", "shred": False, },
|
||||
"max_usages": {"type": "number", "shred": False, },
|
||||
"subevent": {"type": ["null", "number", "object"], "shred": False, },
|
||||
"source": {"type": "string", "shred": False,},
|
||||
"allow_ignore_quota": {"type": "boolean", "shred": False, },
|
||||
"code": {"type": "string", "shred": False,},
|
||||
"comment": {"type": "string", "shred": True,},
|
||||
"price_mode": {"type": "string", "shred": False,},
|
||||
"seat": {"type": "string", "shred": False,},
|
||||
"quota": {"type": ["null", "number"], "shred": False,},
|
||||
"value": {"type": ["null", "string"], "shred": False,},
|
||||
"redeemed": {"type": "number", "shred": False,},
|
||||
"all_addons_included": {"type": "boolean", "shred": False, },
|
||||
"all_bundles_included": {"type": "boolean", "shred": False, },
|
||||
"budget": {"type": ["null", "number"], "shred": False, },
|
||||
"itemvar": {"type": "string", "shred": False,},
|
||||
"show_hidden_items": {"type": "boolean", "shred": False, },
|
||||
|
||||
# bulk create:
|
||||
"bulk": {"type": "boolean", "shred": False,},
|
||||
"seats": {"type": "array", "shred": False,},
|
||||
"send": {"type": ["string", "boolean"], "shred": False,},
|
||||
"send_recipients": {"type": "array", "shred": True,},
|
||||
"send_subject": {"type": "string", "shred": False,},
|
||||
"send_message": {"type": "string", "shred": True,},
|
||||
|
||||
# pretix.voucher.sent
|
||||
"recipient": {"type": "string", "shred": True,},
|
||||
"name": {"type": "string", "shred": True,},
|
||||
"subject": {"type": "string", "shred": False,},
|
||||
"message": {"type": "string", "shred": True,},
|
||||
|
||||
# pretix.voucher.added.waitinglist
|
||||
"email": {"type": "string", "shred": True,},
|
||||
"waitinglistentry": {"type": "number", "shred": False, },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@log_entry_types.new()
|
||||
class VoucherRedeemedLogEntryType(VoucherLogEntryType):
|
||||
action_type = 'pretix.voucher.redeemed'
|
||||
plain = _('The voucher has been redeemed in order {order_code}.')
|
||||
data_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_code": {"type": "string", "shred": False, },
|
||||
},
|
||||
}
|
||||
|
||||
def display(self, logentry, data):
|
||||
url = reverse('control:event.order', kwargs={
|
||||
@@ -634,9 +698,16 @@ class TeamMembershipLogEntryType(LogEntryType):
|
||||
'pretix.team.member.removed': _('{user} has been removed from the team.'),
|
||||
'pretix.team.invite.created': _('{user} has been invited to the team.'),
|
||||
'pretix.team.invite.resent': _('Invite for {user} has been resent.'),
|
||||
'pretix.team.invite.deleted': _('Invite for {user} has been deleted.'),
|
||||
})
|
||||
class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):
|
||||
pass
|
||||
data_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"type": "string", "shred": True, },
|
||||
"user": {"type": "number", "shred": False, },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@log_entry_types.new()
|
||||
@@ -833,6 +904,10 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
|
||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
|
||||
'pretix.event.seats.blocks.changed': _('A seat in the seating plan has been blocked or unblocked.'),
|
||||
'pretix.seatingplan.added': _('A seating plan has been added.'),
|
||||
'pretix.seatingplan.changed': _('A seating plan has been changed.'),
|
||||
'pretix.seatingplan.deleted': _('A seating plan has been deleted.'),
|
||||
})
|
||||
class CoreEventLogEntryType(EventLogEntryType):
|
||||
pass
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -1,35 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% load rich_text %}
|
||||
|
||||
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
|
||||
{% if customer_gift_cards %}
|
||||
<p><strong>
|
||||
<span class="sr-only">{% trans "Information" %}</span>
|
||||
{% trans "The following gift cards are available in your customer account:" %}
|
||||
</strong></p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<ul class="list-group">
|
||||
{% for c in customer_gift_cards %}
|
||||
<li class="list-group-item row row-no-gutters">
|
||||
<div class="col-sm-8 col-md-9" id="gc-code-{{ forloop.counter }}">
|
||||
{{ c }}
|
||||
</div>
|
||||
<div class="col-sm-2 text-right" id="gc-value-{{ forloop.counter }}">
|
||||
{{ c.value|money:c.currency }}
|
||||
</div>
|
||||
<div class="col-sm-2 col-md-1 text-right">
|
||||
<button name="use_giftcard" class="btn btn-primary btn-xs use_giftcard" data-value="{{ c.secret }}"
|
||||
title="{% trans "Use gift card" %}"
|
||||
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}">
|
||||
{% trans "Apply" %}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% bootstrap_form form layout='checkout' %}
|
||||
@@ -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,12 @@
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
<strong>{{ items.total|money:event.currency }}</strong>
|
||||
{% if django_settings.DEBUG %}
|
||||
<br/>
|
||||
<small class="admin-only">
|
||||
tax_rounding_mode = {{ order.tax_rounding_mode }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</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
|
||||
|
||||
@@ -633,7 +633,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
|
||||
|
||||
|
||||
+1160
-1062
File diff suppressed because it is too large
Load Diff
+1184
-1065
File diff suppressed because it is too large
Load Diff
+1160
-1062
File diff suppressed because it is too large
Load Diff
+1182
-1066
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,10 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-29 07:55+0000\n"
|
||||
"PO-Revision-Date: 2025-10-28 17:00+0000\n"
|
||||
"PO-Revision-Date: 2025-10-31 17:00+0000\n"
|
||||
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
"js/ca/>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/"
|
||||
"pretix-js/ca/>\n"
|
||||
"Language: ca\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -651,6 +651,8 @@ msgid ""
|
||||
"Your color has insufficient contrast to white. Accessibility of your site "
|
||||
"will be impacted."
|
||||
msgstr ""
|
||||
"El color no té prou contrast amb el blanc i pot afectar a l'accessibilitat "
|
||||
"del lloc web."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:418
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:438
|
||||
@@ -712,229 +714,226 @@ msgstr[0] "(una data més)"
|
||||
msgstr[1] "({num} dates més)"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:47
|
||||
#, fuzzy
|
||||
#| msgid "The items in your cart are no longer reserved for you."
|
||||
msgid ""
|
||||
"The items in your cart are no longer reserved for you. You can still "
|
||||
"complete your order as long as they’re available."
|
||||
msgstr "El contingut de la cistella ja no el teniu reservat."
|
||||
msgstr ""
|
||||
"Ja no tens reservat el contingut de la cistella. Encara pots completar la "
|
||||
"comanda si els articles seleccionats continuen disponibles."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:49
|
||||
msgid "Cart expired"
|
||||
msgstr "Cistella expirada"
|
||||
msgstr "Cistella caducada"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:58
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:84
|
||||
msgid "Your cart is about to expire."
|
||||
msgstr ""
|
||||
msgstr "La teva cistella està a punt de caducar."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:62
|
||||
#, fuzzy
|
||||
#| msgid "The items in your cart are no longer reserved for you."
|
||||
msgid "The items in your cart are reserved for you for one minute."
|
||||
msgid_plural "The items in your cart are reserved for you for {num} minutes."
|
||||
msgstr[0] "El contingut de la cistella ja no el teniu reservat."
|
||||
msgstr[1] "El contingut de la cistella ja no el teniu reservat."
|
||||
msgstr[0] "El contingut de la teva cistella està reservat durant un minut."
|
||||
msgstr[1] "El contingut de la teva cistella està reservat durant {num} minuts."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:83
|
||||
#, fuzzy
|
||||
#| msgid "Cart expired"
|
||||
msgid "Your cart has expired."
|
||||
msgstr "Cistella expirada"
|
||||
msgstr "La teva cistella ha caducat."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:86
|
||||
#, fuzzy
|
||||
#| msgid "The items in your cart are no longer reserved for you."
|
||||
msgid ""
|
||||
"The items in your cart are no longer reserved for you. You can still "
|
||||
"complete your order as long as they're available."
|
||||
msgstr "El contingut de la cistella ja no el teniu reservat."
|
||||
msgstr ""
|
||||
"Ja no teniu reservat el contingut de la cistella. Encara podeu completar la "
|
||||
"comanda si els articles continuen disponibles."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:87
|
||||
msgid "Do you want to renew the reservation period?"
|
||||
msgstr ""
|
||||
msgstr "Vols ampliar el temps de reserva?"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:90
|
||||
msgid "Renew reservation"
|
||||
msgstr ""
|
||||
msgstr "Renovar reserva"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:194
|
||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||
msgstr ""
|
||||
msgstr "L'entitat organitzadora es queda %(currency)s %(amount)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:202
|
||||
msgid "You get %(currency)s %(amount)s back"
|
||||
msgstr ""
|
||||
msgstr "Rebràs %(currency)s %(amount)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:218
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
msgstr "Introdueix l’import que es queda l’organitzador."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
msgstr "La teva hora local:"
|
||||
|
||||
#: pretix/static/pretixpresale/js/walletdetection.js:39
|
||||
#, fuzzy
|
||||
msgid "Google Pay"
|
||||
msgstr ""
|
||||
msgstr "Google Pay"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:16
|
||||
msgctxt "widget"
|
||||
msgid "Quantity"
|
||||
msgstr ""
|
||||
msgstr "Quantitat"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:17
|
||||
msgctxt "widget"
|
||||
msgid "Decrease quantity"
|
||||
msgstr ""
|
||||
msgstr "Disminueix quantitat"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:18
|
||||
msgctxt "widget"
|
||||
msgid "Increase quantity"
|
||||
msgstr ""
|
||||
msgstr "Augmenta quantitat"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:19
|
||||
msgctxt "widget"
|
||||
msgid "Filter events by"
|
||||
msgstr ""
|
||||
msgstr "Filtra els esdeveniments per"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:20
|
||||
msgctxt "widget"
|
||||
msgid "Filter"
|
||||
msgstr ""
|
||||
msgstr "Filtra"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:21
|
||||
msgctxt "widget"
|
||||
msgid "Price"
|
||||
msgstr ""
|
||||
msgstr "Preu"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:22
|
||||
#, javascript-format
|
||||
msgctxt "widget"
|
||||
msgid "Original price: %s"
|
||||
msgstr ""
|
||||
msgstr "Preu original: %s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:23
|
||||
#, javascript-format
|
||||
msgctxt "widget"
|
||||
msgid "New price: %s"
|
||||
msgstr ""
|
||||
msgstr "Nou preu: %s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:24
|
||||
msgctxt "widget"
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
msgstr "Selecciona"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:25
|
||||
#, javascript-format
|
||||
msgctxt "widget"
|
||||
msgid "Select %s"
|
||||
msgstr ""
|
||||
msgstr "Selecciona %s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:26
|
||||
#, javascript-format
|
||||
msgctxt "widget"
|
||||
msgid "Select variant %s"
|
||||
msgstr ""
|
||||
msgstr "Selecciona la variant %s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:27
|
||||
msgctxt "widget"
|
||||
msgid "Sold out"
|
||||
msgstr ""
|
||||
msgstr "Esgotat"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:28
|
||||
msgctxt "widget"
|
||||
msgid "Buy"
|
||||
msgstr ""
|
||||
msgstr "Compra"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:29
|
||||
msgctxt "widget"
|
||||
msgid "Register"
|
||||
msgstr ""
|
||||
msgstr "Registrar-vos"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:30
|
||||
msgctxt "widget"
|
||||
msgid "Reserved"
|
||||
msgstr ""
|
||||
msgstr "Reservat"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:31
|
||||
msgctxt "widget"
|
||||
msgid "FREE"
|
||||
msgstr ""
|
||||
msgstr "GRATUÏT"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:32
|
||||
msgctxt "widget"
|
||||
msgid "from %(currency)s %(price)s"
|
||||
msgstr ""
|
||||
msgstr "des de %(price)s %(currency)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:33
|
||||
#, javascript-format
|
||||
msgctxt "widget"
|
||||
msgid "Image of %s"
|
||||
msgstr ""
|
||||
msgstr "Imatge de %s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:34
|
||||
msgctxt "widget"
|
||||
msgid "incl. %(rate)s% %(taxname)s"
|
||||
msgstr ""
|
||||
msgstr "Inclòs %(rate)s% %(taxname)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:35
|
||||
msgctxt "widget"
|
||||
msgid "plus %(rate)s% %(taxname)s"
|
||||
msgstr ""
|
||||
msgstr "més %(rate)s% %(taxname)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:36
|
||||
msgctxt "widget"
|
||||
msgid "incl. taxes"
|
||||
msgstr ""
|
||||
msgstr "incl. impostos"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:37
|
||||
msgctxt "widget"
|
||||
msgid "plus taxes"
|
||||
msgstr ""
|
||||
msgstr "més impostos"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:38
|
||||
#, javascript-format
|
||||
msgctxt "widget"
|
||||
msgid "currently available: %s"
|
||||
msgstr ""
|
||||
msgstr "disponibles: %s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:39
|
||||
msgctxt "widget"
|
||||
msgid "Only available with a voucher"
|
||||
msgstr ""
|
||||
msgstr "Només disponible amb un cupó"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:40
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:43
|
||||
msgctxt "widget"
|
||||
msgid "Not yet available"
|
||||
msgstr ""
|
||||
msgstr "Encara no disponible"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:41
|
||||
msgctxt "widget"
|
||||
msgid "Not available anymore"
|
||||
msgstr ""
|
||||
msgstr "Ja no està disponible"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:42
|
||||
msgctxt "widget"
|
||||
msgid "Currently not available"
|
||||
msgstr ""
|
||||
msgstr "No disponible"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:44
|
||||
#, javascript-format
|
||||
msgctxt "widget"
|
||||
msgid "minimum amount to order: %s"
|
||||
msgstr ""
|
||||
msgstr "Comanda mínima: %s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:45
|
||||
msgctxt "widget"
|
||||
msgid "Close ticket shop"
|
||||
msgstr ""
|
||||
msgstr "Tanca la botiga d'entrades"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:46
|
||||
msgctxt "widget"
|
||||
msgid "The ticket shop could not be loaded."
|
||||
msgstr ""
|
||||
msgstr "No s'ha pogut carregar la botiga d'entrades."
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:47
|
||||
msgctxt "widget"
|
||||
|
||||
+1177
-1063
File diff suppressed because it is too large
Load Diff
+1168
-1063
File diff suppressed because it is too large
Load Diff
+1180
-1068
File diff suppressed because it is too large
Load Diff
+1187
-1066
File diff suppressed because it is too large
Load Diff
@@ -362,6 +362,9 @@ Reverse-Proxy
|
||||
Revisionssicherheit
|
||||
Revolut
|
||||
Revolut-App
|
||||
Rundungsabweichungen
|
||||
Rundungskorrektur
|
||||
Rundungsverhalten
|
||||
rückabgewickelt
|
||||
Sa
|
||||
Sammlungsstücken
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -362,6 +362,9 @@ Reverse-Proxy
|
||||
Revisionssicherheit
|
||||
Revolut
|
||||
Revolut-App
|
||||
Rundungsabweichungen
|
||||
Rundungskorrektur
|
||||
Rundungsverhalten
|
||||
rückabgewickelt
|
||||
Sa
|
||||
Sammlungsstücken
|
||||
|
||||
+1160
-1062
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-29 07:55+0000\n"
|
||||
"POT-Creation-Date: 2025-10-30 10:55+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
+1183
-1067
File diff suppressed because it is too large
Load Diff
+1160
-1062
File diff suppressed because it is too large
Load Diff
+1218
-1102
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1160
-1062
File diff suppressed because it is too large
Load Diff
+1176
-1062
File diff suppressed because it is too large
Load Diff
+1178
-1063
File diff suppressed because it is too large
Load Diff
+1160
-1062
File diff suppressed because it is too large
Load Diff
+1221
-1100
File diff suppressed because it is too large
Load Diff
+1173
-1069
File diff suppressed because it is too large
Load Diff
+1181
-1063
File diff suppressed because it is too large
Load Diff
+1180
-1063
File diff suppressed because it is too large
Load Diff
+1172
-1063
File diff suppressed because it is too large
Load Diff
+1180
-1065
File diff suppressed because it is too large
Load Diff
+1174
-1063
File diff suppressed because it is too large
Load Diff
+1213
-1113
File diff suppressed because it is too large
Load Diff
+1178
-1063
File diff suppressed because it is too large
Load Diff
+1168
-1063
File diff suppressed because it is too large
Load Diff
+1161
-1062
File diff suppressed because it is too large
Load Diff
+1176
-1066
File diff suppressed because it is too large
Load Diff
+1160
-1062
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1180
-1063
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1181
-1063
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1164
-1062
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1181
-1065
File diff suppressed because it is too large
Load Diff
+1176
-1065
File diff suppressed because it is too large
Load Diff
+1161
-1063
File diff suppressed because it is too large
Load Diff
+1180
-1063
File diff suppressed because it is too large
Load Diff
+1179
-1063
File diff suppressed because it is too large
Load Diff
+1160
-1062
File diff suppressed because it is too large
Load Diff
+1179
-1063
File diff suppressed because it is too large
Load Diff
+1159
-1062
File diff suppressed because it is too large
Load Diff
+1186
-1067
File diff suppressed because it is too large
Load Diff
+1181
-1065
File diff suppressed because it is too large
Load Diff
+1178
-1063
File diff suppressed because it is too large
Load Diff
+1160
-1062
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
|
||||
from pretix.base.models import Checkin, OrderPayment
|
||||
from pretix.base.signals import (
|
||||
checkin_created, event_copy_data, item_copy_data, logentry_display,
|
||||
@@ -64,19 +65,13 @@ def nav_event_receiver(sender, request, **kwargs):
|
||||
]
|
||||
|
||||
|
||||
@receiver(signal=logentry_display)
|
||||
def logentry_display_receiver(sender, logentry, **kwargs):
|
||||
plains = {
|
||||
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
|
||||
"pretix.plugins.autocheckin.rule.changed": _(
|
||||
"An auto check-in rule was updated"
|
||||
),
|
||||
"pretix.plugins.autocheckin.rule.deleted": _(
|
||||
"An auto check-in rule was deleted"
|
||||
),
|
||||
}
|
||||
if logentry.action_type in plains:
|
||||
return plains[logentry.action_type]
|
||||
@log_entry_types.new_from_dict({
|
||||
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
|
||||
"pretix.plugins.autocheckin.rule.changed": _("An auto check-in rule was updated"),
|
||||
"pretix.plugins.autocheckin.rule.deleted": _("An auto check-in rule was deleted"),
|
||||
})
|
||||
class AutocheckinLogEntryType(LogEntryType):
|
||||
pass
|
||||
|
||||
|
||||
@receiver(item_copy_data, dispatch_uid="autocheckin_item_copy")
|
||||
|
||||
@@ -18,25 +18,19 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="{% if settings.bank_details_type == "sepa" %}col-md-6{% else %}col-md-12{% endif %} col-xs-12">
|
||||
{% if settings.bank_details_type == "sepa" %}
|
||||
<dl class="dl-horizontal">
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Reference code (important):" %}</dt><dd><b>{{ code }}</b></dd>
|
||||
<dt>{% trans "Amount:" %}</dt><dd>{{ amount|money:event.currency }}</dd>
|
||||
{% if settings.bank_details_type == "sepa" %}
|
||||
<dt>{% trans "Account holder" %}:</dt><dd>{{ settings.bank_details_sepa_name }}</dt>
|
||||
<dt>{% trans "IBAN" %}:</dt><dd>{{ settings.bank_details_sepa_iban|ibanformat }}</dt>
|
||||
<dt>{% trans "BIC" %}:</dt><dd>{{ settings.bank_details_sepa_bic }}</dt>
|
||||
<dt>{% trans "Bank" %}:</dt><dd>{{ settings.bank_details_sepa_bank }}</dt>
|
||||
{% if details %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if details %}
|
||||
{{ details | rich_text }}
|
||||
<dl class="dl-horizontal">
|
||||
{% endif %}
|
||||
{% if not settings.bank_details_type == "sepa" and not details %}
|
||||
<dl class="dl-horizontal">
|
||||
{% endif %}
|
||||
<dt>{% trans "Amount:" %}</dt><dd>{{ amount|money:event.currency }}</dd>
|
||||
<dt>{% trans "Reference code (important):" %}</dt><dd><b>{{ code }}</b></dd>
|
||||
</dl>
|
||||
<p class="text-muted">
|
||||
{% trans "There is no further action required on this website." %}
|
||||
@@ -133,4 +127,4 @@ SCT
|
||||
</div>
|
||||
{% if swiss_qrbill %}
|
||||
<link rel="stylesheet" href="{% static "pretixplugins/banktransfer/swisscross.css" %}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -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']):
|
||||
|
||||
@@ -31,6 +31,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from paypalhttp import HttpResponse
|
||||
|
||||
from pretix.base.forms import SecretKeySettingsField
|
||||
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
|
||||
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
|
||||
from pretix.base.settings import settings_hierarkey
|
||||
from pretix.base.signals import (
|
||||
@@ -80,37 +81,36 @@ def html_head_presale(sender, request=None, **kwargs):
|
||||
return ""
|
||||
|
||||
|
||||
@receiver(signal=logentry_display, dispatch_uid="stripe_logentry_display")
|
||||
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
|
||||
if logentry.action_type != 'pretix.plugins.stripe.event':
|
||||
return
|
||||
@log_entry_types.new()
|
||||
class StripeEvent(LogEntryType):
|
||||
action_type = 'pretix.plugins.stripe.event'
|
||||
|
||||
data = json.loads(logentry.data)
|
||||
event_type = data.get('type')
|
||||
text = None
|
||||
plains = {
|
||||
'charge.succeeded': _('Charge succeeded.'),
|
||||
'charge.refunded': _('Charge refunded.'),
|
||||
'charge.updated': _('Charge updated.'),
|
||||
'charge.pending': _('Charge pending'),
|
||||
'source.chargeable': _('Payment authorized.'),
|
||||
'source.canceled': _('Payment authorization canceled.'),
|
||||
'source.failed': _('Payment authorization failed.')
|
||||
}
|
||||
def display(self, logentry, data):
|
||||
event_type = data.get('type')
|
||||
text = None
|
||||
plains = {
|
||||
'charge.succeeded': _('Charge succeeded.'),
|
||||
'charge.refunded': _('Charge refunded.'),
|
||||
'charge.updated': _('Charge updated.'),
|
||||
'charge.pending': _('Charge pending'),
|
||||
'source.chargeable': _('Payment authorized.'),
|
||||
'source.canceled': _('Payment authorization canceled.'),
|
||||
'source.failed': _('Payment authorization failed.')
|
||||
}
|
||||
|
||||
if event_type in plains:
|
||||
text = plains[event_type]
|
||||
elif event_type == 'charge.failed':
|
||||
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
|
||||
elif event_type == 'charge.dispute.created':
|
||||
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
|
||||
elif event_type == 'charge.dispute.updated':
|
||||
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
|
||||
elif event_type == 'charge.dispute.closed':
|
||||
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
|
||||
if event_type in plains:
|
||||
text = plains[event_type]
|
||||
elif event_type == 'charge.failed':
|
||||
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
|
||||
elif event_type == 'charge.dispute.created':
|
||||
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
|
||||
elif event_type == 'charge.dispute.updated':
|
||||
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
|
||||
elif event_type == 'charge.dispute.closed':
|
||||
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
|
||||
|
||||
if text:
|
||||
return _('Stripe reported an event: {}').format(text)
|
||||
if text:
|
||||
return _('Stripe reported an event: {}').format(text)
|
||||
|
||||
|
||||
settings_hierarkey.add_default('payment_stripe_method_card', True, bool)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -9,34 +9,6 @@
|
||||
{% endblock %}
|
||||
{% block inner %}
|
||||
<h3 class="sr-only">{% trans "Payment" %}</h3>
|
||||
{% if customer_gift_cards %}
|
||||
<p><strong>
|
||||
<span class="sr-only">{% trans "Information" %}</span>
|
||||
{% trans "The following gift cards are available in your customer account:" %}
|
||||
</strong></p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<ul class="list-group">
|
||||
{% for c in customer_gift_cards %}
|
||||
<li class="list-group-item row">
|
||||
<div class="col-xs-9" id="gc-code-{{ forloop.counter }}">
|
||||
{{ c }}
|
||||
</div>
|
||||
<div class="col-xs-2 text-right" id="gc-value-{{ forloop.counter }}">
|
||||
{{ c.value|money:c.currency }}
|
||||
</div>
|
||||
<div class="col-xs-1 text-right">
|
||||
<button name="use_giftcard" value="{{ c.secret }}" title="{% trans "Use gift card" %}"
|
||||
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}"
|
||||
class="btn btn-primary btn-xs">
|
||||
{% trans "Apply" %}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_payments %}
|
||||
<p>{% trans "You already selected the following payment methods:" %}</p>
|
||||
<form method="post">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% load rich_text %}
|
||||
|
||||
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
|
||||
{% if customer_gift_cards %}
|
||||
<p><strong>
|
||||
<span class="sr-only">{% trans "Information" %}</span>
|
||||
{% trans "The following gift cards are available in your customer account:" %}
|
||||
</strong></p>
|
||||
{% csrf_token %}
|
||||
<ul class="list-group">
|
||||
{% for c in customer_gift_cards %}
|
||||
<li class="list-group-item row row-no-gutters">
|
||||
<div class="col-sm-8 col-md-9" id="gc-code-{{ forloop.counter }}">
|
||||
{{ c }}
|
||||
</div>
|
||||
<div class="col-sm-2 text-right" id="gc-value-{{ forloop.counter }}">
|
||||
{{ c.value|money:c.currency }}
|
||||
</div>
|
||||
<div class="col-sm-2 col-md-1 text-right">
|
||||
<button name="use_giftcard" class="btn btn-primary btn-xs use_giftcard" data-value="{{ c.secret }}"
|
||||
title="{% trans "Use gift card" %}"
|
||||
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}">
|
||||
{% trans "Apply" %}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% bootstrap_form form layout='checkout' %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user