From 48ae9eb7e29b13558a4a79fb1452b4b54d3f672f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Sat, 9 Sep 2023 17:54:26 +0200 Subject: [PATCH 1/3] Enable submission of repayment requests --- build/requirements/requirements.txt | 1 + sipa/blueprints/usersuite.py | 112 ++++++++++++++++-- sipa/forms.py | 87 ++++++++++++++ sipa/model/misc.py | 8 +- sipa/model/pycroft/api.py | 11 ++ sipa/model/pycroft/user.py | 18 +++ .../templates/usersuite/_payment_details.html | 2 +- 7 files changed, 229 insertions(+), 10 deletions(-) diff --git a/build/requirements/requirements.txt b/build/requirements/requirements.txt index 43d52113..6c735ed2 100644 --- a/build/requirements/requirements.txt +++ b/build/requirements/requirements.txt @@ -24,3 +24,4 @@ recurring_ical_events~=1.0.2b0 cachetools~=5.2.0 python-dotenv~=0.21.0 pydantic~=2.4.2 +schwifty~=2023.9.0 diff --git a/sipa/blueprints/usersuite.py b/sipa/blueprints/usersuite.py index be6a58e5..89e1fcf0 100644 --- a/sipa/blueprints/usersuite.py +++ b/sipa/blueprints/usersuite.py @@ -3,7 +3,10 @@ from collections import OrderedDict import logging from datetime import datetime +from decimal import Decimal +from functools import partial from io import BytesIO +from schwifty import IBAN from babel.numbers import format_currency from flask import ( @@ -25,7 +28,8 @@ from sipa.forms import ContactForm, ChangeMACForm, ChangeMailForm, \ ChangePasswordForm, flash_formerrors, HostingForm, \ PaymentForm, ActivateNetworkAccessForm, TerminateMembershipForm, \ - TerminateMembershipConfirmForm, ContinueMembershipForm + TerminateMembershipConfirmForm, ContinueMembershipForm, \ + RequestRepaymentForm, RequestRepaymentConfirmForm from sipa.mail import send_usersuite_contact_mail from sipa.model.fancy_property import ActiveProperty from sipa.utils import password_changeable, subscribe_to_status_page @@ -207,13 +211,16 @@ def subscribe(): def render_payment_details(details: PaymentDetails, months): return { - gettext("Zahlungsempfänger"): details.recipient, - gettext("Bank"): details.bank, - gettext("IBAN"): details.iban, - gettext("BIC"): details.bic, - gettext("Verwendungszweck"): details.purpose, - gettext("Betrag"): format_currency(months * current_app.config['MEMBERSHIP_CONTRIBUTION'] / 100, 'EUR', - locale='de_DE') + "Zahlungsempfänger": details.recipient, + "Bank": details.iban.bank_name, + "IBAN": details.iban.formatted, + "BIC": details.iban.bic.formatted, + "Verwendungszweck": details.purpose, + "Betrag": format_currency( + months * current_app.config["MEMBERSHIP_CONTRIBUTION"] / 100, + "EUR", + locale="de_DE", + ), } @@ -672,3 +679,92 @@ def get_apple_wlan_mobileconfig(): as_attachment=True, download_name="agdsn.mobileconfig", ) + + +@bp_usersuite.route("/request-repayment", methods=["GET", "POST"]) +@login_required +def request_repayment(): + """ + Request a repayment of excess membership contributions + """ + # Check whether there is a repayment request already + if current_user.get_request_repayment(): + flash(gettext("Du hast bereits einen Rücküberweisungsantrag abgesendet, der sich noch in Bearbeitung befindet."), "warning") + return redirect(url_for(".index")) + + form = RequestRepaymentForm() + + if form.validate_on_submit(): + return redirect( + url_for( + ".request_repayment_confirm", + beneficiary=form.beneficiary.data, + iban=form.iban.data, + amount=form.amount.data, + ) + ) + elif form.is_submitted(): + flash_formerrors(form) + + form_args = { + "form": form, + "cancel_to": url_for(".index"), + "submit_text": gettext("Weiter"), + } + + return render_template( + "generic_form.html", + page_title=gettext("Rücküberweisung beantragen"), + form_args=form_args, + ) + + +@bp_usersuite.route("/request-repayment/confirm", methods=["GET", "POST"]) +@login_required +def request_repayment_confirm(): + """ + Request a repayment of excess membership contributions + """ + # Check whether there is a repayment request already + if current_user.get_request_repayment(): + flash(gettext("Du hast bereits einen Rücküberweisungsantrag abgesendet, der sich noch in Bearbeitung befindet."), "warning") + return redirect(url_for(".index")) + + beneficiary = request.args.get("beneficiary", None) + iban = request.args.get("iban", None, partial(IBAN, validate_bban=True)) + amount = request.args.get("amount", None, Decimal) + + form = RequestRepaymentConfirmForm() + + if None in (beneficiary, iban, amount): + return redirect(url_for(".request_repayment")) + + balance = current_user.finance_information.balance.raw_value + + form.beneficiary.data = beneficiary + form.iban.data = iban.formatted + form.bic.data = iban.bic.formatted + form.bank.data = iban.bank_name + form.amount.data = amount + form.estimated_balance.data = balance - amount + + if form.validate_on_submit(): + # Send request to Pycroft + current_user.post_request_repayment(beneficiary, iban, amount) + + flash(gettext("Rücküberweisungsantrag erfolgreich abgesendet."), "success") + return redirect(url_for(".index")) + elif form.is_submitted(): + flash_formerrors(form) + + form_args = { + "form": form, + "cancel_text": gettext("Zurück"), + "cancel_to": url_for(".request_repayment"), + } + + return render_template( + "generic_form.html", + page_title=gettext("Rücküberweisung beantragen - Bestätigen"), + form_args=form_args, + ) diff --git a/sipa/forms.py b/sipa/forms.py index fb775dc5..ee586ca8 100644 --- a/sipa/forms.py +++ b/sipa/forms.py @@ -1,11 +1,14 @@ import re from datetime import date +from decimal import Decimal from operator import itemgetter from flask_babel import gettext, lazy_gettext from flask import flash from flask_login import current_user from flask_wtf import FlaskForm +from schwifty import IBAN +from schwifty.exceptions import SchwiftyException from werkzeug.local import LocalProxy from wtforms import ( BooleanField, @@ -16,6 +19,7 @@ TextAreaField, IntegerField, DateField, + DecimalField, ) from wtforms.validators import ( DataRequired, @@ -275,6 +279,89 @@ class ChangeMACForm(FlaskForm): ) +def validate_iban(form, field: StringField) -> None: + try: + IBAN(field.data, validate_bban=True) + except SchwiftyException as e: + # schwifty has fine-grained exceptions but such specific information + # might be confusing to users. Feel free to change if you think + # otherwise. + raise ValidationError(lazy_gettext("IBAN ungültig!")) from e + +def validate_amount(form, field: DecimalField) -> None: + if current_user.finance_information.balance.raw_value < field.data: + raise ValidationError(lazy_gettext("Keine ausreichende Guthabendeckung vorhanden!")) + + +class RequestRepaymentForm(FlaskForm): + beneficiary = StrippedStringField( + label=lazy_gettext("Empfänger"), + validators=[DataRequired(lazy_gettext("Empfänger nicht angegeben!"))], + ) + + iban = StrippedStringField( + label=lazy_gettext("IBAN"), + validators=[DataRequired(lazy_gettext("IBAN nicht angegeben!")), validate_iban], + ) + + amount = ( + DecimalField( + label=lazy_gettext("Betrag (EUR)"), + validators=[ + DataRequired(lazy_gettext("Betrag nicht angegeben!")), + NumberRange( + min=Decimal('0.01'), + message=lazy_gettext("Betrag muss mindestens 0,01 € betragen!"), + ), + validate_amount, + ], + ) + ) + + +class RequestRepaymentConfirmForm(FlaskForm): + beneficiary = StrippedStringField( + label=lazy_gettext("Empfänger"), + render_kw={'readonly': True}, + validators=[DataRequired(lazy_gettext("Empfänger nicht angegeben!"))], + ) + + iban = StrippedStringField( + label=lazy_gettext("IBAN"), + render_kw={'readonly': True}, + validators=[DataRequired(lazy_gettext("IBAN nicht angegeben!")), validate_iban], + ) + + bic = StrippedStringField( + label=lazy_gettext("BIC"), + render_kw={'readonly': True}, + ) + + bank = StrippedStringField( + label=lazy_gettext("Bank"), + render_kw={'readonly': True}, + ) + + amount = ( + DecimalField( + label=lazy_gettext("Betrag (EUR)"), + render_kw={'readonly': True}, + validators=[ + DataRequired(lazy_gettext("Betrag nicht angegeben!")), + NumberRange( + min=Decimal('0.01'), + message=lazy_gettext("Betrag muss mindestens 0,01 € betragen!"), + ), + validate_amount, + ], + ) + ) + + estimated_balance = DecimalField( + label=lazy_gettext("Kontostand nach Rücküberweisung (EUR)"), + render_kw={'readonly': True}, + ) + class ActivateNetworkAccessForm(FlaskForm): password = PasswordField( label=lazy_gettext("Passwort"), diff --git a/sipa/model/misc.py b/sipa/model/misc.py index 64c58676..b07df617 100644 --- a/sipa/model/misc.py +++ b/sipa/model/misc.py @@ -5,9 +5,15 @@ from sipa.backends import backends +import typing as t +from schwifty import IBAN + TransactionTuple = namedtuple('Transaction', ['datum', 'value']) -PaymentDetails = namedtuple('PaymentDetails', 'recipient bank iban bic purpose') +class PaymentDetails(t.NamedTuple): + recipient: str + iban: IBAN + purpose: str def has_connection(user): diff --git a/sipa/model/pycroft/api.py b/sipa/model/pycroft/api.py index fad6b9e2..6360b677 100644 --- a/sipa/model/pycroft/api.py +++ b/sipa/model/pycroft/api.py @@ -3,12 +3,14 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import date +from decimal import Decimal from functools import partial from typing import Any import requests import requests.auth from requests import ConnectionError, HTTPError +from schwifty import IBAN from sipa.backends.exceptions import InvalidConfiguration from sipa.utils import dataclass_from_dict @@ -104,6 +106,15 @@ def continue_membership(self, user_id): def reset_wifi_password(self, user_id): return self.patch(f"user/{user_id}/reset-wifi-password") + def get_request_repayment(self, user_id): + return self.get(f"user/{user_id}/request-repayment") + + def post_request_repayment(self, user_id, beneficiary: str, iban: IBAN, amount: Decimal): + """ + Request a repayment of excess membership contributions. + """ + return self.post(f"user/{user_id}/request-repayment", data={"beneficiary": beneficiary, "iban": iban.compact, "amount": amount}) + def request_password_reset(self, user_ident: str, email: str): return self.post("user/reset-password", data={ 'ident': user_ident, diff --git a/sipa/model/pycroft/user.py b/sipa/model/pycroft/user.py index f7e59686..2cab9f97 100644 --- a/sipa/model/pycroft/user.py +++ b/sipa/model/pycroft/user.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging from datetime import date +from decimal import Decimal from pydantic import ValidationError @@ -398,6 +399,23 @@ def password_reset(cls, token, new_password): return result + def get_request_repayment(self): + status, result = api.get_request_repayment(self.user_data.id) + + if status != 200: + raise UnknownError + + return result + + + def post_request_repayment(self, beneficiary: str, iban: IBAN, amount: Decimal): + status, result = api.post_request_repayment(self.user_data.id, beneficiary, iban, amount) + + if status != 200: + raise UnknownError + + return result + def to_kib(v: int) -> int: return (v // 1024) if v is not None else 0 diff --git a/sipa/templates/usersuite/_payment_details.html b/sipa/templates/usersuite/_payment_details.html index ddb54373..d52a9410 100644 --- a/sipa/templates/usersuite/_payment_details.html +++ b/sipa/templates/usersuite/_payment_details.html @@ -17,7 +17,7 @@

{{ _("Zahlungsdetails") }}

{% for key, value in payment_details.items() %} - {{ key }} + {{ _(key) }} {{ value }} {% endfor %} From cfc918467fc122fe00fb4b4ce9819670a3ca2a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Wed, 18 Oct 2023 09:58:28 +0200 Subject: [PATCH 2/3] Extract payment information to configuration --- sipa/blueprints/usersuite.py | 4 ++-- sipa/config/default.py | 3 +++ sipa/model/pycroft/user.py | 7 +++---- sipa/model/sample/user.py | 7 +++---- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/sipa/blueprints/usersuite.py b/sipa/blueprints/usersuite.py index 89e1fcf0..2e576a64 100644 --- a/sipa/blueprints/usersuite.py +++ b/sipa/blueprints/usersuite.py @@ -230,9 +230,9 @@ def generate_epc_qr_code(details: PaymentDetails, months): "BCD\n001\n1\nSCT\n{bic}\n{recipient}\n{iban}\nEUR{amount}\n\n\n{purpose}\n\n" return EPC_FORMAT.format( - bic=details.bic.replace(' ', ''), + bic=details.iban.bic.compact, recipient=details.recipient, - iban=details.iban.replace(' ', ''), + iban=details.iban.compact, amount=months * current_app.config['MEMBERSHIP_CONTRIBUTION'] / 100, purpose=details.purpose) diff --git a/sipa/config/default.py b/sipa/config/default.py index a17f3cb4..eeb1ae98 100644 --- a/sipa/config/default.py +++ b/sipa/config/default.py @@ -80,6 +80,9 @@ # Amount of membership contribution in cents MEMBERSHIP_CONTRIBUTION = 500 +PAYMENT_BENEFICIARY = "StuRa der TUD - AG DSN" +PAYMENT_IBAN = "DE61 8505 0300 3120 2195 40" + # Pycroft backend PYCROFT_ENDPOINT = "http://localhost:5000/api/v0/" PYCROFT_API_KEY = "secret" diff --git a/sipa/model/pycroft/user.py b/sipa/model/pycroft/user.py index 2cab9f97..cd1d4501 100644 --- a/sipa/model/pycroft/user.py +++ b/sipa/model/pycroft/user.py @@ -4,6 +4,7 @@ from decimal import Decimal from pydantic import ValidationError +from schwifty import IBAN from sipa.model.user import BaseUser from sipa.model.finance import BaseFinanceInformation @@ -301,10 +302,8 @@ def finance_information(self) -> FinanceInformation: def payment_details(self) -> PaymentDetails: return PaymentDetails( - recipient="StuRa der TUD - AG DSN", - bank="Ostsächsische Sparkasse Dresden", - iban="DE61 8505 0300 3120 2195 40", - bic="OSDD DE 81 XXX", + recipient=current_app.config["PAYMENT_BENEFICIARY"], + iban=IBAN(current_app.config["PAYMENT_IBAN"], validate_bban=True), purpose=f"{self.user_data.user_id}, {self.user_data.name}, {self.user_data.room}", ) diff --git a/sipa/model/sample/user.py b/sipa/model/sample/user.py index 68647fbc..4eeb4bb7 100644 --- a/sipa/model/sample/user.py +++ b/sipa/model/sample/user.py @@ -4,6 +4,7 @@ from flask import current_app from flask_login import AnonymousUserMixin +from schwifty import IBAN from werkzeug.local import LocalProxy from sipa.model.exceptions import PasswordInvalid, UserNotFound @@ -233,10 +234,8 @@ def birthdate(self): def payment_details(self) -> PaymentDetails: return PaymentDetails( - recipient="Donald Duck", - bank="Geldspeicher GbR", - iban="EH12432543209523", - bic="ENTHAUS123", + recipient="Technische Universität Dresden", + iban=IBAN("DE52 8504 0000 0800 4004 00", validate_bban=True), purpose=self.id.value, ) From dab2be0399a24d4e13180a213646699ef6e36bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gregor=20D=C3=BCster?= Date: Mon, 5 Feb 2024 22:10:37 +0100 Subject: [PATCH 3/3] Stop hardcoding agdsn.me --- sipa/blueprints/usersuite.py | 2 +- sipa/forms.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/sipa/blueprints/usersuite.py b/sipa/blueprints/usersuite.py index 2e576a64..093e0db1 100644 --- a/sipa/blueprints/usersuite.py +++ b/sipa/blueprints/usersuite.py @@ -190,7 +190,7 @@ def subscribe(): email = current_user.mail.raw_value if email == "": - email = f"{current_user.login.raw_value}@agdsn.me" + email = f"{current_user.login.raw_value}@{current_user.datasource.mail_server}" result = subscribe_to_status_page( current_app.config['STATUS_PAGE_API_SUBSCRIBE_ENDPOINT'], diff --git a/sipa/forms.py b/sipa/forms.py index ee586ca8..9fed5cc5 100644 --- a/sipa/forms.py +++ b/sipa/forms.py @@ -238,10 +238,15 @@ class ChangeMailForm(FlaskForm): validators=[DataRequired(lazy_gettext("Passwort nicht angegeben!"))]) email = EmailField(label=lazy_gettext("E-Mail-Adresse")) forwarded = BooleanField( - label=LocalProxy(lambda: - lazy_gettext("Mails für mein AG DSN E-Mail-Konto ({agdsn_email}) an private " - "E-Mail-Adresse weiterleiten") - .format(agdsn_email=f'{current_user.login.value}@agdsn.me'))) + label=LocalProxy( + lambda: lazy_gettext( + "Mails für mein AG DSN E-Mail-Konto ({agdsn_email}) an private " + "E-Mail-Adresse weiterleiten" + ).format( + agdsn_email=f"{current_user.login.value}@{current_user.datasource.mail_server}" + ) + ) + ) def require_unicast_mac(form, field):