diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 00deae46e..d50495481 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -717,7 +717,7 @@ class BasePaymentProvider: The default implementation returns an empty string. - :param order: The order object + :param refund: The refund object """ return '' diff --git a/src/pretix/plugins/banktransfer/csvimport.py b/src/pretix/plugins/banktransfer/csvimport.py index d5369f3fb..c874c9084 100644 --- a/src/pretix/plugins/banktransfer/csvimport.py +++ b/src/pretix/plugins/banktransfer/csvimport.py @@ -2,6 +2,8 @@ import csv import io import re +from django.utils.text import Truncator + class HintMismatchError(Exception): pass @@ -28,6 +30,11 @@ def parse(data, hint): resrow['amount'] = re.sub('[^0-9,+.-]', '', resrow['amount']) if hint.get('date') is not None: resrow['date'] = row[int(hint.get('date'))].strip() + if hint.get('iban') is not None: + resrow['iban'] = Truncator(row[int(hint.get('iban'))].strip()).chars(200) + if hint.get('bic') is not None: + resrow['bic'] = Truncator(row[int(hint.get('bic'))].strip()).chars(200) + if len(resrow['amount']) == 0 or 'amount' not in resrow or resrow.get('date') == '': # This is probably a headline or something other special. continue @@ -88,5 +95,7 @@ def new_hint(data): 'reference': data.getlist('reference') if 'reference' in data else None, 'date': int(data.get('date')) if 'date' in data else None, 'amount': int(data.get('amount')) if 'amount' in data else None, - 'cols': int(data.get('cols')) if 'cols' in data else None + 'cols': int(data.get('cols')) if 'cols' in data else None, + 'iban': int(data.get('iban')) if 'iban' in data else None, + 'bic': int(data.get('bic')) if 'bic' in data else None, } diff --git a/src/pretix/plugins/banktransfer/migrations/0006_auto_20200901_1419.py b/src/pretix/plugins/banktransfer/migrations/0006_auto_20200901_1419.py new file mode 100644 index 000000000..3dbdf7beb --- /dev/null +++ b/src/pretix/plugins/banktransfer/migrations/0006_auto_20200901_1419.py @@ -0,0 +1,30 @@ +# Generated by Django 3.0.9 on 2020-09-01 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('banktransfer', '0005_auto_20181023_2209'), + ] + + operations = [ + migrations.AddField( + model_name='banktransaction', + name='bic', + field=models.CharField(default='', max_length=250), + preserve_default=False, + ), + migrations.AddField( + model_name='banktransaction', + name='date_parsed', + field=models.DateField(null=True), + ), + migrations.AddField( + model_name='banktransaction', + name='iban', + field=models.CharField(default='', max_length=250), + preserve_default=False, + ), + ] diff --git a/src/pretix/plugins/banktransfer/migrations/0007_refundexport.py b/src/pretix/plugins/banktransfer/migrations/0007_refundexport.py new file mode 100644 index 000000000..72d873095 --- /dev/null +++ b/src/pretix/plugins/banktransfer/migrations/0007_refundexport.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.9 on 2020-09-09 15:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0162_remove_seat_name'), + ('banktransfer', '0006_auto_20200901_1419'), + ] + + operations = [ + migrations.CreateModel( + name='RefundExport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('datetime', models.DateTimeField(auto_now_add=True)), + ('testmode', models.BooleanField(default=False)), + ('rows', models.TextField(default='[]')), + ('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='banktransfer_refund_exports', to='pretixbase.Event')), + ('organizer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='banktransfer_refund_exports', to='pretixbase.Organizer')), + ('downloaded', models.BooleanField(default=False)), + ], + ), + ] diff --git a/src/pretix/plugins/banktransfer/models.py b/src/pretix/plugins/banktransfer/models.py index 23fde5245..4ec31250a 100644 --- a/src/pretix/plugins/banktransfer/models.py +++ b/src/pretix/plugins/banktransfer/models.py @@ -1,7 +1,10 @@ import hashlib +import json import re +from decimal import Decimal from django.db import models +from django.utils.functional import cached_property class BankImportJob(models.Model): @@ -61,6 +64,9 @@ class BankTransaction(models.Model): reference = models.TextField(blank=True) amount = models.DecimalField(max_digits=10, decimal_places=2) date = models.CharField(max_length=50) + date_parsed = models.DateField(null=True) + iban = models.CharField(max_length=250, blank=True) + bic = models.CharField(max_length=250, blank=True) order = models.ForeignKey('pretixbase.Order', null=True, blank=True, on_delete=models.CASCADE) comment = models.TextField(blank=True) @@ -80,3 +86,37 @@ class BankTransaction(models.Model): class Meta: unique_together = ('event', 'organizer', 'checksum') ordering = ('date', 'id') + + +class RefundExport(models.Model): + event = models.ForeignKey('pretixbase.Event', related_name='banktransfer_refund_exports', on_delete=models.CASCADE, null=True, blank=True) + organizer = models.ForeignKey('pretixbase.Organizer', related_name='banktransfer_refund_exports', on_delete=models.PROTECT, null=True, blank=True) + datetime = models.DateTimeField(auto_now_add=True) + testmode = models.BooleanField(default=False) + rows = models.TextField(default="[]") + downloaded = models.BooleanField(default=False) + + @cached_property + def entity_slug(self): + if self.organizer: + return self.organizer.slug + else: + return self.event.slug + + @cached_property + def currency(self): + if self.event: + return self.event.currency + return self.organizer.events.first().currency + + @property + def rows_data(self): + return json.loads(self.rows) + + @property + def sum(self): + return sum(Decimal(row["amount"]) for row in self.rows_data) + + @property + def cnt(self): + return len(self.rows_data) diff --git a/src/pretix/plugins/banktransfer/mt940import.py b/src/pretix/plugins/banktransfer/mt940import.py index a60440e9b..0c78e2968 100644 --- a/src/pretix/plugins/banktransfer/mt940import.py +++ b/src/pretix/plugins/banktransfer/mt940import.py @@ -158,8 +158,9 @@ def parse(file): result.append({ 'amount': str(round_decimal(t.data['amount'].amount)), 'reference': reference + (' EREF: {}'.format(eref) if eref else ''), - 'payer': (payer.get('name', '') + ' - ' + payer.get('iban', '')).strip(), - 'date': t.data['date'].isoformat() + 'payer': payer['name'].strip(), + 'date': t.data['date'].isoformat(), + **{k: payer[k].strip() for k in ("iban", "bic") if payer.get(k)} }) else: result.append({ diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index 21659f6c6..cdff2a9e2 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -11,8 +11,9 @@ from i18nfield.fields import I18nFormField, I18nTextarea from i18nfield.forms import I18nTextInput from i18nfield.strings import LazyI18nString from localflavor.generic.forms import BICFormField, IBANFormField +from localflavor.generic.validators import BICValidator, IBANValidator -from pretix.base.models import OrderPayment +from pretix.base.models import OrderPayment, OrderRefund from pretix.base.payment import BasePaymentProvider @@ -144,9 +145,7 @@ class BankTransfer(BasePaymentProvider): def settings_form_clean(self, cleaned_data): if cleaned_data.get('payment_banktransfer_bank_details_type') == 'sepa': - for f in ( - 'bank_details_sepa_name', 'bank_details_sepa_bank', 'bank_details_sepa_bic', - 'bank_details_sepa_iban'): + for f in ('bank_details_sepa_name', 'bank_details_sepa_bank', 'bank_details_sepa_bic', 'bank_details_sepa_iban'): if not cleaned_data.get('payment_banktransfer_%s' % f): raise ValidationError( {'payment_banktransfer_%s' % f: _('Please fill out your bank account details.')}) @@ -213,10 +212,17 @@ class BankTransfer(BasePaymentProvider): return template.render(ctx) def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str: + warning = None + if not self.payment_refund_supported(payment): + warning = _("Invalid IBAN/BIC") + return self._render_control_info(request, payment.order, payment.info_data, warning=warning) + + def _render_control_info(self, request, order, info_data, **extra_context): template = get_template('pretixplugins/banktransfer/control.html') ctx = {'request': request, 'event': self.event, - 'code': self._code(payment.order), - 'payment_info': payment.info_data, 'order': payment.order} + 'code': self._code(order), + 'payment_info': info_data, 'order': order, + **extra_context} return template.render(ctx) def _code(self, order): @@ -234,3 +240,39 @@ class BankTransfer(BasePaymentProvider): d['_shredded'] = True obj.info = json.dumps(d) obj.save(update_fields=['info']) + + @staticmethod + def norm(s): + return s.strip().upper().replace(" ", "") + + def payment_refund_supported(self, payment: OrderPayment) -> bool: + if not all(payment.info_data.get(key) for key in ("payer", "iban", "bic")): + return False + try: + IBANValidator()(self.norm(payment.info_data['iban'])) + BICValidator()(self.norm(payment.info_data['bic'])) + except ValidationError: + return False + else: + return True + + def payment_partial_refund_supported(self, payment: OrderPayment) -> bool: + return self.payment_refund_supported(payment) + + def execute_refund(self, refund: OrderRefund): + """ + We just keep a created refund object. It will be marked as done using the control view + for bank transfer refunds. + """ + if refund.payment is None: + raise ValueError(_("Can only create a bank transfer refund from an existing payment.")) + + refund.info_data = { + 'payer': refund.payment.info_data['payer'], + 'iban': self.norm(refund.payment.info_data['iban']), + 'bic': self.norm(refund.payment.info_data['bic']), + } + refund.save(update_fields=["info"]) + + def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str: + return self._render_control_info(request, refund.order, refund.info_data) diff --git a/src/pretix/plugins/banktransfer/refund_export.py b/src/pretix/plugins/banktransfer/refund_export.py new file mode 100644 index 000000000..585180c4b --- /dev/null +++ b/src/pretix/plugins/banktransfer/refund_export.py @@ -0,0 +1,68 @@ +import codecs +import datetime +import io +from decimal import Decimal + +from defusedcsv import csv +from django.templatetags.l10n import localize +from django.utils.translation import gettext_lazy as _ + +from pretix.plugins.banktransfer.models import RefundExport + + +def _get_filename(refund_export): + return 'bank_transfer_refunds-{}_{}-{}'.format(refund_export.entity_slug, refund_export.datetime.strftime("%Y-%m-%d"), refund_export.id) + + +def get_refund_export_csv(refund_export: RefundExport): + byte_data = io.BytesIO() + StreamWriter = codecs.getwriter('utf-8') + output = StreamWriter(byte_data) + + writer = csv.writer(output) + writer.writerow([_("Payer"), "IBAN", "BIC", _("Amount"), _("Currency"), _("Code")]) + for row in refund_export.rows_data: + writer.writerow([ + row['payer'], + row['iban'], + row['bic'], + localize(Decimal(row['amount'])), + refund_export.currency, + row['id'], + ]) + + filename = _get_filename(refund_export) + ".csv" + byte_data.seek(0) + return filename, 'text/csv', byte_data + + +from sepaxml import SepaTransfer + + +def build_sepa_xml(refund_export: RefundExport, account_holder, iban, bic): + if refund_export.currency != "EUR": + raise ValueError("Cannot create SEPA export for currency other than EUR.") + + config = { + "name": account_holder, + "IBAN": iban, + "BIC": bic, + "batch": True, + "currency": refund_export.currency, + } + sepa = SepaTransfer(config, clean=True) + + for row in refund_export.rows_data: + payment = { + "name": row['payer'], + "IBAN": row["iban"], + "BIC": row["bic"], + "amount": int(Decimal(row['amount']) * 100), # in euro-cents + "execution_date": datetime.date.today(), + "description": f"{_('Refund')} {refund_export.entity_slug} {row['id']}", + } + sepa.add_payment(payment) + + data = sepa.export(validate=True) + filename = _get_filename(refund_export) + ".xml" + return filename, 'application/xml', io.BytesIO(data) diff --git a/src/pretix/plugins/banktransfer/signals.py b/src/pretix/plugins/banktransfer/signals.py index 35e422aed..818ac2026 100644 --- a/src/pretix/plugins/banktransfer/signals.py +++ b/src/pretix/plugins/banktransfer/signals.py @@ -21,14 +21,31 @@ def control_nav_import(sender, request=None, **kwargs): return [] return [ { - 'label': _('Import bank data'), + 'label': _("Bank transfer"), 'url': reverse('plugins:banktransfer:import', kwargs={ 'event': request.event.slug, 'organizer': request.event.organizer.slug, }), - 'active': (url.namespace == 'plugins:banktransfer' and url.url_name == 'import'), - 'icon': 'upload', - } + 'icon': 'university', + 'children': [ + { + 'label': _('Import bank data'), + 'url': reverse('plugins:banktransfer:import', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': (url.namespace == 'plugins:banktransfer' and url.url_name == 'import'), + }, + { + 'label': _('Export refunds'), + 'url': reverse('plugins:banktransfer:refunds.list', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': (url.namespace == 'plugins:banktransfer' and url.url_name.startswith("refunds")), + }, + ] + }, ] @@ -41,12 +58,28 @@ def control_nav_orga_import(sender, request=None, **kwargs): return [] return [ { - 'label': _('Import bank data'), + 'label': _("Bank transfer"), 'url': reverse('plugins:banktransfer:import', kwargs={ 'organizer': request.organizer.slug, }), - 'active': (url.namespace == 'plugins:banktransfer' and url.url_name == 'import'), - 'icon': 'upload', + 'icon': 'university', + 'children': [ + { + 'label': _('Import bank data'), + 'url': reverse('plugins:banktransfer:import', kwargs={ + 'organizer': request.organizer.slug, + }), + 'active': (url.namespace == 'plugins:banktransfer' and url.url_name == 'import'), + 'icon': 'upload', + }, + { + 'label': _('Export refunds'), + 'url': reverse('plugins:banktransfer:refunds.list', kwargs={ + 'organizer': request.organizer.slug, + }), + 'active': (url.namespace == 'plugins:banktransfer' and url.url_name.startswith("refunds")), + }, + ] } ] diff --git a/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js b/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js index fd6f925de..91c0ec967 100644 --- a/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js +++ b/src/pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js @@ -104,6 +104,7 @@ var bankimport_transactionlist = { var text = $box.find("textarea").val(); $box.find("input, textarea, button").prop("disabled", true); bankimport_transactionlist._action(id, "comment:" + text, function () { + $("tr[data-id=" + id + "] button").prop("disabled", false); }); }); $btn2.click(function () { diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py index b084ff5e3..4d5fa26e1 100644 --- a/src/pretix/plugins/banktransfer/tasks.py +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -2,6 +2,7 @@ import logging import re from decimal import Decimal +import dateutil.parser from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.db import transaction @@ -41,9 +42,9 @@ def notify_incomplete_payment(o: Order): def cancel_old_payments(order): for p in order.payments.filter( - state__in=(OrderPayment.PAYMENT_STATE_PENDING, - OrderPayment.PAYMENT_STATE_CREATED), - provider='banktransfer', + state__in=(OrderPayment.PAYMENT_STATE_PENDING, + OrderPayment.PAYMENT_STATE_CREATED), + provider='banktransfer', ): try: with transaction.atomic(): @@ -64,8 +65,8 @@ def cancel_old_payments(order): @transaction.atomic -def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, organizer: Organizer=None, - slug: str=None): +def _handle_transaction(trans: BankTransaction, code: str, event: Event = None, organizer: Organizer = None, + slug: str = None): if event: try: trans.order = event.orders.get(code=code) @@ -117,8 +118,10 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, or p.info_data = { 'reference': trans.reference, - 'date': trans.date, + 'date': trans.date_parsed.isoformat() if trans.date_parsed else trans.date, 'payer': trans.payer, + 'iban': trans.iban, + 'bic': trans.bic, 'trans_id': trans.pk } @@ -150,7 +153,18 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event=None, or trans.save() -def _get_unknown_transactions(job: BankImportJob, data: list, event: Event=None, organizer: Organizer=None): +def parse_date(date_str): + try: + return dateutil.parser.parse( + date_str, + dayfirst="." in date_str, + ).date() + except (ValueError, OverflowError): + pass + return None + + +def _get_unknown_transactions(job: BankImportJob, data: list, event: Event = None, organizer: Organizer = None): amount_pattern = re.compile("[^0-9.-]") known_checksums = set(t['checksum'] for t in BankTransaction.objects.filter( Q(event=event) if event else Q(organizer=organizer) @@ -176,8 +190,11 @@ def _get_unknown_transactions(job: BankImportJob, data: list, event: Event=None, trans = BankTransaction(event=event, organizer=organizer, import_job=job, payer=row.get('payer', ''), reference=row['reference'], - amount=amount, - date=row['date']) + amount=amount, date=row['date'], + iban=row.get('iban', ''), bic=row.get('bic', '')) + + trans.date_parsed = parse_date(trans.date) + trans.checksum = trans.calculate_checksum() if trans.checksum not in known_checksums: trans.state = BankTransaction.STATE_UNCHECKED diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html index c08244269..bc79e6393 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html @@ -4,10 +4,24 @@
{% trans "Payer" %}
{{ payment_info.payer }}
-
{% trans "Payment date" %}
-
{{ payment_info.date }}
-
{% trans "Reference" %}
-
{{ payment_info.reference }}
+ {% if payment_info.iban %} +
{% trans "Account" %} +
+ {{ payment_info.iban }} {{ payment_info.bic }} + {% if warning %} + + {% endif %} +
+ {% endif %} + {% if payment_info.date %} +
{% trans "Payment date" %}
+
{{ payment_info.date }}
+ {% endif %} + {% if payment_info.reference %} +
{% trans "Reference" %}
+
{{ payment_info.reference }}
+ {% endif %}
{% else %}
diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html index b195c92f6..91c8d5853 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html @@ -13,62 +13,94 @@
- - - {% for col in rows.0 %} - - {% endfor %} - - - - {% for col in rows.0 %} - - {% endfor %} - - - - {% for col in rows.0 %} - - {% endfor %} - - - - {% for col in rows.0 %} - - {% endfor %} - + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + + + + {% for col in rows.0 %} + + {% endfor %} + - {% for row in rows|slice:":100" %} - {% with forloop.counter0 as rowid %} - - - {% for col in row %} - - {% endfor %} - - {% endwith %} - {% endfor %} - {% if rows|length > 100 %} + {% for row in rows|slice:":100" %} + {% with forloop.counter0 as rowid %} - + + {% for col in row %} + + {% endfor %} - {% endif %} + {% endwith %} + {% endfor %} + {% if rows|length > 100 %} + + + + {% endif %}
{% trans "Date" %} - -
{% trans "Amount" %} - -
{% trans "Reference" %} - -
{% trans "Payer" %} - -
{% trans "Date" %} + +
{% trans "Amount" %} + +
{% trans "Reference" %} + +
{% trans "Payer" %} + +
+ {% trans "IBAN" %} + + + + + + +
+ {% trans "BIC" %} + + + + + + +
{{ col }}
- {% trans "More data was uploaded but is not shown here. It will still be processed" %} - {{ col }}
+ {% trans "More data was uploaded but is not shown here. It will still be processed" %} +
- - + + diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html index 77715c59e..27e1337b9 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html @@ -23,7 +23,8 @@ Currently, this feature supports .csv files and files in the MT940 format. {% endblocktrans %}

{% if job_running %} -
+
{% trans "An import is currently being processed, please try again in a few minutes." %}
@@ -31,7 +32,8 @@
{% csrf_token %}
- +
{% endif %} - {% if transactions_unhandled|length > 0 or request.GET.search %} + {% if transactions_unhandled|length > 0 or filter_form.is_valid %}

{% trans "Unresolved transactions" %}

@@ -55,25 +57,42 @@ unmatched transactions imported directly for this event. {% endblocktrans %} {% trans "Go to organizer-level import" %} + class="btn btn-default btn-xs">{% trans "Go to organizer-level import" %}

{% endif %} -

- - - - - {% if not request.GET.search %} -

- {% csrf_token %} - + +
+ +
+ {% trans "Amount from" %} + {{ filter_form.amount_min }} + {% trans "up to" %} + {{ filter_form.amount_max }} +
+
+ {% trans "Date from" %} + {{ filter_form.date_min }} + {% trans "up to" %} + {{ filter_form.date_max }} +
+
+ {{ filter_form.search_text }} + + {% trans "Clear" %} +
- {% endif %} -

+
+ {% if not filter_form.is_valid %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
{% if transactions_unhandled|length > 0 %} {% include "pretixcontrol/pagination.html" %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html new file mode 100644 index 000000000..411a6c30b --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html @@ -0,0 +1,113 @@ +{% extends basetpl %} +{% load i18n %} +{% load money %} +{% load static %} +{% block title %}{% trans "Export bank transfer refunds" %}{% endblock %} +{% block content %} +

{% trans "Export bank transfer refunds" %}

+

+ {% blocktrans trimmed %} + {{ num_new }} Bank transfer refunds have been placed and are not yet part of an export. + {% endblocktrans %} +

+ {% if request.event.testmode %} +
+ {% trans "In test mode, your exports will only contain test mode orders." %} +
+ {% elif request.event %} +
+ {% trans "If you want, you can now also create these exports for multiple events combined." %} + + + {% trans "Go to organizer-level exports" %} + + +
+ {% endif %} + + {% if num_new > 0 %} +
+ {% csrf_token %} + +
+
+ +
+
+

+ {% blocktrans %} + Beware that refunds will be marked as done once an export is created. + Make sure to download the export and execute the refunds. + {% endblocktrans %} +

+
+ {% endif %} +

{% trans "Exported files" %}

+
+ + + + + + + + + + + {% for export in exports %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Export date" %}{% trans "Number of orders" %}{% trans "Total amount" %}
+ {{ export.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if export.testmode %} + {% trans "TEST MODE" %} + {% endif %} + {{ export.cnt }} + {{ export.sum|default_if_none:0|money:export.currency }} + + {% if not export.downloaded %} + {% trans "not downloaded" %} + {% endif %} + {% if export.event %} + + {% trans "Download CSV" %} + + {% if export.currency == "EUR" %} + + {% trans "SEPA XML" %} + + {% endif %} + {% else %} + + {% trans "Download CSV" %} + + {% if export.currency == "EUR" %} + + {% trans "SEPA XML" %} + + {% endif %} + {% endif %} +
+ {% trans "No exports have been created yet." %} +
+
+{% endblock %} diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/sepa_export.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/sepa_export.html new file mode 100644 index 000000000..812f850db --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/sepa_export.html @@ -0,0 +1,36 @@ +{% extends basetpl %} +{% load bootstrap3 %} +{% load i18n %} +{% load rich_text %} +{% load money %} +{% load static %} +{% block title %}{% trans "Export bank transfer refunds" %}{% endblock %} +{% block content %} + +

{% trans "Export SEPA xml" %}

+ +

+ {% blocktrans with cnt=export.cnt sum=export.sum|money:export.currency date=export.datetime|date %} + You are trying to download a refund export from {{ date }} with {{ cnt }} order{{ cnt|pluralize }} and a + total of {{ sum }}. + {% endblocktrans %} +

+ +

+ {% blocktrans %} + Please state from which bank account the refunds should be transferred from. + {% endblocktrans %} +

+ +
+ {% csrf_token %} +
+
+ {% bootstrap_form form layout="control" %} +
+
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/transaction_list.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/transaction_list.html index 2e0ebeece..919284e84 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/transaction_list.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/transaction_list.html @@ -22,11 +22,11 @@ {% if trans.order and trans.state == 'invalid' %}
@@ -34,22 +34,23 @@
{% elif trans.state == 'error' %}
@@ -57,23 +58,44 @@
{% endif %} - {{ trans.date }} - {{ trans.payer }}
+ {% if trans.date_parsed is not None %} + {{ trans.date_parsed|date:"SHORT_DATE_FORMAT" }} + {% else %} + {{ trans.date }} + {% endif %} + + +
+ {% if trans.payer %} + {{ trans.payer }} +
+ {% endif %} + {% if trans.iban or trans.bic %} + {% if trans.iban %} + {{ trans.iban }} + {% endif %} + {% if trans.bic %} + {{ trans.bic }} + {% endif %} +
+ {% endif %} +
{{ trans.reference }}
{% trans "Comment:" %} @@ -102,7 +124,7 @@ {% if trans.order %} + data-toggle="tooltip" title="{{ trans.order.total|money:trans.order.event.currency }}"> {% if not request.event %} {{ trans.order.event.slug|upper }}-{{ trans.order.code }} {% else %} diff --git a/src/pretix/plugins/banktransfer/urls.py b/src/pretix/plugins/banktransfer/urls.py index de8dc3533..6ac325c5f 100644 --- a/src/pretix/plugins/banktransfer/urls.py +++ b/src/pretix/plugins/banktransfer/urls.py @@ -13,6 +13,14 @@ urlpatterns = [ views.OrganizerJobDetailView.as_view(), name='import.job'), url(r'^control/organizer/(?P[^/]+)/banktransfer/action/', views.OrganizerActionView.as_view(), name='import.action'), + url(r'^control/organizer/(?P[^/]+)/banktransfer/refunds/', + views.OrganizerRefundExportListView.as_view(), name='refunds.list'), + url(r'^control/organizer/(?P[^/]+)/banktransfer/export/(?P\d+)/$', + views.OrganizerDownloadRefundExportView.as_view(), + name='refunds.download'), + url(r'^control/organizer/(?P[^/]+)/banktransfer/sepa-export/(?P\d+)/$', + views.OrganizerSepaXMLExportView.as_view(), + name='refunds.sepa'), url(r'^control/event/(?P[^/]+)/(?P[^/]+)/banktransfer/import/', views.EventImportView.as_view(), @@ -21,6 +29,15 @@ urlpatterns = [ views.EventJobDetailView.as_view(), name='import.job'), url(r'^control/event/(?P[^/]+)/(?P[^/]+)/banktransfer/action/', views.EventActionView.as_view(), name='import.action'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/banktransfer/refunds/', + views.EventRefundExportListView.as_view(), + name='refunds.list'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/banktransfer/export/(?P\d+)/$', + views.EventDownloadRefundExportView.as_view(), + name='refunds.download'), + url(r'^control/event/(?P[^/]+)/(?P[^/]+)/banktransfer/sepa-export/(?P\d+)/$', + views.EventSepaXMLExportView.as_view(), + name='refunds.sepa'), ] orga_router.register('bankimportjobs', BankImportJobViewSet) diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index b939bb6e5..afabd0cba 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -1,21 +1,28 @@ import csv +import itertools import json import logging from datetime import timedelta from decimal import Decimal +from typing import Set +from django import forms from django.contrib import messages -from django.db.models import Count, Q +from django.db import transaction +from django.db.models import Count, Q, QuerySet from django.db.models.functions import Concat -from django.http import JsonResponse +from django.http import FileResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import gettext as _ -from django.views.generic import DetailView, ListView, View +from django.views.generic import DetailView, FormView, ListView, View +from django.views.generic.detail import SingleObjectMixin +from localflavor.generic.forms import BICFormField, IBANFormField -from pretix.base.models import Order, OrderPayment, OrderRefund, Quota +from pretix.base.forms.widgets import DatePickerWidget +from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota from pretix.base.services.mail import SendMailException from pretix.base.settings import SettingsSandbox from pretix.base.templatetags.money import money_filter @@ -23,8 +30,15 @@ from pretix.control.permissions import ( EventPermissionRequiredMixin, OrganizerPermissionRequiredMixin, ) from pretix.control.views.organizer import OrganizerDetailViewMixin +from pretix.helpers.json import CustomJSONEncoder from pretix.plugins.banktransfer import csvimport, mt940import -from pretix.plugins.banktransfer.models import BankImportJob, BankTransaction +from pretix.plugins.banktransfer.models import ( + BankImportJob, BankTransaction, RefundExport, +) +from pretix.plugins.banktransfer.payment import BankTransfer +from pretix.plugins.banktransfer.refund_export import ( + build_sepa_xml, get_refund_export_csv, +) from pretix.plugins.banktransfer.tasks import process_banktransfers logger = logging.getLogger('pretix.plugins.banktransfer') @@ -70,6 +84,8 @@ class ActionView(View): 'reference': trans.reference, 'date': trans.date, 'payer': trans.payer, + 'iban': trans.iban, + 'bic': trans.bic, 'trans_id': trans.pk }) ) @@ -96,6 +112,8 @@ class ActionView(View): 'reference': trans.reference, 'date': trans.date, 'payer': trans.payer, + 'iban': trans.iban, + 'bic': trans.bic, 'trans_id': trans.pk } try: @@ -274,6 +292,33 @@ class JobDetailView(DetailView): return ctx +class BankTransactionFilterForm(forms.Form): + search_text = forms.CharField(required=False, widget=forms.TextInput(attrs={'class': "form-control", "placeholder": _("Search text")})) + amount_min = forms.DecimalField(required=False, localize=True, widget=forms.TextInput(attrs={'class': "form-control", "placeholder": _("min"), "size": 8})) + amount_max = forms.DecimalField(required=False, localize=True, widget=forms.TextInput(attrs={'class': "form-control", "placeholder": _("max"), "size": 8})) + date_min = forms.DateField(required=False, widget=DatePickerWidget(attrs={"size": 8})) + date_max = forms.DateField(required=False, widget=DatePickerWidget(attrs={"size": 8})) + + def is_valid(self): + return super().is_valid() and any(value for value in self.cleaned_data.values()) + + def filter(self, qs): + if not self.is_valid(): + raise ValueError(_("Filter form is not valid.")) + if self.cleaned_data.get('search_text'): + q = self.cleaned_data['search_text'] + qs = qs.filter(Q(payer__icontains=q) | Q(reference__icontains=q) | Q(comment__icontains=q) | Q(iban__icontains=q) | Q(bic__icontains=q)) + if self.cleaned_data.get('amount_min') is not None: + qs = qs.filter(amount__gte=self.cleaned_data['amount_min']) + if self.cleaned_data.get("amount_max") is not None: + qs = qs.filter(amount__lte=self.cleaned_data['amount_max']) + if self.cleaned_data.get('date_min') is not None: + qs = qs.filter(ate_parsed__gte=self.cleaned_data['date_min']) + if self.cleaned_data.get('date_max') is not None: + qs = qs.filter(date_parsed__lte=self.cleaned_data['date_max']) + return qs + + class ImportView(ListView): template_name = 'pretixplugins/banktransfer/import_form.html' permission = 'can_change_orders' @@ -293,15 +338,12 @@ class ImportView(ListView): BankTransaction.STATE_INVALID, BankTransaction.STATE_ERROR, BankTransaction.STATE_DUPLICATE, BankTransaction.STATE_NOMATCH ]) - if 'search' in self.request.GET: - q = self.request.GET.get('search') - qs = qs.filter( - Q(payer__icontains=q) | Q(reference__icontains=q) | Q(comment__icontains=q) - ).order_by( - '-import_job__created' - ) - return qs + filter_form = BankTransactionFilterForm(self.request.GET or None) + if filter_form.is_valid(): + qs = filter_form.filter(qs) + + return qs.order_by('-import_job__created') def discard_all(self): self.get_queryset().update(payer='', reference='', state=BankTransaction.STATE_DISCARDED) @@ -312,8 +354,7 @@ class ImportView(ListView): self.discard_all() return self.redirect_back() - elif ('file' in self.request.FILES and '.csv' in self.request.FILES.get('file').name.lower()) \ - or 'amount' in self.request.POST: + elif ('file' in self.request.FILES and '.csv' in self.request.FILES.get('file').name.lower()) or 'amount' in self.request.POST: # Process CSV return self.process_csv() @@ -465,6 +506,7 @@ class ImportView(ListView): ctx = super().get_context_data() ctx['job_running'] = self.job_running ctx['no_more_payments'] = False + ctx['filter_form'] = BankTransactionFilterForm(self.request.GET or None) if 'event' in self.kwargs: ctx['basetpl'] = 'pretixplugins/banktransfer/import_base.html' @@ -543,3 +585,232 @@ class OrganizerActionView(OrganizerBanktransferView, OrganizerPermissionRequired organizer=self.request.organizer, can_change_orders=True, can_view_orders=True ).values_list('limit_events__id', flat=True) ) + + +def _row_key_func(row): + return row['iban'], row['bic'] + + +def _unite_transaction_rows(transaction_rows): + united_transactions_rows = [] + transaction_rows = sorted(transaction_rows, key=_row_key_func) + for (iban, bic), group in itertools.groupby(transaction_rows, _row_key_func): + rows = list(group) + united_transactions_rows.append({ + "iban": iban, + "bic": bic, + "id": ", ".join(sorted(set(r['id'] for r in rows))), + "payer": ", ".join(sorted(set(r['payer'] for r in rows))), + "amount": sum(r['amount'] for r in rows), + }) + return united_transactions_rows + + +class RefundExportListView(ListView): + template_name = 'pretixplugins/banktransfer/refund_export.html' + model = RefundExport + context_object_name = 'exports' + + def get_success_url(self): + raise NotImplementedError + + def get_unexported(self) -> QuerySet: + raise NotImplementedError() + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + ctx['num_new'] = self.get_unexported().count() + ctx['basetpl'] = "pretixcontrol/event/base.html" + if not hasattr(self.request, 'event'): + ctx['basetpl'] = "pretixcontrol/organizers/base.html" + return ctx + + @transaction.atomic() + def post(self, request, *args, **kwargs): + unite_transactions = request.POST.get("unite_transactions", False) + valid_refunds: Set[OrderRefund] = set() + for refund in self.get_unexported().select_related('order', 'order__event'): + if not refund.info_data: + # Should not happen + messages.warning(request, + _("We could not find bank account information for the refund {refund_id}. It was marked as failed.") + .format(refund_id=refund.full_id)) + refund.state = OrderRefund.REFUND_STATE_FAILED + refund.save() + continue + else: + valid_refunds.add(refund) + + if valid_refunds: + transaction_rows = [] + + for refund in valid_refunds: + data = refund.info_data + transaction_rows.append({ + "amount": refund.amount, + "id": refund.full_id, + **{key: data[key] for key in ("payer", "iban", "bic")} + }) + refund.done(user=self.request.user) + + if unite_transactions: + transaction_rows = _unite_transaction_rows(transaction_rows) + + rows_data = json.dumps(transaction_rows, cls=CustomJSONEncoder) + if hasattr(request, 'event'): + RefundExport.objects.create(event=self.request.event, testmode=self.request.event.testmode, rows=rows_data) + else: + RefundExport.objects.create(organizer=self.request.organizer, testmode=False, rows=rows_data) + + else: + messages.warning(request, _('No valid orders have been found.')) + + return redirect(self.get_success_url()) + + +class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListView): + permission = 'can_change_orders' + + def get_success_url(self): + return reverse('plugins:banktransfer:refunds.list', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.organizer.slug, + }) + + def get_queryset(self): + return RefundExport.objects.filter( + event=self.request.event + ).order_by('-datetime') + + def get_unexported(self): + return OrderRefund.objects.filter( + order__event=self.request.event, + provider='banktransfer', + state=OrderRefund.REFUND_STATE_CREATED, + order__testmode=self.request.event.testmode, + ) + + +class OrganizerRefundExportListView(OrganizerPermissionRequiredMixin, RefundExportListView): + permission = 'can_change_orders' + + def dispatch(self, request, *args, **kwargs): + if len(request.organizer.events.order_by('currency').values_list('currency', flat=True).distinct()) > 1: + messages.error(request, _('Please perform per-event refund exports as this organizer has events with ' + 'multiple currencies.')) + return redirect('control:organizer', organizer=request.organizer.slug) + return super().dispatch(request, *args, **kwargs) + + def get_success_url(self): + return reverse('plugins:banktransfer:refunds.list', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def get_queryset(self): + return RefundExport.objects.filter( + Q(organizer=self.request.organizer) | Q(event__organizer=self.request.organizer) + ).order_by('-datetime') + + def get_unexported(self): + return OrderRefund.objects.filter( + order__event__organizer=self.request.organizer, + provider='banktransfer', + state=OrderRefund.REFUND_STATE_CREATED, + order__testmode=False, + ) + + +class DownloadRefundExportView(DetailView): + model = RefundExport + + def get(self, request, *args, **kwargs): + self.object: RefundExport = self.get_object() + self.object.downloaded = True + self.object.save(update_fields=["downloaded"]) + filename, content_type, data = get_refund_export_csv(self.object) + return FileResponse(data, as_attachment=True, filename=filename, content_type=content_type) + + +class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefundExportView): + permission = 'can_change_orders' + + def get_object(self, *args, **kwargs): + return get_object_or_404( + RefundExport, + event=self.request.event, + pk=self.kwargs.get('id') + ) + + +class OrganizerDownloadRefundExportView(OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, DownloadRefundExportView): + permission = 'can_change_orders' + + def get_object(self, *args, **kwargs): + return get_object_or_404( + RefundExport, + organizer=self.request.organizer, + pk=self.kwargs.get('id') + ) + + +class SepaXMLExportForm(forms.Form): + account_holder = forms.CharField(label=_("Account holder")) + iban = IBANFormField(label="IBAN") + bic = BICFormField(label="BIC") + + def set_initial_from_event(self, event: Event): + banktransfer = event.get_payment_providers(cached=True)[BankTransfer.identifier] + self.initial["account_holder"] = banktransfer.settings.get("bank_details_sepa_name") + self.initial["iban"] = banktransfer.settings.get("bank_details_sepa_iban") + self.initial["bic"] = banktransfer.settings.get("bank_details_sepa_bic") + + +class SepaXMLExportView(SingleObjectMixin, FormView): + form_class = SepaXMLExportForm + model = RefundExport + template_name = 'pretixplugins/banktransfer/sepa_export.html' + context_object_name = "export" + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + self.object: RefundExport = self.get_object() + + def form_valid(self, form): + self.object.downloaded = True + self.object.save(update_fields=["downloaded"]) + filename, content_type, data = build_sepa_xml(self.object, **form.cleaned_data) + return FileResponse(data, as_attachment=True, filename=filename, content_type=content_type) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + ctx['basetpl'] = "pretixcontrol/event/base.html" + if not hasattr(self.request, 'event'): + ctx['basetpl'] = "pretixcontrol/organizers/base.html" + return ctx + + +class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView): + permission = 'can_change_orders' + + def get_object(self, *args, **kwargs): + return get_object_or_404( + RefundExport, + event=self.request.event, + pk=self.kwargs.get('id') + ) + + def get_form(self, form_class=None): + form = super().get_form(form_class) + form.set_initial_from_event(self.object.event) + return form + + +class OrganizerSepaXMLExportView(OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, SepaXMLExportView): + permission = 'can_change_orders' + + def get_object(self, *args, **kwargs): + return get_object_or_404( + RefundExport, + organizer=self.request.organizer, + pk=self.kwargs.get('id') + ) diff --git a/src/requirements/production.txt b/src/requirements/production.txt index a0aa48235..92185164e 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -70,3 +70,4 @@ tlds>=2020041600 text-unidecode==1.* protobuf==3.13.* cryptography +sepaxml==2.3.* diff --git a/src/setup.py b/src/setup.py index 04d58f569..52c0a6193 100644 --- a/src/setup.py +++ b/src/setup.py @@ -142,7 +142,6 @@ setup( 'defusedcsv>=1.1.0', 'vat_moss_forked==2020.3.20.0.11.0', 'django-localflavor>=2.2', - 'django-localflavor', 'jsonschema', 'django-hijack>=2.1.10,<2.2.0', 'openpyxl==3.0.*', @@ -157,6 +156,7 @@ setup( 'text-unidecode==1.*', 'protobuf==3.13.*', 'cryptography', + 'sepaxml==2.3.*', ], extras_require={ 'dev': [ diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index aa0246c8d..b9bdc8430 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -39,7 +39,6 @@ superuser_urls = [ "sudo/sessions/", ] - staff_urls = [ "global/update/", "sudo/", @@ -305,6 +304,14 @@ event_permission_urls = [ ("can_change_event_settings", "checkinlists/add", 200), ("can_change_event_settings", "checkinlists/1/change", 404), ("can_change_event_settings", "checkinlists/1/delete", 404), + + # bank transfer + ("can_change_orders", "banktransfer/import/", 200), + ("can_change_orders", "banktransfer/job/1/", 404), + ("can_change_orders", "banktransfer/action/", 200), + ("can_change_orders", "banktransfer/refunds/", 200), + ("can_change_orders", "banktransfer/export/1/", 404), + ("can_change_orders", "banktransfer/sepa-export/1/", 404), ] @@ -413,6 +420,14 @@ organizer_permission_urls = [ ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/edit", 404), + + # bank transfer + ("can_change_orders", "organizer/dummy/banktransfer/import/", 200), + ("can_change_orders", "organizer/dummy/banktransfer/job/1/", 404), + ("can_change_orders", "organizer/dummy/banktransfer/action/", 200), + ("can_change_orders", "organizer/dummy/banktransfer/refunds/", 200), + ("can_change_orders", "organizer/dummy/banktransfer/export/1/", 404), + ("can_change_orders", "organizer/dummy/banktransfer/sepa-export/1/", 404), ] diff --git a/src/tests/plugins/banktransfer/test_mt940.py b/src/tests/plugins/banktransfer/test_mt940.py index e969b7f57..117081a08 100644 --- a/src/tests/plugins/banktransfer/test_mt940.py +++ b/src/tests/plugins/banktransfer/test_mt940.py @@ -191,29 +191,33 @@ EXPECTED = [ [ {'amount': '-800.00', 'date': '2002-11-01', - 'payer': 'MUELLER - 234567', + 'payer': 'MUELLER', + 'iban': '234567', 'reference': 'Miete November'}, {'amount': '3000.00', 'date': '2002-11-02', - 'payer': 'MUELLER - 0847564700', + 'payer': 'MUELLER', + 'iban': '0847564700', 'reference': 'Gehalt Oktober Firma Mustermann GmbH'}, ], [ {'amount': '-400.62', 'date': '2012-02-02', - 'payer': 'MARTHAMUELLER -', + 'payer': 'MARTHAMUELLER', 'reference': 'RECHNUNGSNR. 1210815 KUNDENNR. 01234 22222222 DATUM 01.02.2012'}, {'amount': '-1210.00', 'date': '2012-02-03', 'reference': 'MIETE GOETHESTR. 12', - 'payer': 'ABC IMMOBILIEN GMBH - 3333333333'}, + 'payer': 'ABC IMMOBILIEN GMBH', + 'iban': '3333333333'}, {'amount': '30.00', 'date': '2012-02-03', - 'payer': 'STEFAN SCHMIDT -', + 'payer': 'STEFAN SCHMIDT', 'reference': 'RECHNUNG 20120188 STEFAN SCHMIDTKUNDENR. 4711,'}, {'amount': '89.97', 'date': '2012-02-03', - 'payer': 'PETER PETERSEN - 5555555555', + 'payer': 'PETER PETERSEN', + 'iban': '5555555555', 'reference': 'RECHNUNG 20120165 PETER PETERSEN'} ], [ @@ -228,12 +232,21 @@ EXPECTED = [ {'amount': '-50000.00', 'date': '2002-03-24', 'reference': ''} ], [ - {'amount': '12.00', 'date': '2017-08-23', 'payer': 'Peter Schneider - DE13495179316396679327', 'reference': - 'Democon-Abcde (Peter Schneider ), Kategorie: Alles - E innahmen - Veranstaltungen Democon #1111'}, - {'amount': '12.00', 'date': '2017-08-23', 'payer': 'Peter Schneider - DE13495179316396679327', 'reference': - 'Democon-Abcde (Peter Schneider ), Kategorie: Alles - E innahmen - Veranstaltungen Democon #1111'}, - {'amount': '12.00', 'date': '2017-08-24', 'payer': 'Peter Schneider - DE13495179316396679327', 'reference': - 'Democon-Abcde (Peter Schneider ), Kategorie: Alles- E innahmen - Veranstaltungen Democon #1111'}, + {'amount': '12.00', + 'date': '2017-08-23', + 'payer': 'Peter Schneider', + 'iban': 'DE13495179316396679327', + 'reference': 'Democon-Abcde (Peter Schneider ), Kategorie: Alles - E innahmen - Veranstaltungen Democon #1111'}, + {'amount': '12.00', + 'date': '2017-08-23', + 'payer': 'Peter Schneider', + 'iban': 'DE13495179316396679327', + 'reference': 'Democon-Abcde (Peter Schneider ), Kategorie: Alles - E innahmen - Veranstaltungen Democon #1111'}, + {'amount': '12.00', + 'date': '2017-08-24', + 'payer': 'Peter Schneider', + 'iban': 'DE13495179316396679327', + 'reference': 'Democon-Abcde (Peter Schneider ), Kategorie: Alles- E innahmen - Veranstaltungen Democon #1111'}, ] ] diff --git a/src/tests/plugins/banktransfer/test_parsing.py b/src/tests/plugins/banktransfer/test_parsing.py new file mode 100644 index 000000000..2ece0c1e3 --- /dev/null +++ b/src/tests/plugins/banktransfer/test_parsing.py @@ -0,0 +1,21 @@ +from datetime import date + +from pretix.plugins.banktransfer.tasks import parse_date + + +def test_date_formats(): + dt = date(year=2020, month=7, day=1) + assert dt == parse_date("01.07.2020") + assert dt == parse_date("01.07.20") + assert dt == parse_date("1.7.2020") + assert dt == parse_date("1.7.20") + + assert dt == parse_date("07/01/2020") + assert dt == parse_date("07/01/20") + assert dt == parse_date("7/1/2020") + assert dt == parse_date("7/1/20") + + assert dt == parse_date("2020/07/01") + + assert dt == parse_date("2020-07-01") + assert dt == parse_date("2020-7-1") diff --git a/src/tests/plugins/banktransfer/test_refund.py b/src/tests/plugins/banktransfer/test_refund.py new file mode 100644 index 000000000..7ef3ba2a1 --- /dev/null +++ b/src/tests/plugins/banktransfer/test_refund.py @@ -0,0 +1,88 @@ +import json +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils.timezone import now +from django_scopes import scope + +from pretix.base.models import ( + Event, Order, OrderPayment, OrderRefund, Organizer, Team, User, +) + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal' + ) + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t.members.add(user) + t.limit_events.add(event) + order = Order.objects.create( + code='1Z3AS', event=event, email='admin@localhost', + status=Order.STATUS_PAID, + datetime=now(), expires=now() + timedelta(days=10), + total=23 + ) + payment = OrderPayment.objects.create( + order=order, + amount=Decimal("23"), + provider='banktransfer', + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + info=json.dumps({ + 'payer': "Abc Def", + 'iban': "DE27520521540534534466", + 'bic': "HELADEF1MEG", + }) + ) + return event, user, payment + + +@pytest.mark.django_db +def test_perform_refund(client, env): + event, user, payment = env + client.login(email='dummy@dummy.dummy', password='dummy') + assert not OrderRefund.objects.exists() + url = "/control/event/dummy/dummy/orders/1Z3AS/refund" + r = client.post(url, { + f"refund-{payment.id}": "23.00", + "start-mode": "full", + "perform": True, + }) + assert r.status_code == 302 + with scope(organizer=event.organizer): + assert OrderRefund.objects.exists() + refund = OrderRefund.objects.first() + assert refund.payment == payment + assert refund.info_data == { + 'payer': "Abc Def", + 'iban': "DE27520521540534534466", + 'bic': "HELADEF1MEG", + } + + +@pytest.mark.django_db +def test_cannot_perform_refund_with_invalid_iban(client, env): + event, user, payment = env + payment.info_data = { + 'payer': "Abc Def", + 'iban': "DE27520521540534534467", # invalid IBAN + 'bic': "HELADEF1MEG", + } + payment.save() + assert not payment.payment_provider.payment_refund_supported(payment) + + client.login(email='dummy@dummy.dummy', password='dummy') + url = "/control/event/dummy/dummy/orders/1Z3AS/refund" + r = client.post(url, { + f"refund-{payment.id}": "23.00", + "start-mode": "full", + "perform": True, + }) + assert r.status_code == 200 # no successfull POST + with scope(organizer=event.organizer): + assert not OrderRefund.objects.exists() diff --git a/src/tests/plugins/banktransfer/test_refund_export.py b/src/tests/plugins/banktransfer/test_refund_export.py new file mode 100644 index 000000000..f4691a885 --- /dev/null +++ b/src/tests/plugins/banktransfer/test_refund_export.py @@ -0,0 +1,135 @@ +import json +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils.timezone import now + +from pretix.base.models import Event, Order, OrderRefund, Organizer, Team, User +from pretix.plugins.banktransfer.models import RefundExport +from pretix.plugins.banktransfer.views import ( + _row_key_func, _unite_transaction_rows, +) + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal' + ) + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t.members.add(user) + t.limit_events.add(event) + order = Order.objects.create( + code='1Z3AS', event=event, email='admin@localhost', + status=Order.STATUS_PAID, + datetime=now(), expires=now() + timedelta(days=10), + total=23 + ) + refund = OrderRefund.objects.create( + order=order, + amount=Decimal("23"), + provider='banktransfer', + state=OrderRefund.REFUND_STATE_CREATED, + info=json.dumps({ + 'payer': "Abc Def", + 'iban': "DE27520521540534534466", + 'bic': "HELADEF1MEG", + }) + ) + return event, user, refund + + +url_prefixes = [ + "/control/event/dummy/dummy/", + "/control/organizer/dummy/" +] + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_prefix", url_prefixes) +def test_export_refunds_as_sepa_xml(client, env, url_prefix): + client.login(email='dummy@dummy.dummy', password='dummy') + r = client.post(f'{url_prefix}banktransfer/refunds/', {"unite_transactions": True}, follow=True) + assert b"SEPA" in r.content + r = client.get(f'{url_prefix}banktransfer/sepa-export/{RefundExport.objects.last().id}/') + assert r.status_code == 200 + r = client.post(f'{url_prefix}banktransfer/sepa-export/{RefundExport.objects.last().id}/', { + "account_holder": "Fission Festival", + "iban": "DE71720690050653667120", + "bic": "GENODEF1AIL", + }) + assert "DE27520521540534534466" in "".join(str(part) for part in r.streaming_content) + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_prefix", url_prefixes) +def test_export_refunds(client, env, url_prefix): + client.login(email='dummy@dummy.dummy', password='dummy') + r = client.get(f'{url_prefix}banktransfer/refunds/') + assert r.status_code == 200 + r = client.post(f'{url_prefix}banktransfer/refunds/', {"unite_transactions": True}, follow=True) + assert r.status_code == 200 + refund = RefundExport.objects.last() + assert refund is not None + assert b"Download CSV" in r.content + r = client.get(f'{url_prefix}banktransfer/export/{refund.id}/') + assert r.status_code == 200 + assert "DE27520521540534534466" in "".join(str(part) for part in r.streaming_content) + + +def test_unite_transaction_rows(): + rows = sorted([ + { + 'payer': "Abc Def", + 'iban': 'DE12345678901234567890', + 'bic': 'HARKE9000', + 'id': "ROLLA-R-1", + 'amount': Decimal("42.23"), + }, + { + 'payer': "First Last", + 'iban': 'DE111111111111111111111', + 'bic': 'ikswez2020', + 'id': "PARTY-R-1", + 'amount': Decimal("6.50"), + } + ], key=_row_key_func) + + assert _unite_transaction_rows(rows) == rows + + rows = sorted(rows + [ + { + 'payer': "Abc Def", + 'iban': 'DE12345678901234567890', + 'bic': 'HARKE9000', + 'id': "ROLLA-R-1", + 'amount': Decimal("7.77"), + }, + { + 'payer': "Another Last", + 'iban': 'DE111111111111111111111', + 'bic': 'ikswez2020', + 'id': "PARTY-R-2", + 'amount': Decimal("13.50"), + } + ], key=_row_key_func) + + assert _unite_transaction_rows(rows) == sorted([ + { + 'payer': "Abc Def", + 'iban': 'DE12345678901234567890', + 'bic': 'HARKE9000', + 'id': "ROLLA-R-1", + 'amount': Decimal("50.00"), + }, + { + 'payer': 'Another Last, First Last', + 'iban': 'DE111111111111111111111', + 'bic': 'ikswez2020', + 'id': 'PARTY-R-1, PARTY-R-2', + 'amount': Decimal('20.00'), + }], key=_row_key_func)