diff --git a/doc/development/implementation/models.rst b/doc/development/implementation/models.rst index c5cae26161..f0d03d32ef 100644 --- a/doc/development/implementation/models.rst +++ b/doc/development/implementation/models.rst @@ -92,6 +92,9 @@ Carts and Orders .. autoclass:: pretix.base.models.OrderRefund :members: +.. autoclass:: pretix.base.models.Transaction + :members: + .. autoclass:: pretix.base.models.CartPosition :members: diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 1b08d4994e..5c1168aa2a 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1404,6 +1404,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): state=OrderPayment.PAYMENT_STATE_CREATED ) + order.create_transactions(is_new=True, fees=fees, positions=pos_map.values()) return order diff --git a/src/pretix/base/apps.py b/src/pretix/base/apps.py index c053e71a71..435eff51ec 100644 --- a/src/pretix/base/apps.py +++ b/src/pretix/base/apps.py @@ -47,6 +47,7 @@ class PretixBaseConfig(AppConfig): from . import notifications # NOQA from . import email # NOQA from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA + from .models import _transactions # NOQA from django.conf import settings try: diff --git a/src/pretix/base/management/commands/create_order_transactions.py b/src/pretix/base/management/commands/create_order_transactions.py new file mode 100644 index 0000000000..4976dc7100 --- /dev/null +++ b/src/pretix/base/management/commands/create_order_transactions.py @@ -0,0 +1,84 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import time + +from django.core.management.base import BaseCommand +from django.db.models import F, Max, Q +from django.utils.timezone import now +from django_scopes import scopes_disabled +from tqdm import tqdm + +from pretix.base.models import Order + + +class Command(BaseCommand): + help = "Create missing order transactions" + + def add_arguments(self, parser): + parser.add_argument( + "--slowdown", + dest="interval", + type=int, + default=0, + help="Interval for staggered execution. If set to a value different then zero, we will " + "wait this many milliseconds between every order we process.", + ) + + @scopes_disabled() + def handle(self, *args, **options): + t = 0 + qs = Order.objects.annotate( + last_transaction=Max('transactions__created') + ).filter( + Q(last_transaction__isnull=True) | Q(last_modified__gt=F('last_transaction')), + require_approval=False, + ).prefetch_related( + 'all_positions', 'all_fees' + ) + for o in tqdm(qs): + if o.last_transaction is None: + tn = o.create_transactions( + positions=o.all_positions.all(), + fees=o.all_fees.all(), + dt_now=o.datetime, + migrated=True, + is_new=True, + _backfill_before_cancellation=True, + ) + o.create_transactions( + positions=o.all_positions.all(), + fees=o.all_fees.all(), + dt_now=o.cancellation_date or (o.expires if o.status == Order.STATUS_EXPIRED else o.datetime), + migrated=True, + ) + else: + tn = o.create_transactions( + positions=o.all_positions.all(), + fees=o.all_fees.all(), + dt_now=now(), + migrated=True, + ) + if tn: + t += 1 + time.sleep(options.get('slowdown', 0) / 1000) + + self.stderr.write(self.style.SUCCESS(f'Created transactions for {t} orders.')) diff --git a/src/pretix/base/migrations/0200_transaction.py b/src/pretix/base/migrations/0200_transaction.py new file mode 100644 index 0000000000..1c0bcb698a --- /dev/null +++ b/src/pretix/base/migrations/0200_transaction.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.4 on 2021-10-18 10:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0199_auto_20211005_1050'), + ] + + operations = [ + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('datetime', models.DateTimeField(db_index=True)), + ('migrated', models.BooleanField(default=False)), + ('positionid', models.PositiveIntegerField(default=1, null=True)), + ('count', models.IntegerField(default=1)), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('tax_rate', models.DecimalField(decimal_places=2, max_digits=7)), + ('tax_value', models.DecimalField(decimal_places=2, max_digits=10)), + ('fee_type', models.CharField(max_length=100, null=True)), + ('internal_type', models.CharField(max_length=255, null=True)), + ('item', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.item')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='pretixbase.order')), + ('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.subevent')), + ('tax_rule', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.taxrule')), + ('variation', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.itemvariation')), + ], + options={ + 'ordering': ('datetime', 'pk'), + }, + ), + ] diff --git a/src/pretix/base/models/_transactions.py b/src/pretix/base/models/_transactions.py new file mode 100644 index 0000000000..4f4357cfc6 --- /dev/null +++ b/src/pretix/base/models/_transactions.py @@ -0,0 +1,99 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +""" +This module contains helper functions that are supposed to call out code paths missing calls to +``Order.create_transaction()`` by actively breaking them. Read the docstring of the ``Transaction`` class for a +detailed reasoning why this exists. +""" +import inspect +import logging +import os +import threading + +from django.db import transaction + +dirty_transactions = threading.local() + +logger = logging.getLogger(__name__) +fail_loudly = os.getenv('PRETIX_DIRTY_TRANSACTIONS_QUIET', 'false') not in ('true', 'True', 'on', '1') + + +class DirtyTransactionsForOrderException(Exception): + pass + + +def _fail(message): + if fail_loudly: + raise DirtyTransactionsForOrderException(message) + else: + logger.warning(message, stack_info=True) + + +def _check_for_dirty_orders(): + if getattr(dirty_transactions, 'order_ids', None) is None: + dirty_transactions.order_ids = set() + if not dirty_transactions.order_ids and dirty_transactions.order_ids != {None}: + _fail( + "In the transaction that just ended, you created or modified an Order, OrderPosition, or OrderFee " + "object in a way that you should have called `order.create_transactions()` afterwards. The transaction " + "still went through and your data can be fixed with the `create_order_transactions` management command " + "but you should update your code to prevent this from happening." + ) + dirty_transactions.order_ids.clear() + + +def _transactions_mark_order_dirty(order_id, using=None): + if "PYTEST_CURRENT_TEST" in os.environ: + # We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code + # or not. + for frame in inspect.stack(): + if 'pretix/base/models/orders' in frame.filename: + continue + elif 'test_' in frame.filename or 'conftest.py in frame.filename': + return + elif 'pretix/' in frame.filename or 'pretix_' in frame.filename: + # This went through non-test code, let's consider it non-test + break + + if getattr(dirty_transactions, 'order_ids', None) is None: + dirty_transactions.order_ids = set() + dirty_transactions.order_ids.add(order_id) + conn = transaction.get_connection(using) + if not conn.in_atomic_block: + _fail( + "You modified an Order, OrderPosition, or OrderFee object in a way that should create " + "a new Transaction object within the same database transaction, however you are not " + "doing it inside a database transaction!" + ) + + if _check_for_dirty_orders not in [func for savepoint_id, func in conn.run_on_commit]: + transaction.on_commit(_check_for_dirty_orders, using) + + +def _transactions_mark_order_clean(order_id): + if getattr(dirty_transactions, 'order_ids', None) is None: + dirty_transactions.order_ids = set() + try: + dirty_transactions.order_ids.remove(order_id) + except KeyError: + pass diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 1c6b456a02..226a2eac85 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -1431,7 +1431,7 @@ class SubEvent(EventMixin, LoggedModel): return self.event.currency def allow_delete(self): - return not self.orderposition_set.exists() + return not self.orderposition_set.exists() and not self.transaction_set.exists() def delete(self, *args, **kwargs): clear_cache = kwargs.pop('clear_cache', False) diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 5ba5864437..b9f34ce602 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -693,9 +693,9 @@ class Item(LoggedModel): return res def allow_delete(self): - from pretix.base.models.orders import OrderPosition + from pretix.base.models.orders import OrderPosition, Transaction - return not OrderPosition.all.filter(item=self).exists() + return not Transaction.objects.filter(item=self).exists() and not OrderPosition.all.filter(item=self).exists() @property def includes_mixed_tax_rate(self): @@ -958,10 +958,13 @@ class ItemVariation(models.Model): return self.position < other.position def allow_delete(self): - from pretix.base.models.orders import CartPosition, OrderPosition + from pretix.base.models.orders import ( + CartPosition, OrderPosition, Transaction, + ) return ( - not OrderPosition.objects.filter(variation=self).exists() + not Transaction.objects.filter(variation=self).exists() + and not OrderPosition.objects.filter(variation=self).exists() and not CartPosition.objects.filter(variation=self).exists() ) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index eabd610140..e22be5cec0 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -80,6 +80,9 @@ from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import order_gracefully_delete from ...helpers.countries import CachedCountries, FastCountryField +from ._transactions import ( + _fail, _transactions_mark_order_clean, _transactions_mark_order_dirty, +) from .base import LockModel, LoggedModel from .event import Event, SubEvent from .items import Item, ItemVariation, Question, QuestionOption, Quota @@ -262,6 +265,11 @@ class Order(LockModel, LoggedModel): def __str__(self): return self.full_code + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if 'require_approval' not in self.get_deferred_fields() and 'status' not in self.get_deferred_fields(): + self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval + def gracefully_delete(self, user=None, auth=None): from . import GiftCard, GiftCardTransaction, Membership, Voucher @@ -289,6 +297,7 @@ class Order(LockModel, LoggedModel): OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() OrderPosition.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete() + Transaction.objects.filter(order=self).delete() self.refunds.all().delete() self.payments.all().delete() self.event.cache.delete('complain_testmode_orders') @@ -444,7 +453,27 @@ class Order(LockModel, LoggedModel): self.datetime = now() if not self.expires: self.set_expires() - super().save(**kwargs) + + is_new = not self.pk + update_fields = kwargs.get('update_fields', []) + if 'require_approval' not in self.get_deferred_fields() and 'status' not in self.get_deferred_fields(): + status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval + if status_paid_or_pending != self.__initial_status_paid_or_pending or not self.pk: + _transactions_mark_order_dirty(self.pk, using=kwargs.get('using', None)) + elif ( + not kwargs.get('force_save_with_deferred_fields', None) and + (not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields)) + ): + _fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed " + "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " + "this.") + + r = super().save(**kwargs) + + if is_new: + _transactions_mark_order_dirty(self.pk, using=kwargs.get('using', None)) + + return r def touch(self): self.save(update_fields=['last_modified']) @@ -999,6 +1028,59 @@ class Order(LockModel, LoggedModel): continue yield op + def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False, + _backfill_before_cancellation=False, save=True): + dt_now = dt_now or now() + + # Count the transactions we already have + current_transaction_count = Counter() + if not is_new: + for t in self.transactions.all(): + current_transaction_count[Transaction.key(t)] += t.count + + # Count the transactions we'd actually need + target_transaction_count = Counter() + if (_backfill_before_cancellation or self.status in (Order.STATUS_PENDING, Order.STATUS_PAID)) and not self.require_approval: + positions = self.positions.all() if positions is None else positions + for p in positions: + if p.canceled and not _backfill_before_cancellation: + continue + target_transaction_count[Transaction.key(p)] += 1 + + fees = self.fees.all() if fees is None else fees + for f in fees: + if f.canceled and not _backfill_before_cancellation: + continue + target_transaction_count[Transaction.key(f)] += 1 + + 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 = k + d = target_transaction_count[k] - current_transaction_count[k] + if d: + create.append(Transaction( + order=self, + datetime=dt_now, + migrated=migrated, + positionid=positionid, + count=d, + item_id=itemid, + variation_id=variationid, + subevent_id=subeventid, + price=price, + tax_rate=taxrate, + tax_rule_id=taxruleid, + tax_value=taxvalue, + fee_type=feetype, + internal_type=internaltype, + )) + create.sort(key=lambda t: (0 if t.count < 0 else 1, t.positionid or 0)) + if save: + Transaction.objects.bulk_create(create) + _transactions_mark_order_clean(self.pk) + return create + def answerfile_name(instance, filename: str) -> str: secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits) @@ -1468,6 +1550,7 @@ class OrderPayment(models.Model): 'message': can_be_paid }, user=user, auth=auth) raise Quota.QuotaExceededException(can_be_paid) + status_change = self.order.status != Order.STATUS_PENDING self.order.status = Order.STATUS_PAID self.order.save(update_fields=['status']) @@ -1481,6 +1564,8 @@ class OrderPayment(models.Model): if overpaid: self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth) order_paid.send(self.order.event, order=self.order) + if status_change: + self.order.create_transactions() def fail(self, info=None, user=None, auth=None): """ @@ -1958,6 +2043,12 @@ class OrderFee(models.Model): def net_value(self): return self.value - self.tax_value + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.get_deferred_fields(): + self.__initial_transaction_key = Transaction.key(self) + self.__initial_canceled = self.canceled + def __str__(self): if self.description: return '{} - {}'.format(self.get_fee_type_display(), self.description) @@ -1996,6 +2087,15 @@ class OrderFee(models.Model): if self.tax_rate is None: self._calculate_tax() self.order.touch() + + if not self.get_deferred_fields(): + if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk: + _transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None)) + elif not kwargs.get('force_save_with_deferred_fields', None): + _fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed " + "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " + "this.") + return super().save(*args, **kwargs) def delete(self, **kwargs): @@ -2010,7 +2110,7 @@ class OrderPosition(AbstractPosition): AbstractPosition. The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If - you ant all objects, you need to use ``OrderPosition.all`` instead. + you want all objects, you need to use ``OrderPosition.all`` instead. :param order: The order this position is a part of :type order: Order @@ -2061,6 +2161,12 @@ class OrderPosition(AbstractPosition): all = ScopedManager(organizer='order__event__organizer') objects = ActivePositionManager() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.get_deferred_fields(): + self.__initial_transaction_key = Transaction.key(self) + self.__initial_canceled = self.canceled + class Meta: verbose_name = _("Order position") verbose_name_plural = _("Order positions") @@ -2104,6 +2210,7 @@ class OrderPosition(AbstractPosition): op._calculate_tax() op.positionid = i + 1 op.save() + ops.append(op) cp_mapping[cartpos.pk] = op for answ in cartpos.answers.all(): answ.orderposition = op @@ -2169,6 +2276,14 @@ class OrderPosition(AbstractPosition): if not self.pseudonymization_id: self.assign_pseudonymization_id() + if not self.get_deferred_fields(): + if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk: + _transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None)) + elif not kwargs.get('force_save_with_deferred_fields', None): + _fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed " + "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " + "this.") + return super().save(*args, **kwargs) @scopes_disabled() @@ -2264,6 +2379,143 @@ class OrderPosition(AbstractPosition): ) +class Transaction(models.Model): + """ + Transactions are a data structure that is redundant on the first sight but makes it possible to create good + financial reporting. + + To understand this, think of "orders" as something like a contractual relationship between the organizer and the + customer which requires to customer to pay some money and the organizer to provide a ticket. + + The ``Order``, ``OrderPosition``, and ``OrderFee`` models combined give a representation of the current contractual + status of this relationship, i.e. how much and what is owed. The ``OrderPayment`` and ``OrderRefund`` models indicate + the "other side" of the relationship, i.e. how much of the financial obligation has been met so far. + + However, while ``OrderPayment`` and ``OrderRefund`` objects are "final" and no longer change once they reached their + final state, ``Order``, ``OrderPosition`` and ``OrderFee`` are highly mutable and can chane at any time, e.g. if + the customer moves their booking to a different day or a discount is applied retroactively. + + Therefore those models can be used to answer the question "how many tickets of type X have been sold for my event + as of today?" but they cannot accurately answer the question "how many tickets of type X have been sold for my event + as of last month?" because they lack this kind of historical information. + + Transactions help here because they are "immutable copies" or "modification records" of call positions and fees + at the time of their creation and change. They only record data that is usually relevant for financial reporting, + such as amounts, prices, products and dates involved. They do not record data like attendee names etc. + + Even before the introduction of the Transaction Model pretix *did* store historical data for auditability in the + LogEntry model. However, it's almost impossible to do efficient reporting on that data. + + Transactions should never be generated manually but only through the ``order.create_transactions()`` + method which should be called **within the same database transaction**. + + The big downside of this approach is that you need to remember to update transaction records every time you change + or create orders in new code paths. The mechanism introduced in ``pretix.base.models._transactions`` as well as + the ``save()`` methods of ``Order``, ``OrderPosition`` and ``OrderFee`` intends to help you notice if you missed + it. The only thing this *doesn't* catch is usage of ``OrderPosition.objects.bulk_create`` (and likewise for + ``bulk_update`` and ``OrderFee``). + + :param id: ID of the transaction + :param order: Order the transaction belongs to + :param datetime: Date and time of the transaction + :param migrated: Whether this object was reconstructed because the order was created before transactions where introduced + :param positionid: Affected Position ID, in case this transaction represents a change in an order position + :param count: An amount, multiplicator for price etc. For order positions this can *currently* only be -1 or +1, for + fees it can also be more in special cases + :param item: ``Item``, in case this transaction represents a change in an order position + :param variation: ``ItemVariation``, in case this transaction represents a change in an order position + :param subevent: ``subevent``, in case this transaction represents a change in an order position + :param price: Price of the changed position + :param tax_rate: Tax rate of the changed position + :param tax_rule: Used tax rule + :param tax_value: Tax value in event currency + :param fee_type: Fee type code in case this transaction represents a change in an order fee + :param internal_type: Internal fee type in case this transaction represents a change in an order fee + """ + id = models.BigAutoField(primary_key=True) + order = models.ForeignKey( + Order, + verbose_name=_("Order"), + related_name='transactions', + on_delete=models.PROTECT + ) + created = models.DateTimeField( + auto_now_add=True, + db_index=True, + ) + datetime = models.DateTimeField( + verbose_name=_("Date"), + db_index=True, + ) + migrated = models.BooleanField( + default=False + ) + positionid = models.PositiveIntegerField(default=1, null=True, blank=True) + count = models.IntegerField( + default=1 + ) + item = models.ForeignKey( + Item, + null=True, blank=True, + verbose_name=_("Item"), + on_delete=models.PROTECT + ) + variation = models.ForeignKey( + ItemVariation, + null=True, blank=True, + verbose_name=_("Variation"), + on_delete=models.PROTECT + ) + subevent = models.ForeignKey( + SubEvent, + null=True, blank=True, + on_delete=models.PROTECT, + verbose_name=pgettext_lazy("subevent", "Date"), + ) + price = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Price") + ) + tax_rate = models.DecimalField( + max_digits=7, decimal_places=2, + verbose_name=_('Tax rate') + ) + tax_rule = models.ForeignKey( + 'TaxRule', + on_delete=models.PROTECT, + null=True, blank=True + ) + tax_value = models.DecimalField( + max_digits=10, decimal_places=2, + verbose_name=_('Tax value') + ) + fee_type = models.CharField( + max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True + ) + internal_type = models.CharField(max_length=255, null=True, blank=True) + + class Meta: + ordering = 'datetime', 'pk' + + def save(self, *args, **kwargs): + if not self.fee_type and not self.item: + raise ValidationError('Should set either item or fee type') + return super().save(*args, **kwargs) + + @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) + 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) + 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) + raise ValueError('invalid state') # noqa + + class CartPosition(AbstractPosition): """ A cart position is similar to an order line, except that it is not diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index ebe6545a46..978ef9474d 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -162,10 +162,13 @@ class TaxRule(LoggedModel): pass def allow_delete(self): - from pretix.base.models.orders import OrderFee, OrderPosition + from pretix.base.models.orders import ( + OrderFee, OrderPosition, Transaction, + ) return ( - not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists() + not Transaction.objects.filter(tax_rule=self, order__event=self.event).exists() + and not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists() and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists() and not self.event.items.filter(tax_rule=self).exists() and self.event.settings.tax_rate_default != self diff --git a/src/pretix/base/services/orderimport.py b/src/pretix/base/services/orderimport.py index 2751237a97..ff6665df80 100644 --- a/src/pretix/base/services/orderimport.py +++ b/src/pretix/base/services/orderimport.py @@ -33,6 +33,7 @@ from pretix.base.models import ( CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition, User, ) +from pretix.base.models.orders import Transaction from pretix.base.orderimport import get_all_columns from pretix.base.services.invoices import generate_invoice, invoice_qualified from pretix.base.services.tasks import ProfiledEventTask @@ -146,6 +147,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user) # quota check? with event.lock(): with transaction.atomic(): + save_transactions = [] for o in orders: o.total = sum([c.price for c in o._positions]) # currently no support for fees if o.total == Decimal('0.00'): @@ -187,6 +189,8 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user) user=user, data={'source': 'import'} ) + save_transactions += o.create_transactions(is_new=True, fees=[], positions=o._positions, save=False) + Transaction.objects.bulk_create(save_transactions) for o in orders: with language(o.locale, event.settings.region): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 762de7a289..3589eafec0 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -181,6 +181,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None for m in position.granted_memberships.all(): m.canceled = False m.save() + order.create_transactions() else: raise OrderError(is_available) @@ -202,6 +203,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User if new_date < now(): raise OrderError(_('The new expiry date needs to be in the future.')) + @transaction.atomic def change(was_expired=True): order.expires = new_date if was_expired: @@ -221,6 +223,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User num_invoices = order.invoices.filter(is_cancellation=False).count() if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order): generate_invoice(order) + order.create_transactions() if order.status == Order.STATUS_PENDING: change(was_expired=False) @@ -262,6 +265,7 @@ def mark_order_expired(order, user=None, auth=None): i = order.invoices.filter(is_cancellation=False).last() if i and not i.refered.exists(): generate_cancellation(i) + order.create_transactions() order_expired.send(order.event, order=order) return order @@ -293,6 +297,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth, ignore_date=True, force=force) except Quota.QuotaExceededException: raise OrderError(error_messages['unavailable']) + order.create_transactions() order_approved.send(order.event, order=order) @@ -352,6 +357,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): for position in order.positions.all(): if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + order.create_transactions() order_denied.send(order.event, order=order) @@ -452,6 +458,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device order.total = cancellation_fee order.cancellation_date = now() order.save(update_fields=['status', 'cancellation_date', 'total']) + order.create_transactions(positions=[]) if cancel_invoice and i: invoices.append(generate_invoice(order)) @@ -460,6 +467,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device order.status = Order.STATUS_CANCELED order.cancellation_date = now() order.save(update_fields=['status', 'cancellation_date']) + order.create_transactions() for position in order.positions.all(): assign_ticket_secret( @@ -904,7 +912,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d fee=pf ) - OrderPosition.transform_cart_positions(positions, order) + orderpositions = OrderPosition.transform_cart_positions(positions, order) + order.create_transactions(positions=orderpositions, fees=fees, is_new=True) order.log_action('pretix.event.order.placed') if order.require_approval: order.log_action('pretix.event.order.placed.require_approval') @@ -2123,6 +2132,7 @@ class OrderChangeManager: if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): self._reissue_invoice() self._clear_tickets_cache() + self.order.create_transactions() self.order.touch() self._check_paid_price_change() self._check_paid_to_free() diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 2dece2d272..10de1ad2b5 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -52,18 +52,22 @@ {% if order.status == 'n' or order.status == 'e' %} + {% trans "Mark as paid" %} + {% trans "Extend payment term" %} {% endif %} {% if order.cancel_allowed %} + {% trans "Cancel order" %} {% elif order.status == 'c' %} + {% trans "Reactivate order" %} {% endif %} @@ -71,11 +75,17 @@ + {% trans "View order as user" %} + {% trans "View email history" %} + + + {% trans "View transaction history" %} + diff --git a/src/pretix/control/templates/pretixcontrol/order/transactions.html b/src/pretix/control/templates/pretixcontrol/order/transactions.html new file mode 100644 index 0000000000..cdf951ddaf --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/transactions.html @@ -0,0 +1,61 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load money %} +{% block title %}{% trans "Transaction history" %}{% endblock %} +{% block content %} +

+ {% trans "Transaction history" %} + + {% blocktrans trimmed with order=order.code %} + Back to order {{ order }} + {% endblocktrans %} + +

+ + + + + + + + + + + + + {% for t in transactions %} + + + + + + + + + {% endfor %} + +
{% trans "Date" %}{% trans "Product" %}{% trans "Quantity" %}{% trans "Tax rate" %}{% trans "Tax value" %}{% trans "Price" %}
+ {{ t.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if t.migrated %} + + {% endif %} + + {% if t.item %} + {{ t.item }} + {% if t.variation %} + – {{ t.variation }} + {% endif %} + {% endif %} + {% if t.fee_type %} + {{ t.get_fee_type_display }} + {% endif %} + {% if t.subevent %} +
{{ t.subevent }} + {% endif %} +
{{ t.count }} ×{{ t.tax_rate }} %{{ t.tax_value|money:request.event.currency }}{{ t.price|money:request.event.currency }}
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 5d23f4fcb0..da14f08f28 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -345,6 +345,7 @@ urlpatterns = [ re_path(r'^orders/(?P[0-9A-Z]+)/cancellationrequests/(?P\d+)/delete$', orders.OrderCancellationRequestDelete.as_view(), name='event.order.cancellationrequests.delete'), + re_path(r'^orders/(?P[0-9A-Z]+)/transactions/$', orders.OrderTransactions.as_view(), name='event.order.transactions'), re_path(r'^orders/(?P[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'), re_path(r'^invoice/(?P[^/]+)$', orders.InvoiceDownload.as_view(), name='event.invoice.download'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 031474875a..b08a88f5bc 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -401,6 +401,18 @@ class OrderDetail(OrderView): } +class OrderTransactions(OrderView): + template_name = 'pretixcontrol/order/transactions.html' + permission = 'can_view_orders' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['transactions'] = self.order.transactions.select_related( + 'item', 'variation', 'subevent' + ).order_by('datetime') + return ctx + + class OrderDownload(AsyncAction, OrderView): task = generate permission = 'can_view_orders' diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 585ea2339b..502c1e8696 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -1173,6 +1173,9 @@ def test_order_mark_paid_canceled(token_client, organizer, event, order): def test_order_mark_paid_expired_quota_free(token_client, organizer, event, order, quota): order.status = Order.STATUS_EXPIRED order.save() + with scopes_disabled(): + order.create_transactions() + assert order.transactions.count() == 0 resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/{}/mark_paid/'.format( organizer.slug, event.slug, order.code @@ -1186,6 +1189,8 @@ def test_order_mark_paid_expired_quota_free(token_client, organizer, event, orde order.refresh_from_db() assert len(djmail.outbox) == 0 assert order.status == Order.STATUS_PAID + with scopes_disabled(): + assert order.transactions.count() == 2 @pytest.mark.django_db @@ -1223,6 +1228,9 @@ def test_order_mark_paid_locked(token_client, organizer, event, order): def test_order_reactivate(token_client, organizer, event, order, quota): order.status = Order.STATUS_CANCELED order.save() + with scopes_disabled(): + order.create_transactions() + assert order.transactions.count() == 0 resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/{}/reactivate/'.format( organizer.slug, event.slug, order.code @@ -1230,6 +1238,8 @@ def test_order_reactivate(token_client, organizer, event, order, quota): ) assert resp.status_code == 200 assert resp.data['status'] == Order.STATUS_PENDING + with scopes_disabled(): + assert order.transactions.count() == 2 @pytest.mark.django_db @@ -1244,6 +1254,9 @@ def test_order_reactivate_invalid(token_client, organizer, event, order): @pytest.mark.django_db def test_order_mark_canceled_pending(token_client, organizer, event, order): + with scopes_disabled(): + order.create_transactions() + assert order.transactions.count() == 2 djmail.outbox = [] resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format( @@ -1253,6 +1266,8 @@ def test_order_mark_canceled_pending(token_client, organizer, event, order): assert resp.status_code == 200 assert resp.data['status'] == Order.STATUS_CANCELED assert len(djmail.outbox) == 1 + with scopes_disabled(): + assert order.transactions.count() == 4 @pytest.mark.django_db @@ -1302,6 +1317,9 @@ def test_order_mark_canceled_expired(token_client, organizer, event, order): def test_order_mark_paid_canceled_keep_fee(token_client, organizer, event, order): order.status = Order.STATUS_PAID order.save() + with scopes_disabled(): + order.create_transactions() + assert order.transactions.count() == 2 with scopes_disabled(): order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=order.total) resp = token_client.post( @@ -1316,6 +1334,8 @@ def test_order_mark_paid_canceled_keep_fee(token_client, organizer, event, order order.refresh_from_db() assert order.status == Order.STATUS_PAID assert order.total == Decimal('6.00') + with scopes_disabled(): + assert order.transactions.count() == 4 @pytest.mark.django_db @@ -1502,6 +1522,9 @@ def test_order_extend_expired_quota_left(token_client, organizer, event, order, order.save() quota.size = 2 quota.save() + with scopes_disabled(): + order.create_transactions() + assert order.transactions.count() == 0 newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d") resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format( @@ -1514,12 +1537,17 @@ def test_order_extend_expired_quota_left(token_client, organizer, event, order, order.refresh_from_db() assert order.status == Order.STATUS_PENDING assert order.expires.astimezone(event.timezone).strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59" + with scopes_disabled(): + assert order.transactions.count() == 2 @pytest.mark.django_db def test_order_pending_approve(token_client, organizer, event, order): order.require_approval = True order.save() + with scopes_disabled(): + order.create_transactions() + assert order.transactions.count() == 0 resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/{}/approve/'.format( organizer.slug, event.slug, order.code @@ -1528,6 +1556,8 @@ def test_order_pending_approve(token_client, organizer, event, order): assert resp.status_code == 200 assert resp.data['status'] == Order.STATUS_PENDING assert not resp.data['require_approval'] + with scopes_disabled(): + assert order.transactions.count() == 2 @pytest.mark.django_db @@ -1690,6 +1720,8 @@ def test_order_create(token_client, organizer, event, item, quota, question): answ = pos.answers.first() assert answ.question == question assert answ.answer == "S" + with scopes_disabled(): + assert o.transactions.count() == 2 @pytest.mark.django_db diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 3b077584ee..b50fcb0ee8 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -26,6 +26,7 @@ from decimal import Decimal import pytest import pytz from django.core import mail as djmail +from django.db.models import F, Sum from django.test import TestCase from django.utils.timezone import make_aware, now from django_countries.fields import Country @@ -204,7 +205,19 @@ def test_expiring(event): datetime=now(), expires=now() - timedelta(days=10), total=12, ) + ticket = Item.objects.create(event=event, name='Early-bird ticket', + default_price=Decimal('23.00'), admission=True) + OrderPosition.objects.create( + order=o1, item=ticket, variation=None, + price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 + ) + OrderPosition.objects.create( + order=o2, item=ticket, variation=None, + price=Decimal("12.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 + ) generate_invoice(o2) + o1.create_transactions() + o2.create_transactions() expire_orders(None) o1 = Order.objects.get(id=o1.id) assert o1.status == Order.STATUS_PENDING @@ -212,6 +225,9 @@ def test_expiring(event): assert o2.status == Order.STATUS_EXPIRED assert o2.invoices.count() == 2 assert o2.invoices.last().is_cancellation is True + assert o1.transactions.count() == 1 + assert o2.transactions.count() == 2 + assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('0.00') @pytest.mark.django_db @@ -222,8 +238,19 @@ def test_expiring_paid_invoice(event): datetime=now(), expires=now() - timedelta(days=10), total=12, ) + ticket = Item.objects.create(event=event, name='Early-bird ticket', + default_price=Decimal('12.00'), admission=True) + q = event.quotas.create(name='Q', size=None) + q.items.add(ticket) + OrderPosition.objects.create( + order=o2, item=ticket, variation=None, + price=Decimal("12.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 + ) generate_invoice(o2) + o2.create_transactions() + assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('12.00') expire_orders(None) + assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('0.00') o2 = Order.objects.get(id=o2.id) assert o2.status == Order.STATUS_EXPIRED assert o2.invoices.count() == 2 @@ -232,6 +259,8 @@ def test_expiring_paid_invoice(event): ).confirm() assert o2.invoices.count() == 3 assert o2.invoices.last().is_cancellation is False + assert o2.transactions.count() == 3 + assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('12.00') @pytest.mark.django_db @@ -309,8 +338,17 @@ def test_approve(event): datetime=now(), expires=now() - timedelta(days=10), total=10, require_approval=True, locale='en' ) + ticket = Item.objects.create(event=event, name='Early-bird ticket', + default_price=Decimal('23.00'), admission=True) + OrderPosition.objects.create( + order=o1, item=ticket, variation=None, + price=Decimal("23.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 + ) + o1.create_transactions() + assert o1.transactions.count() == 0 approve_order(o1) o1.refresh_from_db() + assert o1.transactions.count() == 1 assert o1.expires > now() assert o1.status == Order.STATUS_PENDING assert not o1.require_approval @@ -609,6 +647,7 @@ class OrderCancelTests(TestCase): order=self.order, item=self.ticket, variation=None, price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2 ) + self.order.create_transactions() generate_invoice(self.order) djmail.outbox = [] @@ -659,6 +698,8 @@ class OrderCancelTests(TestCase): assert self.order.status == Order.STATUS_CANCELED assert self.order.all_logentries().last().action_type == 'pretix.event.order.canceled' assert self.order.invoices.count() == 2 + assert self.order.transactions.count() == 4 + assert self.order.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('0.00') @classscope(attr='o') def test_cancel_paid_with_too_high_fee(self): @@ -820,6 +861,8 @@ class OrderChangeManagerTests(TestCase): order=self.order, item=self.ticket, variation=None, price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2 ) + self.order.create_transactions(is_new=True) + assert self.order.transactions.count() == 2 self.ocm = OrderChangeManager(self.order, None) self.quota = self.event.quotas.create(name='Test', size=None) self.quota.items.add(self.ticket) @@ -899,6 +942,7 @@ class OrderChangeManagerTests(TestCase): self.order.refresh_from_db() assert self.op1.subevent == se2 assert self.op1.item == self.shirt + assert self.order.transactions.count() == 4 @classscope(attr='o') def test_change_subevent_success(self): @@ -921,6 +965,7 @@ class OrderChangeManagerTests(TestCase): assert self.op1.price == Decimal('23.00') assert self.order.total == self.op1.price + self.op2.price assert self.op1.secret == s + assert self.order.transactions.count() == 4 @classscope(attr='o') def test_change_subevent_success_change_secret(self): @@ -944,6 +989,7 @@ class OrderChangeManagerTests(TestCase): assert self.op1.price == Decimal('23.00') assert self.order.total == self.op1.price + self.op2.price assert self.op1.secret != s + assert self.order.transactions.count() == 4 @classscope(attr='o') def test_change_subevent_with_price_success(self): @@ -965,6 +1011,7 @@ class OrderChangeManagerTests(TestCase): assert self.op1.subevent == se2 assert self.op1.price == Decimal('12.00') assert self.order.total == self.op1.price + self.op2.price + assert self.order.transactions.count() == 4 @classscope(attr='o') def test_change_subevent_sold_out(self): @@ -983,6 +1030,7 @@ class OrderChangeManagerTests(TestCase): self.ocm.commit() self.op1.refresh_from_db() assert self.op1.subevent == se1 + assert self.order.transactions.count() == 2 @classscope(attr='o') def test_change_item_quota_required(self): @@ -1004,6 +1052,7 @@ class OrderChangeManagerTests(TestCase): assert self.op1.tax_value == Decimal('3.67') assert self.op1.tax_rule == self.shirt.tax_rule assert self.op1.secret != s + assert self.order.transactions.count() == 4 @classscope(attr='o') def test_change_item_keep_price(self): @@ -1018,6 +1067,7 @@ class OrderChangeManagerTests(TestCase): assert self.op1.tax_value == Decimal('3.67') assert self.op1.tax_rule == self.shirt.tax_rule assert self.op1.secret == s + assert self.order.transactions.count() == 4 @classscope(attr='o') def test_change_item_change_price_before_voucher(self): @@ -1074,6 +1124,19 @@ class OrderChangeManagerTests(TestCase): assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value assert self.order.total == self.op1.price + self.op2.price + t0 = self.order.transactions.filter(positionid=self.op1.positionid)[0] + assert t0.item == self.ticket + assert t0.price == Decimal('23.00') + assert t0.count == 1 + t1 = self.order.transactions.filter(positionid=self.op1.positionid)[1] + assert t1.item == self.ticket + assert t1.price == Decimal('23.00') + assert t1.count == -1 + t2 = self.order.transactions.filter(positionid=self.op1.positionid)[2] + assert t2.item == self.shirt + assert t2.price == Decimal('12.00') + assert t2.count == 1 + @classscope(attr='o') def test_change_price_success(self): self.ocm.change_price(self.op1, Decimal('24.00')) @@ -1109,6 +1172,7 @@ class OrderChangeManagerTests(TestCase): self.op1.refresh_from_db() assert self.op1.canceled assert self.op1.secret == s + assert self.order.transactions.count() == 3 @classscope(attr='o') def test_cancel_success_changed_secret(self): @@ -1284,6 +1348,7 @@ class OrderChangeManagerTests(TestCase): self.op2.refresh_from_db() assert self.op1.item == self.shirt assert self.op2.item == self.shirt + assert self.order.transactions.count() == 6 @classscope(attr='o') def test_multiple_items_quotas_partially_full(self): @@ -1394,6 +1459,7 @@ class OrderChangeManagerTests(TestCase): assert self.order.total == Decimal('48.00') assert self.order.pending_sum == Decimal('2.00') assert self.order.status == Order.STATUS_PENDING + assert self.order.transactions.count() == 4 @classscope(attr='o') def test_change_paid_stays_paid_when_overpaid(self): @@ -1436,6 +1502,7 @@ class OrderChangeManagerTests(TestCase): assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rule.rate))) == nop.tax_value assert self.order.total == self.op1.price + self.op2.price + nop.price assert nop.positionid == 3 + assert self.order.transactions.count() == 3 @classscope(attr='o') def test_add_item_net_price_success(self): @@ -1615,6 +1682,7 @@ class OrderChangeManagerTests(TestCase): assert op.tax_rate == Decimal('100.00') assert self.order.total == Decimal('86.00') + fee.value + assert self.order.transactions.count() == 7 @classscope(attr='o') def test_recalculate_country_rate_keep_gross(self): @@ -2973,6 +3041,7 @@ class OrderReactivateTest(TestCase): self.quota.items.add(self.ticket) self.seat_a1 = self.event.seats.create(seat_number="A1", product=self.stalls, seat_guid="A1") generate_invoice(self.order) + self.order.create_transactions() djmail.outbox = [] @classscope(attr='o') @@ -2994,6 +3063,7 @@ class OrderReactivateTest(TestCase): @classscope(attr='o') def test_reactivate_paid(self): + assert self.order.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] in (None, Decimal('0.00')) self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5) reactivate_order(self.order) self.order.refresh_from_db() @@ -3001,6 +3071,7 @@ class OrderReactivateTest(TestCase): assert self.order.all_logentries().last().action_type == 'pretix.event.order.reactivated' assert self.order.invoices.count() == 3 assert not self.order.cancellation_date + assert self.order.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('46.00') @classscope(attr='o') def test_reactivate_sold_out(self): diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index d783420379..926dc0d34e 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -490,6 +490,10 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): assert pos.price == Decimal('23.20') assert pos.tax_rate == Decimal('20.00') assert pos.tax_value == Decimal('3.87') + t = o.transactions.get() + assert t.price == Decimal('23.20') + assert t.tax_rate == Decimal('20.00') + assert t.tax_value == Decimal('3.87') def test_country_taxing_free_price_and_voucher(self): self._enable_country_specific_taxing()