diff --git a/pycroft/lib/finance/__init__.py b/pycroft/lib/finance/__init__.py index 7549aec15..0287fef5b 100644 --- a/pycroft/lib/finance/__init__.py +++ b/pycroft/lib/finance/__init__.py @@ -41,6 +41,10 @@ take_actions_for_payment_in_default_users, get_pid_csv, ) +from .retransfer import ( + get_activities_to_return, + generate_activities_return_sepaxml, +) from .transaction_crud import ( simple_transaction, complex_transaction, diff --git a/pycroft/lib/finance/retransfer.py b/pycroft/lib/finance/retransfer.py new file mode 100644 index 000000000..3600b5f0f --- /dev/null +++ b/pycroft/lib/finance/retransfer.py @@ -0,0 +1,49 @@ +from collections.abc import Sequence +from datetime import datetime, timedelta + +from sepaxml import SepaTransfer +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +from pycroft.helpers.utc import ensure_tz +from pycroft.model import session +from pycroft.model.finance import BankAccountActivity + + +def get_activities_to_return() -> Sequence[BankAccountActivity]: + statement = ( + select(BankAccountActivity) + .options(joinedload(BankAccountActivity.bank_account)) + .filter(BankAccountActivity.transaction_id.is_(None)) + .filter(BankAccountActivity.amount > 0) + .filter( + BankAccountActivity.imported_at + < ensure_tz(datetime.utcnow() - timedelta(days=14)) + ) + ) + + return session.session.scalars(statement).all() + + +def generate_activities_return_sepaxml(activities: list[BankAccountActivity]) -> bytes: + config = { + "name": "Studierendenrat der TU Dresden", + "IBAN": "DE61850503003120219540", + "BIC": "OSDDDE81", + "batch": False, + "currency": "EUR", + } + sepa = SepaTransfer(config, clean=False) + + for activity in activities: + payment = { + "name": activity.other_name, + "IBAN": activity.other_account_number, + "BIC": activity.other_routing_number, + "amount": int(activity.amount * 100), + "execution_date": datetime.now().date(), + "description": f"Rücküberweisung nicht zuordenbarer Überweisung vom {activity.posted_on} mit Referenz {activity.reference}", + } + sepa.add_payment(payment) + + return sepa.export() diff --git a/web/blueprints/finance/__init__.py b/web/blueprints/finance/__init__.py index daff02296..eddfde969 100644 --- a/web/blueprints/finance/__init__.py +++ b/web/blueprints/finance/__init__.py @@ -11,11 +11,12 @@ """ import typing as t from decimal import Decimal -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from datetime import date from datetime import timedelta, datetime from functools import partial from itertools import zip_longest, chain +from io import BytesIO import wtforms from fints.dialog import FinTSDialogError @@ -34,6 +35,7 @@ request, url_for, make_response, + send_file, ) from flask.typing import ResponseReturnValue from flask_login import current_user @@ -67,6 +69,8 @@ get_system_accounts, ImportedTransactions, match_activities, + get_activities_to_return, + generate_activities_return_sepaxml, get_all_bank_accounts, get_unassigned_bank_account_activities, get_all_mt940_errors, @@ -548,6 +552,66 @@ def bank_account_activities_match() -> ResponseReturnValue: activities_team=matched_activities_team) +class ActivityEntry(t.TypedDict): + bank_account: str + name: str + valid_on: date + reference: str + amount: int + + +@bp.route("/bank-account-activities/return/") +@access.require("finance_change") +def bank_account_activities_return() -> ResponseReturnValue: + field_list: BooleanFieldList = [] + activities: dict[str, ActivityEntry] = {} + + for activity in get_activities_to_return(): + activities[str(activity.id)] = { + "bank_account": activity.bank_account.name, + "name": activity.other_name, + "valid_on": activity.valid_on, + "reference": activity.reference, + "amount": activity.amount, + } + + field_list.append((str(activity.id), BooleanField(str(activity.id), default=True))) + + form: t.Any = _create_form(field_list) + + return render_template( + "finance/bank_account_activities_return.html", + form=form(), + activities=activities, + ) + + +@bp.route("/bank-account-activities/return/do/", methods=["POST"]) +@access.require("finance_change") +def bank_account_activities_return_do() -> ResponseReturnValue: + field_list: BooleanFieldList = [] + activities_to_return: Sequence[BankAccountActivity] = get_activities_to_return() + + for activity in activities_to_return: + field_list.append((str(activity.id), BooleanField(str(activity.id), default=True))) + + form: t.Any = _create_form(field_list)() + + if form.validate_on_submit(): + selected_activities: list[BankAccountActivity] = [ + activity for activity in activities_to_return if form[str(activity.id)].data + ] + + sepa_xml: bytes = generate_activities_return_sepaxml(selected_activities) + + return send_file( + BytesIO(sepa_xml), + as_attachment=True, + download_name=f"non-attributable-transactions-{datetime.now().date()}.xml", + ) + + + class UserMatch(t.TypedDict): purpose: str name: str diff --git a/web/templates/finance/bank_account_activities_return.html b/web/templates/finance/bank_account_activities_return.html new file mode 100644 index 000000000..305a99c35 --- /dev/null +++ b/web/templates/finance/bank_account_activities_return.html @@ -0,0 +1,46 @@ +{# +SPDX-FileCopyrightText: 2024 Gregor Düster + +SPDX-License-Identifier: Apache-2.0 +#} +{% extends "layout.html" %} + +{% set page_title = "Unzuordenbare Überweisungen zurücküberweisen" %} + +{% block content %} +
+
+
+ {{ form.csrf_token }} + + + + + + + + + + + {% for field in form %}{% if field.type != 'CSRFTokenField' %} + + + + + + + + + {% endif %}{% endfor %} + +
BankkontoNameGültig amVerwendungszweckBetrag
{{ field }}{{ activities[field.id]["bank_account"] }}{{ activities[field.id]["name"] }}{{ activities[field.id]["valid_on"] }}{{ activities[field.id]["reference"] }}{{ activities[field.id]["amount"] }} €
+
+
+ +
+
+ +
+
+
+{% endblock %} diff --git a/web/templates/finance/bank_accounts_list.html b/web/templates/finance/bank_accounts_list.html index f700c47ea..07b02b18b 100644 --- a/web/templates/finance/bank_accounts_list.html +++ b/web/templates/finance/bank_accounts_list.html @@ -14,6 +14,7 @@
Kontobewegungen matchen + Unzugeordnete Kontobewegungen rücküberweisen {{ bank_account_activity_table.render('bank_accounts_activities') }}
{% endblock %}