diff --git a/src/pretix/plugins/banktransfer/tasks.py b/src/pretix/plugins/banktransfer/tasks.py index 8f630efa6b..af31140eec 100644 --- a/src/pretix/plugins/banktransfer/tasks.py +++ b/src/pretix/plugins/banktransfer/tasks.py @@ -4,9 +4,9 @@ from decimal import Decimal import dateutil.parser from celery.exceptions import MaxRetriesExceededError -from django.conf import settings from django.db import transaction -from django.db.models import Q +from django.db.models import Max, Min, Q +from django.db.models.functions import Length from django.utils.translation import gettext, gettext_noop from django_scopes import scope, scopes_disabled @@ -65,43 +65,69 @@ 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, matches: tuple, event: Event = None, organizer: Organizer = None): + orders = [] if event: - try: - trans.order = event.orders.get(code=code) - except Order.DoesNotExist: - normalized_code = Order.normalize_code(code) + for slug, code in matches: try: - trans.order = event.orders.get(code=normalized_code) + orders.append(event.orders.get(code=code)) except Order.DoesNotExist: - trans.state = BankTransaction.STATE_NOMATCH - trans.save() - return + normalized_code = Order.normalize_code(code) + try: + orders.append(event.orders.get(code=normalized_code)) + except Order.DoesNotExist: + pass else: qs = Order.objects.filter(event__organizer=organizer) - if slug: - qs = qs.filter(event__slug__iexact=slug) - try: - trans.order = qs.get(code=code) - except Order.DoesNotExist: - normalized_code = Order.normalize_code(code) + for slug, code in matches: try: - trans.order = qs.get(code=normalized_code) + orders.append(qs.get(event__slug__iexact=slug, code=code)) except Order.DoesNotExist: - trans.state = BankTransaction.STATE_NOMATCH - trans.save() - return + normalized_code = Order.normalize_code(code) + try: + orders.append(qs.get(event__slug__iexact=slug, code=normalized_code)) + except Order.DoesNotExist: + pass - if trans.order.status == Order.STATUS_PAID and trans.order.pending_sum <= Decimal('0.00'): - trans.state = BankTransaction.STATE_DUPLICATE - elif trans.order.status == Order.STATUS_CANCELED: - trans.state = BankTransaction.STATE_ERROR - trans.message = gettext_noop('The order has already been canceled.') + if not orders: + # No match + trans.state = BankTransaction.STATE_NOMATCH + trans.save() + return else: + trans.order = orders[0] + + for o in orders: + if o.status == Order.STATUS_PAID and o.pending_sum <= Decimal('0.00'): + trans.state = BankTransaction.STATE_DUPLICATE + trans.save() + return + elif o.status == Order.STATUS_CANCELED: + trans.state = BankTransaction.STATE_ERROR + trans.message = gettext_noop('The order has already been canceled.') + trans.save() + return + + if len(orders) > 1: + # Multi-match! Can we split this automatically? + order_pending_sum = sum(o.pending_sum for o in orders) + if order_pending_sum != trans.amount: + # we can't :( this needs to be dealt with by a human + trans.state = BankTransaction.STATE_NOMATCH + trans.message = gettext_noop('Automatic split to multiple orders not possible.') + trans.save() + return + + # we can! + splits = [(o, o.pending_sum) for o in orders] + else: + splits = [(orders[0], trans.amount)] + + trans.state = BankTransaction.STATE_VALID + for order, amount in splits: try: - p, created = trans.order.payments.get_or_create( - amount=trans.amount, + p, created = order.payments.get_or_create( + amount=amount, provider='banktransfer', state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING), defaults={ @@ -110,8 +136,8 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event = None, ) except OrderPayment.MultipleObjectsReturned: created = False - p = trans.order.payments.filter( - amount=trans.amount, + p = order.payments.filter( + amount=amount, provider='banktransfer', state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING), ).last() @@ -122,12 +148,13 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event = None, 'payer': trans.payer, 'iban': trans.iban, 'bic': trans.bic, + 'full_amount': str(trans.amount), 'trans_id': trans.pk } if created: # We're perform a payment method switching on-demand here - old_fee, new_fee, fee, p = change_payment_provider(trans.order, p.payment_provider, p.amount, + old_fee, new_fee, fee, p = change_payment_provider(order, p.payment_provider, p.amount, new_payment=p, create_log=False) # noqa if fee: p.fee = fee @@ -136,19 +163,17 @@ def _handle_transaction(trans: BankTransaction, code: str, event: Event = None, try: p.confirm() except Quota.QuotaExceededException: - trans.state = BankTransaction.STATE_VALID - cancel_old_payments(trans.order) + # payment confirmed but order status could not be set, no longer problem of this plugin + cancel_old_payments(order) except SendMailException: - trans.state = BankTransaction.STATE_VALID - cancel_old_payments(trans.order) + # payment confirmed but order status could not be set, no longer problem of this plugin + cancel_old_payments(order) else: - trans.state = BankTransaction.STATE_VALID - cancel_old_payments(trans.order) + cancel_old_payments(order) - o = trans.order - o.refresh_from_db() - if o.pending_sum > Decimal('0.00') and o.status == Order.STATUS_PENDING: - notify_incomplete_payment(o) + order.refresh_from_db() + if order.pending_sum > Decimal('0.00') and order.status == Order.STATUS_PENDING: + notify_incomplete_payment(order) trans.save() @@ -213,7 +238,6 @@ def process_banktransfers(self, job: int, data: list) -> None: with scope(organizer=job.organizer or job.event.organizer): job.state = BankImportJob.STATE_RUNNING job.save() - prefixes = [] try: # Delete left-over transactions from a failed run before so they can reimported @@ -221,26 +245,30 @@ def process_banktransfers(self, job: int, data: list) -> None: transactions = _get_unknown_transactions(job, data, **job.owner_kwargs) - code_len = settings.ENTROPY['order_code'] + code_len_agg = Order.objects.filter(event__organizer=job.organizer).annotate( + clen=Length('code') + ).aggregate(min=Min('clen'), max=Max('clen')) if job.event: - pattern = re.compile(job.event.slug.upper() + r"[ \-_]*([A-Z0-9]{%s})" % code_len) + prefixes = [job.event.slug.upper()] else: - if not prefixes: - prefixes = [e.slug.upper().replace(".", r"\.").replace("-", r"[\- ]*") - for e in job.organizer.events.all()] - pattern = re.compile("(%s)[ \\-_]*([A-Z0-9]{%s})" % ("|".join(prefixes), code_len)) + prefixes = [e.slug.upper().replace(".", r"\.").replace("-", r"[\- ]*") + for e in job.organizer.events.all()] + pattern = re.compile( + "(%s)[ \\-_]*([A-Z0-9]{%s,%s})" % ( + "|".join(prefixes), + code_len_agg['min'] or 0, + code_len_agg['max'] or 5 + ) + ) for trans in transactions: - match = pattern.search(trans.reference.replace(" ", "").replace("\n", "").upper()) + matches = pattern.findall(trans.reference.replace(" ", "").replace("\n", "").upper()) - if match: + if matches: if job.event: - code = match.group(1) - _handle_transaction(trans, code, event=job.event) + _handle_transaction(trans, matches, event=job.event) else: - slug = match.group(1) - code = match.group(2) - _handle_transaction(trans, code, organizer=job.organizer, slug=slug) + _handle_transaction(trans, matches, organizer=job.organizer) else: trans.state = BankTransaction.STATE_NOMATCH trans.save() diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html index bc79e63930..e9e0b7c668 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html @@ -18,6 +18,10 @@
{% trans "Payment date" %}
{{ payment_info.date }}
{% endif %} + {% if payment_info.reference %} +
{% trans "Transfer amount" %}
+
{{ payment_info.full_amount }}
+ {% endif %} {% if payment_info.reference %}
{% trans "Reference" %}
{{ payment_info.reference }}
diff --git a/src/tests/plugins/banktransfer/test_import.py b/src/tests/plugins/banktransfer/test_import.py index 8d40f9af7f..acc7ba86fa 100644 --- a/src/tests/plugins/banktransfer/test_import.py +++ b/src/tests/plugins/banktransfer/test_import.py @@ -274,6 +274,20 @@ def test_mark_paid_organizer_dash_in_slug(env, orga_job): assert env[2].status == Order.STATUS_PAID +@pytest.mark.django_db +def test_mark_paid_organizer_varying_order_code_length(env, orga_job): + env[2].code = "123412341234" + env[2].save() + process_banktransfers(orga_job, [{ + 'payer': 'Karla Kundin', + 'reference': 'Bestellung DUMMY-123412341234', + 'date': '2016-01-26', + 'amount': '23.00' + }]) + env[2].refresh_from_db() + assert env[2].status == Order.STATUS_PAID + + @pytest.mark.django_db def test_mark_paid_organizer_weird_slug(env, orga_job): env[0].slug = 'du.m-y' @@ -318,6 +332,56 @@ def test_keep_unmatched(env, orga_job): assert t.state == BankTransaction.STATE_NOMATCH +@pytest.mark.django_db +def test_split_payment_success(env, orga_job): + o4 = Order.objects.create( + code='99999', event=env[0], + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=12 + ) + process_banktransfers(orga_job, [{ + 'payer': 'Karla Kundin', + 'reference': 'Bestellungen DUMMY-1Z3AS DUMMY-99999', + 'date': '2016-01-26', + 'amount': '35.00' + }]) + with scopes_disabled(): + job = BankImportJob.objects.last() + t = job.transactions.last() + assert t.state == BankTransaction.STATE_VALID + env[2].refresh_from_db() + assert env[2].status == Order.STATUS_PAID + assert env[2].payments.get().amount == Decimal('23.00') + o4.refresh_from_db() + assert o4.status == Order.STATUS_PAID + assert o4.payments.get().amount == Decimal('12.00') + + +@pytest.mark.django_db +def test_split_payment_mismatch(env, orga_job): + o4 = Order.objects.create( + code='99999', event=env[0], + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=12 + ) + process_banktransfers(orga_job, [{ + 'payer': 'Karla Kundin', + 'reference': 'Bestellungen DUMMY-1Z3AS DUMMY-99999', + 'date': '2016-01-26', + 'amount': '36.00' + }]) + with scopes_disabled(): + job = BankImportJob.objects.last() + t = job.transactions.last() + assert t.state == BankTransaction.STATE_NOMATCH + env[2].refresh_from_db() + assert env[2].status == Order.STATUS_PENDING + o4.refresh_from_db() + assert o4.status == Order.STATUS_PENDING + + @pytest.mark.django_db def test_import_very_long_csv_file(client, env): client.login(email='dummy@dummy.dummy', password='dummy')