Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable export of non-attributable transfers #702

Merged
merged 6 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pycroft/helpers/printing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class BankAccount(t.Protocol):
bank: t.Any
iban: t.Any
bic: t.Any
owner: t.Any


class Building(t.Protocol):
Expand Down Expand Up @@ -289,7 +290,7 @@ def generate_user_sheet(
six monthly contributions at once.'''.format(
contribution / 100), style['JustifyText']))

recipient = 'Studierendenrat TUD - AG DSN'
recipient = bank_account.owner
FestplattenSchnitzel marked this conversation as resolved.
Show resolved Hide resolved

if user.room:
purpose = '{id}, {name}, {dorm} {level} {room}'.format(
Expand Down
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:
FestplattenSchnitzel marked this conversation as resolved.
Show resolved Hide resolved
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}",
FestplattenSchnitzel marked this conversation as resolved.
Show resolved Hide resolved
}
sepa.add_payment(payment)

return sepa.export()
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""add bankaccount owner

Revision ID: bc0e0dd480d4
Revises: 55e9f0d9b5f4
Create Date: 2024-03-16 08:42:48.684471

"""

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "bc0e0dd480d4"
down_revision = "55e9f0d9b5f4"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"bank_account", sa.Column("owner", sa.String(length=255), nullable=False, server_default="")
)


def downgrade():
op.drop_column("bank_account", "owner")
1 change: 1 addition & 0 deletions pycroft/model/finance.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ def check_split_on_update(mapper, connection, target):
class BankAccount(IntegerIdModel):
name: Mapped[str255]
bank: Mapped[str255]
owner: Mapped[str255]
account_number: Mapped[str] = mapped_column(String(10))
routing_number: Mapped[str] = mapped_column(String(8))
iban: Mapped[str] = mapped_column(String(34))
Expand Down
4 changes: 4 additions & 0 deletions stubs/sepaxml/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .debit import SepaDD as SepaDD
from .transfer import SepaTransfer as SepaTransfer

version: str
9 changes: 9 additions & 0 deletions stubs/sepaxml/debit.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .shared import SepaPaymentInitn as SepaPaymentInitn
from .utils import ADDRESS_MAPPING as ADDRESS_MAPPING, int_to_decimal_str as int_to_decimal_str, make_id as make_id

class SepaDD(SepaPaymentInitn):
root_el: str
def __init__(self, config, schema: str = 'pain.008.003.02', clean: bool = True) -> None: ...
def check_config(self, config): ...
def check_payment(self, payment): ...
def add_payment(self, payment) -> None: ...
10 changes: 10 additions & 0 deletions stubs/sepaxml/shared.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from .utils import decimal_str_to_int as decimal_str_to_int, int_to_decimal_str as int_to_decimal_str, make_msg_id as make_msg_id
from .validation import try_valid_xml as try_valid_xml
from _typeshed import Incomplete

class SepaPaymentInitn:
schema: Incomplete
msg_id: Incomplete
clean: Incomplete
def __init__(self, config, schema, clean: bool = True) -> None: ...
def export(self, validate: bool = True, pretty_print: bool = False) -> bytes: ...
9 changes: 9 additions & 0 deletions stubs/sepaxml/transfer.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .shared import SepaPaymentInitn as SepaPaymentInitn
from .utils import ADDRESS_MAPPING as ADDRESS_MAPPING, int_to_decimal_str as int_to_decimal_str, make_id as make_id

class SepaTransfer(SepaPaymentInitn):
root_el: str
def __init__(self, config, schema: str = 'pain.001.001.03', clean: bool = True) -> None: ...
def check_config(self, config): ...
def check_payment(self, payment): ...
def add_payment(self, payment) -> None: ...
11 changes: 11 additions & 0 deletions stubs/sepaxml/utils.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from _typeshed import Incomplete

using_sysrandom: bool

def get_rand_string(length: int = 12, allowed_chars: str = '0123456789abcdef'): ...
def make_msg_id(): ...
def make_id(name): ...
def int_to_decimal_str(integer): ...
def decimal_str_to_int(decimal_string): ...

ADDRESS_MAPPING: Incomplete
3 changes: 3 additions & 0 deletions stubs/sepaxml/validation.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class ValidationError(Exception): ...

def try_valid_xml(xmlout, schema) -> None: ...
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
4 changes: 1 addition & 3 deletions tests/frontend/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,7 @@ def assert_url_forbidden(self, url: str, method: str = "HEAD", **kw) -> Response
__tracebackhide__ = True
resp = self.open(url, method=method, **kw)
status = resp.status_code
assert (
status == 403
), f"Access to {url} expected to be forbidden, got status {status}"
assert status == 403, f"Access to {url} expected to be forbidden, got status {status}"
return resp

def assert_forbidden(self, endpoint: str, method: str = "HEAD", **kw) -> Response:
Expand Down
7 changes: 5 additions & 2 deletions tests/frontend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,13 @@ def config(module_session: Session) -> Config:

@pytest.fixture(scope="session")
def blueprint_urls(app: PycroftFlask) -> BlueprintUrls:
def _blueprint_urls(blueprint_name: str) -> list[str]:
def _blueprint_urls(
blueprint_name: str, methods: set[str] = {"GET", "POST"} # noqa: B006
) -> list[str]:
return [
_build_rule(request_ctx.url_adapter, rule)
(_build_rule(request_ctx.url_adapter, rule), method)
for rule in app.url_map.iter_rules()
for method in rule.methods & methods
if rule.endpoint.startswith(f"{blueprint_name}.")
]
return _blueprint_urls
Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/fixture_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def login_context(test_client: TestClient, login: str, password: str):
test_client.get("/logout")


BlueprintUrls: t.TypeAlias = t.Callable[[str], list[str]]
BlueprintUrls: t.TypeAlias = t.Callable[[str], list[str, str]]
_argument_creator_map = {
IntegerConverter: lambda c: 1,
UnicodeConverter: lambda c: "test",
Expand Down
20 changes: 10 additions & 10 deletions tests/frontend/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,26 +104,26 @@ def member_logged_in(
yield

def test_access_user(self, client: TestClient, blueprint_urls: BlueprintUrls):
for url in blueprint_urls("user"):
client.assert_url_forbidden(url)
for url, method in blueprint_urls("user"):
client.assert_url_forbidden(url, method=method)

def test_access_finance(self, client: TestClient, blueprint_urls: BlueprintUrls):
for url in blueprint_urls("finance"):
client.assert_url_forbidden(url)
for url, method in blueprint_urls("finance"):
client.assert_url_forbidden(url, method=method)

def test_access_buildings(self, client: TestClient, blueprint_urls: BlueprintUrls):
for url in blueprint_urls("facilities"):
client.assert_url_forbidden(url)
for url, method in blueprint_urls("facilities"):
client.assert_url_forbidden(url, method=method)

def test_access_infrastructure(
self, client: TestClient, blueprint_urls: BlueprintUrls
):
for url in blueprint_urls("infrastructure"):
client.assert_url_forbidden(url)
for url, method in blueprint_urls("infrastructure"):
client.assert_url_forbidden(url, method=method)

def test_access_properties(self, client: TestClient, blueprint_urls: BlueprintUrls):
for url in blueprint_urls("properties"):
client.assert_url_forbidden(url)
for url, method in blueprint_urls("properties"):
client.assert_url_forbidden(url, method=method)

def test_access_login(self, client: TestClient, blueprint_urls: BlueprintUrls):
# Login see Test_010_Anonymous
Expand Down
5 changes: 0 additions & 5 deletions tests/lib/data_test_finance.csv

This file was deleted.

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))
Loading
Loading