diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst
index 89ab1cff1..479fa28a3 100644
--- a/doc/api/resources/checkin.rst
+++ b/doc/api/resources/checkin.rst
@@ -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.
diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst
index 642befcce..8b9d96cdd 100644
--- a/doc/api/resources/index.rst
+++ b/doc/api/resources/index.rst
@@ -19,6 +19,7 @@ at :ref:`plugin-docs`.
item_bundles
item_add-ons
item_meta_properties
+ item_program_times
questions
question_options
quotas
diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst
index de9245941..3a97ca412 100644
--- a/doc/api/resources/invoices.rst
+++ b/doc/api/resources/invoices.rst
@@ -22,6 +22,7 @@ invoice_from_name string Sender address:
invoice_from string Sender address: Address lines
invoice_from_zipcode string Sender address: ZIP code
invoice_from_city string Sender address: City
+invoice_from_state string Sender address: State (only used in some countries)
invoice_from_country string Sender address: Country code
invoice_from_tax_id string Sender address: Local Tax ID
invoice_from_vat_id string Sender address: EU VAT ID
@@ -233,6 +234,7 @@ List of all invoices
"invoice_from": "Demo street 12",
"invoice_from_zipcode":"",
"invoice_from_city":"Demo town",
+ "invoice_from_state":"CA",
"invoice_from_country":"US",
"invoice_from_tax_id":"",
"invoice_from_vat_id":"",
@@ -381,6 +383,7 @@ Fetching individual invoices
"invoice_from": "Demo street 12",
"invoice_from_zipcode":"",
"invoice_from_city":"Demo town",
+ "invoice_from_state":"CA",
"invoice_from_country":"US",
"invoice_from_tax_id":"",
"invoice_from_vat_id":"",
diff --git a/doc/api/resources/item_program_times.rst b/doc/api/resources/item_program_times.rst
new file mode 100644
index 000000000..eedf3be0a
--- /dev/null
+++ b/doc/api/resources/item_program_times.rst
@@ -0,0 +1,223 @@
+Item program times
+==================
+
+Resource description
+--------------------
+
+Program times for products (items) that can be set in addition to event times, e.g. to display seperate schedules within an event.
+Note that ``program_times`` are not available for items inside event series.
+The program times resource contains the following public fields:
+
+.. rst-class:: rest-resource-table
+
+===================================== ========================== =======================================================
+Field Type Description
+===================================== ========================== =======================================================
+id integer Internal ID of the program time
+start datetime The start date time for this program time slot.
+end datetime The end date time for this program time slot.
+===================================== ========================== =======================================================
+
+.. versionchanged:: TODO
+
+ The resource has been added.
+
+
+Endpoints
+---------
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
+
+ Returns a list of all program times for a given item.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/items/11/program_times/ 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": 3,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 2,
+ "start": "2025-08-14T22:00:00Z",
+ "end": "2025-08-15T00:00:00Z"
+ },
+ {
+ "id": 3,
+ "start": "2025-08-12T22:00:00Z",
+ "end": "2025-08-13T22:00:00Z"
+ },
+ {
+ "id": 14,
+ "start": "2025-08-15T22:00:00Z",
+ "end": "2025-08-17T22:00:00Z"
+ }
+ ]
+ }
+
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :param event: The ``slug`` field of the event to fetch
+ :param item: The ``id`` field of the item to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
+
+ Returns information on one program time, identified by its ID.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ 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
+
+ {
+ "id": 1,
+ "start": "2025-08-15T22:00:00Z",
+ "end": "2025-10-27T23:00:00Z"
+ }
+
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :param event: The ``slug`` field of the event to fetch
+ :param item: The ``id`` field of the item to fetch
+ :param id: The ``id`` field of the program time 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.
+
+.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
+
+ Creates a new program time
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ POST /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+ Content-Type: application/json
+
+ {
+ "start": "2025-08-15T10:00:00Z",
+ "end": "2025-08-15T22:00:00Z"
+ }
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 201 Created
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 17,
+ "start": "2025-08-15T10:00:00Z",
+ "end": "2025-08-15T22:00:00Z"
+ }
+
+ :param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
+ :param event: The ``slug`` field of the event to create a program time for
+ :param item: The ``id`` field of the item to create a program time for
+ :statuscode 201: no error
+ :statuscode 400: The program time could not be created due to invalid submitted data.
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
+
+.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
+
+ Update a program time. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
+ the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
+ want to change.
+
+ You can change all fields of the resource except the ``id`` field.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+ Content-Type: application/json
+ Content-Length: 94
+
+ {
+ "start": "2025-08-14T10:00:00Z"
+ }
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 1,
+ "start": "2025-08-14T10:00:00Z",
+ "end": "2025-08-15T12:00:00Z"
+ }
+
+ :param organizer: The ``slug`` field of the organizer to modify
+ :param event: The ``slug`` field of the event to modify
+ :param id: The ``id`` field of the item to modify
+ :param id: The ``id`` field of the program time to modify
+ :statuscode 200: no error
+ :statuscode 400: The program time could not be modified due to invalid submitted data
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
+
+.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/program_times/(id)/
+
+ Delete a program time.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 204 No Content
+ Vary: Accept
+
+ :param organizer: The ``slug`` field of the organizer to modify
+ :param event: The ``slug`` field of the event to modify
+ :param id: The ``id`` field of the item to modify
+ :param id: The ``id`` field of the program time to delete
+ :statuscode 204: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst
index 383da02d7..a8a5a1482 100644
--- a/doc/api/resources/items.rst
+++ b/doc/api/resources/items.rst
@@ -139,6 +139,10 @@ has_variations boolean Shows whether
variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
+program_times list of objects A list with one object for each program time of this item.
+ Can be empty. Only writable during creation,
+ use separate endpoint to modify this later.
+ Not available for items in event series.
├ id integer Internal ID of the variation
├ value multi-lingual string The "name" of the variation
├ default_price money (string) The price set directly for this variation or ``null``
@@ -225,6 +229,10 @@ meta_data object Values set fo
The ``hidden_if_item_available_mode`` attributes has been added.
+.. versionchanged:: 2025.9
+
+ The ``program_times`` attribute has been added.
+
Notes
-----
@@ -232,9 +240,11 @@ Please note that an item either always has variations or never has. Once created
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
one variation.
-Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations,
-bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
-with nested ``variations``, ``bundles`` and/or ``addons``.
+Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` are only supported on ``POST``. To update/delete variations,
+bundles, add-ons and program times please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
+with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``.
+
+``program_times`` is not available to items in event series.
Endpoints
---------
@@ -373,7 +383,8 @@ Endpoints
}
],
"addons": [],
- "bundles": []
+ "bundles": [],
+ "program_times": []
}
]
}
@@ -525,7 +536,8 @@ Endpoints
}
],
"addons": [],
- "bundles": []
+ "bundles": [],
+ "program_times": []
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -653,7 +665,13 @@ Endpoints
}
],
"addons": [],
- "bundles": []
+ "bundles": [],
+ "program_times": [
+ {
+ "start": "2025-08-14T22:00:00Z",
+ "end": "2025-08-15T00:00:00Z"
+ }
+ ]
}
**Example response**:
@@ -773,7 +791,13 @@ Endpoints
}
],
"addons": [],
- "bundles": []
+ "bundles": [],
+ "program_times": [
+ {
+ "start": "2025-08-14T22:00:00Z",
+ "end": "2025-08-15T00:00:00Z"
+ }
+ ]
}
:param organizer: The ``slug`` field of the organizer of the event to create an item for
@@ -789,8 +813,9 @@ Endpoints
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
- You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If
- you need to update/delete variations or add-ons please use the nested dedicated endpoints.
+ You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the
+ ``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested
+ dedicated endpoints.
**Example request**:
@@ -924,7 +949,8 @@ Endpoints
}
],
"addons": [],
- "bundles": []
+ "bundles": [],
+ "program_times": []
}
:param organizer: The ``slug`` field of the organizer to modify
diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst
index 2894d16c6..f863a6df4 100644
--- a/doc/api/resources/orders.rst
+++ b/doc/api/resources/orders.rst
@@ -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)
diff --git a/doc/development/algorithms/pricing.rst b/doc/development/algorithms/pricing.rst
index 1b8863389..aa1c7769f 100644
--- a/doc/development/algorithms/pricing.rst
+++ b/doc/development/algorithms/pricing.rst
@@ -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
+ ========== ========== ============================= ============================== =============
diff --git a/doc/images/cart_pricing.png b/doc/images/cart_pricing.png
index 4b1f3a713..b2d667eea 100644
Binary files a/doc/images/cart_pricing.png and b/doc/images/cart_pricing.png differ
diff --git a/doc/images/cart_pricing.puml b/doc/images/cart_pricing.puml
index ae945e0dd..47c1c49ff 100644
--- a/doc/images/cart_pricing.puml
+++ b/doc/images/cart_pricing.puml
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index c1a12ddf8..ddda75cbd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,9 +33,9 @@ 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[argon2]==4.2.*,>=4.2.26",
"django-bootstrap3==25.2",
"django-compressor==4.5.1",
"django-countries==7.6.*",
@@ -81,7 +81,7 @@ dependencies = [
"pycountry",
"pycparser==2.23",
"pycryptodome==3.23.*",
- "pypdf==6.1.*",
+ "pypdf==6.3.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
@@ -91,7 +91,7 @@ dependencies = [
"redis==6.4.*",
"reportlab==4.4.*",
"requests==2.32.*",
- "sentry-sdk==2.42.*",
+ "sentry-sdk==2.45.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py
index ac9cd86de..86a2cb9ad 100644
--- a/src/pretix/__init__.py
+++ b/src/pretix/__init__.py
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
#
', '
').replace('
\n', '\n').replace('
', '\n')
+
+ # start first line with different settings than the rest of the description
+ curr_description = description.split("\n", maxsplit=1)[0]
+ cellpadding = 6 # default cellpadding is only set on right side of column
+ max_width = colwidths[0] - cellpadding
+ max_height = self.stylesheet['Normal'].leading * 5
+ p_style = self.stylesheet['Normal']
+ for __ in range(1000):
+ p = FontFallbackParagraph(
+ self._clean_text(curr_description, tags=['br']),
+ p_style
+ )
+ h = p.wrap(max_width, doc.height)[1]
+ if h <= max_height:
+ description_p_list.append(p)
+ if curr_description == description:
+ break
+ description = description[len(curr_description):].lstrip()
+ curr_description = description.split("\n", maxsplit=1)[0]
+ # use different settings for all except first line
+ max_width = sum(colwidths[0:3 if has_taxes else 2]) - cellpadding
+ max_height = self.stylesheet['Fineprint'].leading * 8
+ p_style = self.stylesheet['Fineprint']
+ continue
+
+ if not description_p_list:
+ # first "manual" line is larger than 5 "real" lines => only allow one line and set rest in Fineprint
+ max_height = self.stylesheet['Normal'].leading
+
+ if h > max_height * 1.1:
+ # quickly bring the text-length down to a managable length to then stepwise reduce
+ wrap_to = math.ceil(len(curr_description) * max_height * 1.1 / h)
+ else:
+ # trim to 95% length, but at most 10 chars to not have strangely short lines in the middle of a paragraph
+ wrap_to = max(len(curr_description) - 10, math.ceil(len(curr_description) * 0.95))
+ curr_description = textwrap.wrap(curr_description, wrap_to, replace_whitespace=False, drop_whitespace=False)[0]
+
# Try to be clever and figure out when organizers would want to show the period. This heuristic is
# not perfect and the only "fully correct" way would be to include the period on every line always,
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
@@ -810,7 +859,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice
request_show_service_date = period_line
elif period_line:
- description += "\n" + period_line
+ description_p_list.append(FontFallbackParagraph(
+ period_line,
+ self.stylesheet['Fineprint']
+ ))
lines = list(lines)
if has_taxes:
@@ -819,13 +871,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
- description = description + "\n" + single_price_line
+ description_p_list.append(FontFallbackParagraph(
+ single_price_line,
+ self.stylesheet['Fineprint']
+ ))
tdata.append((
- FontFallbackParagraph(
- self._clean_text(description, tags=['br']),
- self.stylesheet['Normal']
- ),
+ description_p_list.pop(0),
str(len(lines)),
localize(tax_rate) + " %",
FontFallbackParagraph(
@@ -837,23 +889,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['NormalRight']
),
))
+ for p in description_p_list:
+ tdata.append((p, "", "", "", ""))
+ tstyledata.append((
+ 'SPAN',
+ (0, len(tdata) - 1),
+ (2, len(tdata) - 1),
+ ))
else:
if len(lines) > 1:
single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency),
)
- description = description + "\n" + single_price_line
+ description_p_list.append(FontFallbackParagraph(
+ single_price_line,
+ self.stylesheet['Fineprint']
+ ))
tdata.append((
- FontFallbackParagraph(
- self._clean_text(description, tags=['br']),
- self.stylesheet['Normal']
- ),
+ description_p_list.pop(0),
str(len(lines)),
FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
))
+ for p in description_p_list:
+ tdata.append((p, "", ""))
+ tstyledata.append((
+ 'SPAN',
+ (0, len(tdata) - 1),
+ (1, len(tdata) - 1),
+ ))
+
+ tstyledata += [
+ (
+ 'BOTTOMPADDING',
+ (0, len(tdata) - len(description_p_list)),
+ (-1, len(tdata) - 2),
+ 0
+ ),
+ (
+ 'TOPPADDING',
+ (0, len(tdata) - len(description_p_list)),
+ (-1, len(tdata) - 1),
+ 0
+ ),
+ ]
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
total += gross_value * len(lines)
@@ -863,13 +944,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency)
])
- colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency)
])
- colwidths = [a * doc.width for a in (.65, .20, .15)]
if not self.invoice.is_cancellation:
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
diff --git a/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py b/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py
new file mode 100644
index 000000000..548e84225
--- /dev/null
+++ b/src/pretix/base/migrations/0293_cartposition_price_includes_rounding_correction_and_more.py
@@ -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),
+ ),
+ ]
diff --git a/src/pretix/base/migrations/0294_item_program_time.py b/src/pretix/base/migrations/0294_item_program_time.py
new file mode 100644
index 000000000..4a53b9f0f
--- /dev/null
+++ b/src/pretix/base/migrations/0294_item_program_time.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.19 on 2025-08-11 10:25
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0293_cartposition_price_includes_rounding_correction_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ItemProgramTime',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('start', models.DateTimeField()),
+ ('end', models.DateTimeField()),
+ ('item',
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='program_times',
+ to='pretixbase.item')),
+ ],
+ ),
+ ]
diff --git a/src/pretix/base/migrations/0295_user_is_verified.py b/src/pretix/base/migrations/0295_user_is_verified.py
new file mode 100644
index 000000000..2a6a5d59e
--- /dev/null
+++ b/src/pretix/base/migrations/0295_user_is_verified.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.23 on 2025-09-04 16:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pretixbase", "0294_item_program_time"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="is_verified",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/src/pretix/base/migrations/0296_invoice_invoice_from_state.py b/src/pretix/base/migrations/0296_invoice_invoice_from_state.py
new file mode 100644
index 000000000..9b7ae9e77
--- /dev/null
+++ b/src/pretix/base/migrations/0296_invoice_invoice_from_state.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.24 on 2025-11-10 16:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pretixbase", "0295_user_is_verified"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="invoice",
+ name="invoice_from_state",
+ field=models.CharField(max_length=190, null=True),
+ ),
+ ]
diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py
index 944737501..372fc8287 100644
--- a/src/pretix/base/models/__init__.py
+++ b/src/pretix/base/models/__init__.py
@@ -36,8 +36,9 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
- ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
- SubEventItem, SubEventItemVariation, itempicture_upload_to,
+ ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
+ QuestionOption, Quota, SubEventItem, SubEventItemVariation,
+ itempicture_upload_to,
)
from .log import LogEntry
from .media import ReusableMedium
diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py
index 31871dadb..347f4d271 100644
--- a/src/pretix/base/models/auth.py
+++ b/src/pretix/base/models/auth.py
@@ -35,6 +35,7 @@
import binascii
import json
import operator
+import secrets
from datetime import timedelta
from functools import reduce
@@ -44,6 +45,7 @@ from django.contrib.auth.models import (
)
from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import BadRequest, PermissionDenied
from django.db import IntegrityError, models, transaction
from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac
@@ -239,9 +241,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
+ MAX_CONFIRMATION_CODE_ATTEMPTS = 10
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('Email'), max_length=190)
+ is_verified = models.BooleanField(default=False, verbose_name=_('Verified email address'))
fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name'))
is_active = models.BooleanField(default=True,
@@ -353,6 +357,77 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
except SendMailException:
pass # Already logged
+ def send_confirmation_code(self, session, reason, email=None, state=None):
+ """
+ Sends a confirmation code via email to the user. The code is only valid for the action specified by `reason`.
+ The email is either sent to the email address currently on file for the user, or to the one given in the optional `email` parameter.
+ A `state` value can be provided which is bound to this confirmation code, and returned on successfully checking the code.
+ :param session: the user's request session
+ :param reason: the action which should be confirmed using this confirmation code (currently, only `email_change` is allowed)
+ :param email: optional, the email address to send the confirmation code to
+ :param state: optional
+ """
+ from pretix.base.services.mail import mail
+
+ with language(self.locale):
+ if reason == 'email_change':
+ msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
+ old_email=self.email, new_email=email,
+ ))
+ elif reason == 'email_verify':
+ msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
+ email=self.email,
+ ))
+ else:
+ raise Exception('Invalid confirmation code reason')
+
+ code = "%07d" % secrets.SystemRandom().randint(0, 9999999)
+ session['user_confirmation_code:' + reason] = {
+ 'code': code,
+ 'state': state,
+ 'attempts': 0,
+ }
+ mail(
+ email or self.email,
+ _('pretix confirmation code'),
+ 'pretixcontrol/email/confirmation_code.txt',
+ {
+ 'user': self,
+ 'reason': msg,
+ 'code': code,
+ },
+ event=None,
+ user=self,
+ locale=self.locale
+ )
+
+ def check_confirmation_code(self, session, reason, code):
+ """
+ Checks a confirmation code entered by the user against the valid code stored in the session.
+ If the code is correct, an optional state bound to the code is returned.
+ If the code is incorrect, PermissionDenied is raised. If the code could not be validated, either because no
+ code for the given reason is stored, or the number of input attempts is exceeded, BadRequest is raised.
+
+ :param session: the user's request session
+ :param reason: the action which should be confirmed using this confirmation code
+ :param code: the code entered by the user
+ :return: optional state bound to this code using the state parameter of send_confirmation_code, None otherwise
+ """
+ stored = session.get('user_confirmation_code:' + reason)
+ if not stored:
+ raise BadRequest
+
+ if stored['attempts'] > User.MAX_CONFIRMATION_CODE_ATTEMPTS:
+ raise BadRequest
+
+ if int(stored['code']) == int(code):
+ del session['user_confirmation_code:' + reason]
+ return stored['state']
+ else:
+ stored['attempts'] += 1
+ session['user_confirmation_code:' + reason] = stored
+ raise PermissionDenied
+
def send_password_reset(self):
from pretix.base.services.mail import mail
diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py
index c96ee870d..dfe1616cb 100644
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -847,7 +847,7 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
- ItemVariationMetaValue, Question, Quota,
+ ItemProgramTime, ItemVariationMetaValue, Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -990,6 +990,12 @@ class Event(EventMixin, LoggedModel):
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save(force_insert=True)
+ if not self.has_subevents and not other.has_subevents:
+ for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'):
+ ipt.pk = None
+ ipt.item = item_map[ipt.item.pk]
+ ipt.save(force_insert=True)
+
quota_map = {}
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
quota_map[q.pk] = q
diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py
index 602e062d8..e4e9eaa6c 100644
--- a/src/pretix/base/models/invoices.py
+++ b/src/pretix/base/models/invoices.py
@@ -142,6 +142,7 @@ class Invoice(models.Model):
invoice_from_name = models.CharField(max_length=190, null=True)
invoice_from_zipcode = models.CharField(max_length=190, null=True)
invoice_from_city = models.CharField(max_length=190, null=True)
+ invoice_from_state = models.CharField(max_length=190, null=True)
invoice_from_country = FastCountryField(null=True)
invoice_from_tax_id = models.CharField(max_length=190, null=True)
invoice_from_vat_id = models.CharField(max_length=190, null=True)
@@ -218,10 +219,23 @@ class Invoice(models.Model):
taxidrow = "ABN: %s" % self.invoice_from_tax_id
else:
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
+
+ state_name = ""
+ if self.invoice_from_state:
+ state_name = self.invoice_from_state
+ if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
+ if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
+ try:
+ state_name = pycountry.subdivisions.get(
+ code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
+ ).name
+ except:
+ pass
+
parts = [
self.invoice_from_name,
self.invoice_from,
- (self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
+ ((self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or "") + " " + (state_name or "")).strip(),
self.invoice_from_country.name if self.invoice_from_country else "",
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
taxidrow,
@@ -230,10 +244,22 @@ class Invoice(models.Model):
@property
def address_invoice_from(self):
+ state_name = ""
+ if self.invoice_from_state:
+ state_name = self.invoice_from_state
+ if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
+ if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
+ try:
+ state_name = pycountry.subdivisions.get(
+ code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
+ ).name
+ except:
+ pass
+
parts = [
self.invoice_from_name,
self.invoice_from,
- (self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
+ " ".join(s for s in [self.invoice_from_zipcode, self.invoice_from_city, state_name] if s),
self.invoice_from_country.name if self.invoice_from_country else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py
index 7e42f2cb7..d307ba9c7 100644
--- a/src/pretix/base/models/items.py
+++ b/src/pretix/base/models/items.py
@@ -505,8 +505,7 @@ class Item(LoggedModel):
verbose_name=_("Free price input"),
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
- "additional donations for your event. This is currently not supported for products that are "
- "bought as an add-on to other products.")
+ "additional donations for your event.")
)
free_price_suggestion = models.DecimalField(
verbose_name=_("Suggested price"),
@@ -2294,3 +2293,29 @@ class ItemVariationMetaValue(LoggedModel):
class Meta:
unique_together = ('variation', 'property')
+
+
+class ItemProgramTime(models.Model):
+ """
+ This model can be used to add a program time to an item.
+
+ :param item: The item the program time applies to
+ :type item: Item
+ :param start: The date and time this program time starts
+ :type start: datetime
+ :param end: The date and time this program time ends
+ :type end: datetime
+ """
+ item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
+ start = models.DateTimeField(verbose_name=_("Start"))
+ end = models.DateTimeField(verbose_name=_("End"))
+
+ def clean(self):
+ if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
+ raise ValidationError(_("You cannot use program times on an event series."))
+ self.clean_start_end(start=self.start, end=self.end)
+ super().clean()
+
+ def clean_start_end(self, start: datetime = None, end: datetime = None):
+ if start and end and start > end:
+ raise ValidationError(_("The program end must not be before the program start."))
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index adcb69bcc..f8201f071 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -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):
diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py
index 70ac0b892..8ddc0b605 100644
--- a/src/pretix/base/models/seating.py
+++ b/src/pretix/base/models/seating.py
@@ -280,13 +280,13 @@ class Seat(models.Model):
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
sales_channel='web',
- ignore_distancing=False, distance_ignore_cart_id=None):
+ ignore_distancing=False, distance_ignore_cart_id=None, always_allow_blocked=False):
from .orders import Order
from .organizer import SalesChannel
if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier
- if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
+ if not always_allow_blocked and self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
return False
opqs = self.orderposition_set.filter(
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py
index 72f127770..67e54a824 100644
--- a/src/pretix/base/payment.py
+++ b/src/pretix/base/payment.py
@@ -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,
})
diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py
index ed00d9231..7117551ff 100644
--- a/src/pretix/base/pdf.py
+++ b/src/pretix/base/pdf.py
@@ -84,6 +84,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
+from pretix.helpers.daterange import datetimerange
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.presale.style import get_fonts
@@ -490,6 +491,12 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
) if op.valid_until else ""
}),
+ ("program_times", {
+ "label": _("Program times: date and time"),
+ "editor_sample": _(
+ "2017-05-31 10:00 – 12:00\n2017-05-31 14:00 – 16:00\n2017-05-31 14:00 – 2017-06-01 14:00"),
+ "evaluate": lambda op, order, ev: get_program_times(op, ev)
+ }),
("medium_identifier", {
"label": _("Reusable Medium ID"),
"editor_sample": "ABC1234DEF4567",
@@ -734,6 +741,16 @@ def get_seat(op: OrderPosition):
return None
+def get_program_times(op: OrderPosition, ev: Event):
+ return '\n'.join([
+ datetimerange(
+ pt.start.astimezone(ev.timezone),
+ pt.end.astimezone(ev.timezone),
+ as_html=False
+ ) for pt in op.item.program_times.all()
+ ])
+
+
def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int)
addons = [p for p in (
diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py
index 89d0d82b6..694ea3598 100644
--- a/src/pretix/base/services/cancelevent.py
+++ b/src/pretix/base/services/cancelevent.py
@@ -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
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index efffda374..66c7c47bd 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -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
diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py
index 704ea90da..6db88be3e 100644
--- a/src/pretix/base/services/invoices.py
+++ b/src/pretix/base/services/invoices.py
@@ -93,6 +93,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
+ invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -258,9 +259,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
if resp:
desc += "
" + 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 += "
{}{} {}".format(
answ.question.question,
"" if str(answ.question.question).endswith("?") else ":",
@@ -453,6 +460,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
+ cancellation.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -556,6 +564,7 @@ def build_preview_invoice_pdf(event):
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
+ invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
@@ -687,7 +696,7 @@ def retry_stuck_invoices(sender, **kwargs):
with transaction.atomic():
qs = Invoice.objects.filter(
transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT,
- transmission_date__lte=now() - timedelta(hours=24),
+ transmission_date__lte=now() - timedelta(hours=48),
).select_for_update(
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
)
diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py
index 03bd489e7..3e05e9792 100644
--- a/src/pretix/base/services/mail.py
+++ b/src/pretix/base/services/mail.py
@@ -222,7 +222,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
'invoice_company': ''
})
renderer = ClassicMailRenderer(None, organizer)
- content_plain = body_plain = render_mail(template, context)
+ body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN)
subject = str(subject).format_map(TolerantDict(context))
sender = (
sender or
@@ -316,6 +316,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
with override(timezone):
try:
+ content_plain = render_mail(template, context, placeholder_mode=None)
if plain_text_only:
body_html = None
elif 'context' in inspect.signature(renderer.render).parameters:
@@ -751,11 +752,11 @@ def mail_send(*args, **kwargs):
mail_send_task.apply_async(args=args, kwargs=kwargs)
-def render_mail(template, context):
+def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN):
if isinstance(template, LazyI18nString):
body = str(template)
- if context:
- body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
+ if context and placeholder_mode:
+ body = format_map(body, context, mode=placeholder_mode)
else:
tpl = get_template(template)
body = tpl.render(context)
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index bfe817d0f..e8be424cf 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -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
@@ -146,6 +146,10 @@ error_messages = {
'race_condition': gettext_lazy("This order was changed by someone else simultaneously. Please check if your "
"changes are still accurate and try again."),
'empty': gettext_lazy("Your cart is empty."),
+ 'max_items': ngettext_lazy(
+ "You cannot select more than %s item per order.",
+ "You cannot select more than %s items per order."
+ ),
'max_items_per_product': ngettext_lazy(
"You cannot select more than %(max)s item of the product %(product)s. We removed the surplus items from your cart.",
"You cannot select more than %(max)s items of the product %(product)s. We removed the surplus items from your cart.",
@@ -763,6 +767,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
shared_lock_objects=[event]
)
+ # Check maximum order size
+ limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
+ if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
+ err = err or (error_messages['max_items'] % limit)
+
# Check availability
for i, cp in enumerate(sorted_positions):
if cp.pk in deleted_positions:
@@ -947,10 +956,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 +983,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 +998,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 +1053,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 +1062,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 +1086,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 +1101,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 +1189,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 +1295,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:
@@ -1656,15 +1679,16 @@ class OrderChangeManager:
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
- def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
+ def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order
self.user = user
self.auth = auth
self.event = order.event
self.split_order = None
self.reissue_invoice = reissue_invoice
+ self.allow_blocked_seats = allow_blocked_seats
self._committed = False
- self._totaldiff = 0
+ self._totaldiff_guesstimate = 0
self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = []
@@ -1781,7 +1805,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 +1850,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 +1938,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])
@@ -2183,7 +2207,7 @@ class OrderChangeManager:
for seat, diff in self._seatdiff.items():
if diff <= 0:
continue
- if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True) or diff > 1:
+ if not seat.is_available(sales_channel=self.order.sales_channel, ignore_distancing=True, always_allow_blocked=self.allow_blocked_seats) or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents:
@@ -2210,8 +2234,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 +2243,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 +2270,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 +2290,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 +2302,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 +2431,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 +2706,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 +2730,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 +2810,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 +2843,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 +2878,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 +3008,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 +3030,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 +3037,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 +3049,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 +3271,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 +3288,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 +3354,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:
diff --git a/src/pretix/base/services/placeholders.py b/src/pretix/base/services/placeholders.py
index c4cdc03c3..9376748eb 100644
--- a/src/pretix/base/services/placeholders.py
+++ b/src/pretix/base/services/placeholders.py
@@ -26,7 +26,7 @@ from decimal import Decimal
from django.dispatch import receiver
from django.utils.formats import date_format
-from django.utils.html import escape
+from django.utils.html import escape, mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -123,6 +123,10 @@ class BaseRichTextPlaceholder(BaseTextPlaceholder):
def identifier(self):
return self._identifier
+ @property
+ def allowed_in_plain_content(self):
+ return False
+
@property
def required_context(self):
return self._args
@@ -194,6 +198,33 @@ class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
return f'{text}: {url}'
+class MarkdownTextPlaceholder(BaseRichTextPlaceholder):
+ def __init__(self, identifier, args, func, sample, inline):
+ super().__init__(identifier, args)
+ self._func = func
+ self._sample = sample
+ self._snippet = inline
+
+ @property
+ def allowed_in_plain_content(self):
+ return self._snippet
+
+ def render_plain(self, **context):
+ return self._func(**{k: context[k] for k in self._args})
+
+ def render_html(self, **context):
+ return mark_safe(markdown_compile_email(self.render_plain(**context), snippet=self._snippet))
+
+ def render_sample_plain(self, event):
+ if callable(self._sample):
+ return self._sample(event)
+ else:
+ return self._sample
+
+ def render_sample_html(self, event):
+ return mark_safe(markdown_compile_email(self.render_sample_plain(event), snippet=self._snippet))
+
+
class PlaceholderContext(SafeFormatter):
"""
Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -574,7 +605,7 @@ def base_placeholders(sender, **kwargs):
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
_('Sample Corporation')
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
@@ -604,6 +635,7 @@ def base_placeholders(sender, **kwargs):
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
]
),
+ inline=False,
),
SimpleFunctionalTextPlaceholder(
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
@@ -618,12 +650,13 @@ def base_placeholders(sender, **kwargs):
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
'68CYU2H6ZTP3WLK5'
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
- ' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
+ '68CYU2H6ZTP3WLK5 \n7MB94KKPVEPSMVF2',
+ inline=False,
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_url_list', ['event', 'voucher_list'],
lambda event, voucher_list: ' \n'.join([
@@ -638,6 +671,7 @@ def base_placeholders(sender, **kwargs):
) + '?voucher=' + c
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
]),
+ inline=False,
),
SimpleFunctionalTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
@@ -656,13 +690,13 @@ def base_placeholders(sender, **kwargs):
'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'),
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
'payment_info', ['order', 'payments'], _placeholder_payments,
- _('The amount has been charged to your card.'),
+ _('The amount has been charged to your card.'), inline=False,
),
- SimpleFunctionalTextPlaceholder(
+ MarkdownTextPlaceholder(
'payment_info', ['payment_info'], lambda payment_info: payment_info,
- _('Please transfer money to this bank account: 9999-9999-9999-9999'),
+ _('Please transfer money to this bank account: 9999-9999-9999-9999'), inline=False,
),
SimpleFunctionalTextPlaceholder(
'attendee_name', ['position'], lambda position: position.attendee_name,
@@ -719,13 +753,13 @@ def base_placeholders(sender, **kwargs):
))
for k, v in sender.meta_data.items():
- ph.append(SimpleFunctionalTextPlaceholder(
+ ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
- v
+ v, inline=True,
))
- ph.append(SimpleFunctionalTextPlaceholder(
+ ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
- v
+ v, inline=True,
))
return ph
@@ -753,7 +787,7 @@ def get_available_placeholders(event, base_parameters, rich=False):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
- if isinstance(v, BaseRichTextPlaceholder) and not rich:
+ if isinstance(v, BaseRichTextPlaceholder) and not rich and not v.allowed_in_plain_content:
continue
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
@@ -775,13 +809,13 @@ def get_sample_context(event, context_parameters, rich=True):
)
)
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
- context_dict[k] = '
+ {% blocktrans trimmed %} + Your email address is not confirmed yet. To secure your account, please confirm your email address using + a confirmation code we will send to your email address. + {% endblocktrans %} +
++
+ +