From 1749704390a4af63d200f1b61f3b54199219f975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Fri, 27 Sep 2024 08:42:32 +0200 Subject: [PATCH 1/7] Use `docker compose` always `docker-compose` is missing some necessary features. --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index e8cbca488..56050caf0 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # https://github.com/casey/just#packages # execute `just --evaluate ` to check the values of the variables set below -drc := if `docker compose 2>&1 >/dev/null; echo $?` == "0" { "docker compose" } else { "docker-compose" } +drc := "docker compose" export COMPOSE_FILE := "docker-compose.dev.yml:docker-compose.test.yml" export PGPASSFILE := ".pycroft.pgpass" psql_pycroft_uri := "postgresql:///pycroft?options=-csearch_path%3Dpycroft,public" From 6a8ba034b438a70a2f6ccf3c22602038c23bc5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Fri, 27 Sep 2024 11:40:01 +0200 Subject: [PATCH 2/7] finance: Replace "Konto anzeigen" button with regular action button --- web/blueprints/finance/__init__.py | 16 +++++++++++----- web/blueprints/finance/tables.py | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/web/blueprints/finance/__init__.py b/web/blueprints/finance/__init__.py index 4479b51ca..ed6d051d5 100644 --- a/web/blueprints/finance/__init__.py +++ b/web/blueprints/finance/__init__.py @@ -166,6 +166,16 @@ def bank_accounts_list() -> ResponseReturnValue: @bp.route('/bank-accounts/list/json') def bank_accounts_list_json() -> ResponseReturnValue: + def actions(bank_account: BankAccount) -> list[BtnColResponse]: + return [ + BtnColResponse( + href=url_for('.accounts_show', account_id=bank_account.account_id), + title="", + btn_class="btn-primary", + icon="fa-eye", + ) + ] + return TableResponse[BankAccountRow]( items=[ BankAccountRow( @@ -173,17 +183,13 @@ def bank_accounts_list_json() -> ResponseReturnValue: bank=bank_account.bank, iban=bank_account.iban, bic=bank_account.bic, - kto=BtnColResponse( - href=url_for(".accounts_show", account_id=bank_account.account_id), - title="Konto anzeigen", - btn_class="btn-primary", - ), balance=money_filter(bank_account.balance), last_imported_at=( str(datetime.date(i)) if (i := bank_account.last_imported_at) is not None else "-" ), + actions=actions(bank_account), ) for bank_account in get_all_bank_accounts(session) ] diff --git a/web/blueprints/finance/tables.py b/web/blueprints/finance/tables.py index 9e3897154..8621ea73f 100644 --- a/web/blueprints/finance/tables.py +++ b/web/blueprints/finance/tables.py @@ -194,7 +194,7 @@ class BankAccountTable(BootstrapTable): bic = Column("SWIFT-BIC") balance = Column("Saldo") last_imported_at = Column("Zuletzt importiert") - kto = BtnColumn("Konto") + actions = MultiBtnColumn("Aktionen") def __init__(self, *, create_account: bool = False, **kw: t.Any) -> None: self.create_account = create_account @@ -220,9 +220,9 @@ class BankAccountRow(BaseModel): bank: str iban: str bic: str - kto: BtnColResponse balance: str last_imported_at: str # TODO perhaps date + actions: list[BtnColResponse] class BankAccountActivityTable(BootstrapTable): From 2a7f27a2677fd42e08f36a0e1e09f995f8fbd2f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Fri, 27 Sep 2024 12:28:05 +0200 Subject: [PATCH 3/7] finance: Move bank account import to action button --- web/blueprints/finance/__init__.py | 54 +++++++++++++----------------- web/blueprints/finance/forms.py | 1 - web/blueprints/finance/tables.py | 2 -- 3 files changed, 23 insertions(+), 34 deletions(-) diff --git a/web/blueprints/finance/__init__.py b/web/blueprints/finance/__init__.py index ed6d051d5..3ed3865e2 100644 --- a/web/blueprints/finance/__init__.py +++ b/web/blueprints/finance/__init__.py @@ -169,11 +169,17 @@ def bank_accounts_list_json() -> ResponseReturnValue: def actions(bank_account: BankAccount) -> list[BtnColResponse]: return [ BtnColResponse( - href=url_for('.accounts_show', account_id=bank_account.account_id), + href=url_for(".accounts_show", account_id=bank_account.account_id), title="", - btn_class="btn-primary", + btn_class="btn-primary btn-sm", icon="fa-eye", - ) + ), + BtnColResponse( + href=url_for(".bank_accounts_import", bank_account_id=bank_account.id), + title="", + btn_class="btn-primary btn-sm", + icon="fa-file-import", + ), ] return TableResponse[BankAccountRow]( @@ -182,7 +188,6 @@ def actions(bank_account: BankAccount) -> list[BtnColResponse]: name=bank_account.name, bank=bank_account.bank, iban=bank_account.iban, - bic=bank_account.bic, balance=money_filter(bank_account.balance), last_imported_at=( str(datetime.date(i)) @@ -270,14 +275,11 @@ def flash_fints_errors() -> t.Iterator[None]: nicht erreicht werden.', 'error') raise PycroftException from e -@bp.route('/bank-accounts/import', methods=['GET', 'POST']) -@access.require('finance_change') -@nav.navigate("Bankkontobewegungen importieren", icon='fa-file-import') -def bank_accounts_import() -> ResponseReturnValue: + +@bp.route("/bank-accounts//import", methods=["GET", "POST"]) +@access.require("finance_change") +def bank_accounts_import(bank_account_id: int) -> ResponseReturnValue: form = BankAccountActivitiesImportForm() - form.account.choices = [ - (acc.id, acc.name) for acc in get_all_bank_accounts(session) if not acc.account.legacy - ] imported = ImportedTransactions([], [], []) def display_form_response( @@ -290,31 +292,21 @@ def display_form_response( doubtful_transactions=imported.doubtful, ) + bank_account = session.get(BankAccount, bank_account_id) + if not form.is_submitted(): - del (form.start_date) + form.start_date.data = ( + datetime.date(i) + if (i := bank_account.last_imported_at) is not None + else date(2018, 1, 1) + ) form.end_date.data = date.today() - timedelta(days=1) - form.account.data = config.membership_fee_bank_account_id return display_form_response(imported) if not form.validate(): return display_form_response(imported) - bank_account = session.get(BankAccount, form.account.data) - - # set start_date, end_date - if form.start_date.data is None: - # NB: start date default depends on `bank_account` - form.start_date.data = ( - datetime.date(i) - if (i := bank_account.last_imported_at) is not None - else date(2018, 1, 1) - ) - if form.end_date.data is None: - form.end_date.data = date.today() - start_date = form.start_date.data - end_date = form.end_date.data - try: with flash_fints_errors(): statement, errors = get_fints_transactions( @@ -322,13 +314,13 @@ def display_form_response( user_id=form.user.data, secret_pin=form.secret_pin.data, bank_account=bank_account, - start_date=start_date, - end_date=end_date, + start_date=form.start_date.data, + end_date=form.end_date.data, ) except PycroftException: return display_form_response(imported) - flash(f"Transaktionen vom {start_date} bis {end_date}.") + flash(f"Transaktionen vom {form.start_date.data} bis {form.end_date.data}.") if errors: flash( f"{len(errors)} Statements enthielten fehlerhafte Daten und müssen " diff --git a/web/blueprints/finance/forms.py b/web/blueprints/finance/forms.py index 039725832..83df603b7 100644 --- a/web/blueprints/finance/forms.py +++ b/web/blueprints/finance/forms.py @@ -111,7 +111,6 @@ class BankAccountActivityEditForm(BankAccountActivityReadForm): class BankAccountActivitiesImportForm(Form): - account = SelectField("Bankkonto", coerce=int) user = StringField("Loginname", validators=[DataRequired()]) secret_pin = PasswordField("PIN", validators=[DataRequired()]) start_date = DateField("Startdatum") diff --git a/web/blueprints/finance/tables.py b/web/blueprints/finance/tables.py index 8621ea73f..d229fa3ad 100644 --- a/web/blueprints/finance/tables.py +++ b/web/blueprints/finance/tables.py @@ -191,7 +191,6 @@ class BankAccountTable(BootstrapTable): name = Column("Name") bank = Column("Bank") iban = IbanColumn("IBAN") - bic = Column("SWIFT-BIC") balance = Column("Saldo") last_imported_at = Column("Zuletzt importiert") actions = MultiBtnColumn("Aktionen") @@ -219,7 +218,6 @@ class BankAccountRow(BaseModel): name: str bank: str iban: str - bic: str balance: str last_imported_at: str # TODO perhaps date actions: list[BtnColResponse] From cdfe939c1e4c8e40b38639d9a5707222a74a6050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Sat, 28 Sep 2024 21:39:18 +0200 Subject: [PATCH 4/7] Move FinTS client creation to seperate function --- pycroft/lib/finance/fints.py | 28 ++++++--- tests/external_services/test_fints.py | 89 +++++++++++++++++---------- web/blueprints/finance/__init__.py | 15 +++-- 3 files changed, 83 insertions(+), 49 deletions(-) diff --git a/pycroft/lib/finance/fints.py b/pycroft/lib/finance/fints.py index 7e15b75e9..e828214f4 100644 --- a/pycroft/lib/finance/fints.py +++ b/pycroft/lib/finance/fints.py @@ -2,6 +2,7 @@ # This file is part of the Pycroft project and licensed under the terms of # the Apache License, Version 2.0. See the LICENSE file for details +import typing as t from datetime import date from mt940.models import Transaction as MT940Transaction @@ -10,15 +11,30 @@ from pycroft.model.finance import BankAccount -def get_fints_transactions( +def get_fints_client( *, product_id: str, user_id: int, secret_pin: str, bank_account: BankAccount, + **kwargs: t.Any, +) -> FinTS3Client: + return FinTS3Client( + bank_identifier=bank_account.routing_number, + user_id=user_id, + pin=secret_pin, + server=bank_account.fints_endpoint, + product_id=product_id, + **kwargs, + ) + + +def get_fints_transactions( + *, start_date: date, end_date: date, - FinTSClient: type[FinTS3Client] = FinTS3Client, + bank_account: BankAccount, + fints_client: FinTS3Client, ) -> tuple[list[MT940Transaction], list[StatementError]]: """Get the transactions from FinTS @@ -26,14 +42,6 @@ def get_fints_transactions( - FinTS (:module:`pycroft.external_services.fints`) """ - # login with fints - fints_client = FinTSClient( - bank_identifier=bank_account.routing_number, - user_id=user_id, - pin=secret_pin, - server=bank_account.fints_endpoint, - product_id=product_id, - ) acc = next( (a for a in fints_client.get_sepa_accounts() if a.iban == bank_account.iban), None, diff --git a/tests/external_services/test_fints.py b/tests/external_services/test_fints.py index 3379eae1b..deff62c82 100644 --- a/tests/external_services/test_fints.py +++ b/tests/external_services/test_fints.py @@ -18,6 +18,61 @@ from tests.factories.finance import BankAccountFactory as BankAccountFactory_ +def test_fints_connection(default_fints_client_args, default_transaction_args): + bank_account = BankAccountFactory.build(iban="DE61850503003120219540") + fints_client = StubFintsClient( + **default_fints_client_args, + bank_identifier=bank_account.routing_number, + server=bank_account.fints_endpoint, + ) + + transactions, errors = get_fints_transactions( + **default_transaction_args, + bank_account=bank_account, + fints_client=fints_client, + ) + assert transactions == [] + assert errors == [] + + +def test_transactions_unknown_iban(default_fints_client_args, default_transaction_args): + bank_account = BankAccountFactory.build() + fints_client = StubFintsClient( + **default_fints_client_args, + bank_identifier=bank_account.routing_number, + server=bank_account.fints_endpoint, + ) + + with pytest.raises(KeyError, match="BankAccount with IBAN.*not found"): + get_fints_transactions( + **default_transaction_args, + bank_account=bank_account, + fints_client=fints_client, + ) + + +@pytest.fixture(scope="session") +def default_transaction_args() -> dict: + return { + "start_date": today() - timedelta(days=30), + "end_date": today(), + } + + +@pytest.fixture(scope="session") +def default_fints_client_args() -> dict: + return { + "product_id": "1", + "user_id": 1, + "pin": "123456", + } + + +class BankAccountFactory(BankAccountFactory_): + fints_endpoint = "https://banking-sn5.s-fints-pt-sn.de/fints30" + routing_number = "85050300" + + class StubHTTPSConnection(FinTSHTTPSConnection): def send(self, msg: FinTSMessage): # response = base64.b64decode(r.content.decode('iso-8859-1')) @@ -60,37 +115,3 @@ def get_sepa_accounts(self): def _find_highest_supported_command(self, *segment_classes, **kwargs): return segment_classes[-1] - - -class BankAccountFactory(BankAccountFactory_): - fints_endpoint = "https://banking-sn5.s-fints-pt-sn.de/fints30" - routing_number = "85050300" - - -@pytest.fixture(scope="session") -def default_transaction_args() -> dict: - return { - "product_id": "1", - "user_id": 1, - "secret_pin": "123456", - "start_date": today() - timedelta(days=30), - "end_date": today(), - "FinTSClient": StubFintsClient, - } - - -def test_fints_connection(default_transaction_args): - transactions, errors = get_fints_transactions( - **default_transaction_args, - bank_account=BankAccountFactory.build(iban="DE61850503003120219540"), - ) - assert transactions == [] - assert errors == [] - - -def test_transactions_unknown_iban(default_transaction_args): - with pytest.raises(KeyError, match="BankAccount with IBAN.*not found"): - get_fints_transactions( - **default_transaction_args, - bank_account=BankAccountFactory.build(), - ) diff --git a/web/blueprints/finance/__init__.py b/web/blueprints/finance/__init__.py index 3ed3865e2..0b57792b0 100644 --- a/web/blueprints/finance/__init__.py +++ b/web/blueprints/finance/__init__.py @@ -78,7 +78,7 @@ get_last_import_date, get_last_membership_fee, ) -from pycroft.lib.finance.fints import get_fints_transactions +from pycroft.lib.finance.fints import get_fints_transactions, get_fints_client from pycroft.lib.finance.matching import UserMatching, AccountMatching from pycroft.lib.mail import MemberNegativeBalance from pycroft.lib.user import encode_type2_user_id, user_send_mails @@ -307,15 +307,20 @@ def display_form_response( if not form.validate(): return display_form_response(imported) + fints_client = get_fints_client( + product_id=config.fints_product_id, + user_id=form.user.data, + secret_pin=form.secret_pin.data, + bank_account=bank_account, + ) + try: with flash_fints_errors(): statement, errors = get_fints_transactions( - product_id=config.fints_product_id, - user_id=form.user.data, - secret_pin=form.secret_pin.data, - bank_account=bank_account, start_date=form.start_date.data, end_date=form.end_date.data, + bank_account=bank_account, + fints_client=fints_client, ) except PycroftException: return display_form_response(imported) From 3be51db736d5971b0b0278b32ccdcf1f2f36b19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Sun, 29 Sep 2024 20:13:50 +0200 Subject: [PATCH 5/7] Enable import from bank account if TAN is required after login --- web/blueprints/finance/__init__.py | 138 +++++++++++++++++- web/blueprints/finance/forms.py | 12 +- .../finance/bank_accounts_import.html | 2 +- web/templates/finance/fints_login.html | 14 ++ web/templates/finance/fints_tan.html | 22 +++ 5 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 web/templates/finance/fints_login.html create mode 100644 web/templates/finance/fints_tan.html diff --git a/web/blueprints/finance/__init__.py b/web/blueprints/finance/__init__.py index 0b57792b0..0c242ad0f 100644 --- a/web/blueprints/finance/__init__.py +++ b/web/blueprints/finance/__init__.py @@ -9,7 +9,9 @@ :copyright: (c) 2012 by AG DSN. """ +import logging import typing as t +from base64 import b64encode, b64decode from decimal import Decimal from collections.abc import Iterable, Sequence from datetime import date @@ -25,6 +27,7 @@ FinTSError, FinTSClientTemporaryAuthError, ) +from fints.client import FinTS3PinTanClient, NeedTANResponse, NeedRetryResponse from fints.utils import mt940_to_array from flask import ( Blueprint, @@ -36,10 +39,12 @@ url_for, make_response, send_file, + current_app, ) from flask.typing import ResponseReturnValue from flask_login import current_user from flask_wtf import FlaskForm +from itsdangerous import Signer from mt940.models import Transaction as MT940Transaction from sqlalchemy import ( or_, @@ -102,6 +107,8 @@ BankAccountActivityReadForm, BankAccountActivitiesImportManualForm, ConfirmPaymentReminderMail, + FinTSClientForm, + FinTSTANForm, ) from web.blueprints.finance.tables import ( FinanceTable, @@ -141,6 +148,7 @@ bp = Blueprint('finance', __name__) access = BlueprintAccess(bp, required_properties=['finance_show']) nav = BlueprintNavigation(bp, "Finanzen", icon='fa-euro-sign', blueprint_access=access) +logger = logging.getLogger(__name__) @bp.route('/') @@ -175,7 +183,9 @@ def actions(bank_account: BankAccount) -> list[BtnColResponse]: icon="fa-eye", ), BtnColResponse( - href=url_for(".bank_accounts_import", bank_account_id=bank_account.id), + href=url_for( + ".bank_accounts_login", bank_account_id=bank_account.id, action="import" + ), title="", btn_class="btn-primary btn-sm", icon="fa-file-import", @@ -233,6 +243,67 @@ def actions(activity_id: int) -> list[BtnColResponse]: ).model_dump() +# Move to lib? +def b64_sign(data: bytes) -> str: + s = Signer(current_app.secret_key) + return s.sign(b64encode(data)).decode("utf-8") + + +@bp.route("/bank-accounts//login/", methods=["GET", "POST"]) +def bank_accounts_login(bank_account_id: int, action: str) -> ResponseReturnValue: + form = FinTSTANForm() + + if not form.is_submitted(): + del form.tan + return render_template("finance/fints_login.html", form=form) + + bank_account = session.get(BankAccount, bank_account_id) + + client = FinTS3PinTanClient( + bank_account.routing_number, + form.user.data, + form.secret_pin.data, + bank_account.fints_endpoint, + product_id=config.fints_product_id, + ) + with client: + mechanisms = client.get_tan_mechanisms() + + if "913" in mechanisms: + client.set_tan_mechanism("913") # chipTAN-QR + else: + logger.error("FinTS: No suitable TAN mechanism available.", exc_info=True) + flash( + f"TAN-Verfahren „chipTAN-QR“ wird benötigt, jedoch sind am FinTS-Endpunkt nur folgende Verfahren verfügbar: {', '.join(m.name for m in mechanisms.values())}.", + "error", + ) + return redirect( + url_for(".bank_accounts_login", bank_account_id=bank_account_id, action=action) + ) + + with client: + if client.init_tan_response: + challenge: NeedTANResponse = client.init_tan_response + qrcode = "data:image/png;base64," + b64encode(challenge.challenge_matrix[1]).decode( + "ascii" + ) + dialog_data = client.pause_dialog() + + client_data = client.deconstruct(including_private=True) + + form.fints_challenge.data = b64_sign(challenge.get_data()) + form.fints_dialog.data = b64_sign(dialog_data) + form.fints_client.data = b64_sign(client_data) + + return render_template( + "finance/fints_tan.html", + form=form, + action=action, + bank_account_id=bank_account.id, + qrcode=qrcode, + ) + + @bp.route('/bank-accounts/import/errors/json') def bank_accounts_errors_json() -> ResponseReturnValue: return TableResponse[ImportErrorRow]( @@ -255,6 +326,59 @@ def bank_accounts_errors_json() -> ResponseReturnValue: ).model_dump() +def b64_unsign(data: str) -> bytes: + s = Signer(current_app.secret_key) + return b64decode(s.unsign(data)) + + +def get_set_up_fints_client(form: FinTSTANForm, bank_account: BankAccount) -> FinTS3PinTanClient: + client_data = b64_unsign(form.fints_client.data) + dialog_data = b64_unsign(form.fints_dialog.data) + challenge = b64_unsign(form.fints_challenge.data) + + client = get_fints_client( + product_id=config.fints_product_id, + user_id=form.user.data, + secret_pin=form.secret_pin.data, + bank_account=bank_account, + from_data=client_data, + ) + + with client.resume_dialog(dialog_data): + client.send_tan(NeedRetryResponse.from_data(challenge), form.tan.data) + + return client + + +@bp.route("/bank-accounts//import", methods=["POST"]) +@access.require("finance_change") +def bank_accounts_import(bank_account_id: int) -> ResponseReturnValue: + fints_form = FinTSTANForm() + bank_account = session.get(BankAccount, bank_account_id) + + # Send TAN + client = get_set_up_fints_client(fints_form, bank_account) + + form = BankAccountActivitiesImportForm() + form.user.data = fints_form.user.data + form.secret_pin.data = fints_form.secret_pin.data + form.fints_client.data = b64_sign(client.deconstruct(including_private=True)) + + form.start_date.data = ( + datetime.date(i) if (i := bank_account.last_imported_at) is not None else date(2018, 1, 1) + ) + form.end_date.data = date.today() - timedelta(days=1) + + return render_template( + "finance/bank_accounts_import.html", + form=form, + transactions=[], + old_transactions=[], + doubtful_transactions=[], + bank_account_id=bank_account.id, + ) + + from contextlib import contextmanager @contextmanager @@ -276,11 +400,12 @@ def flash_fints_errors() -> t.Iterator[None]: raise PycroftException from e -@bp.route("/bank-accounts//import", methods=["GET", "POST"]) +@bp.route("/bank-accounts//import/run", methods=["POST"]) @access.require("finance_change") -def bank_accounts_import(bank_account_id: int) -> ResponseReturnValue: +def bank_accounts_import_run(bank_account_id: int) -> ResponseReturnValue: form = BankAccountActivitiesImportForm() imported = ImportedTransactions([], [], []) + bank_account = session.get(BankAccount, bank_account_id) def display_form_response( imported: ImportedTransactions, @@ -290,9 +415,9 @@ def display_form_response( transactions=imported.new, old_transactions=imported.old, doubtful_transactions=imported.doubtful, + bank_account_id=bank_account.id, ) - bank_account = session.get(BankAccount, bank_account_id) if not form.is_submitted(): form.start_date.data = ( @@ -307,11 +432,14 @@ def display_form_response( if not form.validate(): return display_form_response(imported) + fints_client_data = b64_unsign(form.fints_client.data) + fints_client = get_fints_client( product_id=config.fints_product_id, user_id=form.user.data, secret_pin=form.secret_pin.data, bank_account=bank_account, + from_data=fints_client_data, ) try: @@ -340,6 +468,8 @@ def display_form_response( f"/ {len(imported.doubtful)} zu neu (Buchung >= {date.today()}T00:00Z)." ) if not form.do_import.data: + form.fints_client.data = b64_sign(fints_client.deconstruct(including_private=True)) + return display_form_response(imported) # persist transactions and errors diff --git a/web/blueprints/finance/forms.py b/web/blueprints/finance/forms.py index 83df603b7..e24b28d47 100644 --- a/web/blueprints/finance/forms.py +++ b/web/blueprints/finance/forms.py @@ -110,9 +110,19 @@ class BankAccountActivityEditForm(BankAccountActivityReadForm): description = StringField("Beschreibung") -class BankAccountActivitiesImportForm(Form): +class FinTSClientForm(Form): user = StringField("Loginname", validators=[DataRequired()]) secret_pin = PasswordField("PIN", validators=[DataRequired()]) + fints_client = HiddenField("FinTS client data", validators=[DataRequired()]) + + +class FinTSTANForm(FinTSClientForm): + tan = StringField("TAN", validators=[DataRequired()]) + fints_challenge = HiddenField("FinTS Challenge", validators=[DataRequired()]) + fints_dialog = HiddenField("FinTS dialog data", validators=[DataRequired()]) + + +class BankAccountActivitiesImportForm(FinTSClientForm): start_date = DateField("Startdatum") end_date = DateField("Enddatum") do_import = BooleanField("Import durchführen", default=False) diff --git a/web/templates/finance/bank_accounts_import.html b/web/templates/finance/bank_accounts_import.html index 20fa224f8..c5ecf1697 100644 --- a/web/templates/finance/bank_accounts_import.html +++ b/web/templates/finance/bank_accounts_import.html @@ -11,7 +11,7 @@ {% block content %}
- {{ forms.simple_form(form, '', url_for('.bank_accounts_list'), autocomplete="on") }} + {{ forms.simple_form(form, url_for('.bank_accounts_import_run', bank_account_id=bank_account_id), url_for('.bank_accounts_list'), autocomplete="on") }}
diff --git a/web/templates/finance/fints_login.html b/web/templates/finance/fints_login.html new file mode 100644 index 000000000..785142110 --- /dev/null +++ b/web/templates/finance/fints_login.html @@ -0,0 +1,14 @@ +{# + Copyright (c) 2024 The Pycroft Authors. See the AUTHORS file. + This file is part of the Pycroft project and licensed under the terms of + the Apache License, Version 2.0. See the LICENSE file for details. +#} +{% extends "layout.html" %} + +{% set page_title = "FinTS-Zugang erlangen" %} + +{% import "macros/forms.html" as forms %} + +{% block single_row_content %} + {{ forms.simple_form(form, '', url_for('.bank_accounts_list') ) }} +{% endblock %} diff --git a/web/templates/finance/fints_tan.html b/web/templates/finance/fints_tan.html new file mode 100644 index 000000000..8cf9aeb2b --- /dev/null +++ b/web/templates/finance/fints_tan.html @@ -0,0 +1,22 @@ +{# + Copyright (c) 2024 The Pycroft Authors. See the AUTHORS file. + This file is part of the Pycroft project and licensed under the terms of + the Apache License, Version 2.0. See the LICENSE file for details. +#} +{% extends "layout.html" %} + +{% set page_title = "FinTS-Zugang erlangen" %} + +{% import "macros/forms.html" as forms %} + +{% block content %} +
+
+ {{ forms.simple_form(form, url_for('.bank_accounts_' ~ action, bank_account_id=bank_account_id), url_for('.bank_accounts_list'), autocomplete="on") }} +
+ +
+ +
+
+{% endblock %} From b3b94108cc25c2ab9ea59ab6bcecf99bc0ff013d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Sun, 29 Sep 2024 20:18:31 +0200 Subject: [PATCH 6/7] finance: Correct type of FinTS user id --- pycroft/lib/finance/fints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycroft/lib/finance/fints.py b/pycroft/lib/finance/fints.py index e828214f4..2f5f33862 100644 --- a/pycroft/lib/finance/fints.py +++ b/pycroft/lib/finance/fints.py @@ -14,7 +14,7 @@ def get_fints_client( *, product_id: str, - user_id: int, + user_id: str, secret_pin: str, bank_account: BankAccount, **kwargs: t.Any, From 4b23632cf859515d067b0abd55b71c657c62be87 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Mon, 30 Sep 2024 19:03:08 +0200 Subject: [PATCH 7/7] web.blueprints.finance: Enforce strict nullability --- pyproject.toml | 2 + web/blueprints/finance/__init__.py | 153 +++++++++++++++++++---------- web/blueprints/finance/tables.py | 8 +- 3 files changed, 108 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 09247d653..7cf82ec0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,6 +174,8 @@ module = [ "pycroft.lib.mail", "pycroft.lib.user", "pycroft.lib.user.*", + "web.blueprints.finance", + "web.blueprints.finance.*", ] strict_optional = true diff --git a/web/blueprints/finance/__init__.py b/web/blueprints/finance/__init__.py index 0c242ad0f..b3b30d136 100644 --- a/web/blueprints/finance/__init__.py +++ b/web/blueprints/finance/__init__.py @@ -56,6 +56,7 @@ Over, ColumnElement, ) +from sqlalchemy.orm import Session from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from sqlalchemy.sql.expression import literal_column, func, select, Join from wtforms import BooleanField, FormField, Field @@ -87,6 +88,7 @@ from pycroft.lib.finance.matching import UserMatching, AccountMatching from pycroft.lib.mail import MemberNegativeBalance from pycroft.lib.user import encode_type2_user_id, user_send_mails +from pycroft.model.base import ModelBase from pycroft.model.finance import Account, Transaction from pycroft.model.finance import ( BankAccount, BankAccountActivity, Split, MembershipFee, MT940Error) @@ -243,12 +245,6 @@ def actions(activity_id: int) -> list[BtnColResponse]: ).model_dump() -# Move to lib? -def b64_sign(data: bytes) -> str: - s = Signer(current_app.secret_key) - return s.sign(b64encode(data)).decode("utf-8") - - @bp.route("/bank-accounts//login/", methods=["GET", "POST"]) def bank_accounts_login(bank_account_id: int, action: str) -> ResponseReturnValue: form = FinTSTANForm() @@ -257,7 +253,7 @@ def bank_accounts_login(bank_account_id: int, action: str) -> ResponseReturnValu del form.tan return render_template("finance/fints_login.html", form=form) - bank_account = session.get(BankAccount, bank_account_id) + bank_account = _get_or_404(session, BankAccount, bank_account_id) client = FinTS3PinTanClient( bank_account.routing_number, @@ -291,9 +287,10 @@ def bank_accounts_login(bank_account_id: int, action: str) -> ResponseReturnValu client_data = client.deconstruct(including_private=True) - form.fints_challenge.data = b64_sign(challenge.get_data()) - form.fints_dialog.data = b64_sign(dialog_data) - form.fints_client.data = b64_sign(client_data) + signer = get_signer() + form.fints_challenge.data = b64_sign(challenge.get_data(), s=signer) + form.fints_dialog.data = b64_sign(dialog_data, s=signer) + form.fints_client.data = b64_sign(client_data, s=signer) return render_template( "finance/fints_tan.html", @@ -304,6 +301,16 @@ def bank_accounts_login(bank_account_id: int, action: str) -> ResponseReturnValu ) +def b64_sign(data: bytes, s: Signer) -> str: + return s.sign(b64encode(data)).decode() + + +def get_signer() -> Signer: + if (sk := current_app.secret_key) is None: + raise RuntimeError("secret key not set") + return Signer(sk) + + @bp.route('/bank-accounts/import/errors/json') def bank_accounts_errors_json() -> ResponseReturnValue: return TableResponse[ImportErrorRow]( @@ -326,16 +333,14 @@ def bank_accounts_errors_json() -> ResponseReturnValue: ).model_dump() -def b64_unsign(data: str) -> bytes: - s = Signer(current_app.secret_key) - return b64decode(s.unsign(data)) - - -def get_set_up_fints_client(form: FinTSTANForm, bank_account: BankAccount) -> FinTS3PinTanClient: - client_data = b64_unsign(form.fints_client.data) - dialog_data = b64_unsign(form.fints_dialog.data) - challenge = b64_unsign(form.fints_challenge.data) +def get_set_up_fints_client( + form: FinTSTANForm, bank_account: BankAccount, signer: Signer +) -> FinTS3PinTanClient: + client_data = b64_unsign(form.fints_client.data, s=signer) + dialog_data = b64_unsign(form.fints_dialog.data, s=signer) + challenge = b64_unsign(form.fints_challenge.data, s=signer) + assert config.fints_product_id is not None, "config not persisted" client = get_fints_client( product_id=config.fints_product_id, user_id=form.user.data, @@ -350,19 +355,25 @@ def get_set_up_fints_client(form: FinTSTANForm, bank_account: BankAccount) -> Fi return client +def b64_unsign(data: str, s: Signer) -> bytes: + return b64decode(s.unsign(data)) + + @bp.route("/bank-accounts//import", methods=["POST"]) @access.require("finance_change") def bank_accounts_import(bank_account_id: int) -> ResponseReturnValue: fints_form = FinTSTANForm() - bank_account = session.get(BankAccount, bank_account_id) + bank_account = _get_or_404(session, BankAccount, bank_account_id) # Send TAN - client = get_set_up_fints_client(fints_form, bank_account) + signer = get_signer() + client = get_set_up_fints_client(fints_form, bank_account, signer) form = BankAccountActivitiesImportForm() form.user.data = fints_form.user.data form.secret_pin.data = fints_form.secret_pin.data - form.fints_client.data = b64_sign(client.deconstruct(including_private=True)) + s = get_signer() + form.fints_client.data = b64_sign(client.deconstruct(including_private=True), s=s) form.start_date.data = ( datetime.date(i) if (i := bank_account.last_imported_at) is not None else date(2018, 1, 1) @@ -405,7 +416,7 @@ def flash_fints_errors() -> t.Iterator[None]: def bank_accounts_import_run(bank_account_id: int) -> ResponseReturnValue: form = BankAccountActivitiesImportForm() imported = ImportedTransactions([], [], []) - bank_account = session.get(BankAccount, bank_account_id) + bank_account = _get_or_404(session, BankAccount, bank_account_id) def display_form_response( imported: ImportedTransactions, @@ -432,8 +443,10 @@ def display_form_response( if not form.validate(): return display_form_response(imported) - fints_client_data = b64_unsign(form.fints_client.data) + s = get_signer() + fints_client_data = b64_unsign(form.fints_client.data, s=s) + assert config.fints_product_id is not None fints_client = get_fints_client( product_id=config.fints_product_id, user_id=form.user.data, @@ -468,7 +481,10 @@ def display_form_response( f"/ {len(imported.doubtful)} zu neu (Buchung >= {date.today()}T00:00Z)." ) if not form.do_import.data: - form.fints_client.data = b64_sign(fints_client.deconstruct(including_private=True)) + signer = get_signer() + form.fints_client.data = b64_sign( + fints_client.deconstruct(including_private=True), s=signer + ) return display_form_response(imported) @@ -530,7 +546,7 @@ def bank_accounts_import_errors() -> ResponseReturnValue: @bp.route('/bank-accounts/importerrors/', methods=['GET', 'POST']) @access.require('finance_change') def fix_import_error(error_id: int) -> ResponseReturnValue: - error = session.get(MT940Error, error_id) + error = _get_or_404(session, MT940Error, error_id) form = FixMT940Form() imported = ImportedTransactions([], [], []) new_exception = None @@ -952,7 +968,8 @@ def balance_json(account_id: int) -> ResponseReturnValue: Split.transaction_id == Transaction.id)) .where(Split.account_id == account_id)) - res = session.execute(json_agg_core(balance_json)).first()[0] + res = session.scalar(json_agg_core(balance_json)) + assert res is not None return {"items": res} @@ -1042,6 +1059,8 @@ def _prefixed_merge( @bp.route('/accounts//json') def accounts_show_json(account_id: int) -> ResponseReturnValue: style = request.args.get('style') + if style is None: + abort(400, "query parameter `style` missing") limit = request.args.get('limit', type=int) offset = request.args.get('offset', type=int) sort_by = request.args.get('sort', default="valid_on") @@ -1104,7 +1123,7 @@ def transactions_show(transaction_id: int) -> ResponseReturnValue: @bp.route('/transactions//json') def transactions_show_json(transaction_id: int) -> ResponseReturnValue: - transaction = session.get(Transaction, transaction_id) + transaction = _get_or_404(session, Transaction, transaction_id) return TransactionSplitResponse( description=transaction.description, items=[ @@ -1162,8 +1181,8 @@ def _iter_transaction_buttons( def _format_transaction_row( transaction: Transaction, - user_account: Account, - bank_acc_act: BankAccountActivity, + user_account: Account | None, + bank_acc_act: BankAccountActivity | None, ) -> UnconfirmedTransactionsRow: return UnconfirmedTransactionsRow( id=transaction.id, @@ -1173,23 +1192,31 @@ def _format_transaction_row( new_tab=True, glyphicon="fa-external-link-alt", ), - user=LinkColResponse( - href=url_for("user.user_show", user_id=user_account.user.id), - title="{} ({})".format( - user_account.user.name, - encode_type2_user_id(user_account.user.id), - ), - new_tab=True, - ) - if user_account - else None, - room=user_account.user.room.short_name - if user_account and user_account.user.room - else None, - author=LinkColResponse( - href=url_for("user.user_show", user_id=transaction.author.id), - title=transaction.author.name, - new_tab=True, + user=( + LinkColResponse( + href=url_for("user.user_show", user_id=user_account.user.id), + title="{} ({})".format( + user_account.user.name, + encode_type2_user_id(user_account.user.id), + ), + new_tab=True, + ) + if user_account and user_account.user + else None + ), + room=( + user_account.user.room.short_name + if user_account and user_account.user and user_account.user.room + else None + ), + author=( + LinkColResponse( + href=url_for("user.user_show", user_id=transaction.author.id), + title=transaction.author.name, + new_tab=True, + ) + if transaction.author + else None ), date=date_format(transaction.posted_at, formatter=date_filter), amount=money_filter(transaction.amount), @@ -1256,6 +1283,7 @@ def transactions_confirm_selected() -> ResponseReturnValue: if not request.is_json: return redirect(url_for(".transactions_unconfirmed")) + assert request.json is not None ids = request.json.get("ids", []) if not isinstance(ids, Iterable): ids = [] @@ -1391,8 +1419,8 @@ def transactions_all_json() -> ResponseReturnValue: else: q = q.where(Transaction.valid_on <= upper) - res = session.execute(json_agg_core(q)).fetchone()[0] or [] - return {"items": res} + res = session.scalar(json_agg_core(q)) + return {"items": res or []} @bp.route('/transactions/create', methods=['GET', 'POST']) @@ -1400,9 +1428,18 @@ def transactions_all_json() -> ResponseReturnValue: @access.require('finance_change') def transactions_create() -> ResponseReturnValue: form = TransactionCreateForm() + + def _ensure_decimal(v: t.Any) -> Decimal: + if isinstance(v, Decimal): + return v + abort(400, f"{v!r} is not a decimal value.") + if form.validate_on_submit(): splits = [ - (session.get(Account, split_form.account_id.data), split_form.amount.data) + ( + _get_or_404(session, Account, split_form.account_id.data), + _ensure_decimal(split_form.amount.data), + ) for split_form in form.splits ] transaction = finance.complex_transaction( @@ -1735,7 +1772,11 @@ def payment_reminder_mail() -> ResponseReturnValue: form = ConfirmPaymentReminderMail() if form.validate_on_submit() and form.confirm.data: - last_import_date = get_last_import_date(session).date() + if (lid := get_last_import_date(session)) is None: + flash("Konnte kein letztes import date finden", "error") + return redirect(url_for(".membership_fees")) + + last_import_date = lid.date() if last_import_date >= utcnow().date() - timedelta(days=3): negative_users = get_negative_members() user_send_mails(negative_users, MemberNegativeBalance()) @@ -1759,3 +1800,13 @@ def payment_reminder_mail() -> ResponseReturnValue: page_title="Zahlungserinnerungen per E-Mail versenden", form_args=form_args, form=form) + + +TModel = t.TypeVar("TModel", bound=ModelBase) + + +def _get_or_404(session: Session, Model: type[TModel], pkey: t.Any) -> TModel: + obj = session.get(Model, pkey) + if obj is None: + abort(404, f"Could not find {Model} with primary key {pkey}") + return obj diff --git a/web/blueprints/finance/tables.py b/web/blueprints/finance/tables.py index d229fa3ad..955207a0f 100644 --- a/web/blueprints/finance/tables.py +++ b/web/blueprints/finance/tables.py @@ -60,7 +60,7 @@ def __init__( self.saldo = saldo - if inverted: + if inverted and saldo is not None: self._enforced_url_params = frozenset( {('style', 'inverted')} .union(self._enforced_url_params) @@ -248,10 +248,10 @@ def __init__(self, *, finance_change: bool = False, **kw: t.Any) -> None: @property @lazy_join - def toolbar(self) -> t.Iterator[str] | None: + def toolbar(self) -> t.Iterator[str]: """Do operations on BankAccountActivities""" if not self.finance_change: - return None + return yield from button_toolbar( "Kontobewegungen zuordnen", url_for(".bank_account_activities_match"), @@ -323,7 +323,7 @@ class UnconfirmedTransactionsRow(BaseModel): room: str | None = None date: DateColResponse amount: str - author: LinkColResponse + author: LinkColResponse | None = None actions: list[BtnColResponse]