feat: add member address and SEPA data

This commit is contained in:
Marcel Peterkau
2026-06-21 22:14:45 +02:00
parent 0622a22794
commit 6c7bf63280
12 changed files with 362 additions and 10 deletions
+82 -2
View File
@@ -32,7 +32,19 @@ DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
DEFAULT_CONFIGURATION = {
"schema_version": 1,
"organization": "Chaos Computer Club Mannheim e.V.",
"organization": {
"name": "Chaos Computer Club Mannheim e.V.",
"street": "",
"postal_code": "",
"city": "Mannheim",
"country": "Deutschland",
"email": "",
"phone": "",
"website": "",
"iban": "",
"bic": "",
"creditor_id": "",
},
"member_number_policy": {
"mode": "automatic",
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
@@ -165,7 +177,7 @@ class MemberRepository:
raw = read_json(path)
if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
for section_name in ("person", "membership", "contribution_profile"):
for section_name in ("person", "address", "banking", "membership", "contribution_profile"):
if section_name in raw and not isinstance(raw[section_name], dict):
raise TypeError(f"{section_name} muss ein JSON-Objekt sein")
return Member.from_dict(raw)
@@ -193,6 +205,7 @@ class MemberRepository:
first_name: str,
last_name: str,
email: str = "",
phone: str = "",
birth_date: str = "",
member_number: str = "",
) -> Member:
@@ -221,6 +234,7 @@ class MemberRepository:
first_name=first_name.strip(),
last_name=last_name.strip(),
email=email.strip(),
phone=phone.strip(),
birth_date=birth_date,
)
write_json_atomic(directory / "member.json", member.to_dict())
@@ -240,6 +254,10 @@ class MemberRepository:
member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum")
member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss")
member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit")
member.mandate_signed_at = normalize_date_input(member.mandate_signed_at, "Mandat erteilt am")
member.mandate_revoked_at = normalize_date_input(
member.mandate_revoked_at, "Mandat widerrufen am"
)
validate_member_dates(
birth_date=member.birth_date,
accepted_at=member.accepted_at,
@@ -247,6 +265,16 @@ class MemberRepository:
)
except DateValidationError as exc:
raise RepositoryError(str(exc)) from exc
member.iban = normalize_iban(member.iban)
member.bic = member.bic.replace(" ", "").upper()
validate_iban(member.iban)
validate_bic(member.bic)
if member.mandate_active and not (
member.iban and member.mandate_reference.strip() and member.mandate_signed_at
):
raise RepositoryError(
"Ein aktives Lastschriftmandat benötigt IBAN, Mandatsreferenz und Erteilungsdatum."
)
if member.member_number != existing.member_number:
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
changes = self._summarize_changes(existing, member)
@@ -721,6 +749,10 @@ class MemberRepository:
member.last_name,
member.display_name,
member.email,
member.phone,
member.street,
member.postal_code,
member.city,
member.birth_date,
_german_date(member.birth_date),
]
@@ -767,6 +799,18 @@ class MemberRepository:
config.setdefault("member_number_sequences", {})
write_json_atomic(self.root / "repository.json", config)
def save_organization(self, values: dict[str, str]) -> None:
organization = {key: str(value).strip() for key, value in values.items()}
organization["iban"] = normalize_iban(organization.get("iban", ""))
organization["bic"] = organization.get("bic", "").replace(" ", "").upper()
validate_iban(organization["iban"])
validate_bic(organization["bic"])
if not organization.get("name"):
raise RepositoryError("Der Vereinsname ist erforderlich.")
config = self.get_configuration()
config["organization"] = organization
write_json_atomic(self.root / "repository.json", config)
def preview_member_number(self, pattern: str | None = None) -> str:
selected_pattern = pattern or self.get_member_number_policy()["pattern"]
validate_member_number_pattern(selected_pattern)
@@ -832,12 +876,25 @@ class MemberRepository:
"first_name": "Vorname",
"last_name": "Nachname",
"email": "E-Mail-Adresse",
"phone": "Telefonnummer",
"birth_date": "Geburtsdatum",
"status": "Status",
"payment_frequency": "Zahlungsweise",
"contribution_rule_id": "Beitragsregel",
"honorary": "Ehrenmitgliedschaft",
"notes": "interne Notiz",
"street": "Anschrift",
"address_addition": "Adresszusatz",
"postal_code": "Postleitzahl",
"city": "Ort",
"country": "Land",
"account_holder": "Kontoinhaber",
"iban": "IBAN",
"bic": "BIC",
"mandate_reference": "SEPA-Mandatsreferenz",
"mandate_signed_at": "Mandat erteilt am",
"mandate_active": "Lastschriftmandat aktiv",
"mandate_revoked_at": "Mandat widerrufen am",
}
changes: list[str] = []
for field, label in labels.items():
@@ -854,6 +911,29 @@ class MemberRepository:
return changes
def normalize_iban(value: str) -> str:
return "".join(value.split()).upper()
def validate_iban(value: str) -> None:
if not value:
return
if not 15 <= len(value) <= 34 or not value[:2].isalpha() or not value[2:].isalnum():
raise RepositoryError("Die IBAN hat kein gültiges Format.")
rearranged = value[4:] + value[:4]
numeric = "".join(
str(ord(character) - 55) if character.isalpha() else character
for character in rearranged
)
if int(numeric) % 97 != 1:
raise RepositoryError("Die IBAN-Prüfsumme ist ungültig.")
def validate_bic(value: str) -> None:
if value and (len(value) not in {8, 11} or not value.isalnum()):
raise RepositoryError("Der BIC muss 8 oder 11 Zeichen enthalten.")
def _normalize(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value.casefold().strip())
return "".join(character for character in normalized if not unicodedata.combining(character))