Data model for transactional history (#2147)

This commit is contained in:
Raphael Michel
2021-10-18 17:28:58 +02:00
committed by GitHub
parent c4e71011ee
commit 8ebba9de86
19 changed files with 699 additions and 10 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
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.'))

View File

@@ -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'),
},
),
]

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
"""
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

View File

@@ -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)

View File

@@ -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()
)

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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()

View File

@@ -52,18 +52,22 @@
{% if order.status == 'n' or order.status == 'e' %}
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=p"
class="btn {% if overpaid >= 0 %}btn-success{% else %}btn-default{% endif %}">
<span class="fa fa-check"></span>
{% trans "Mark as paid" %}
</a>
<a href="{% url "control:event.order.extend" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
<span class="fa fa-clock-o"></span>
{% trans "Extend payment term" %}
</a>
{% endif %}
{% if order.cancel_allowed %}
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
<span class="fa fa-ban"></span>
{% trans "Cancel order" %}
</a>
{% elif order.status == 'c' %}
<a href="{% url "control:event.order.reactivate" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
<span class="fa fa-reply"></span>
{% trans "Reactivate order" %}
</a>
{% endif %}
@@ -71,11 +75,17 @@
<a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}"
class="btn btn-default" target="_blank">
<span class="fa fa-eye"></span>
{% trans "View order as user" %}
</a>
<a href="{% url "control:event.order.mail_history" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
<span class="fa fa-envelope-o"></span>
{% trans "View email history" %}
</a>
<a href="{% url "control:event.order.transactions" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
<span class="fa fa-list"></span>
{% trans "View transaction history" %}
</a>
</div>
</div>
</form>

View File

@@ -0,0 +1,61 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}{% trans "Transaction history" %}{% endblock %}
{% block content %}
<h1>
{% trans "Transaction history" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
</h1>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "Product" %}</th>
<th class="text-right flip">{% trans "Quantity" %}</th>
<th class="text-right flip">{% trans "Tax rate" %}</th>
<th class="text-right flip">{% trans "Tax value" %}</th>
<th class="text-right flip">{% trans "Price" %}</th>
</tr>
</thead>
<tbody>
{% for t in transactions %}
<tr class="{% if t.count < 0 %}text-danger{% endif %}">
<td>
{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if t.migrated %}
<span class="fa fa-warning text-warning"
data-toggle="tooltip"
title="{% trans 'This order was created before we introduced this table, therefore this data might be inaccurate.' %}"
></span>
{% endif %}
</td>
<td>
{% 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 %}
<br>{{ t.subevent }}
{% endif %}
</td>
<td class="text-right flip">{{ t.count }} &times;</td>
<td class="text-right flip">{{ t.tax_rate }} %</td>
<td class="text-right flip">{{ t.tax_value|money:request.event.currency }}</td>
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -345,6 +345,7 @@ urlpatterns = [
re_path(r'^orders/(?P<code>[0-9A-Z]+)/cancellationrequests/(?P<req>\d+)/delete$',
orders.OrderCancellationRequestDelete.as_view(),
name='event.order.cancellationrequests.delete'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transactions/$', orders.OrderTransactions.as_view(), name='event.order.transactions'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
name='event.invoice.download'),

View File

@@ -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'

View File

@@ -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

View File

@@ -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):

View File

@@ -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()