Skip to content

Commit

Permalink
Enable export of non-attributable transfers
Browse files Browse the repository at this point in the history
  • Loading branch information
FestplattenSchnitzel committed May 17, 2024
1 parent 4a3890d commit 21962fa
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 9 deletions.
4 changes: 4 additions & 0 deletions pycroft/lib/finance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions pycroft/lib/finance/retransfer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from collections.abc import Sequence
from datetime import datetime, timedelta

from sepaxml import SepaTransfer
from sqlalchemy import select
from sqlalchemy.orm import joinedload, Session

from pycroft import config
from pycroft.helpers.utc import ensure_tz
from pycroft.model.finance import BankAccountActivity


def get_activities_to_return(session: Session) -> 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.scalars(statement).all()


def generate_activities_return_sepaxml(activities: list[BankAccountActivity]) -> bytes:
transfer_config: dict = {
"name": config.membership_fee_bank_account.owner,
"IBAN": config.membership_fee_bank_account.iban,
"BIC": config.membership_fee_bank_account.bic,
"batch": False,
"currency": "EUR",
}
sepa = SepaTransfer(transfer_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()
15 changes: 8 additions & 7 deletions tests/factories/finance.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ class BankAccountFactory(BaseFactory):
class Meta:
model = BankAccount

name = Faker('word')
bank = Faker('word')
account_number = Faker('random_number', digits=10)
routing_number = Faker('random_number', digits=8)
iban = Faker('iban')
name = Faker("word")
bank = Faker("word")
owner = Faker("word")
account_number = Faker("random_number", digits=10)
routing_number = Faker("random_number", digits=8)
iban = Faker("iban")
bic = Faker("swift", length=11)
fints_endpoint = Faker('url')
account = SubFactory(AccountFactory, type='BANK_ASSET')
Expand All @@ -51,8 +52,8 @@ class Meta:
bank_account = SubFactory(BankAccountFactory)
amount = Faker('random_number', digits=5)
reference = Sequence(lambda n: f"Reference {n}")
other_account_number = Faker('random_number', digits=10)
other_routing_number = Faker('random_number', digits=8)
other_account_number = Faker("iban")
other_routing_number = Faker("swift")
other_name = Faker('word')
imported_at = LazyAttribute(lambda o: session.utcnow().date() - timedelta(days=4))
posted_on = LazyAttribute(lambda o: o.imported_at + timedelta(days=1))
Expand Down
67 changes: 66 additions & 1 deletion tests/lib/test_finance.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
estimate_balance,
post_transactions_for_membership_fee, get_users_with_payment_in_default,
end_payment_in_default_memberships,
take_actions_for_payment_in_default_users)
take_actions_for_payment_in_default_users,
get_activities_to_return,
generate_activities_return_sepaxml,
)
from pycroft.model.finance import (
Transaction,
Split,
Expand Down Expand Up @@ -654,3 +657,65 @@ def test_last_imported_at(self, session: Session):
assert finance.get_last_import_date(session) == datetime(
2020, 1, 1, tzinfo=timezone.utc
)


class TestReturnNonAttributable:
@pytest.mark.parametrize(
"expected, set_transaction_id, amount_negative, imported_at_old",
[
(0, False, False, False), # too young
(0, True, False, False), # too young, already attributed
(1, False, False, True),
(0, True, False, True), # already attributed
(0, False, True, False), # negative amount, too young
(0, True, True, False), # negative amount, too young, already attributed
(0, False, True, True), # negative amount
(0, True, True, True), # negative amount, already attributed
],
)
def test_activities_to_return(
self,
session: Session,
utcnow,
expected,
set_transaction_id,
amount_negative,
imported_at_old,
):
kwargs = {}

if amount_negative:
kwargs["amount"] = -1000
if imported_at_old:
kwargs["imported_at"] = utcnow.date() - timedelta(days=20)

activity = BankAccountActivityFactory.create(**kwargs)

if set_transaction_id:
user = UserFactory.create()

debit_account = user.account
credit_account = activity.bank_account.account
transaction = finance.simple_transaction(
description=activity.reference,
debit_account=debit_account,
credit_account=credit_account,
amount=activity.amount,
author=user,
valid_on=activity.valid_on,
)
activity.split = next(
split for split in transaction.splits if split.account_id == credit_account.id
)

session.add(activity)

activities_to_return = get_activities_to_return(session)

assert len(activities_to_return) == expected

def test_generate_sepa_xml(self, session: Session, utcnow):
BankAccountActivityFactory.create(imported_at=utcnow.date() - timedelta(days=20))
BankAccountActivityFactory.create(imported_at=utcnow.date() - timedelta(days=21))

generate_activities_return_sepaxml(get_activities_to_return(session))
66 changes: 65 additions & 1 deletion web/blueprints/finance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +35,7 @@
request,
url_for,
make_response,
send_file,
)
from flask.typing import ResponseReturnValue
from flask_login import current_user
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -549,6 +553,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(session):
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(session)

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
Expand Down
41 changes: 41 additions & 0 deletions web/templates/finance/bank_account_activities_return.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{% extends "layout.html" %}

{% set page_title = "Unzuordenbare Überweisungen zurücküberweisen" %}

{% block content %}
<form action="{{ url_for('.bank_account_activities_return_do') }}" method="POST">
<div class="row">
<div class="col-md-12">
{{ form.csrf_token }}
<table class="table table-striped table-responsive activities">
<thead>
<th></th>
<th>Bankkonto</th>
<th>Name</th>
<th>Gültig am</th>
<th>Verwendungszweck</th>
<th>Betrag</th>
</thead>
<tbody>
{% for field in form %}{% if field.type != 'CSRFTokenField' %}
<tr>
<td>{{ field }}</td>
<td>{{ activities[field.id]["bank_account"] }}</td>
<td>{{ activities[field.id]["name"] }}</td>
<td>{{ activities[field.id]["valid_on"] }}</td>
<td>{{ activities[field.id]["reference"] }}</td>
<td>{{ activities[field.id]["amount"] }} &euro;</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
</div>

<div class="row">
<div class="col-md-12">
<button type="submit" class="btn btn-primary">SEPA-XML generieren</button>
</div>
</div>
</form>
{% endblock %}
1 change: 1 addition & 0 deletions web/templates/finance/bank_accounts_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ <h2 class="page-header">{{ _("Übersicht") }}</h2>
<section>
<h2 class="page-header">{{ _("Unzugeordnete Kontobewegungen") }}</h2>
<a href="{{ url_for('.bank_account_activities_match') }}" class="btn btn-primary">Kontobewegungen matchen</a>
<a href="{{ url_for('.bank_account_activities_return') }}" class="btn btn-outline-secondary">Unzugeordnete Kontobewegungen rücküberweisen</a>
{{ bank_account_activity_table.render('bank_accounts_activities') }}
</section>
{% endblock %}

0 comments on commit 21962fa

Please sign in to comment.