Compare commits

...

33 Commits

Author SHA1 Message Date
Mira Weller f8e5c7867b add log message with json path on schema validation error 2025-11-18 18:15:23 +01:00
Mira Weller 7010752bb0 Fix schema 2025-11-18 18:15:23 +01:00
Mira Weller 7d8bcd4b10 Migrate log action types to registry 2025-11-18 18:15:23 +01:00
Mira Weller 2121566ae5 Fix incorrect or missing log action types 2025-11-05 20:27:56 +01:00
Mira Weller 9f6216e6f1 Add some example schemas 2025-11-05 20:27:56 +01:00
Mira Weller c824663946 Implement schema validation and schema-based shredding 2025-11-05 19:41:47 +01:00
Yasunobu YesNo Kawaguchi 29906c6288 Translations: Update Japanese
Currently translated at 100.0% (6132 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 3380bd3e82 Translations: Update Spanish
Currently translated at 100.0% (6132 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 6ae8e7cbb6 Translations: Update French
Currently translated at 100.0% (6132 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 23c2d9266e Translations: Update Spanish
Currently translated at 99.9% (6130 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es ba155faaa3 Translations: Update French
Currently translated at 99.9% (6131 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2025-11-04 12:04:23 +01:00
Núria Masclans fd177fa89f Translations: Update Catalan
Currently translated at 78.7% (200 of 254 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/ca/

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es 0b051c1400 Translations: Update Spanish
Currently translated at 100.0% (6132 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2025-11-04 12:04:23 +01:00
CVZ-es af8d0f0b65 Translations: Update French
Currently translated at 100.0% (6132 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2025-11-04 12:04:23 +01:00
Raphael Michel 1b7bba195c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6132 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2025-11-04 12:04:23 +01:00
Raphael Michel f056f77dc0 Translations: Update German
Currently translated at 100.0% (6132 of 6132 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2025-11-04 12:04:23 +01:00
Richard Schreiber ee4e7f618f Sort answers according to questions order
* Sort answers according to questions (Z#23212280)

* Undo ordering in Meta-class

* filter and order answers only on invoice
2025-11-04 11:33:16 +01:00
dependabot[bot] cd450f1780 Bump @babel/core from 7.28.4 to 7.28.5 in /src/pretix/static/npm_dir (#5579)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.28.4 to 7.28.5.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 11:26:29 +01:00
dependabot[bot] fc876978b2 Bump @rollup/plugin-babel in /src/pretix/static/npm_dir (#5581)
Bumps [@rollup/plugin-babel](https://github.com/rollup/plugins/tree/HEAD/packages/babel) from 6.0.4 to 6.1.0.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/babel/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/url-v6.1.0/packages/babel)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-babel"
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 11:26:22 +01:00
dependabot[bot] d8efaa47f7 Update css-inline requirement from ==0.17.* to ==0.18.*
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/v0.17.0...v0.18.0)

---
updated-dependencies:
- dependency-name: css-inline
  dependency-version: 0.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 11:25:45 +01:00
dependabot[bot] f0c3514588 Bump @babel/preset-env in /src/pretix/static/npm_dir (#5580)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.28.3 to 7.28.5.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.28.5/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.28.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 17:40:47 +01:00
dependabot[bot] e1ad4d9dba Bump @rollup/plugin-node-resolve in /src/pretix/static/npm_dir (#5578)
Bumps [@rollup/plugin-node-resolve](https://github.com/rollup/plugins/tree/HEAD/packages/node-resolve) from 16.0.1 to 16.0.3.
- [Changelog](https://github.com/rollup/plugins/blob/master/packages/node-resolve/CHANGELOG.md)
- [Commits](https://github.com/rollup/plugins/commits/node-resolve-v16.0.3/packages/node-resolve)

---
updated-dependencies:
- dependency-name: "@rollup/plugin-node-resolve"
  dependency-version: 16.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 17:40:15 +01:00
Raphael Michel 3ab587883e Translations: Add words to spellcheck wordlist 2025-11-03 17:40:04 +01:00
Raphael Michel b02e1a1515 Gift card payment: Clean-up some code (#5574)
* Remove apparently unused code

* Move templates that do not belong in pretixcontrol
2025-11-03 17:38:56 +01:00
Raphael Michel 41780add40 Gift cards: Remove nested form tags 2025-10-30 18:03:47 +01:00
Raphael Michel b07a61e4f1 Remove visible rounding mode 2025-10-30 17:54:06 +01:00
Raphael Michel dead2a9bed Bank transfer: Reorder fields for pending bank details (#5562)
* Reorder fields for pending bank transfer

* Remove unnecessary endif statement in pending.html

* Fix indentation in pending.html template
2025-10-30 16:40:39 +01:00
Raphael Michel 94389c3913 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2025-10-30 11:55:47 +01:00
Raphael Michel 3e972eddbf Allow to round taxes on order-level (#5019)
* Allow to round taxes on order-level

* Rename get_cart_total

* Persist rounding mode with order

* Add general docs

* Order creation API

* Update fee algorithm

* Rounding on payment method change

* Round when splitting order

* Fix failing tests

* Add settings page

* Add tests

* Replace algorithm

* Add test case for currency rounding

* Improve order change

* Update flowchart

* Update discount logic (more hypothetical, we don't store rounding on cart positions atm)

* Rename internal method

* Fix typo

* Update help text

* Apply suggestions from code review

Co-authored-by: luelista <weller@rami.io>

* Order rounding refactor (#5571)

* Add RoundingCorrectionMixin providing before-rounding-values as properties

* Use gross_price_before_rounding in more places

* Update doc/development/algorithms/pricing.rst

Co-authored-by: Martin Gross <gross@rami.io>

* Allow to override on perform_order

* Rebase migration

* Fix event cancellation

---------

Co-authored-by: luelista <weller@rami.io>
Co-authored-by: Martin Gross <gross@rami.io>
2025-10-30 11:49:31 +01:00
dependabot[bot] cdeb1e86bd Update sentry-sdk requirement from ==2.42.* to ==2.43.*
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.42.0...2.43.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.43.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-30 11:33:10 +01:00
Raphael Michel 9a69b76880 API: Expose history of check-ins (Z#23206049) 2025-10-30 10:45:01 +01:00
Richard Schreiber 7d5df2b69e Fix required label for multi-checkbox form-groups (#5568) 2025-10-30 10:44:17 +01:00
Raphael Michel d203eee5ab Bump version to 2025.10.0.dev0 2025-10-30 09:53:25 +01:00
116 changed files with 64957 additions and 57207 deletions
+91
View File
@@ -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.
+8
View File
@@ -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)
+121
View File
@@ -178,3 +178,124 @@ Flowchart
---------
.. image:: /images/cart_pricing.png
.. _`algorithms-rounding`:
Rounding of taxes
-----------------
pretix internally always stores taxes on a per-line level, like this:
========== ========== =========== ======= =============
Product Tax rate Net price Tax Gross price
========== ========== =========== ======= =============
Ticket A 19 % 84.03 15.97 100.00
Ticket B 19 % 84.03 15.97 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 79.85 500.00
========== ========== =========== ======= =============
Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line.
The line-based computation has a few significant advantages:
- We can report both net and gross prices for every individual ticket.
- We can report both net and gross prices for every filter imaginable, such as the gross sum of all sales of Ticket A
or the net sum of all sales for a specific date in an event series. All numbers will be exact.
- When splitting the order into two, both net price and gross price are split without any changes in rounding.
The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15)
and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98
(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation
to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that
does not allow the computation as created by pretix.
However, calculating the tax rate from the net total has significant disadvantages:
- It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to
consumers, they will be confused when they only need to pay €499.98 for 5 tickets.
- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there
is no two-decimal net price that would be computed to a gross price of €99.99.
- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the
original order. Therefore, additional payments or refunds of very small amounts might be necessary.
To allow organizers to make their own choices on this matter, pretix provides the following options:
Compute taxes for every line individually
"""""""""""""""""""""""""""""""""""""""""
Algorithm identifier: ``line``
This is our original algorithm where the tax value is rounded for every line individually.
**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below).
For the example above:
========== ========== =========== ======= =============
Product Tax rate Net price Tax Gross price
========== ========== =========== ======= =============
Ticket A 19 % 84.03 15.97 100.00
Ticket B 19 % 84.03 15.97 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 79.85 500.00
========== ========== =========== ======= =============
Compute taxes based on net total
""""""""""""""""""""""""""""""""
Algorithm identifier: ``sum_by_net``
In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within
our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01).
The net price of the tickets always stay the same.
**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.**
The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways.
For the example above, the customer expects to pay 5 times 100.00, but they are are in fact charged 499.98:
========== ========== =========== ============================== ==============================
Product Tax rate Net price Tax Gross price
========== ========== =========== ============================== ==============================
Ticket A 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
Ticket B 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.15 78.83 499.98
========== ========== =========== ============================== ==============================
Compute taxes based on net total with stable gross prices
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
Algorithm identifier: ``sum_by_net_keep_gross``
In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices
of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting
gross prices stay the same.
**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.**
The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different.
Full computation for the example above:
========== ========== ============================= ============================== =============
Product Tax rate Net price Tax Gross price
========== ========== ============================= ============================== =============
Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.17 79.83 500.00
========== ========== ============================= ============================== =============
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 49 KiB

+1
View File
@@ -23,6 +23,7 @@ partition "For every cart position" {
--> "Store as line_price (gross), tax_rate"
}
--> "Apply discount engine"
--> "Apply tax rounding"
--> "Store as price (gross)"
@enduml
+2 -2
View File
@@ -33,7 +33,7 @@ dependencies = [
"celery==5.5.*",
"chardet==5.2.*",
"cryptography>=44.0.0",
"css-inline==0.17.*",
"css-inline==0.18.*",
"defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.24",
"django-bootstrap3==25.2",
@@ -91,7 +91,7 @@ dependencies = [
"redis==6.4.*",
"reportlab==4.4.*",
"requests==2.32.*",
"sentry-sdk==2.42.*",
"sentry-sdk==2.43.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2025.9.0"
__version__ = "2025.10.0.dev0"
+1
View File
@@ -829,6 +829,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_eu_currencies',
'invoice_logo_image',
'invoice_renderer_highlight_order_code',
'tax_rounding',
'cancel_allow_user',
'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep',
+59 -14
View File
@@ -52,9 +52,10 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher,
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -64,10 +65,13 @@ from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
apply_discounts, apply_rounding, get_line_price, get_listed_price,
is_included_for_free,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
)
from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -325,7 +329,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
return data
class CheckinSerializer(I18nAwareModelSerializer):
class InlineCheckinSerializer(I18nAwareModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
slug_field='device_id',
@@ -337,6 +341,21 @@ class CheckinSerializer(I18nAwareModelSerializer):
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type')
class CheckinSerializer(I18nAwareModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
slug_field='device_id',
read_only=True,
)
class Meta:
model = Checkin
fields = (
'id', 'successful', 'error_reason', 'error_explanation', 'position', 'datetime', 'list', 'created',
'auto_checked_in', 'gate', 'device', 'device_id', 'type'
)
class PrintLogSerializer(serializers.ModelSerializer):
device_id = serializers.SlugRelatedField(
source='device',
@@ -560,7 +579,7 @@ class OrderPositionPluginDataField(serializers.Field):
class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True, read_only=True)
checkins = InlineCheckinSerializer(many=True, read_only=True)
print_logs = PrintLogSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True)
downloads = PositionDownloadsField(source='*', read_only=True)
@@ -833,14 +852,15 @@ class OrderSerializer(I18nAwareModelSerializer):
list_serializer_class = OrderListSerializer
fields = (
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data',
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
'plugin_data',
)
read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date'
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
)
def __init__(self, *args, **kwargs):
@@ -1159,6 +1179,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
queryset=SalesChannel.objects.none(),
required=False,
)
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
def __init__(self, *args, **kwargs):
@@ -1175,7 +1196,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
'require_approval', 'valid_if_pending', 'expires', 'api_meta')
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
def validate_payment_provider(self, pp):
if pp is None:
@@ -1701,7 +1722,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
f.save()
order.total += sum([f.value for f in fees])
rounding_mode = validated_data.get("tax_rounding_mode")
if not rounding_mode:
if isinstance(self.context.get("auth"), Device):
# Safety fallback to avoid differences in tax reporting
brand = self.context.get("auth").software_brand or ""
if "pretixPOS" in brand or "pretixKIOSK" in brand:
rounding_mode = "line"
if not rounding_mode:
rounding_mode = self.context["event"].settings.tax_rounding
changed = apply_rounding(
rounding_mode,
self.context["event"].currency,
[*pos_map.values(), *fees]
)
for line in changed:
if isinstance(line, OrderPosition):
line.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(line, OrderFee):
line.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees])
if simulate:
order.fees = fees
order.positions = pos_map.values()
+1
View File
@@ -92,6 +92,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'seats', event.SeatViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'checkins', checkin.CheckinViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet)
event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet)
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
+34 -1
View File
@@ -56,7 +56,8 @@ from pretix.api.serializers.checkin import (
)
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import (
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
CheckinListOrderPositionSerializer, CheckinSerializer,
FailedCheckinSerializer,
)
from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
@@ -96,6 +97,16 @@ with scopes_disabled():
)
return queryset.filter(expr)
class CheckinFilter(FilterSet):
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
class Meta:
model = Checkin
fields = ['successful', 'error_reason', 'list', 'type', 'gate', 'device', 'auto_checked_in']
class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
@@ -1080,3 +1091,25 @@ class CheckinRPCAnnulView(views.APIView):
checkin_annulled.send(ci.position.order.event, checkin=ci)
return Response({"status": "ok"}, status=status.HTTP_200_OK)
class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinSerializer
queryset = Checkin.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filterset_class = CheckinFilter
ordering = ('created', 'id')
ordering_fields = ('created', 'datetime', 'id',)
permission = 'can_view_orders'
def get_queryset(self):
qs = Checkin.all.filter().select_related(
"position",
"device",
)
return qs
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
+1
View File
@@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['auth'] = self.request.auth
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx
+1 -1
View File
@@ -800,7 +800,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
identifier=serializer.instance.identifier,
)
inst.log_action(
'pretix.sales_channel.changed',
'pretix.saleschannel.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
+57 -1
View File
@@ -19,15 +19,20 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
import logging
from collections import defaultdict
from functools import cached_property
from typing import Optional
import jsonschema
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.signals import PluginAwareRegistry
logger = logging.getLogger(__name__)
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
if a_map:
@@ -105,12 +110,38 @@ They are annotated with their ``action_type`` and the defining ``plugin``.
log_entry_types = LogEntryTypeRegistry()
def prepare_schema(schema):
def handle_properties(t):
return {"shred_properties": [k for k, v in t["properties"].items() if v["shred"]]}
def walk_tree(schema):
if type(schema) is dict:
new_keys = {}
for k, v in schema.items():
if k == "properties":
new_keys = handle_properties(schema)
walk_tree(v)
if schema.get("type") == "object" and "additionalProperties" not in new_keys:
new_keys["additionalProperties"] = False
schema.update(new_keys)
elif type(schema) is list:
for v in schema:
walk_tree(v)
walk_tree(schema)
return schema
class LogEntryType:
"""
Base class for a type of LogEntry, identified by its action_type.
"""
data_schema = None # {"type": "object", "properties": []}
def __init__(self, action_type=None, plain=None):
if self.data_schema:
print(self.__class__.__name__, "has schema", self._prepared_schema)
if action_type:
self.action_type = action_type
if plain:
@@ -147,12 +178,37 @@ class LogEntryType:
object_link_wrapper = '{val}'
def validate_data(self, parsed_data):
if not self._prepared_schema:
return
try:
jsonschema.validate(parsed_data, self._prepared_schema)
except jsonschema.exceptions.ValidationError as ex:
logger.warning("%s schema validation failed: %s %s", type(self).__name__, ex.json_path, ex.message)
raise
@cached_property
def _prepared_schema(self):
if self.data_schema:
return prepare_schema(self.data_schema)
def shred_pii(self, logentry):
"""
To be used for shredding personally identified information contained in the data field of a LogEntry of this
type.
"""
raise NotImplementedError
if self._prepared_schema:
def shred_fun(validator, value, instance, schema):
for key in value:
instance[key] = "##########"
v = jsonschema.validators.extend(jsonschema.validators.Draft202012Validator,
validators={"shred_properties": shred_fun})
data = logentry.parsed_data
jsonschema.validate(data, self._prepared_schema, v)
logentry.data = json.dumps(data)
else:
raise NotImplementedError
class NoOpShredderMixin:
@@ -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),
),
]
+7
View File
@@ -80,6 +80,7 @@ class LoggingMixin:
from pretix.api.models import OAuthAccessToken, OAuthApplication
from pretix.api.webhooks import notify_webhooks
from ..logentrytype_registry import log_entry_types
from ..services.notifications import notify
from .devices import Device
from .event import Event
@@ -124,7 +125,13 @@ class LoggingMixin:
if (sensitivekey in k) and v:
data[k] = "********"
type, meta = log_entry_types.get(action_type=action)
if not type:
raise TypeError("Undefined log entry type '%s'" % action)
logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True)
type.validate_data(json.loads(logentry.data))
elif data:
raise TypeError("You should only supply dictionaries as log data.")
if save:
+117 -19
View File
@@ -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):
+10 -6
View File
@@ -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,
})
+1 -1
View File
@@ -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
+49 -21
View File
@@ -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
+9 -3
View File
@@ -258,9 +258,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
if resp:
desc += "<br/>" + resp
for answ in p.answers.all():
if not answ.question.print_on_invoice:
continue
answers_qs = p.answers.filter(
question__print_on_invoice=True
).select_related(
'question'
).order_by(
'question__position',
'question__id'
)
for answ in answers_qs:
desc += "<br />{}{} {}".format(
answ.question.question,
"" if str(answ.question.question).endswith("?") else ":",
+157 -77
View File
@@ -95,7 +95,7 @@ from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order,
)
from pretix.base.services.pricing import (
apply_discounts, get_listed_price, get_price,
apply_discounts, apply_rounding, get_listed_price, get_price,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
@@ -947,10 +947,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
]
)
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
cp.price = new_price
cp.price_includes_rounding_correction = Decimal("0.00")
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
# After applying discounts, add-on positions might still have a reference to the *old* version of the
# parent position, which can screw up ordering later since the system sees inconsistent data.
@@ -973,10 +974,11 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
raise OrderError(err)
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
fees = []
total = sum([c.price for c in positions])
# Pre-rounding, pre-fee total is used for fee calculation
total = sum([c.gross_price_before_rounding for c in positions])
gift_cards = [] # for backwards compatibility
for p in payment_requests:
@@ -987,40 +989,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
meta_info=meta_info, positions=positions, gift_cards=gift_cards):
if resp:
fees += resp
total += sum(f.value for f in fees)
total_remaining = total
for fee in fees:
fee._calculate_tax(invoice_address=address, event=event)
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
# Apply rounding to get final total in case no payment fees will be added
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments_assigned = Decimal("0.00")
for p in payment_requests:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
p['payment_amount'] = Decimal('0.00')
continue
to_pay = total_remaining
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payment_fee = p['pprov'].calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
p['payment_amount'] = to_pay
if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=p['pprov'].identifier)
pf._calculate_tax(invoice_address=address, event=event)
fees.append(pf)
p['fee'] = pf
if total_remaining != Decimal('0.00') and not require_approval:
# Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payments_assigned += to_pay
p['payment_amount'] = to_pay
if total != payments_assigned and not require_approval:
raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees
@@ -1029,7 +1044,7 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
customer=None, valid_if_pending=False, api_meta: dict=None):
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
payments = []
try:
@@ -1038,10 +1053,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
raise OrderError(e.message)
require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
# Final calculation of fees, also performs final rounding
try:
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
order = Order(
@@ -1059,6 +1077,7 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
sales_channel=sales_channel,
customer=customer,
valid_if_pending=valid_if_pending,
tax_rounding_mode=tax_rounding_mode or event.settings.tax_rounding,
)
if customer:
order.email_known_to_work = customer.is_verified
@@ -1073,12 +1092,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
for fee in fees:
fee.order = order
try:
fee._calculate_tax()
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
fee.save()
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
@@ -1167,7 +1180,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
shown_total=None, customer=None, api_meta: dict=None):
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
for p in payment_requests:
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
if not p['pprov']:
@@ -1273,6 +1286,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
customer=customer,
valid_if_pending=valid_if_pending,
api_meta=api_meta,
tax_rounding_mode=tax_rounding_mode,
)
try:
@@ -1664,7 +1678,7 @@ class OrderChangeManager:
self.split_order = None
self.reissue_invoice = reissue_invoice
self._committed = False
self._totaldiff = 0
self._totaldiff_guesstimate = 0
self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = []
@@ -1781,7 +1795,7 @@ class OrderChangeManager:
if position.issued_gift_cards.exists():
raise OrderError(self.error_messages['gift_card_change'])
self._totaldiff += price.gross - position.price
self._totaldiff_guesstimate += price.gross - position.gross_price_before_rounding
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
@@ -1826,29 +1840,29 @@ class OrderChangeManager:
else:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code)
self._totaldiff += new_tax.gross - pos.price
self._totaldiff_guesstimate += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee):
self._totaldiff -= fee.value
self._totaldiff_guesstimate -= fee.value
self._operations.append(self.CancelFeeOperation(fee, -fee.value))
self._invoice_dirty = True
def add_fee(self, fee: OrderFee):
self._totaldiff += fee.value
self._totaldiff_guesstimate += fee.value
self._invoice_dirty = True
self._operations.append(self.AddFeeOperation(fee, fee.value))
def change_fee(self, fee: OrderFee, value: Decimal):
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address,
force_fixed_gross_price=True)
self._totaldiff += value.gross - fee.value
self._totaldiff_guesstimate += value.gross - fee.value
self._invoice_dirty = True
self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value))
def cancel(self, position: OrderPosition):
self._totaldiff -= position.price
self._totaldiff_guesstimate -= position.price
self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position, -position.price))
if position.seat:
@@ -1914,7 +1928,7 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff += price.gross
self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
@@ -2210,8 +2224,8 @@ class OrderChangeManager:
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise OrderError(self.error_messages['quota'].format(name=quota.name))
def _check_paid_price_change(self):
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
def _check_paid_price_change(self, totaldiff):
if self.order.status == Order.STATUS_PAID and totaldiff > 0:
if self.order.pending_sum > Decimal('0.00'):
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
@@ -2219,7 +2233,7 @@ class OrderChangeManager:
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save()
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff < 0:
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
self.order.status = Order.STATUS_PAID
self.order.save()
@@ -2246,7 +2260,7 @@ class OrderChangeManager:
user=self.user,
auth=self.auth
)
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0:
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff > 0:
if self.open_payment:
try:
self.open_payment.payment_provider.cancel_payment(self.open_payment)
@@ -2266,11 +2280,11 @@ class OrderChangeManager:
auth=self.auth,
)
def _check_paid_to_free(self):
if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"):
def _check_paid_to_free(self, totaldiff):
if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"):
raise OrderError(error_messages['currency_XXX'])
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
if self.order.total == 0 and (totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
if not self.order.fees.exists() and not self.order.positions.exists():
# The order is completely empty now, so we cancel it.
self.order.status = Order.STATUS_CANCELED
@@ -2278,7 +2292,7 @@ class OrderChangeManager:
order_canceled.send(self.order.event, order=self.order)
elif self.order.status != Order.STATUS_CANCELED:
# if the order becomes free, mark it paid using the 'free' provider
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
# this could happen if positions have been made cheaper or removed (totaldiff < 0)
# or positions got split off to a new order (split_order with positive total)
p = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
@@ -2407,10 +2421,15 @@ class OrderChangeManager:
'new_price': op.price.gross
})
position.price = op.price.gross
position.price_includes_rounding_correction = Decimal("0.00")
position.tax_rate = op.price.rate
position.tax_value = op.price.tax
position.tax_value_includes_rounding_correction = Decimal("0.00")
position.tax_code = op.price.code
position.save(update_fields=['price', 'tax_rate', 'tax_value', 'tax_code'])
position.save(update_fields=[
'price', 'price_includes_rounding_correction', 'tax_rate', 'tax_value',
'tax_value_includes_rounding_correction', 'tax_code'
])
elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition):
position = position_cache.setdefault(op.position.pk, op.position)
@@ -2677,14 +2696,18 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist:
pass
split_order.total = sum([p.price for p in split_positions if not p.canceled])
fees = []
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
split_order.total += new_fee.value
new_fee.save()
fees.append(new_fee)
changed_by_rounding = set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled])
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider()
@@ -2697,9 +2720,27 @@ class OrderChangeManager:
fee._calculate_tax()
if payment_fee != 0:
fee.save()
fees.append(fee)
elif fee.pk:
if fee in fees:
fees.remove(fee)
fee.delete()
split_order.total += fee.value
changed_by_rounding |= set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
for l in changed_by_rounding:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
@@ -2759,9 +2800,12 @@ class OrderChangeManager:
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum
def _recalculate_total_and_payment_fee(self):
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
def _recalculate_rounding_total_and_payment_fee(self):
positions = list(self.order.positions.all())
fees = list(self.order.fees.all())
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
payment_fee = Decimal('0.00')
fee_changed = False
if self.open_payment:
current_fee = Decimal('0.00')
fee = None
@@ -2789,14 +2833,32 @@ class OrderChangeManager:
fee.value = payment_fee
fee._calculate_tax()
fee.save()
fee_changed = True
if not self.open_payment.fee:
self.open_payment.fee = fee
self.open_payment.save(update_fields=['fee'])
elif fee and not fee.canceled:
fee.delete()
fee_changed = True
self.order.total = total + payment_fee
if fee_changed:
fees = list(self.order.fees.all())
changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
for l in changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
self.order.total = total
self.order.save()
return total
def _check_order_size(self):
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
@@ -2806,23 +2868,6 @@ class OrderChangeManager:
}
)
def _payment_fee_diff(self):
total = self.order.total + self._totaldiff
if self.open_payment:
current_fee = Decimal('0.00')
if self.open_payment and self.open_payment.fee:
current_fee = self.open_payment.fee.value
total -= current_fee
# Do not change payment fees of paid orders
payment_fee = Decimal('0.00')
if self.order.pending_sum - current_fee != 0:
prov = self.open_payment.payment_provider
if prov:
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
self._totaldiff += payment_fee - current_fee
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
if self.reissue_invoice and self._invoice_dirty:
@@ -2953,6 +2998,13 @@ class OrderChangeManager:
shared_lock_objects=[self.event]
)
def guess_totaldiff(self):
"""
Return the estimated difference of ``order.total`` based on the currently queued operations. This is only
a guess since it does not account for (a) tax rounding or (b) payment fee changes.
"""
return self._totaldiff_guesstimate
def commit(self, check_quotas=True):
if self._committed:
# an order change can only be committed once
@@ -2968,8 +3020,6 @@ class OrderChangeManager:
# so it's dangerous to keep the cache around.
self.order._prefetched_objects_cache = {}
# finally, incorporate difference in payment fees
self._payment_fee_diff()
self._check_order_size()
with transaction.atomic():
@@ -2977,6 +3027,7 @@ class OrderChangeManager:
if locked_instance.last_modified != self.order.last_modified:
raise OrderError(error_messages['race_condition'])
original_total = self.order.total
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
if check_quotas:
self._check_quotas()
@@ -2988,9 +3039,10 @@ class OrderChangeManager:
self._perform_operations()
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee()
self._check_paid_price_change()
self._check_paid_to_free()
new_total = self._recalculate_rounding_total_and_payment_fee()
totaldiff = new_total - original_total
self._check_paid_price_change(totaldiff)
self._check_paid_to_free(totaldiff)
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
self._reissue_invoice()
self._clear_tickets_cache()
@@ -3209,6 +3261,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
raise Exception('change_payment_provider should only be called in atomic transaction!')
oldtotal = order.total
already_paid = order.payment_refund_sum
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED))
open_fees = list(
@@ -3225,19 +3278,46 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order)
old_fee = fee.value
positions = list(order.positions.all())
fees = list(order.fees.all())
rounding_changed = set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
))
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
new_fee = payment_provider.calculate_fee(
order.pending_sum - old_fee if amount is None else amount
pending_sum_without_fee if amount is None else amount
)
if new_fee:
fee.value = new_fee
fee.internal_type = payment_provider.identifier
fee._calculate_tax()
if fee in fees:
fees.remove(fee)
# "Update instance in the fees array
fees.append(fee)
fee.save()
else:
if fee in fees:
fees.remove(fee)
if fee.pk:
fee.delete()
fee = None
rounding_changed |= set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *fees]
))
for l in rounding_changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
open_payment = None
if new_payment:
lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
@@ -3264,7 +3344,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
},
)
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
order.total = sum(c.price for c in positions) + sum(f.value for f in fees)
order.save(update_fields=['total'])
if not new_payment:
+128 -6
View File
@@ -23,15 +23,17 @@ import re
from collections import defaultdict
from datetime import datetime
from decimal import Decimal
from typing import List, Optional, Tuple, Union
from itertools import groupby
from typing import List, Literal, Optional, Tuple, Union
from django import forms
from django.conf import settings
from django.db.models import Q
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
SalesChannel, Voucher,
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
@@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice:
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal,
is_bundled=False) -> TaxedPrice:
if not tax_rule:
tax_rule = TaxRule(
name='',
@@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross',
override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum)
else:
@@ -164,7 +168,7 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
"""
Applies any dynamic discounts to a cart
@@ -203,3 +207,121 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
new_prices.update(result)
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
"""
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and
``tax_value_includes_rounding_correction`` attributes.
When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line.
When rounding mode is set to ``"sum_by_net_keep_gross"``, the tax values of the individual lines will be adjusted
such that the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant.
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
:param currency: Currency that will be used to determine rounding precision
:param lines: List of order/cart contents
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
"""
def _key(line):
return (line.tax_rate, line.tax_code)
places = settings.CURRENCY_PLACES.get(currency, 2)
minimum_unit = Decimal('1') / 10 ** places
changed = []
if rounding_mode == "sum_by_net":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
# Compute the net and gross total of the line-based computation method
net_total = sum(l.net_price_before_rounding for l in lines)
gross_total = sum(l.gross_price_before_rounding for l in lines)
# Compute the gross total we need to achieve based on the net total
target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency)
# Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same)
# until the values align
diff = target_gross_total - gross_total
diff_sgn = -1 if diff < 0 else 1
for l in lines:
if diff:
apply_diff = diff_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
l.tax_value = l.tax_value_before_rounding + apply_diff
l.tax_value_includes_rounding_correction = apply_diff
diff -= apply_diff
changed.append(l)
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "sum_by_net_keep_gross":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
# Compute the net and gross total of the line-based computation method
net_total = sum(l.net_price_before_rounding for l in lines)
gross_total = sum(l.gross_price_before_rounding for l in lines)
# Compute the net total that would yield the correct gross total (if possible)
target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency)
# Compute the gross total that would be computed from that net total this will be different than
# gross_total when there is no possible net value for the gross total
# e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98
target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency)
diff_gross = target_gross_total - gross_total
diff_net = target_net_total - net_total
diff_gross_sgn = -1 if diff_gross < 0 else 1
diff_net_sgn = -1 if diff_net < 0 else 1
for l in lines:
if diff_gross:
apply_diff = diff_gross_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
l.tax_value = l.tax_value_before_rounding + apply_diff
l.tax_value_includes_rounding_correction = apply_diff
changed.append(l)
diff_gross -= apply_diff
elif diff_net:
apply_diff = diff_net_sgn * minimum_unit
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding - apply_diff
l.tax_value_includes_rounding_correction = -apply_diff
changed.append(l)
diff_net -= apply_diff
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "line":
for l in lines:
if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.gross_price_before_rounding
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
else:
raise ValueError("Unknown rounding_mode")
return changed
+27 -1
View File
@@ -77,6 +77,13 @@ from pretix.control.forms import (
)
from pretix.helpers.countries import CachedCountries
ROUNDING_MODES = (
('line', _('Compute taxes for every line individually')),
('sum_by_net', _('Compute taxes based on net total')),
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
# We could also have sum_by_gross, but we're not aware of any use-cases for it
)
def country_choice_kwargs():
allcountries = list(CachedCountries())
@@ -324,7 +331,7 @@ DEFAULTS = {
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
label=_("Show net prices instead of gross prices in the product list"),
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
"paid."),
@@ -465,6 +472,25 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
)
},
'tax_rounding': {
'default': 'line',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'form_kwargs': dict(
label=_("Rounding of taxes"),
widget=forms.RadioSelect,
choices=ROUNDING_MODES,
help_text=_(
"Note that if you transfer your sales data from pretix to an external system for tax reporting, you "
"need to make sure to account for possible rounding differences if your external system rounds "
"differently than pretix."
)
),
'serializer_kwargs': dict(
choices=ROUNDING_MODES,
),
},
'invoice_address_asked': {
'default': 'True',
'type': bool,
+79 -3
View File
@@ -68,7 +68,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS, validate_event_settings,
PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings,
)
from pretix.base.validators import multimail_validate
from pretix.control.forms import (
@@ -541,7 +541,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'show_date_to',
'show_times',
'show_items_outside_presale_period',
'display_net_prices',
'hide_prices_from_attendees',
'presale_start_show_date',
'locales',
@@ -799,6 +798,80 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
return value
class DisplayNetPricesBooleanSelect(forms.RadioSelect):
def __init__(self, attrs=None):
choices = (
("false", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices including tax"),
_("Recommended if you sell tickets at least partly to consumers.")
)),
("true", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices excluding tax"),
_("Recommended only if you sell tickets primarily to business customers.")
)),
)
super().__init__(attrs, choices)
def format_value(self, value):
try:
return {
True: "true",
False: "false",
"true": "true",
"false": "false",
}[value]
except KeyError:
return "unknown"
def value_from_datadict(self, data, files, name):
value = data.get(name)
return {
True: True,
"True": True,
"False": False,
False: False,
"true": True,
"false": False,
}.get(value)
class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
auto_fields = [
'display_net_prices',
'tax_rounding',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["display_net_prices"].label = _("Prices shown to customer")
self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect()
help_text = {
"line": _(
"Recommended when e-invoicing is not required. Each product will be sold with the advertised "
"net and gross price. However, in orders of more than one product, the total tax amount "
"can differ from when it would be computed from the order total."
),
"sum_by_net": _(
"Recommended for e-invoicing when you primarily sell to business customers and "
"show prices to customers excluding tax. "
"The gross price of some products may be changed to ensure correct rounding, while the net "
"prices will be kept as configured. This may cause the actual payment amount to differ."
),
"sum_by_net_keep_gross": _(
"Recommended for e-invoicing when you primarily sell to consumers. "
"The gross or net price of some products may be changed automatically to ensure correct "
"rounding of the order total. The system attempts to keep gross prices as configured whenever "
"possible. Gross prices may still change if they are impossible to derive from a rounded net price."
),
}
self.fields["tax_rounding"].choices = (
(k, format_html('{}<br><span class="text-muted">{}</span>', v, help_text.get(k, "")))
for k, v in ROUNDING_MODES
)
class ProviderForm(SettingsForm):
"""
This is a SettingsForm, but if fields are set to required=True, validation
@@ -1527,7 +1600,10 @@ class TaxRuleLineForm(I18nForm):
rate = forms.DecimalField(
label=_('Deviating tax rate'),
max_digits=10, decimal_places=2,
required=False
required=False,
widget=forms.NumberInput(attrs={
'placeholder': _('Deviating tax rate'),
})
)
invoice_text = I18nFormField(
label=_('Text on invoice'),
+79 -4
View File
@@ -503,7 +503,6 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'),
'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'),
'pretix.event.order.expired': _('The order has been marked as expired.'),
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'),
'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.reactivated': _('The order has been reactivated.'),
@@ -535,7 +534,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
'toggled.'),
'pretix.event.order.checkin_text': _('The order\'s check-in text has been changed.'),
'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'unpaid has been toggled.'),
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
@@ -575,6 +574,21 @@ class CoreOrderLogEntryType(OrderLogEntryType):
pass
@log_entry_types.new()
class OrderPaidLogEntryType(CoreOrderLogEntryType):
action_type = 'pretix.event.order.paid'
plain = _('The order has been marked as paid.')
data_schema = {
"type": "object",
"properties": {
"provider": {"type": ["null", "string"], "shred": False, },
"info": {"type": ["null", "string", "object"], "shred": True, },
"date": {"type": ["null", "string"], "shred": False, },
"force": {"type": "boolean", "shred": False, },
},
}
@log_entry_types.new_from_dict({
'pretix.voucher.added': _('The voucher has been created.'),
'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'),
@@ -585,13 +599,63 @@ class CoreOrderLogEntryType(OrderLogEntryType):
'pretix.voucher.added.waitinglist': _('The voucher has been assigned to {email} through the waiting list.'),
})
class CoreVoucherLogEntryType(VoucherLogEntryType):
pass
data_schema = {
"type": "object",
"properties": {
"item": {"type": ["null", "number"], "shred": False, },
"variation": {"type": ["null", "number"], "shred": False, },
"tag": {"type": "string", "shred": False,},
"block_quota": {"type": "boolean", "shred": False, },
"valid_until": {"type": ["null", "string"], "shred": False, },
"min_usages": {"type": "number", "shred": False, },
"max_usages": {"type": "number", "shred": False, },
"subevent": {"type": ["null", "number", "object"], "shred": False, },
"source": {"type": "string", "shred": False,},
"allow_ignore_quota": {"type": "boolean", "shred": False, },
"code": {"type": "string", "shred": False,},
"comment": {"type": "string", "shred": True,},
"price_mode": {"type": "string", "shred": False,},
"seat": {"type": "string", "shred": False,},
"quota": {"type": ["null", "number"], "shred": False,},
"value": {"type": ["null", "string"], "shred": False,},
"redeemed": {"type": "number", "shred": False,},
"all_addons_included": {"type": "boolean", "shred": False, },
"all_bundles_included": {"type": "boolean", "shred": False, },
"budget": {"type": ["null", "number"], "shred": False, },
"itemvar": {"type": "string", "shred": False,},
"show_hidden_items": {"type": "boolean", "shred": False, },
# bulk create:
"bulk": {"type": "boolean", "shred": False,},
"seats": {"type": "array", "shred": False,},
"send": {"type": ["string", "boolean"], "shred": False,},
"send_recipients": {"type": "array", "shred": True,},
"send_subject": {"type": "string", "shred": False,},
"send_message": {"type": "string", "shred": True,},
# pretix.voucher.sent
"recipient": {"type": "string", "shred": True,},
"name": {"type": "string", "shred": True,},
"subject": {"type": "string", "shred": False,},
"message": {"type": "string", "shred": True,},
# pretix.voucher.added.waitinglist
"email": {"type": "string", "shred": True,},
"waitinglistentry": {"type": "number", "shred": False, },
},
}
@log_entry_types.new()
class VoucherRedeemedLogEntryType(VoucherLogEntryType):
action_type = 'pretix.voucher.redeemed'
plain = _('The voucher has been redeemed in order {order_code}.')
data_schema = {
"type": "object",
"properties": {
"order_code": {"type": "string", "shred": False, },
},
}
def display(self, logentry, data):
url = reverse('control:event.order', kwargs={
@@ -634,9 +698,16 @@ class TeamMembershipLogEntryType(LogEntryType):
'pretix.team.member.removed': _('{user} has been removed from the team.'),
'pretix.team.invite.created': _('{user} has been invited to the team.'),
'pretix.team.invite.resent': _('Invite for {user} has been resent.'),
'pretix.team.invite.deleted': _('Invite for {user} has been deleted.'),
})
class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):
pass
data_schema = {
"type": "object",
"properties": {
"email": {"type": "string", "shred": True, },
"user": {"type": "number", "shred": False, },
},
}
@log_entry_types.new()
@@ -833,6 +904,10 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
'pretix.event.seats.blocks.changed': _('A seat in the seating plan has been blocked or unblocked.'),
'pretix.seatingplan.added': _('A seating plan has been added.'),
'pretix.seatingplan.changed': _('A seating plan has been changed.'),
'pretix.seatingplan.deleted': _('A seating plan has been deleted.'),
})
class CoreEventLogEntryType(EventLogEntryType):
pass
+1 -1
View File
@@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest):
'active': url.url_name == 'event.settings.mail',
},
{
'label': _('Tax rules'),
'label': _('Taxes'),
'url': reverse('control:event.settings.tax', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
@@ -243,7 +243,6 @@
{% bootstrap_field sform.show_times layout="control" %}
<h4>{% trans "Product list" %}</h4>
{% bootstrap_field sform.show_quota_left layout="control" %}
{% bootstrap_field sform.display_net_prices layout="control" %}
{% bootstrap_field sform.show_variations_expanded layout="control" %}
{% bootstrap_field sform.hide_sold_out layout="control" %}
@@ -0,0 +1,120 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Taxes" %}{% endblock %}
{% block inside %}
<h1>{% trans "Taxes" %}</h1>
{% bootstrap_form_errors form layout="control" %}
<fieldset>
<legend>{% trans "Tax rules" %}</legend>
<p>
{% blocktrans trimmed %}
Tax rules define different taxation scenarios that can then be assigned to the individual products.
Each tax rule contains a default tax rate and can optionally contain additional rules that depend
on the customer's country and type.
{% endblocktrans %}
</p>
{% if taxrules|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any tax rules yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th>{% trans "Usage" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for tr in taxrules %}
<tr>
<td>
<strong><a
href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>
{% if tr.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% else %}
<form class="form-inline" method="post"
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td>
{% blocktrans trimmed count count=tr.c_items %}
{{ count }} product
{% plural %}
{{ count }} products
{% endblocktrans %}
</td>
<td>
{% if tr.price_includes_tax %}
{% blocktrans with rate=tr.rate %}incl. {{ rate }} %{% endblocktrans %}
{% else %}
{% blocktrans with rate=tr.rate %}excl. {{ rate }} %{% endblocktrans %}
{% endif %}
{% if tr.has_custom_rules %}
<br><small>{% trans "with custom rules" %}</small>
{% elif tr.eu_reverse_charge %}
<br><small>{% trans "reverse charge enabled" %}</small>
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="5">
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
</a>
</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
</fieldset>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<fieldset>
<legend>{% trans "Tax settings" %}</legend>
{% bootstrap_field form.tax_rounding layout="control" %}
{% bootstrap_field form.display_net_prices layout="control" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,77 +0,0 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% block title %}{% trans "Tax rules" %}{% endblock %}
{% block inside %}
<h1>{% trans "Tax rules" %}</h1>
{% if taxrules|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any tax rules yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for tr in taxrules %}
<tr>
<td>
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>
{% if tr.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% else %}
<form class="form-inline" method="post"
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td>
{% if tr.price_includes_tax %}
{% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %}
{% else %}
{% blocktrans with rate=tr.rate%}excl. {{ rate }} %{% endblocktrans %}
{% endif %}
{% if tr.eu_reverse_charge %}
({% trans "reverse charge enabled" %})
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
@@ -1,35 +0,0 @@
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% load rich_text %}
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
{% if customer_gift_cards %}
<p><strong>
<span class="sr-only">{% trans "Information" %}</span>
{% trans "The following gift cards are available in your customer account:" %}
</strong></p>
<form method="post">
{% csrf_token %}
<ul class="list-group">
{% for c in customer_gift_cards %}
<li class="list-group-item row row-no-gutters">
<div class="col-sm-8 col-md-9" id="gc-code-{{ forloop.counter }}">
{{ c }}
</div>
<div class="col-sm-2 text-right" id="gc-value-{{ forloop.counter }}">
{{ c.value|money:c.currency }}
</div>
<div class="col-sm-2 col-md-1 text-right">
<button name="use_giftcard" class="btn btn-primary btn-xs use_giftcard" data-value="{{ c.secret }}"
title="{% trans "Use gift card" %}"
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}">
{% trans "Apply" %}
</button>
</div>
</li>
{% endfor %}
</ul>
</form>
{% endif %}
{% bootstrap_form form layout='checkout' %}
@@ -681,6 +681,16 @@
</small>
{% endif %}
{% endif %}
{% if django_settings.DEBUG %}
<br/>
<small class="admin-only">
price = {{ line.price|floatformat:2 }}<br>
rounding_correction = {{ line.price_includes_rounding_correction|floatformat:2 }}<br>
tax_value = {{ line.tax_value|floatformat:2 }}<br>
tax_value_rounding_correction = {{ line.tax_value_includes_rounding_correction|floatformat:2 }}<br>
voucher_budget_use = {{ line.voucher_budget_use|floatformat:2 }}
</small>
{% endif %}
</div>
<div class="clearfix"></div>
</div>
@@ -721,6 +731,16 @@
</small>
{% endif %}
{% endif %}
{% if django_settings.DEBUG %}
<br/>
<small class="admin-only">
price = {{ fee.value|floatformat:2 }}<br>
rounding_correction = {{ fee.value_includes_rounding_correction|floatformat:2 }}<br>
tax_value = {{ fee.tax_value|floatformat:2 }}<br>
tax_value_rounding_correction = {{ fee.tax_value_includes_rounding_correction|floatformat:2 }}<br>
voucher_budget_use = {{ fee.voucher_budget_use|floatformat:2 }}
</small>
{% endif %}
</div>
<div class="clearfix"></div>
</div>
@@ -749,6 +769,12 @@
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
<strong>{{ items.total|money:event.currency }}</strong>
{% if django_settings.DEBUG %}
<br/>
<small class="admin-only">
tax_rounding_mode = {{ order.tax_rounding_mode }}
</small>
{% endif %}
</div>
<div class="clearfix"></div>
</div>
@@ -56,8 +56,26 @@
<td>{{ t.get_tax_code_display }}</td>
<td class="text-right flip">{{ t.count }} &times;</td>
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
<td class="text-right flip">{{ t.full_tax_value|money:request.event.currency }}</td>
<td class="text-right flip">{{ t.full_price|money:request.event.currency }}</td>
<td class="text-right flip">
{{ t.full_tax_value|money:request.event.currency }}
{% if t.full_price_includes_rounding_correction %}
<br><small>
{% blocktrans trimmed with amount=t.full_tax_value_includes_rounding_correction|money:request.event.currency %}
incl. {{ amount }} rounding correction
{% endblocktrans %}
</small>
{% endif %}
</td>
<td class="text-right flip">
{{ t.full_price|money:request.event.currency }}
{% if t.full_price_includes_rounding_correction %}
<br><small>
{% blocktrans trimmed with amount=t.full_price_includes_rounding_correction|money:request.event.currency %}
incl. {{ amount }} rounding correction
{% endblocktrans %}
</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
+1 -1
View File
@@ -290,7 +290,7 @@ urlpatterns = [
re_path(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
re_path(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
re_path(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
re_path(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'),
re_path(r'^settings/tax/$', event.TaxSettings.as_view(), name='event.settings.tax'),
re_path(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'),
re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'),
re_path(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
+21 -12
View File
@@ -54,7 +54,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.db import transaction
from django.db.models import ProtectedError
from django.db.models import Count, ProtectedError
from django.forms import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
@@ -90,7 +90,7 @@ from pretix.control.forms.event import (
EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm,
EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm,
MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm,
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, TaxSettingsForm,
TicketSettingsForm, WidgetCodeForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
@@ -648,6 +648,25 @@ class PaymentSettings(EventSettingsViewMixin, EventSettingsFormView):
return context
class TaxSettings(EventSettingsViewMixin, EventSettingsFormView):
template_name = 'pretixcontrol/event/tax.html'
form_class = TaxSettingsForm
permission = 'can_change_event_settings'
def get_success_url(self) -> str:
return reverse('control:event.settings.tax', kwargs={
'organizer': self.request.organizer.slug,
'event': self.request.event.slug,
})
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['taxrules'] = self.request.event.tax_rules.annotate(
c_items=Count("item")
).all()
return context
class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
model = Event
form_class = InvoiceSettingsForm
@@ -1263,16 +1282,6 @@ class EventComment(EventPermissionRequiredMixin, View):
})
class TaxList(EventSettingsViewMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):
model = TaxRule
context_object_name = 'taxrules'
template_name = 'pretixcontrol/event/tax_index.html'
permission = 'can_change_event_settings'
def get_queryset(self):
return self.request.event.tax_rules.all()
class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView):
model = TaxRule
form_class = TaxRuleForm
+2
View File
@@ -633,7 +633,9 @@ class OrderTransactions(OrderView):
ctx['sums'] = self.order.transactions.aggregate(
sum_count=Sum('count'),
full_price=Sum(F('count') * F('price')),
full_price_includes_rounding_correction=Sum(F('count') * F('price_includes_rounding_correction')),
full_tax_value=Sum(F('count') * F('tax_value')),
full_tax_value_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')),
)
return ctx
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54 -55
View File
@@ -8,10 +8,10 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-29 07:55+0000\n"
"PO-Revision-Date: 2025-10-28 17:00+0000\n"
"PO-Revision-Date: 2025-10-31 17:00+0000\n"
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
"js/ca/>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/"
"pretix-js/ca/>\n"
"Language: ca\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -651,6 +651,8 @@ msgid ""
"Your color has insufficient contrast to white. Accessibility of your site "
"will be impacted."
msgstr ""
"El color no té prou contrast amb el blanc i pot afectar a l'accessibilitat "
"del lloc web."
#: pretix/static/pretixcontrol/js/ui/main.js:418
#: pretix/static/pretixcontrol/js/ui/main.js:438
@@ -712,229 +714,226 @@ msgstr[0] "(una data més)"
msgstr[1] "({num} dates més)"
#: pretix/static/pretixpresale/js/ui/cart.js:47
#, fuzzy
#| msgid "The items in your cart are no longer reserved for you."
msgid ""
"The items in your cart are no longer reserved for you. You can still "
"complete your order as long as theyre available."
msgstr "El contingut de la cistella ja no el teniu reservat."
msgstr ""
"Ja no tens reservat el contingut de la cistella. Encara pots completar la "
"comanda si els articles seleccionats continuen disponibles."
#: pretix/static/pretixpresale/js/ui/cart.js:49
msgid "Cart expired"
msgstr "Cistella expirada"
msgstr "Cistella caducada"
#: pretix/static/pretixpresale/js/ui/cart.js:58
#: pretix/static/pretixpresale/js/ui/cart.js:84
msgid "Your cart is about to expire."
msgstr ""
msgstr "La teva cistella està a punt de caducar."
#: pretix/static/pretixpresale/js/ui/cart.js:62
#, fuzzy
#| msgid "The items in your cart are no longer reserved for you."
msgid "The items in your cart are reserved for you for one minute."
msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "El contingut de la cistella ja no el teniu reservat."
msgstr[1] "El contingut de la cistella ja no el teniu reservat."
msgstr[0] "El contingut de la teva cistella està reservat durant un minut."
msgstr[1] "El contingut de la teva cistella està reservat durant {num} minuts."
#: pretix/static/pretixpresale/js/ui/cart.js:83
#, fuzzy
#| msgid "Cart expired"
msgid "Your cart has expired."
msgstr "Cistella expirada"
msgstr "La teva cistella ha caducat."
#: pretix/static/pretixpresale/js/ui/cart.js:86
#, fuzzy
#| msgid "The items in your cart are no longer reserved for you."
msgid ""
"The items in your cart are no longer reserved for you. You can still "
"complete your order as long as they're available."
msgstr "El contingut de la cistella ja no el teniu reservat."
msgstr ""
"Ja no teniu reservat el contingut de la cistella. Encara podeu completar la "
"comanda si els articles continuen disponibles."
#: pretix/static/pretixpresale/js/ui/cart.js:87
msgid "Do you want to renew the reservation period?"
msgstr ""
msgstr "Vols ampliar el temps de reserva?"
#: pretix/static/pretixpresale/js/ui/cart.js:90
msgid "Renew reservation"
msgstr ""
msgstr "Renovar reserva"
#: pretix/static/pretixpresale/js/ui/main.js:194
msgid "The organizer keeps %(currency)s %(amount)s"
msgstr ""
msgstr "L'entitat organitzadora es queda %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js:202
msgid "You get %(currency)s %(amount)s back"
msgstr ""
msgstr "Rebràs %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js:218
msgid "Please enter the amount the organizer can keep."
msgstr ""
msgstr "Introdueix limport que es queda lorganitzador."
#: pretix/static/pretixpresale/js/ui/main.js:570
msgid "Your local time:"
msgstr ""
msgstr "La teva hora local:"
#: pretix/static/pretixpresale/js/walletdetection.js:39
#, fuzzy
msgid "Google Pay"
msgstr ""
msgstr "Google Pay"
#: pretix/static/pretixpresale/js/widget/widget.js:16
msgctxt "widget"
msgid "Quantity"
msgstr ""
msgstr "Quantitat"
#: pretix/static/pretixpresale/js/widget/widget.js:17
msgctxt "widget"
msgid "Decrease quantity"
msgstr ""
msgstr "Disminueix quantitat"
#: pretix/static/pretixpresale/js/widget/widget.js:18
msgctxt "widget"
msgid "Increase quantity"
msgstr ""
msgstr "Augmenta quantitat"
#: pretix/static/pretixpresale/js/widget/widget.js:19
msgctxt "widget"
msgid "Filter events by"
msgstr ""
msgstr "Filtra els esdeveniments per"
#: pretix/static/pretixpresale/js/widget/widget.js:20
msgctxt "widget"
msgid "Filter"
msgstr ""
msgstr "Filtra"
#: pretix/static/pretixpresale/js/widget/widget.js:21
msgctxt "widget"
msgid "Price"
msgstr ""
msgstr "Preu"
#: pretix/static/pretixpresale/js/widget/widget.js:22
#, javascript-format
msgctxt "widget"
msgid "Original price: %s"
msgstr ""
msgstr "Preu original: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:23
#, javascript-format
msgctxt "widget"
msgid "New price: %s"
msgstr ""
msgstr "Nou preu: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:24
msgctxt "widget"
msgid "Select"
msgstr ""
msgstr "Selecciona"
#: pretix/static/pretixpresale/js/widget/widget.js:25
#, javascript-format
msgctxt "widget"
msgid "Select %s"
msgstr ""
msgstr "Selecciona %s"
#: pretix/static/pretixpresale/js/widget/widget.js:26
#, javascript-format
msgctxt "widget"
msgid "Select variant %s"
msgstr ""
msgstr "Selecciona la variant %s"
#: pretix/static/pretixpresale/js/widget/widget.js:27
msgctxt "widget"
msgid "Sold out"
msgstr ""
msgstr "Esgotat"
#: pretix/static/pretixpresale/js/widget/widget.js:28
msgctxt "widget"
msgid "Buy"
msgstr ""
msgstr "Compra"
#: pretix/static/pretixpresale/js/widget/widget.js:29
msgctxt "widget"
msgid "Register"
msgstr ""
msgstr "Registrar-vos"
#: pretix/static/pretixpresale/js/widget/widget.js:30
msgctxt "widget"
msgid "Reserved"
msgstr ""
msgstr "Reservat"
#: pretix/static/pretixpresale/js/widget/widget.js:31
msgctxt "widget"
msgid "FREE"
msgstr ""
msgstr "GRATUÏT"
#: pretix/static/pretixpresale/js/widget/widget.js:32
msgctxt "widget"
msgid "from %(currency)s %(price)s"
msgstr ""
msgstr "des de %(price)s %(currency)s"
#: pretix/static/pretixpresale/js/widget/widget.js:33
#, javascript-format
msgctxt "widget"
msgid "Image of %s"
msgstr ""
msgstr "Imatge de %s"
#: pretix/static/pretixpresale/js/widget/widget.js:34
msgctxt "widget"
msgid "incl. %(rate)s% %(taxname)s"
msgstr ""
msgstr "Inclòs %(rate)s% %(taxname)s"
#: pretix/static/pretixpresale/js/widget/widget.js:35
msgctxt "widget"
msgid "plus %(rate)s% %(taxname)s"
msgstr ""
msgstr "més %(rate)s% %(taxname)s"
#: pretix/static/pretixpresale/js/widget/widget.js:36
msgctxt "widget"
msgid "incl. taxes"
msgstr ""
msgstr "incl. impostos"
#: pretix/static/pretixpresale/js/widget/widget.js:37
msgctxt "widget"
msgid "plus taxes"
msgstr ""
msgstr "més impostos"
#: pretix/static/pretixpresale/js/widget/widget.js:38
#, javascript-format
msgctxt "widget"
msgid "currently available: %s"
msgstr ""
msgstr "disponibles: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:39
msgctxt "widget"
msgid "Only available with a voucher"
msgstr ""
msgstr "Només disponible amb un cupó"
#: pretix/static/pretixpresale/js/widget/widget.js:40
#: pretix/static/pretixpresale/js/widget/widget.js:43
msgctxt "widget"
msgid "Not yet available"
msgstr ""
msgstr "Encara no disponible"
#: pretix/static/pretixpresale/js/widget/widget.js:41
msgctxt "widget"
msgid "Not available anymore"
msgstr ""
msgstr "Ja no està disponible"
#: pretix/static/pretixpresale/js/widget/widget.js:42
msgctxt "widget"
msgid "Currently not available"
msgstr ""
msgstr "No disponible"
#: pretix/static/pretixpresale/js/widget/widget.js:44
#, javascript-format
msgctxt "widget"
msgid "minimum amount to order: %s"
msgstr ""
msgstr "Comanda mínima: %s"
#: pretix/static/pretixpresale/js/widget/widget.js:45
msgctxt "widget"
msgid "Close ticket shop"
msgstr ""
msgstr "Tanca la botiga d'entrades"
#: pretix/static/pretixpresale/js/widget/widget.js:46
msgctxt "widget"
msgid "The ticket shop could not be loaded."
msgstr ""
msgstr "No s'ha pogut carregar la botiga d'entrades."
#: pretix/static/pretixpresale/js/widget/widget.js:47
msgctxt "widget"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -362,6 +362,9 @@ Reverse-Proxy
Revisionssicherheit
Revolut
Revolut-App
Rundungsabweichungen
Rundungskorrektur
Rundungsverhalten
rückabgewickelt
Sa
Sammlungsstücken
File diff suppressed because it is too large Load Diff
@@ -362,6 +362,9 @@ Reverse-Proxy
Revisionssicherheit
Revolut
Revolut-App
Rundungsabweichungen
Rundungskorrektur
Rundungsverhalten
rückabgewickelt
Sa
Sammlungsstücken
+1160 -1062
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-29 07:55+0000\n"
"POT-Creation-Date: 2025-10-30 10:55+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -13
View File
@@ -26,6 +26,7 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
from pretix.base.models import Checkin, OrderPayment
from pretix.base.signals import (
checkin_created, event_copy_data, item_copy_data, logentry_display,
@@ -64,19 +65,13 @@ def nav_event_receiver(sender, request, **kwargs):
]
@receiver(signal=logentry_display)
def logentry_display_receiver(sender, logentry, **kwargs):
plains = {
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
"pretix.plugins.autocheckin.rule.changed": _(
"An auto check-in rule was updated"
),
"pretix.plugins.autocheckin.rule.deleted": _(
"An auto check-in rule was deleted"
),
}
if logentry.action_type in plains:
return plains[logentry.action_type]
@log_entry_types.new_from_dict({
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
"pretix.plugins.autocheckin.rule.changed": _("An auto check-in rule was updated"),
"pretix.plugins.autocheckin.rule.deleted": _("An auto check-in rule was deleted"),
})
class AutocheckinLogEntryType(LogEntryType):
pass
@receiver(item_copy_data, dispatch_uid="autocheckin_item_copy")
@@ -18,25 +18,19 @@
<div class="row">
<div class="{% if settings.bank_details_type == "sepa" %}col-md-6{% else %}col-md-12{% endif %} col-xs-12">
{% if settings.bank_details_type == "sepa" %}
<dl class="dl-horizontal">
<dl class="dl-horizontal">
<dt>{% trans "Reference code (important):" %}</dt><dd><b>{{ code }}</b></dd>
<dt>{% trans "Amount:" %}</dt><dd>{{ amount|money:event.currency }}</dd>
{% if settings.bank_details_type == "sepa" %}
<dt>{% trans "Account holder" %}:</dt><dd>{{ settings.bank_details_sepa_name }}</dt>
<dt>{% trans "IBAN" %}:</dt><dd>{{ settings.bank_details_sepa_iban|ibanformat }}</dt>
<dt>{% trans "BIC" %}:</dt><dd>{{ settings.bank_details_sepa_bic }}</dt>
<dt>{% trans "Bank" %}:</dt><dd>{{ settings.bank_details_sepa_bank }}</dt>
{% if details %}
</dl>
{% endif %}
{% endif %}
</dl>
{% if details %}
{{ details | rich_text }}
<dl class="dl-horizontal">
{% endif %}
{% if not settings.bank_details_type == "sepa" and not details %}
<dl class="dl-horizontal">
{% endif %}
<dt>{% trans "Amount:" %}</dt><dd>{{ amount|money:event.currency }}</dd>
<dt>{% trans "Reference code (important):" %}</dt><dd><b>{{ code }}</b></dd>
</dl>
<p class="text-muted">
{% trans "There is no further action required on this website." %}
@@ -133,4 +127,4 @@ SCT
</div>
{% if swiss_qrbill %}
<link rel="stylesheet" href="{% static "pretixplugins/banktransfer/swisscross.css" %}">
{% endif %}
{% endif %}
+6 -5
View File
@@ -73,7 +73,7 @@ from pretix.plugins.paypal2.payment import (
PaypalMethod, PaypalMethod as Paypal, PaypalWallet,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.presale.views import get_cart, get_cart_total
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session
logger = logging.getLogger('pretix.plugins.paypal2')
@@ -147,7 +147,7 @@ class XHRView(View):
cart_total = order.pending_sum + fee
else:
cart_total = get_cart_total(request)
cart = get_cart(request)
cart_payments = cart_session(request).get('payments', [])
multi_use_cart_payments = [p for p in cart_payments if p.get('multi_use_supported')]
simulated_payments = multi_use_cart_payments + [{
@@ -159,12 +159,13 @@ class XHRView(View):
}]
try:
for fee in get_fees(request.event, request, cart_total, None, simulated_payments, get_cart(request)):
cart_total += fee.value
fees = get_fees(event=request.event, request=request, invoice_address=None,
payments=simulated_payments, positions=cart)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
pass
fees = []
cart_total = sum([c.price for c in cart]) + sum([f.value for f in fees])
total_remaining = cart_total
for p in multi_use_cart_payments:
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
+28 -28
View File
@@ -31,6 +31,7 @@ from django.utils.translation import gettext_lazy as _
from paypalhttp import HttpResponse
from pretix.base.forms import SecretKeySettingsField
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.settings import settings_hierarkey
from pretix.base.signals import (
@@ -80,37 +81,36 @@ def html_head_presale(sender, request=None, **kwargs):
return ""
@receiver(signal=logentry_display, dispatch_uid="stripe_logentry_display")
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
if logentry.action_type != 'pretix.plugins.stripe.event':
return
@log_entry_types.new()
class StripeEvent(LogEntryType):
action_type = 'pretix.plugins.stripe.event'
data = json.loads(logentry.data)
event_type = data.get('type')
text = None
plains = {
'charge.succeeded': _('Charge succeeded.'),
'charge.refunded': _('Charge refunded.'),
'charge.updated': _('Charge updated.'),
'charge.pending': _('Charge pending'),
'source.chargeable': _('Payment authorized.'),
'source.canceled': _('Payment authorization canceled.'),
'source.failed': _('Payment authorization failed.')
}
def display(self, logentry, data):
event_type = data.get('type')
text = None
plains = {
'charge.succeeded': _('Charge succeeded.'),
'charge.refunded': _('Charge refunded.'),
'charge.updated': _('Charge updated.'),
'charge.pending': _('Charge pending'),
'source.chargeable': _('Payment authorized.'),
'source.canceled': _('Payment authorization canceled.'),
'source.failed': _('Payment authorization failed.')
}
if event_type in plains:
text = plains[event_type]
elif event_type == 'charge.failed':
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
elif event_type == 'charge.dispute.created':
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.updated':
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.closed':
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
if event_type in plains:
text = plains[event_type]
elif event_type == 'charge.failed':
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
elif event_type == 'charge.dispute.created':
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.updated':
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.closed':
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
if text:
return _('Stripe reported an event: {}').format(text)
if text:
return _('Stripe reported an event: {}').format(text)
settings_hierarkey.add_default('payment_stripe_method_card', True, bool)
+31 -20
View File
@@ -91,9 +91,7 @@ from pretix.presale.signals import (
question_form_fields_overrides,
)
from pretix.presale.utils import customer_login
from pretix.presale.views import (
CartMixin, get_cart, get_cart_is_free, get_cart_total,
)
from pretix.presale.views import CartMixin, get_cart, get_cart_is_free
from pretix.presale.views.cart import (
_items_from_post_data, cart_session, create_empty_cart_id,
get_or_create_cart_id,
@@ -1262,18 +1260,16 @@ class PaymentStep(CartMixin, TemplateFlowStep):
@cached_property
def _total_order_value(self):
cart = get_cart(self.request)
total = get_cart_total(self.request)
try:
total += sum([
f.value for f in get_fees(
self.request.event, self.request, total, self.invoice_address,
[p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')],
cart,
)
])
fees = get_fees(
event=self.request.event, request=self.request, invoice_address=self.invoice_address,
payments=[p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')],
positions=cart,
)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
pass
fees = []
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
return Decimal(total)
@cached_property
@@ -1399,7 +1395,13 @@ class PaymentStep(CartMixin, TemplateFlowStep):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['current_payments'] = [p for p in self.current_selected_payments(self._total_order_value) if p.get('multi_use_supported')]
ctx['cart'] = self.get_cart()
ctx['current_payments'] = [
p for p in self.current_selected_payments(
ctx['cart']['raw'], ctx['cart']['fees'], ctx['cart']['invoice_address'],
)
if p.get('multi_use_supported')
]
ctx['remaining'] = self._total_order_value - sum(p['payment_amount'] for p in ctx['current_payments']) + sum(p['fee'] for p in ctx['current_payments'])
ctx['providers'] = self.provider_forms
ctx['show_fees'] = any(p['fee'] for p in self.provider_forms)
@@ -1412,7 +1414,6 @@ class PaymentStep(CartMixin, TemplateFlowStep):
ctx['selected'] = self.single_use_payment['provider']
else:
ctx['selected'] = ''
ctx['cart'] = self.get_cart()
return ctx
def _is_allowed(self, prov, request):
@@ -1425,14 +1426,20 @@ class PaymentStep(CartMixin, TemplateFlowStep):
return False
cart = get_cart(self.request)
total = get_cart_total(self.request)
try:
total += sum([f.value for f in get_fees(self.request.event, self.request, total, self.invoice_address,
self.cart_session.get('payments', []), cart)])
fees = get_fees(
event=self.request.event,
request=self.request,
invoice_address=self.invoice_address,
payments=self.cart_session.get('payments', []),
positions=cart
)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
pass
selected = self.current_selected_payments(total, warn=warn, total_includes_payment_fees=True)
fees = []
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
selected = self.current_selected_payments(cart, fees, self.invoice_address, warn=warn)
if sum(p['payment_amount'] for p in selected) != total:
if warn:
messages.error(request, _('Please select a payment method to proceed.'))
@@ -1516,7 +1523,11 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart(answers=True)
selected_payments = self.current_selected_payments(ctx['cart']['total'], total_includes_payment_fees=True)
selected_payments = self.current_selected_payments(
ctx['cart']['raw'],
ctx['cart']['fees'],
ctx['cart']['invoice_address'],
)
ctx['payments'] = []
for p in selected_payments:
if p['provider'] == 'free':
@@ -9,34 +9,6 @@
{% endblock %}
{% block inner %}
<h3 class="sr-only">{% trans "Payment" %}</h3>
{% if customer_gift_cards %}
<p><strong>
<span class="sr-only">{% trans "Information" %}</span>
{% trans "The following gift cards are available in your customer account:" %}
</strong></p>
<form method="post">
{% csrf_token %}
<ul class="list-group">
{% for c in customer_gift_cards %}
<li class="list-group-item row">
<div class="col-xs-9" id="gc-code-{{ forloop.counter }}">
{{ c }}
</div>
<div class="col-xs-2 text-right" id="gc-value-{{ forloop.counter }}">
{{ c.value|money:c.currency }}
</div>
<div class="col-xs-1 text-right">
<button name="use_giftcard" value="{{ c.secret }}" title="{% trans "Use gift card" %}"
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}"
class="btn btn-primary btn-xs">
{% trans "Apply" %}
</button>
</div>
</li>
{% endfor %}
</ul>
</form>
{% endif %}
{% if current_payments %}
<p>{% trans "You already selected the following payment methods:" %}</p>
<form method="post">
@@ -0,0 +1,33 @@
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% load rich_text %}
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
{% if customer_gift_cards %}
<p><strong>
<span class="sr-only">{% trans "Information" %}</span>
{% trans "The following gift cards are available in your customer account:" %}
</strong></p>
{% csrf_token %}
<ul class="list-group">
{% for c in customer_gift_cards %}
<li class="list-group-item row row-no-gutters">
<div class="col-sm-8 col-md-9" id="gc-code-{{ forloop.counter }}">
{{ c }}
</div>
<div class="col-sm-2 text-right" id="gc-value-{{ forloop.counter }}">
{{ c.value|money:c.currency }}
</div>
<div class="col-sm-2 col-md-1 text-right">
<button name="use_giftcard" class="btn btn-primary btn-xs use_giftcard" data-value="{{ c.secret }}"
title="{% trans "Use gift card" %}"
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}">
{% trans "Apply" %}
</button>
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% bootstrap_form form layout='checkout' %}

Some files were not shown because too many files have changed in this diff Show More