diff --git a/doc/_themes/pretix_theme/static/css/pretix.css b/doc/_themes/pretix_theme/static/css/pretix.css index 2424fb24d7..1de054f0f9 100644 --- a/doc/_themes/pretix_theme/static/css/pretix.css +++ b/doc/_themes/pretix_theme/static/css/pretix.css @@ -6067,3 +6067,35 @@ url('../opensans_regular_macroman/OpenSans-Regular-webfont.svg#open_sansregular' img.screenshot, a.screenshot img { box-shadow: 0 4px 18px 0 rgba(0,0,0,0.1), 0 6px 20px 0 rgba(0,0,0,0.09); } + +/* Changes */ +.versionchanged { + background: #e7f2fa; + padding: 12px; + line-height: 24px; + margin-bottom: 24px; + -webkit-font-smoothing: antialiased; +} +.versionmodified { + background: #6ab0de; + font-weight: bold; + display: block; + color: #fff; + margin: -12px; + padding: 6px 12px; + margin-bottom: 12px; + font-family: inherit; +} +.versionmodified:before { + font-family: "FontAwesome"; + display: inline-block; + font-style: normal; + font-weight: normal; + line-height: 1; + text-decoration: inherit; + content: ""; + margin-right: 4px; +} +.versionchanged p:last-child { + margin-bottom: 0; +} diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index dc533f755d..01f30a9b95 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -48,6 +48,17 @@ invoice_address object Invoice address EU VAT service and validation was successful. This only happens in rare cases. position list of objects List of order positions (see below) +fees list of objects List of fees included in the order total (i.e. + payment fees) +├ fee_type string Type of fee (currently ``payment``, ``passbook``, + ``other``) +├ value money (string) Fee amount +├ description string Human-readable string with more details (can be empty) +├ internal_type string Internal string (i.e. ID of the payment provider), + can be empty +├ tax_rate decimal (string) VAT rate applied for this fee +├ tax_value money (string) VAT included in this fee +└ tax_rule integer The ID of the used tax rule (or ``null``) downloads list of objects List of ticket download options for order-wise ticket downloading. This might be a multi-page PDF or a ZIP file of tickets for outputs that do not support @@ -65,8 +76,9 @@ downloads list of objects List of ticket .. versionchanged:: 1.7 - The attributes ``payment_fee_tax_rule``, ``invoice_address.vat_id_validated`` and ``invoice_address.is_business`` - have been added. + The attributes ``invoice_address.vat_id_validated`` and ``invoice_address.is_business`` have been added. + The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate`` and ``order.payment_fee_tax_value`` have been + deprecated in favour of the new ``fees`` attribute but will still be served and removed in 1.9. Order position resource @@ -146,10 +158,7 @@ Order endpoints "expires": "2017-12-10T10:00:00Z", "payment_date": "2017-12-05", "payment_provider": "banktransfer", - "payment_fee": "0.00", - "payment_fee_tax_rate": "0.00", - "payment_fee_tax_value": "0.00", - "payment_fee_tax_rule": null, + "fees": [], "total": "23.00", "comment": "", "invoice_address": { @@ -254,10 +263,7 @@ Order endpoints "expires": "2017-12-10T10:00:00Z", "payment_date": "2017-12-05", "payment_provider": "banktransfer", - "payment_fee": "0.00", - "payment_fee_tax_rate": "0.00", - "payment_fee_tax_value": "0.00", - "payment_fee_tax_rule": null, + "fees": [], "total": "23.00", "comment": "", "invoice_address": { diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 71a297d820..77386759a5 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from rest_framework import serializers from rest_framework.reverse import reverse @@ -6,6 +8,7 @@ from pretix.base.models import ( Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition, QuestionAnswer, ) +from pretix.base.models.orders import OrderFee from pretix.base.signals import register_ticket_outputs @@ -101,16 +104,38 @@ class OrderPositionSerializer(I18nAwareModelSerializer): 'answers', 'tax_rule') +class OrderFeeSerializer(I18nAwareModelSerializer): + class Meta: + model = OrderFee + fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule') + + +class PaymentFeeLegacyField(serializers.Field): + def __init__(self, *args, **kwargs): + self.attr = kwargs.pop('attribute') + super().__init__(*args, **kwargs) + + def to_representation(self, instance: Order): + return str( + sum([getattr(f, self.attr) for f in instance.fees.all() if f.fee_type == OrderFee.FEE_TYPE_PAYMENT], + Decimal('0.00')) + ) + + class OrderSerializer(I18nAwareModelSerializer): invoice_address = InvoiceAdddressSerializer() positions = OrderPositionSerializer(many=True) + fees = OrderFeeSerializer(many=True) downloads = OrderDownloadsField(source='*') + payment_fee = PaymentFeeLegacyField(source='*', attribute='value') # TODO: Remove in 1.9 + payment_fee_tax_rate = PaymentFeeLegacyField(source='*', attribute='tax_rate') # TODO: Remove in 1.9 + payment_fee_tax_value = PaymentFeeLegacyField(source='*', attribute='tax_value') # TODO: Remove in 1.9 class Meta: model = Order fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', - 'payment_provider', 'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value', - 'payment_fee_tax_rule', 'total', 'comment', 'invoice_address', 'positions', 'downloads') + 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', + 'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value') class InlineInvoiceLineSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 4de69ba046..bf778f1adb 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -37,7 +37,8 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return self.request.event.orders.prefetch_related( - 'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options' + 'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options', + 'fees' ).select_related( 'invoice_address' ) diff --git a/src/pretix/base/exporters/json.py b/src/pretix/base/exporters/json.py index cd504d6257..04e3561ba9 100644 --- a/src/pretix/base/exporters/json.py +++ b/src/pretix/base/exporters/json.py @@ -61,7 +61,13 @@ class JSONExporter(BaseExporter): 'status': order.status, 'user': order.email, 'datetime': order.datetime, - 'payment_fee': order.payment_fee, + 'fees': [ + { + 'type': fee.fee_type, + 'description': fee.description, + 'value': fee.value, + } for fee in order.fees.all() + ], 'total': order.total, 'positions': [ { @@ -82,7 +88,7 @@ class JSONExporter(BaseExporter): } for position in order.positions.all() ] } for order in - self.event.orders.all().prefetch_related('positions', 'positions__answers') + self.event.orders.all().prefetch_related('positions', 'positions__answers', 'fees') ], 'quotas': [ { diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index b021dc18b4..32d31d7e7a 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -11,6 +11,7 @@ from django.utils.formats import localize from django.utils.translation import ugettext as _, ugettext_lazy from pretix.base.models import InvoiceAddress, Order, OrderPosition +from pretix.base.models.orders import OrderFee from ..exporter import BaseExporter from ..signals import register_data_exporters @@ -35,7 +36,10 @@ class OrderListExporter(BaseExporter): def _get_all_tax_rates(self, qs): tax_rates = set( - qs.exclude(payment_fee=0).values_list('payment_fee_tax_rate', flat=True).distinct().order_by() + a for a + in OrderFee.objects.filter( + order__event=self.event + ).values_list('tax_rate', flat=True).distinct().order_by() ) tax_rates |= set( a for a @@ -59,7 +63,7 @@ class OrderListExporter(BaseExporter): headers = [ _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'), _('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), - _('Payment date'), _('Payment type'), _('Payment method fee'), + _('Payment date'), _('Payment type'), _('Fees'), ] for tr in tax_rates: @@ -78,6 +82,16 @@ class OrderListExporter(BaseExporter): for k, v in self.event.get_payment_providers().items() } + full_fee_sum_cache = { + o['order__id']: o['grosssum'] for o in + OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate(grosssum=Sum('value')) + } + fee_sum_cache = { + (o['order__id'], o['tax_rate']): o for o in + OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate( + taxsum=Sum('tax_value'), grosssum=Sum('value') + ) + } sum_cache = { (o['order__id'], o['tax_rate']): o for o in OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate( @@ -109,19 +123,18 @@ class OrderListExporter(BaseExporter): row += [ order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', provider_names.get(order.payment_provider, order.payment_provider), - localize(order.payment_fee) + localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')) ] for tr in tax_rates: taxrate_values = sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')}) - if tr == order.payment_fee_tax_rate and order.payment_fee_tax_value: - taxrate_values['grosssum'] += order.payment_fee - taxrate_values['taxsum'] += order.payment_fee_tax_value + fee_taxrate_values = fee_sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')}) row += [ - localize(taxrate_values['grosssum']), - localize(taxrate_values['grosssum'] - taxrate_values['taxsum']), - localize(taxrate_values['taxsum']), + localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']), + localize(taxrate_values['grosssum'] - taxrate_values['taxsum'] + + fee_taxrate_values['grosssum'] - fee_taxrate_values['taxsum']), + localize(taxrate_values['taxsum'] + fee_taxrate_values['taxsum']), ] row.append(', '.join([i.number for i in order.invoices.all()])) diff --git a/src/pretix/base/migrations/0076_orderfee.py b/src/pretix/base/migrations/0076_orderfee.py new file mode 100644 index 0000000000..c851bc9a40 --- /dev/null +++ b/src/pretix/base/migrations/0076_orderfee.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-28 14:35 +from __future__ import unicode_literals + +from decimal import Decimal + +import django.db.models.deletion +from django.db import migrations, models + + +def fee_converter(app, schema_editor): + OrderFee = app.get_model('pretixbase', 'OrderFee') + Order = app.get_model('pretixbase', 'Order') + + of = [] + for o in Order.objects.exclude(payment_fee=Decimal('0.00')).iterator(): + of.append(OrderFee( + order=o, + value=o.payment_fee, + fee_type='payment', + tax_rate=o.payment_fee_tax_rate, + tax_rule=o.payment_fee_tax_rule, + tax_value=o.payment_fee_tax_value, + internal_type=o.payment_provider + )) + if len(of) > 900: + OrderFee.objects.bulk_create(of) + of = [] + OrderFee.objects.bulk_create(of) + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0075_auto_20170828_0901'), + ] + + operations = [ + migrations.CreateModel( + name='OrderFee', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Value')), + ('description', models.CharField(blank=True, max_length=190)), + ('internal_type', models.CharField(blank=True, max_length=255)), + ('fee_type', models.CharField(choices=[('payment', 'Payment method fee'), ('shipping', 'Shipping fee')], max_length=100)), + ('tax_rate', models.DecimalField(decimal_places=2, max_digits=7, verbose_name='Tax rate')), + ('tax_value', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Tax value')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='fees', to='pretixbase.Order', verbose_name='Order')), + ('tax_rule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.TaxRule')), + ], + ), + migrations.RunPython( + fee_converter, migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='order', + name='payment_fee', + ), + migrations.RemoveField( + model_name='order', + name='payment_fee_tax_rate', + ), + migrations.RemoveField( + model_name='order', + name='payment_fee_tax_rule', + ), + migrations.RemoveField( + model_name='order', + name='payment_fee_tax_value', + ), + ] diff --git a/src/pretix/base/migrations/0077_auto_20170829_1126.py b/src/pretix/base/migrations/0077_auto_20170829_1126.py new file mode 100644 index 0000000000..d9f39f2acc --- /dev/null +++ b/src/pretix/base/migrations/0077_auto_20170829_1126.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-29 11:26 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def assign_positions(app, schema_editor): + Invoice = app.get_model('pretixbase', 'Invoice') + + for i in Invoice.objects.iterator(): + for j, l in enumerate(i.lines.all()): + l.position = j + l.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0076_orderfee'), + ] + + operations = [ + migrations.AddField( + model_name='invoiceline', + name='position', + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name='orderfee', + name='fee_type', + field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('other', 'Other fees')], max_length=100), + ), + migrations.RunPython( + assign_positions, migrations.RunPython.noop + ), + migrations.AlterModelOptions( + name='invoiceline', + options={'ordering': ('position', 'pk')}, + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 1284c307b8..a4d11eee64 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -168,6 +168,7 @@ class InvoiceLine(models.Model): :type tax_name: str """ invoice = models.ForeignKey('Invoice', related_name='lines') + position = models.PositiveIntegerField(default=0) description = models.TextField() gross_value = models.DecimalField(max_digits=10, decimal_places=2) tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00')) @@ -177,3 +178,6 @@ class InvoiceLine(models.Model): @property def net_value(self): return self.gross_value - self.tax_value + + class Meta: + ordering = ('position', 'pk') diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index d913bb3e31..d9dd910fe0 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -78,12 +78,6 @@ class Order(LoggedModel): :type payment_date: datetime :param payment_provider: The payment provider selected by the user :type payment_provider: str - :param payment_fee: The payment fee calculated at checkout time - :type payment_fee: decimal.Decimal - :param payment_fee_tax_value: The absolute amount of tax included in the payment fee - :type payment_fee_tax_value: decimal.Decimal - :param payment_fee_tax_rate: The tax rate applied to the payment fee (in percent) - :type payment_fee_tax_rate: decimal.Decimal :param payment_info: Arbitrary information stored by the payment provider :type payment_info: str :param total: The total amount of the order, including the payment fee @@ -149,23 +143,6 @@ class Order(LoggedModel): max_length=255, verbose_name=_("Payment provider") ) - payment_fee = models.DecimalField( - decimal_places=2, max_digits=10, - default=0, verbose_name=_("Payment method fee") - ) - payment_fee_tax_rate = models.DecimalField( - decimal_places=2, max_digits=10, - verbose_name=_("Payment method fee tax rate") - ) - payment_fee_tax_value = models.DecimalField( - decimal_places=2, max_digits=10, - default=0, verbose_name=_("Payment method fee tax") - ) - payment_fee_tax_rule = models.ForeignKey( - 'TaxRule', - on_delete=models.PROTECT, - null=True, blank=True - ) payment_info = models.TextField( verbose_name=_("Payment information"), null=True, blank=True @@ -224,43 +201,11 @@ class Order(LoggedModel): self.assign_code() if not self.datetime: self.datetime = now() - if self.payment_fee_tax_rate is None: - self._calculate_tax() super().save(*args, **kwargs) - def _calculate_tax(self): - """ - Calculates the taxes on the payment fees and sets the parameters payment_fee_tax_rate - and payment_fee_tax_value accordingly. - """ - if self.event.settings.tax_rate_default: - tr = self.event.settings.tax_rate_default - tax = tr.tax(self.payment_fee, base_price_is='gross') - rate, tax = tax.rate, tax.tax - - try: - ia = self.invoice_address - except InvoiceAddress.DoesNotExist: - ia = None - if not tr.tax_applicable(ia): - rate = 0 - tax = 0 - - self.payment_fee_tax_rate = rate - self.payment_fee_tax_value = tax - self.payment_fee_tax_rule = tr - else: - self.payment_fee_tax_rate = Decimal('0.00') - self.payment_fee_tax_value = Decimal('0.00') - self.payment_fee_tax_rule = None - - @property - def payment_fee_net(self): - return self.payment_fee - self.payment_fee_tax_value - @cached_property def tax_total(self): - return (self.positions.aggregate(s=Sum('tax_value'))['s'] or 0) + self.payment_fee_tax_value + return (self.positions.aggregate(s=Sum('tax_value'))['s'] or 0) + (self.fees.aggregate(s=Sum('tax_value'))['s'] or 0) @property def net_total(self): @@ -669,6 +614,91 @@ class AbstractPosition(models.Model): else self.variation.quotas.filter(subevent=self.subevent)) +class OrderFee(models.Model): + """ + An OrderFee objet represents a fee that is added to the order total independently of + the actual positions. This might for example be a payment or a shipping fee. + """ + FEE_TYPE_PAYMENT = "payment" + FEE_TYPE_SHIPPING = "shipping" + FEE_TYPE_OTHER = "other" + FEE_TYPES = ( + (FEE_TYPE_PAYMENT, _("Payment fee")), + (FEE_TYPE_SHIPPING, _("Shipping fee")), + (FEE_TYPE_OTHER, _("Other fees")), + ) + + value = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Value") + ) + order = models.ForeignKey( + Order, + verbose_name=_("Order"), + related_name='fees', + on_delete=models.PROTECT + ) + fee_type = models.CharField( + max_length=100, choices=FEE_TYPES + ) + description = models.CharField(max_length=190, blank=True) + internal_type = models.CharField(max_length=255, blank=True) + 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') + ) + + @property + def net_value(self): + return self.value - self.tax_value + + def __str__(self): + if self.description: + return '{} - {}'.format(self.get_fee_type_display(), self.description) + else: + return self.get_fee_type_display() + + def __repr__(self): + return '' % ( + self.fee_type, self.value + ) + + def _calculate_tax(self): + try: + ia = self.order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + + if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rate_default: + self.tax_rule = self.order.event.settings.tax_rate_default + + if self.tax_rule: + if self.tax_rule.tax_applicable(ia): + tax = self.tax_rule.tax(self.value, base_price_is='gross') + self.tax_rate = tax.rate + self.tax_value = tax.tax + else: + self.tax_value = Decimal('0.00') + self.tax_rate = Decimal('0.00') + else: + self.tax_value = Decimal('0.00') + self.tax_rate = Decimal('0.00') + + def save(self, *args, **kwargs): + if self.tax_rate is None: + self._calculate_tax() + return super().save(*args, **kwargs) + + class OrderPosition(AbstractPosition): """ An OrderPosition is one line of an order, representing one ordered item diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index a91d50eb4f..c15f5115f5 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -14,7 +14,8 @@ from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, ItemVariation, Voucher, ) from pretix.base.models.event import SubEvent -from pretix.base.models.tax import TAXED_ZERO, TaxedPrice +from pretix.base.models.orders import OrderFee +from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.services.async import ProfiledTask from pretix.base.services.locking import LockTimeoutException from pretix.base.services.pricing import get_price @@ -626,6 +627,40 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress return totaldiff +def get_fees(event, total, invoice_address, provider): + fees = [] + + if total == 0: + return fees + + if provider: + provider = event.get_payment_providers().get(provider) + if provider: + payment_fee = provider.calculate_fee(total) + + if payment_fee: + payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero() + if payment_fee_tax_rule.tax_applicable(invoice_address): + payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross') + fees.append(OrderFee( + fee_type="PAYMENT", + value=payment_fee, + tax_rate=payment_fee_tax.rate, + tax_value=payment_fee_tax.tax, + tax_rule=payment_fee_tax_rule + )) + else: + fees.append(OrderFee( + fee_type="PAYMENT", + value=payment_fee, + tax_rate=Decimal('0.00'), + tax_value=Decimal('0.00'), + tax_rule=payment_fee_tax_rule + )) + + return fees + + @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en', invoice_address: int=None) -> None: diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index f153654c3b..716f8a4d24 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -99,7 +99,7 @@ def build_invoice(invoice: Invoice) -> Invoice: reverse_charge = False positions.sort(key=lambda p: p.sort_key) - for p in positions: + for i, p in enumerate(positions): if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c: continue @@ -109,7 +109,7 @@ def build_invoice(invoice: Invoice) -> Invoice: if p.addon_to_id: desc = " + " + desc InvoiceLine.objects.create( - invoice=invoice, description=desc, + position=i, invoice=invoice, description=desc, gross_value=p.price, tax_value=p.tax_value, tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else '' ) @@ -127,13 +127,19 @@ def build_invoice(invoice: Invoice) -> Invoice: ) invoice.save() - if invoice.order.payment_fee: + offset = len(positions) + for i, fee in enumerate(invoice.order.fees.all()): + fee_title = _(fee.get_fee_type_display()) + if fee.description: + fee_title += " - " + fee.description InvoiceLine.objects.create( + position=i + offset, invoice=invoice, - description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)), - gross_value=invoice.order.payment_fee, tax_value=invoice.order.payment_fee_tax_value, - tax_rate=invoice.order.payment_fee_tax_rate, - tax_name=invoice.order.payment_fee_tax_rule.name if invoice.order.payment_fee_tax_rule else '' + description=fee_title, + gross_value=fee.value, + tax_value=fee.tax_value, + tax_rate=fee.tax_rate, + tax_name=fee.tax_rule.name if fee.tax_rule else '' ) return invoice diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 18e42b212b..4b7b845944 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -23,7 +23,7 @@ from pretix.base.models import ( User, Voucher, ) from pretix.base.models.event import SubEvent -from pretix.base.models.orders import CachedTicket, InvoiceAddress +from pretix.base.models.orders import CachedTicket, InvoiceAddress, OrderFee from pretix.base.models.tax import TaxedPrice from pretix.base.payment import BasePaymentProvider from pretix.base.reldate import RelativeDateWrapper @@ -342,14 +342,23 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio raise OrderError(err, errargs) +def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider): + fees = [] + total = sum([c.price for c in positions]) + payment_fee = payment_provider.calculate_fee(total) + if payment_fee: + fees.append(OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee)) + + return fees + + def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: int=None, meta_info: dict=None): from datetime import time - total = sum([c.price for c in positions]) - payment_fee = payment_provider.calculate_fee(total) - total += payment_fee + fees = _get_fees(positions, payment_provider) + total = sum([c.price for c in positions]) + sum([c.value for c in fees]) tz = pytz.timezone(event.settings.timezone) exp_by_date = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int)) @@ -387,7 +396,6 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d expires=expires, locale=locale, total=total, - payment_fee=payment_fee, payment_provider=payment_provider.identifier, meta_info=json.dumps(meta_info or {}), ) @@ -398,9 +406,13 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d address.order = order address.save() - order._calculate_tax() # Might have changed due to new invoice address order.save() + for fee in fees: + fee.order = order + fee._calculate_tax() + fee.save() + OrderPosition.transform_cart_positions(positions, order) order.log_action('pretix.event.order.placed') @@ -825,15 +837,21 @@ class OrderChangeManager: }) def _recalculate_total_and_payment_fee(self): - self.order.total = sum([p.price for p in self.order.positions.all()]) payment_fee = Decimal('0.00') if self.order.total != 0: prov = self._get_payment_provider() if prov: payment_fee = prov.calculate_fee(self.order.total) - self.order.payment_fee = payment_fee - self.order.total += payment_fee - self.order._calculate_tax() + + if payment_fee: + fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] + fee.value = payment_fee + fee._calculate_tax() + fee.save() + else: + self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).delete() + + self.order.total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) self.order.save() def _reissue_invoice(self): diff --git a/src/pretix/base/services/stats.py b/src/pretix/base/services/stats.py index 4626baa15c..9732e86fce 100644 --- a/src/pretix/base/services/stats.py +++ b/src/pretix/base/services/stats.py @@ -6,6 +6,7 @@ from django.utils.translation import ugettext_lazy as _ from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition from pretix.base.models.event import SubEvent +from pretix.base.models.orders import OrderFee class DummyObject: @@ -157,33 +158,35 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It # Payment fees payment_cat_obj = DummyObject() - payment_cat_obj.name = _('Payment method fees') + payment_cat_obj.name = _('Fees') payment_items = [] if not subevent: - counters = event.orders.values('payment_provider', 'status').annotate( - cnt=Count('id'), payment_fee=Sum('payment_fee'), tax_value=Sum('payment_fee_tax_value') - ).order_by() + counters = OrderFee.objects.filter( + order__event=event + ).values( + 'fee_type', 'internal_type', 'order__status' + ).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by() num_canceled = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_CANCELED + (o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value']) + for o in counters if o['order__status'] == Order.STATUS_CANCELED } num_refunded = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_REFUNDED + (o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value']) + for o in counters if o['order__status'] == Order.STATUS_REFUNDED } num_pending = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_PENDING + (o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value']) + for o in counters if o['order__status'] == Order.STATUS_PENDING } num_expired = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_EXPIRED + (o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value']) + for o in counters if o['order__status'] == Order.STATUS_EXPIRED } num_paid = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_PAID + (o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value']) + for o in counters if o['order__status'] == Order.STATUS_PAID } num_total = dictsum(num_pending, num_paid) @@ -191,11 +194,15 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It k: v.verbose_name for k, v in event.get_payment_providers().items() } + names = dict(OrderFee.FEE_TYPES) for pprov, total in num_total.items(): ppobj = DummyObject() - ppobj.name = provider_names.get(pprov, pprov) - ppobj.provider = pprov + if pprov[0] == OrderFee.FEE_TYPE_PAYMENT: + ppobj.name = '{} - {}'.format(names[OrderFee.FEE_TYPE_PAYMENT], provider_names.get(pprov[1], pprov[1])) + else: + ppobj.name = '{} - {}'.format(names[OrderFee.FEE_TYPE_PAYMENT], pprov[1]) + ppobj.provider = pprov[1] ppobj.has_variations = False ppobj.num_total = total ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0)) diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 66e9adb2e4..ea302b74db 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -266,28 +266,31 @@
{% endfor %} - {% if items.payment_fee %} + {% for fee in items.fees %}
- {% trans "Payment method fee" %} + {{ fee.get_fee_type_display }} + {% if fee.description %} + – {{ fee.description }} + {% endif %}
{% if event.settings.display_net_prices %} - {{ event.currency }} {{ order.payment_fee_net|floatformat:2 }} - {% if order.payment_fee_tax_rate %} + {{ event.currency }} {{ fee.net_value|floatformat:2 }} + {% if fee.tax_rate %}
- {% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %} + {% blocktrans trimmed with rate=fee.tax_rate taxname=fee.tax_rule.name|default:s_taxes %} plus {{ rate }}% {{ taxname }} {% endblocktrans %} {% endif %} {% else %} - {{ event.currency }} {{ items.payment_fee|floatformat:2 }} - {% if order.payment_fee_tax_rate %} + {{ event.currency }} {{ fee.value|floatformat:2 }} + {% if fee.tax_rate %}
- {% blocktrans trimmed with rate=order.payment_fee_tax_rate taxname=order.payment_fee_tax_rule.name|default:s_taxes %} + {% blocktrans trimmed with rate=fee.tax_rate taxname=fee.tax_rule.name|default:s_taxes %} incl. {{ rate }}% {{ taxname }} {% endblocktrans %} @@ -296,7 +299,7 @@
- {% endif %} + {% endfor %} {% if event.settings.display_net_prices and items.net_total %}
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 7284cbf42b..038184260a 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -29,6 +29,7 @@ from pretix.base.models import ( Order, OrderPosition, RequiredAction, TaxRule, Voucher, ) from pretix.base.models.event import EventMetaValue +from pretix.base.models.orders import OrderFee from pretix.base.services import tickets from pretix.base.services.invoices import build_preview_invoice_pdf from pretix.base.signals import event_live_issues, register_ticket_outputs @@ -957,7 +958,7 @@ class TaxDelete(EventPermissionRequiredMixin, DeleteView): def is_allowed(self) -> bool: o = self.object return ( - not self.request.event.orders.filter(payment_fee_tax_rule=o).exists() + not OrderFee.objects.filter(tax_rule=o, order__event=self.request.event).exists() and not OrderPosition.objects.filter(tax_rule=o, order__event=self.request.event).exists() and not self.request.event.items.filter(tax_rule=o).exists() and self.request.event.settings.tax_rate_default != o diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 2281b927b0..ffb9f1a750 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -27,6 +27,7 @@ from pretix.base.models import ( generate_position_secret, generate_secret, ) from pretix.base.models.event import SubEvent +from pretix.base.models.orders import OrderFee from pretix.base.models.tax import EU_COUNTRIES from pretix.base.services.export import export from pretix.base.services.invoices import ( @@ -183,7 +184,7 @@ class OrderDetail(OrderView): 'positions': positions, 'raw': cartpos, 'total': self.object.total, - 'payment_fee': self.object.payment_fee, + 'fees': self.object.fees.all(), 'net_total': self.object.net_total, 'tax_total': self.object.tax_total, } @@ -860,7 +861,7 @@ class OverView(EventPermissionRequiredMixin, TemplateView): ctx['items_by_category'], ctx['total'] = order_overview(self.request.event, subevent=subevent) ctx['subevent_warning'] = self.request.event.has_subevents and subevent and ( - self.request.event.orders.filter(payment_fee__gt=0).exists() + OrderFee.objects.filter(order__event=self.request.event).exclude(value=0).exists() ) return ctx diff --git a/src/pretix/plugins/reports/exporters.py b/src/pretix/plugins/reports/exporters.py index e146b3593c..aecb92f065 100644 --- a/src/pretix/plugins/reports/exporters.py +++ b/src/pretix/plugins/reports/exporters.py @@ -14,6 +14,7 @@ from django.utils.translation import pgettext, pgettext_lazy, ugettext as _ from pretix.base.exporter import BaseExporter from pretix.base.models import Order, OrderPosition from pretix.base.models.event import SubEvent +from pretix.base.models.orders import OrderFee from pretix.base.services.stats import order_overview @@ -299,9 +300,10 @@ class OrderTaxListReport(Report): tz = pytz.timezone(self.event.settings.timezone) tax_rates = set( - self.event.orders.exclude(payment_fee=0).values_list('payment_fee_tax_rate', flat=True) - .filter(status__in=self.form_data['status']) - .distinct().order_by() + a for a + in OrderFee.objects.filter( + order__event=self.event + ).values_list('tax_rate', flat=True).distinct().order_by() ) tax_rates |= set( a for a @@ -347,13 +349,20 @@ class OrderTaxListReport(Report): order__status__in=self.form_data['status'], order__event=self.event, ).values( - 'order__code', 'order__datetime', 'order__payment_date', 'order__total', 'order__payment_fee', - 'order__payment_fee_tax_rate', 'order__payment_fee_tax_value', 'tax_rate', 'order__status' + 'order__code', 'order__datetime', 'order__payment_date', 'order__total', 'tax_rate', 'order__status', + 'order__id' ).annotate(prices=Sum('price'), tax_values=Sum('tax_value')).order_by( 'order__datetime' if self.form_data['sort'] == 'datetime' else 'order__payment_date', 'order__datetime', 'order__code' ) + fee_sum_cache = { + (o['order__id'], o['tax_rate']): o for o in + OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate( + taxsum=Sum('tax_value'), grosssum=Sum('value') + ) + } + last_order_code = None tax_sums = defaultdict(Decimal) price_sums = defaultdict(Decimal) @@ -370,11 +379,13 @@ class OrderTaxListReport(Report): ] + sum((['', ''] for t in tax_rates), []), ) last_order_code = op['order__code'] - if op['order__payment_fee_tax_value']: - tdata[-1][5 + 2 * tax_rates.index(op['order__payment_fee_tax_rate'])] = str(op['order__payment_fee']) - tdata[-1][6 + 2 * tax_rates.index(op['order__payment_fee_tax_rate'])] = str(op['order__payment_fee_tax_value']) - tax_sums[op['order__payment_fee_tax_rate']] += op['order__payment_fee_tax_value'] - price_sums[op['order__payment_fee_tax_rate']] += op['order__payment_fee'] + for i, rate in enumerate(tax_rates): + odata = fee_sum_cache.get((op['order__id'], rate)) + if odata: + tdata[-1][5 + 2 * i] = str(odata['grosssum'] or '0.00') + tdata[-1][6 + 2 * i] = str(odata['taxsum'] or '0.00') + tax_sums[rate] += odata['taxsum'] or 0 + price_sums[rate] += odata['grosssum'] or 0 i = tax_rates.index(op['tax_rate']) tdata[-1][5 + 2 * i] = str(Decimal(tdata[-1][5 + 2 * i] or '0') + op['prices']) diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index afe440e6bb..6ef31db450 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -156,28 +156,28 @@
{% endfor %} -{% if cart.payment_fee %} +{% for fee in cart.fees %}
{% trans "Payment method fee" %}
{% if event.settings.display_net_prices %} - {{ event.currency }} {{ cart.payment_fee_net|floatformat:2 }} - {% if cart.payment_fee_tax_rate %} + {{ event.currency }} {{ fee.net_value|floatformat:2 }} + {% if fee.tax_rate %}
- {% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %} + {% blocktrans trimmed with rate=fee.tax_rate taxname=fee.tax_rule.name|default:s_taxes %} plus {{ rate }}% {{ taxname }} {% endblocktrans %} {% endif %} {% else %} - {{ event.currency }} {{ cart.payment_fee|floatformat:2 }} - {% if cart.payment_fee_tax_rate %} + {{ event.currency }} {{ fee.value|floatformat:2 }} + {% if fee.tax_rate %}
- {% blocktrans trimmed with rate=cart.payment_fee_tax_rate taxname=cart.payment_fee_tax_rule.name|default:s_taxes %} + {% blocktrans trimmed with rate=fee.tax_rate taxname=fee.tax_rule.name|default:s_taxes %} incl. {{ rate }}% {{ taxname }} {% endblocktrans %} @@ -186,7 +186,7 @@
-{% endif %} +{% endfor %} {% if event.settings.display_net_prices and cart.tax_total %}
diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index be16bcccc8..4752a5f027 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -1,6 +1,5 @@ from collections import defaultdict from datetime import timedelta -from decimal import Decimal from itertools import groupby from django.db.models import Sum @@ -8,7 +7,7 @@ from django.utils.functional import cached_property from django.utils.timezone import now from pretix.base.models import CartPosition, InvoiceAddress, OrderPosition -from pretix.base.models.tax import TaxRule +from pretix.base.services.cart import get_fees from pretix.presale.signals import question_form_fields @@ -101,34 +100,21 @@ class CartMixin: tax_total = sum(p.total - p.net_total for p in positions) if order: - payment_fee = order.payment_fee - tax_total += order.payment_fee_tax_value - payment_fee_net = order.payment_fee - order.payment_fee_tax_value - net_total += payment_fee_net - payment_fee_tax_rule = order.payment_fee_tax_rule - payment_fee_tax_rate = order.payment_fee_tax_rate + fees = order.fees.all() else: - payment_fee = self.get_payment_fee(total) - payment_fee_tax_rule = self.request.event.settings.tax_rate_default or TaxRule.zero() - iapk = self.request.session.get('invoice_address_{}'.format(self.request.event.pk)) ia = None - if payment_fee_tax_rule.eu_reverse_charge and iapk: + if iapk: try: ia = InvoiceAddress.objects.get(pk=iapk, order__isnull=True) except InvoiceAddress.DoesNotExist: pass - if payment_fee_tax_rule.tax_applicable(ia): - payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross') - tax_total += payment_fee_tax.tax - net_total += payment_fee_tax.net - payment_fee_net = payment_fee_tax.net - payment_fee_tax_rate = payment_fee_tax.rate - else: - net_total += payment_fee - payment_fee_net = payment_fee - payment_fee_tax_rate = Decimal('0.00') + fees = get_fees(self.request.event, total, ia, self.request.session.get('payment')) + + total += sum([f.value for f in fees]) + net_total += sum([f.net_value for f in fees]) + tax_total += sum([f.tax_value for f in fees]) try: first_expiry = min(p.expires for p in positions) if positions else now() @@ -140,28 +126,15 @@ class CartMixin: return { 'positions': positions, 'raw': cartpos, - 'total': total + payment_fee, + 'total': total, 'net_total': net_total, 'tax_total': tax_total, - 'payment_fee': payment_fee, - 'payment_fee_net': payment_fee_net, - 'payment_fee_tax_rate': payment_fee_tax_rate, - 'payment_fee_tax_rule': payment_fee_tax_rule, + 'fees': fees, 'answers': answers, 'minutes_left': minutes_left, 'first_expiry': first_expiry, } - def get_payment_fee(self, total): - if total == 0: - return Decimal('0.00') - payment_fee = 0 - if 'payment' in self.request.session: - provider = self.request.event.get_payment_providers().get(self.request.session['payment']) - if provider: - payment_fee = provider.calculate_fee(total) - return payment_fee - def get_cart(request): if not hasattr(request, '_cart_cache'): diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index a6857ff653..7613adde80 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1,5 +1,6 @@ import mimetypes import os +from decimal import Decimal from django.contrib import messages from django.db import transaction @@ -12,7 +13,7 @@ from django.utils.translation import ugettext_lazy as _ from django.views.generic import TemplateView, View from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition -from pretix.base.models.orders import InvoiceAddress, QuestionAnswer +from pretix.base.models.orders import InvoiceAddress, OrderFee, QuestionAnswer from pretix.base.payment import PaymentException from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, @@ -305,11 +306,12 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): if not provider.is_enabled or not provider.order_change_allowed(self.order): continue fee = provider.calculate_fee(self._total_order_value) + current_fee = self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or Decimal('0.00') providers.append({ 'provider': provider, 'fee': fee, - 'fee_diff': fee - self.order.payment_fee, - 'fee_diff_abs': abs(fee - self.order.payment_fee), + 'fee_diff': fee - current_fee, + 'fee_diff_abs': abs(fee - current_fee), 'total': abs(self._total_order_value + fee), 'form': provider.payment_form_render(self.request) }) @@ -323,16 +325,29 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): request.session['payment_change_{}'.format(self.order.pk)] = '1' new_fee = p['provider'].calculate_fee(self._total_order_value) + if new_fee: + fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] + old_fee = fee.value + fee.value = new_fee + fee.internal_type = p['provider'].identifier + fee._calculate_tax() + fee.save() + else: + try: + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + old_fee = fee.value + fee.delete() + except OrderFee.DoesNotExist: + old_fee = Decimal('0.00') + self.order.payment_provider = p['provider'].identifier - self.order.payment_fee = new_fee - self.order.total = self._total_order_value + new_fee - self.order._calculate_tax() + self.order.total = self._total_order_value + (self.order.fees.aggregate(sum=Sum('value'))['sum'] or 0) resp = p['provider'].order_prepare(request, self.order) if resp: with transaction.atomic(): self.order.log_action('pretix.event.order.payment.changed', { - 'old_fee': self.order.payment_fee, + 'old_fee': old_fee, 'new_fee': new_fee, 'old_provider': self.order.payment_provider, 'new_provider': p['provider'].identifier diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 287a4c3aaa..e3f39960ea 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -1,12 +1,15 @@ import datetime from decimal import Decimal +from distutils.version import LooseVersion from unittest import mock import pytest from django_countries.fields import Country from pytz import UTC +from pretix import __version__ from pretix.base.models import InvoiceAddress, Order, OrderPosition +from pretix.base.models.orders import OrderFee from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, ) @@ -18,7 +21,12 @@ def item(event): @pytest.fixture -def order(event, item): +def taxrule(event): + return event.tax_rules.create(rate=Decimal('19.00')) + + +@pytest.fixture +def order(event, item, taxrule): testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) with mock.patch('django.utils.timezone.now') as mock_now: @@ -30,6 +38,8 @@ def order(event, item): expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), total=23, payment_provider='banktransfer', locale='en' ) + o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'), + tax_value=Decimal('0.05'), tax_rule=taxrule) InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ')) OrderPosition.objects.create( order=o, @@ -71,11 +81,20 @@ TEST_ORDER_RES = { "datetime": "2017-12-01T10:00:00Z", "expires": "2017-12-10T10:00:00Z", "payment_date": None, + "fees": [ + { + "fee_type": "payment", + "value": "0.25", + "description": "", + "internal_type": "", + "tax_rate": "19.00", + "tax_value": "0.05" + } + ], "payment_provider": "banktransfer", - "payment_fee": "0.00", - "payment_fee_tax_rate": "0.00", - "payment_fee_tax_value": "0.00", - "payment_fee_tax_rule": None, + "payment_fee": "0.25", + "payment_fee_tax_rate": "19.00", + "payment_fee_tax_value": "0.05", "total": "23.00", "comment": "", "invoice_address": { @@ -96,10 +115,15 @@ TEST_ORDER_RES = { @pytest.mark.django_db -def test_order_list(token_client, organizer, event, order, item): +@pytest.mark.xfail( + LooseVersion(__version__) >= LooseVersion("1.9.0.dev0"), + reason="Deprecated attributes payment_fee_* should be removed by now", +) +def test_order_list(token_client, organizer, event, order, item, taxrule): res = dict(TEST_ORDER_RES) res["positions"][0]["id"] = order.positions.first().pk res["positions"][0]["item"] = item.pk + res["fees"][0]["tax_rule"] = taxrule.pk resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/'.format(organizer.slug, event.slug)) assert resp.status_code == 200 @@ -129,10 +153,11 @@ def test_order_list(token_client, organizer, event, order, item): @pytest.mark.django_db -def test_order_detail(token_client, organizer, event, order, item): +def test_order_detail(token_client, organizer, event, order, item, taxrule): res = dict(TEST_ORDER_RES) res["positions"][0]["id"] = order.positions.first().pk res["positions"][0]["item"] = item.pk + res["fees"][0]["tax_rule"] = taxrule.pk resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/'.format(organizer.slug, event.slug, order.code)) assert resp.status_code == 200 @@ -282,6 +307,13 @@ TEST_INVOICE_RES = { "tax_value": "0.00", "tax_name": "", "tax_rate": "0.00" + }, + { + "description": "Payment fee", + "gross_value": "0.25", + "tax_value": "0.05", + "tax_name": "", + "tax_rate": "19.00" } ] } diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index d1d91c1d67..bd6ebdd624 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -12,6 +12,7 @@ from pretix.base.models import ( Event, Invoice, InvoiceAddress, Item, ItemVariation, Order, OrderPosition, Organizer, ) +from pretix.base.models.orders import OrderFee from pretix.base.services.invoices import ( build_preview_invoice_pdf, generate_cancellation, generate_invoice, invoice_pdf_task, regenerate_invoice, @@ -27,15 +28,15 @@ def env(): organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer' ) - tr = event.tax_rules.create(rate=Decimal('19.00')) o = Order.objects.create( code='FOO', event=event, email='dummy@dummy.test', status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), - total=0, payment_provider='banktransfer', - payment_fee=Decimal('0.25'), payment_fee_tax_rate=Decimal('19.00'), - payment_fee_tax_value=Decimal('0.04'), locale='en', payment_fee_tax_rule=tr + total=0, payment_provider='banktransfer', locale='en' ) + tr = event.tax_rules.create(rate=Decimal('19.00')) + o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'), + tax_value=Decimal('0.05'), tax_rule=tr) ticket = Item.objects.create(event=event, name='Early-bird ticket', category=None, default_price=23, tax_rule=tr, admission=True) @@ -206,9 +207,10 @@ def test_positions(env): last = inv.lines.last() assert 'Payment' in last.description - assert last.gross_value == order.payment_fee - assert last.tax_rate == order.payment_fee_tax_rate - assert last.tax_value == order.payment_fee_tax_value + fee = order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert last.gross_value == fee.value + assert last.tax_rate == fee.tax_rate + assert last.tax_value == fee.tax_value assert inv.invoice_to == "" @@ -274,9 +276,10 @@ def test_invoice_numbers(env): status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), total=0, payment_provider='banktransfer', - payment_fee=Decimal('0.25'), payment_fee_tax_rate=0, - payment_fee_tax_value=0, locale='en' + locale='en' ) + order2.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('0.00'), + tax_value=Decimal('0.00')) inv1 = generate_invoice(order) inv2 = generate_invoice(order) @@ -320,9 +323,10 @@ def test_invoice_number_prefixes(env): status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), total=0, payment_provider='banktransfer', - payment_fee=Decimal('0.25'), payment_fee_tax_rate=0, - payment_fee_tax_value=0, locale='en' + locale='en' ) + order2.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('0.00'), + tax_value=Decimal('0.00')) event.settings.set('invoice_numbers_consecutive', False) event2.settings.set('invoice_numbers_consecutive', False) assert generate_invoice(order).number == 'DUMMY-{}-1'.format(order.code) diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 9ce59a9dd4..20cbf4cc0f 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -13,6 +13,7 @@ from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, Order, OrderPosition, Organizer, ) from pretix.base.models.items import SubEventItem +from pretix.base.models.orders import OrderFee from pretix.base.payment import FreeOrderProvider from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.invoices import generate_invoice @@ -539,10 +540,10 @@ class OrderChangeManagerTests(TestCase): self.ocm.commit() self.order.refresh_from_db() assert self.order.total == Decimal('47.30') - assert self.order.payment_fee == prov.calculate_fee(self.order.total) - assert self.order.payment_fee_tax_rate == Decimal('19.00') - assert round_decimal(self.order.payment_fee * ( - 1 - 100 / (100 + self.order.payment_fee_tax_rate))) == self.order.payment_fee_tax_value + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert fee.value == prov.calculate_fee(self.order.total) + assert fee.tax_rate == Decimal('19.00') + assert round_decimal(fee.value * (1 - 100 / (100 + fee.tax_rate))) == fee.tax_value def test_require_pending(self): self.order.status = Order.STATUS_PAID @@ -725,9 +726,10 @@ class OrderChangeManagerTests(TestCase): self.ocm._recalculate_total_and_payment_fee() assert self.order.total == Decimal('46.30') - assert self.order.payment_fee == prov.calculate_fee(self.order.total) - assert self.order.payment_fee_tax_rate == Decimal('19.00') - assert self.order.payment_fee_tax_value == Decimal('0.05') + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert fee.value == prov.calculate_fee(self.order.total) + assert fee.tax_rate == Decimal('19.00') + assert fee.tax_value == Decimal('0.05') ia = self._enable_reverse_charge() self.ocm.recalculate_taxes() @@ -739,9 +741,10 @@ class OrderChangeManagerTests(TestCase): assert op.tax_rate == Decimal('0.00') assert self.order.total == Decimal('43.30') - assert self.order.payment_fee == prov.calculate_fee(self.order.total) - assert self.order.payment_fee_tax_rate == Decimal('0.00') - assert self.order.payment_fee_tax_value == Decimal('0.00') + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert fee.value == prov.calculate_fee(self.order.total) + assert fee.tax_rate == Decimal('0.00') + assert fee.tax_value == Decimal('0.00') ia.vat_id_validated = False ia.save() @@ -755,6 +758,7 @@ class OrderChangeManagerTests(TestCase): assert op.tax_rate == Decimal('7.00') assert self.order.total == Decimal('46.32') - assert self.order.payment_fee == prov.calculate_fee(self.order.total) - assert self.order.payment_fee_tax_rate == Decimal('19.00') - assert self.order.payment_fee_tax_value == Decimal('0.05') + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert fee.value == prov.calculate_fee(self.order.total) + assert fee.tax_rate == Decimal('19.00') + assert fee.tax_value == Decimal('0.05') diff --git a/src/tests/control/test_taxrates.py b/src/tests/control/test_taxrates.py index decb22aad3..2c4d614373 100644 --- a/src/tests/control/test_taxrates.py +++ b/src/tests/control/test_taxrates.py @@ -5,6 +5,7 @@ from django.utils.timezone import now from tests.base import SoupTest, extract_form_fields from pretix.base.models import Event, Order, Organizer, Team, User +from pretix.base.models.orders import OrderFee class TaxRateFormTest(SoupTest): @@ -76,18 +77,16 @@ class TaxRateFormTest(SoupTest): self.assertIn("VAT", doc.select("#page-wrapper")[0].text) assert self.event1.tax_rules.exists() - def test_delete_order_existing(self): + def test_delete_fee_existing(self): tr = self.event1.tax_rules.create(rate=19, name="VAT") - self.event1.orders.create( + o = self.event1.orders.create( code='FOO', event=self.event1, email='dummy@dummy.test', status=Order.STATUS_PENDING, datetime=now(), expires=now() + datetime.timedelta(days=10), total=14, payment_provider='banktransfer', locale='en', - payment_fee=0.3, - payment_fee_tax_rate=19, - payment_fee_tax_value=0.05, - payment_fee_tax_rule=tr ) + o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'), + tax_value=Decimal('0.05'), tax_rule=tr) doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id)) form_data = extract_form_fields(doc.select('.container-fluid form')[0]) doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id), diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 4d85d835ad..1128b90e0c 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -11,6 +11,7 @@ from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Order, OrderPosition, Organizer, Question, Quota, ) +from pretix.base.models.orders import OrderFee from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.invoices import generate_invoice @@ -417,8 +418,9 @@ class OrdersTest(TestCase): ) self.order.refresh_from_db() assert self.order.payment_provider == 'testdummy' - assert self.order.payment_fee == Decimal('12.00') - assert self.order.total == Decimal('23.00') + self.order.payment_fee + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert fee.value == Decimal('12.00') + assert self.order.total == Decimal('23.00') + fee.value assert self.order.invoices.count() == 3 def test_answer_download_token(self):