diff --git a/pycroft/lib/finance/__init__.py b/pycroft/lib/finance/__init__.py index 0287fef5b..0191fe4b0 100644 --- a/pycroft/lib/finance/__init__.py +++ b/pycroft/lib/finance/__init__.py @@ -42,8 +42,9 @@ get_pid_csv, ) from .retransfer import ( - get_activities_to_return, + attribute_activities_as_returned, generate_activities_return_sepaxml, + get_activities_to_return, ) from .transaction_crud import ( simple_transaction, diff --git a/pycroft/lib/finance/retransfer.py b/pycroft/lib/finance/retransfer.py index 231c5098c..f5b88eb3c 100644 --- a/pycroft/lib/finance/retransfer.py +++ b/pycroft/lib/finance/retransfer.py @@ -1,13 +1,17 @@ from collections.abc import Sequence from datetime import datetime, timedelta +from schwifty import IBAN from sepaxml import SepaTransfer from sqlalchemy import select -from sqlalchemy.orm import joinedload, Session +from sqlalchemy.orm import Session, joinedload from pycroft import config from pycroft.helpers.utc import ensure_tz from pycroft.model.finance import BankAccountActivity +from pycroft.model.user import User + +from .transaction_crud import simple_transaction def get_activities_to_return(session: Session) -> Sequence[BankAccountActivity]: @@ -33,10 +37,11 @@ def generate_activities_return_sepaxml(activities: list[BankAccountActivity]) -> sepa = SepaTransfer(transfer_config, clean=False) for activity in activities: + bic = activity.other_routing_number or IBAN(activity.other_account_number).bic.compact payment = { "name": activity.other_name, "IBAN": activity.other_account_number, - "BIC": activity.other_routing_number, + "BIC": bic, "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}"[ @@ -46,3 +51,25 @@ def generate_activities_return_sepaxml(activities: list[BankAccountActivity]) -> sepa.add_payment(payment) return sepa.export() + + +def attribute_activities_as_returned( + session: Session, activities: list[BankAccountActivity], author: User +) -> None: + for activity in activities: + debit_account = config.non_attributable_transactions_account + credit_account = activity.bank_account.account + + transaction = simple_transaction( + description=activity.reference, + debit_account=debit_account, + credit_account=credit_account, + amount=activity.amount, + author=author, + valid_on=activity.valid_on, + confirmed=False, + ) + activity.split = next( + split for split in transaction.splits if split.account_id == credit_account.id + ) + session.add(activity) diff --git a/pycroft/model/alembic/versions/2d7e4df39a3b_add_account_for_non_attributable_.py b/pycroft/model/alembic/versions/2d7e4df39a3b_add_account_for_non_attributable_.py new file mode 100644 index 000000000..9a4f70aa1 --- /dev/null +++ b/pycroft/model/alembic/versions/2d7e4df39a3b_add_account_for_non_attributable_.py @@ -0,0 +1,40 @@ +"""add account for non-attributable transfers to config + +Revision ID: 2d7e4df39a3b +Revises: bc0e0dd480d4 +Create Date: 2024-06-06 20:21:16.972195 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b64618e97415" +down_revision = "5234d7ac2b4a" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "config", + sa.Column( + "non_attributable_transactions_account_id", + sa.Integer(), + nullable=False, + server_default="10", + ), + ) + op.create_foreign_key( + None, "config", "account", ["non_attributable_transactions_account_id"], ["id"] + ) + + +def downgrade(): + op.drop_constraint(None, "config", type_="foreignkey") + op.drop_column("config", "non_attributable_transactions_account_id") + op.create_index( + "bank_account_activity_imported_at", "bank_account_activity", ["imported_at"], unique=False + ) diff --git a/pycroft/model/config.py b/pycroft/model/config.py index 754eb1e96..734681004 100644 --- a/pycroft/model/config.py +++ b/pycroft/model/config.py @@ -72,6 +72,11 @@ class Config(IntegerIdModel): foreign_keys=[membership_fee_bank_account_id] ) + non_attributable_transactions_account_id: Mapped[int] = col(ForeignKey(Account.id)) + non_attributable_transactions_account: Mapped[Account] = relationship( + foreign_keys=[non_attributable_transactions_account_id] + ) + fints_product_id: Mapped[str | None] __table_args__ = (CheckConstraint("id = 1"),) diff --git a/pyproject.toml b/pyproject.toml index 2968b8248..63b4fef16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ "python-dotenv ~= 0.21.0", "reportlab ~= 4.2.5", # usersheet generation "rich ~= 13.8.0", + "schwifty ~= 2024.9.0", "sentry-sdk[Flask] ~= 1.29.2", "simplejson ~= 3.11.1", # decimal serialization "SQLAlchemy >= 2.0.1", diff --git a/requirements.dev.txt b/requirements.dev.txt index 41e6fc5fe..1c0880cf9 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1108,6 +1108,10 @@ pyasn1==0.6.0 \ --hash=sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c \ --hash=sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473 # via ldap3 +pycountry==24.6.1 \ + --hash=sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221 \ + --hash=sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f + # via schwifty pydantic==2.9.2 \ --hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \ --hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12 @@ -1342,6 +1346,10 @@ rich==13.8.0 \ --hash=sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc \ --hash=sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4 # via pycroft (pyproject.toml) +rstr==3.2.2 \ + --hash=sha256:c4a564d4dfb4472d931d145c43d1cf1ad78c24592142e7755b8866179eeac012 \ + --hash=sha256:f39195d38da1748331eeec52f1276e71eb6295e7949beea91a5e9af2340d7b3b + # via schwifty ruamel-yaml==0.18.6 \ --hash=sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636 \ --hash=sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b @@ -1417,6 +1425,10 @@ ruff==0.3.5 \ --hash=sha256:dfd3504e881082959b4160ab02f7a205f0fadc0a9619cc481982b6837b2fd4c0 \ --hash=sha256:faeeae9905446b975dcf6d4499dc93439b131f1443ee264055c5716dd947af55 # via pycroft (pyproject.toml) +schwifty==2024.9.0 \ + --hash=sha256:88f5a73549c5e4acb627ca669d52f49368b00ed3c3dae3778068471d0f6f4f62 \ + --hash=sha256:acee9f5021587c254fc5f77ebefcd62feb0c0c3a80fea4523d451307dd04f330 + # via pycroft (pyproject.toml) sentry-sdk==1.29.2 \ --hash=sha256:3e17215d8006612e2df02b0e73115eb8376c37e3f586d8436fa41644e605074d \ --hash=sha256:a99ee105384788c3f228726a88baf515fe7b5f1d2d0f215a03d194369f158df7 diff --git a/requirements.prod.txt b/requirements.prod.txt index c6a9760b3..ca0066736 100644 --- a/requirements.prod.txt +++ b/requirements.prod.txt @@ -842,6 +842,10 @@ pyasn1==0.6.0 \ --hash=sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c \ --hash=sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473 # via ldap3 +pycountry==24.6.1 \ + --hash=sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221 \ + --hash=sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f + # via schwifty pydantic==2.9.2 \ --hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \ --hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12 @@ -1001,6 +1005,14 @@ rich==13.8.0 \ --hash=sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc \ --hash=sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4 # via pycroft (pyproject.toml) +rstr==3.2.2 \ + --hash=sha256:c4a564d4dfb4472d931d145c43d1cf1ad78c24592142e7755b8866179eeac012 \ + --hash=sha256:f39195d38da1748331eeec52f1276e71eb6295e7949beea91a5e9af2340d7b3b + # via schwifty +schwifty==2024.9.0 \ + --hash=sha256:88f5a73549c5e4acb627ca669d52f49368b00ed3c3dae3778068471d0f6f4f62 \ + --hash=sha256:acee9f5021587c254fc5f77ebefcd62feb0c0c3a80fea4523d451307dd04f330 + # via pycroft (pyproject.toml) sentry-sdk==1.29.2 \ --hash=sha256:3e17215d8006612e2df02b0e73115eb8376c37e3f586d8436fa41644e605074d \ --hash=sha256:a99ee105384788c3f228726a88baf515fe7b5f1d2d0f215a03d194369f158df7 diff --git a/requirements.txt b/requirements.txt index 9677bca46..6e645aff2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -842,6 +842,10 @@ pyasn1==0.6.0 \ --hash=sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c \ --hash=sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473 # via ldap3 +pycountry==24.6.1 \ + --hash=sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221 \ + --hash=sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f + # via schwifty pydantic==2.9.2 \ --hash=sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f \ --hash=sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12 @@ -1001,6 +1005,14 @@ rich==13.8.0 \ --hash=sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc \ --hash=sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4 # via pycroft (pyproject.toml) +rstr==3.2.2 \ + --hash=sha256:c4a564d4dfb4472d931d145c43d1cf1ad78c24592142e7755b8866179eeac012 \ + --hash=sha256:f39195d38da1748331eeec52f1276e71eb6295e7949beea91a5e9af2340d7b3b + # via schwifty +schwifty==2024.9.0 \ + --hash=sha256:88f5a73549c5e4acb627ca669d52f49368b00ed3c3dae3778068471d0f6f4f62 \ + --hash=sha256:acee9f5021587c254fc5f77ebefcd62feb0c0c3a80fea4523d451307dd04f330 + # via pycroft (pyproject.toml) sentry-sdk==1.29.2 \ --hash=sha256:3e17215d8006612e2df02b0e73115eb8376c37e3f586d8436fa41644e605074d \ --hash=sha256:a99ee105384788c3f228726a88baf515fe7b5f1d2d0f215a03d194369f158df7 diff --git a/tests/factories/config.py b/tests/factories/config.py index 03cb9136f..2291fc0e7 100644 --- a/tests/factories/config.py +++ b/tests/factories/config.py @@ -36,3 +36,4 @@ class Meta: # `Account`s membership_fee_account = SubFactory(AccountFactory, type="REVENUE") membership_fee_bank_account = SubFactory(BankAccountFactory) + non_attributable_transactions_account = SubFactory(AccountFactory, type="REVENUE") diff --git a/web/blueprints/finance/__init__.py b/web/blueprints/finance/__init__.py index 52e262b13..cd7b29a10 100644 --- a/web/blueprints/finance/__init__.py +++ b/web/blueprints/finance/__init__.py @@ -77,6 +77,7 @@ match_activities, get_activities_to_return, generate_activities_return_sepaxml, + attribute_activities_as_returned, get_all_bank_accounts, get_unassigned_bank_account_activities, get_all_mt940_errors, @@ -751,12 +752,21 @@ def bank_account_activities_return_do() -> ResponseReturnValue: 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 - ] + if not form.validate_on_submit(): + return render_template( + "finance/bank_account_activities_return.html", + form=form(), + activities=activities_to_return, + ) - sepa_xml: bytes = generate_activities_return_sepaxml(selected_activities) + 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) + + attribute_activities_as_returned(session, selected_activities, current_user) + session.commit() return send_file( BytesIO(sepa_xml), diff --git a/web/templates/finance/bank_account_activities_return.html b/web/templates/finance/bank_account_activities_return.html index 0afcad2f8..6f91f329d 100644 --- a/web/templates/finance/bank_account_activities_return.html +++ b/web/templates/finance/bank_account_activities_return.html @@ -34,7 +34,7 @@