mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Compare commits
96
Commits
v0.1.0
...
e3807bf7dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3807bf7dc | ||
|
|
47f424252f | ||
|
|
ac161e8838 | ||
|
|
c68e8b7d6a | ||
|
|
cc4aaef895 | ||
|
|
6b0da82b45 | ||
|
|
b7554478ac | ||
|
|
cba463a648 | ||
|
|
f5c8d81057 | ||
|
|
85a865fa0e | ||
|
|
4951d81ea3 | ||
|
|
e7fdde8691 | ||
|
|
4e2595cec4 | ||
|
|
aa5829acb0 | ||
|
|
76e0e8326c | ||
|
|
f861732131 | ||
|
|
2ce764b007 | ||
|
|
bfb65b2583 | ||
|
|
d87a57775e | ||
|
|
25f5d51d77 | ||
|
|
a1fb2f4e30 | ||
|
|
0641285681 | ||
|
|
56a3f5f037 | ||
|
|
4ecdf87d05 | ||
|
|
186af530b0 | ||
|
|
f3bdffd347 | ||
|
|
3940926a22 | ||
|
|
d4dfd0066a | ||
|
|
a1719cad5e | ||
|
|
46795fc110 | ||
|
|
227ba8b128 | ||
|
|
7144d961c7 | ||
|
|
0a70776154 | ||
|
|
e0125d008f | ||
|
|
7596f94444 | ||
|
|
9cd611aeba | ||
|
|
171ba6bf28 | ||
|
|
ee99577b20 | ||
|
|
c07bdff04e | ||
|
|
1e29cc1e3d | ||
|
|
2484a1631d | ||
|
|
44228dd7dd | ||
|
|
44af7df8e2 | ||
|
|
5cbc5c3aad | ||
|
|
a91c4ddd24 | ||
|
|
1c26f2ed0a | ||
|
|
222fc1a337 | ||
|
|
9bf62809b7 | ||
|
|
4906db3d55 | ||
|
|
52904a29b4 | ||
|
|
195ae0e228 | ||
|
|
3d81a728b1 | ||
|
|
7440d18c35 | ||
|
|
d2f4c9588f | ||
|
|
e25accd35b | ||
|
|
c0486770b8 | ||
|
|
65bcc9f447 | ||
|
|
7d3891b39b | ||
|
|
241ca69190 | ||
|
|
ad5aeb74b6 | ||
|
|
4e4aa22589 | ||
|
|
42cde2a0c8 | ||
|
|
745e634a8b | ||
|
|
e7a18b5cde | ||
|
|
4dd625c09f | ||
|
|
a5d9abd59a | ||
|
|
42fb4c4224 | ||
|
|
a61ea3cb57 | ||
|
|
aa94145105 | ||
|
|
ade92bfe06 | ||
|
|
6ac205feb8 | ||
|
|
5bacc005f6 | ||
|
|
539859d4c5 | ||
|
|
7a0cdbc04e | ||
|
|
6b62263706 | ||
|
|
ba3238e61a | ||
|
|
c3cdf71506 | ||
|
|
e3f0a1df86 | ||
|
|
6d9c50b148 | ||
|
|
54cb413eff | ||
|
|
cfd5de42f2 | ||
|
|
460dcac77c | ||
|
|
2280a0c658 | ||
|
|
f14011efdc | ||
|
|
060a5aea01 | ||
|
|
fc4eacb591 | ||
|
|
3d3b845d9f | ||
|
|
f4c8ae4f35 | ||
|
|
975cb47d3e | ||
|
|
9942e7aa22 | ||
|
|
68c24219ec | ||
|
|
6a76c7b84b | ||
|
|
7146615499 | ||
|
|
dbae0ce29c | ||
|
|
070684d9bc | ||
|
|
aeaaca1459 |
@@ -1,6 +1,48 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"date": "upcoming",
|
||||
"changes": [
|
||||
"Mitgliedsanträge und Mitgliederakten enthalten jetzt ein separates Feld für das Antragsdatum.",
|
||||
"Eigentum und aktueller Gewahrsam von Assets werden getrennt dokumentiert; neben Vereinseigentum kann nun auch Eigentum von Mitgliedern oder externen Personen zentral erfasst werden.",
|
||||
"Mitgliedseigentum wird per UUID mit der Mitgliedsakte verknüpft und erscheint dort gemeinsam mit den vom Verein erhaltenen Assets in getrennt verständlichen Ansichten.",
|
||||
"Standort, Übergabedatum, Zustand und Wert eines Assets können für Übergabe- und Versicherungsnachweise festgehalten werden; Eigentumsänderungen erscheinen zusätzlich in der Chronik.",
|
||||
"Forderungen können nachträglich bearbeitet werden, ohne zusätzliche Forderungspositionen oder bereits verbuchte Zahlungen zu verlieren.",
|
||||
"Zahlungen können bearbeitet, neu auf mehrere Forderungen verteilt oder einschließlich ihrer Zuordnungen gelöscht werden.",
|
||||
"Das Austrittsdatum eines Mitglieds wird gespeichert und validiert; automatische Beitragsforderungen werden nicht mehr für Fälligkeiten nach dem Mitgliedschaftsende erzeugt.",
|
||||
"Anteilig berechnete Mitgliedsbeiträge weisen den abgerechneten Zeitraum verständlicher in ihrer Beschreibung aus.",
|
||||
"Die Mitgliederliste zeigt standardmäßig aktive Mitglieder, ordnet Nachname vor Vorname an und sortiert aufsteigend nach Nachnamen.",
|
||||
"Mitglieder mit offenen Hausmeister-Vorgängen werden in der Liste je nach höchster Dringlichkeit blau, gelb oder rot hervorgehoben.",
|
||||
"Offene Forderungen von Mitgliedern mit aktivem Lastschriftmandat können für einen frei wählbaren Fälligkeitszeitraum gesammelt und als bankfähige SEPA-XML-Datei oder als CSV-Übersicht exportiert werden; unvollständige Mandate werden dabei verständlich ausgewiesen.",
|
||||
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
|
||||
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
|
||||
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert.",
|
||||
"Die Zahlweise kann pro Mitglied als monatlich, quartalsweise, halbjährlich oder jährlich festgelegt werden; Hausmeister und Lastschriftläufe erzeugen und berücksichtigen die dazu passenden Beitragsforderungen.",
|
||||
"Forderungen können nun auch vollständig gelöscht werden, nicht nur storniert; zugeordnete Zahlungen werden dabei automatisch wieder gelöst und stehen erneut zur Zuordnung bereit.",
|
||||
"Zahlungen können direkt im Zahlungen-Tab eines Mitglieds angelegt werden, ohne vorher eine Forderung öffnen zu müssen.",
|
||||
"Ein neuer Spenden-Tab je Mitglied erfasst Beträge, die über den Mitgliedsbeitrag hinausgehen, und erlaubt die Zuordnung vorhandener oder neuer Zahlungen zu einer Spende.",
|
||||
"Beim Anlegen oder Bearbeiten einer Zahlung lassen sich offene Forderungen und Spenden direkt im selben Fenster live auswählen und mit Beträgen zuordnen, inklusive der Möglichkeit, dort sofort eine neue Spende anzulegen.",
|
||||
"Die Zuordnungsübersicht im Zahlungsfenster zeigt nur noch offene oder bereits zugeordnete Forderungen und Spenden; entfernte Zuordnungen lassen sich innerhalb desselben Fensters wieder herstellen.",
|
||||
"Der Hausmeister meldet eine überfällige Forderung nicht mehr doppelt als eigene Überfällig- und Mahnungsmeldung; bei Mitgliedern mit aktivem Lastschriftmandat erscheint statt einer Mahnung ein Hinweis, dass der Lastschrifteinzug geprüft werden sollte.",
|
||||
"Die Spaltenbreiten in den Tabellen für Forderungen, Zahlungen, Spenden und Hausmeister-Vorgänge sind jetzt einheitlich: Datum-, Betrag- und Statusspalten haben eine feste Breite, nur die abschließende Beschreibungs- bzw. Referenzspalte passt sich dynamisch an die Fensterbreite an.",
|
||||
"Die Chronik im Mitgliedsfenster nimmt standardmäßig ein Drittel der Fensterbreite ein.",
|
||||
"Der GnuCash-Import öffnet sich mit 80 % der Bildschirmgröße und erlaubt die Mehrfachauswahl von Buchungen per Klick, Strg+Klick oder Umschalt+Klick; Buchungen, die einer bereits vorhandenen Zahlung entsprechen, können beim Import übersprungen oder mit der Beschreibung aus GnuCash aktualisiert werden.",
|
||||
"Beim Anklicken einer noch nicht zugeordneten Forderung oder Spende im Zahlungsfenster wird der Betrag automatisch mit dem sinnvollen Vorschlag vorausgefüllt.",
|
||||
"Individuell abweichende Mitgliedsbeiträge (z. B. ermäßigter Beitrag für Schüler) können pro Mitglied mit Zeitraum, Begründung und wahlweise als fester Jahresbetrag oder prozentualer Ermäßigung hinterlegt werden; künftig erzeugte Beitragsforderungen berücksichtigen das monatsgenau, auch wenn die Ermäßigung mitten in einer Zahlungsperiode beginnt oder endet.",
|
||||
"Der Hausmeister kann per Knopfdruck direkt aus der Mitgliederakte gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt.",
|
||||
"Eine Mahnung kann statt einer einzelnen Gebühr mehrere frei bearbeitbare Positionen (Beschreibung & Betrag) enthalten; für eine nicht eingelöste Lastschrift steht eine eigene Rücklastschrift-Vorlage bereit, die dieselbe Mahnstufe wie eine reguläre Mahnung belegt und einen vorausgefüllten Hinweistext für die Mahnungs-E-Mail mitbringt.",
|
||||
"Mahnstufen, Zahlungsfristen und Standardpositionen (Bezeichnung & Betrag) für Mahnungen lassen sich jetzt unter Optionen → Mahnungen zentral konfigurieren.",
|
||||
"Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden.",
|
||||
"Eine überfällige Forderung, für die bereits eine Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr fälschlich als unbearbeiteter Fehler gemeldet, sondern als niedrig priorisierter Hinweis, dass die laufende Frist noch nicht abgelaufen ist.",
|
||||
"Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt.",
|
||||
"Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft.",
|
||||
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt.",
|
||||
"Mahnungs- und SEPA-Info-Mails müssen nicht mehr zwingend als lokale Datei gespeichert und von Hand in Thunderbird importiert werden: unter Optionen → E-Mail-Versand kann ein Postfach mit SMTP- und IMAP-Zugangsdaten hinterlegt werden, wahlweise mit direktem Versand, Ablage als Entwurf im Postfach oder einer Rückfrage bei jeder Erzeugung; die bisherige lokale Ablage bleibt als Standard erhalten.",
|
||||
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"date": "2026-06-27",
|
||||
"changes": [
|
||||
"First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.",
|
||||
|
||||
+10
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
||||
class AppConfig:
|
||||
store_path: str = ""
|
||||
gnucash_path: str = ""
|
||||
gnucash_last_accounts: dict[str, str] = field(default_factory=dict)
|
||||
theme_mode: str = "dark"
|
||||
run_housekeeper_on_startup: bool = True
|
||||
splash_minimum_seconds: float = 5.0
|
||||
@@ -43,6 +44,7 @@ class AppConfig:
|
||||
"schema_version": 1,
|
||||
"store_path": self.store_path,
|
||||
"gnucash_path": self.gnucash_path,
|
||||
"gnucash_last_accounts": self.gnucash_last_accounts,
|
||||
"theme_mode": self.theme_mode,
|
||||
"run_housekeeper_on_startup": self.run_housekeeper_on_startup,
|
||||
"splash_minimum_seconds": _non_negative_float(self.splash_minimum_seconds, 5.0),
|
||||
@@ -97,9 +99,16 @@ def load_config() -> AppConfig:
|
||||
monitor_bounds = None
|
||||
if isinstance(monitor_raw, list) and len(monitor_raw) == 4:
|
||||
monitor_bounds = tuple(int(value) for value in monitor_raw)
|
||||
last_accounts_raw = data.get("gnucash_last_accounts")
|
||||
gnucash_last_accounts = (
|
||||
{str(key): str(value) for key, value in last_accounts_raw.items()}
|
||||
if isinstance(last_accounts_raw, dict)
|
||||
else {}
|
||||
)
|
||||
return AppConfig(
|
||||
store_path=store_override or str(data.get("store_path", "")),
|
||||
gnucash_path=str(data.get("gnucash_path", "")),
|
||||
gnucash_last_accounts=gnucash_last_accounts,
|
||||
theme_mode=str(data.get("theme_mode", "dark")),
|
||||
run_housekeeper_on_startup=bool(data.get("run_housekeeper_on_startup", True)),
|
||||
splash_minimum_seconds=_non_negative_float(data.get("splash_minimum_seconds", 5.0), 5.0),
|
||||
|
||||
@@ -18,6 +18,18 @@ CLAIM_STATUS_LABELS = {
|
||||
"cancelled": "STORNIERT",
|
||||
}
|
||||
|
||||
DONATION_STATUS_LABELS = {
|
||||
"open": "OFFEN",
|
||||
"partially_allocated": "TEILWEISE ZUGEORDNET",
|
||||
"allocated": "ZUGEORDNET",
|
||||
"overallocated": "ÜBERZUGEORDNET",
|
||||
}
|
||||
|
||||
CONTRIBUTION_OVERRIDE_KIND_LABELS = {
|
||||
"amount": "BETRAG",
|
||||
"percent": "PROZENT",
|
||||
}
|
||||
|
||||
|
||||
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
|
||||
text = str(value).strip().replace(",", ".")
|
||||
@@ -109,6 +121,65 @@ def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal:
|
||||
return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT)
|
||||
|
||||
|
||||
def donation_allocated_total(data: ContributionData, donation_id: str) -> Decimal:
|
||||
return sum(
|
||||
(
|
||||
decimal_value(allocation.get("amount", "0"))
|
||||
for allocation in data.allocations
|
||||
if str(allocation.get("donation_id", "")) == donation_id
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
def donation_amount(donation: dict[str, Any]) -> Decimal:
|
||||
return decimal_value(donation.get("amount", "0"))
|
||||
|
||||
|
||||
def donation_balance(data: ContributionData, donation: dict[str, Any]) -> Decimal:
|
||||
donation_id = str(donation.get("donation_id", ""))
|
||||
return (donation_amount(donation) - donation_allocated_total(data, donation_id)).quantize(CENT)
|
||||
|
||||
|
||||
def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
|
||||
balance = donation_balance(data, donation)
|
||||
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
|
||||
if balance < 0:
|
||||
return "overallocated"
|
||||
if balance == 0:
|
||||
return "allocated"
|
||||
if allocated > 0:
|
||||
return "partially_allocated"
|
||||
return "open"
|
||||
|
||||
|
||||
def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
|
||||
return sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in reminder.get("items") or []),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
|
||||
valid_from = str(override.get("valid_from", ""))
|
||||
valid_until = str(override.get("valid_until", "") or "")
|
||||
if valid_from and month < valid_from:
|
||||
return False
|
||||
if valid_until and month > valid_until:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def contribution_override_ranges_overlap(
|
||||
valid_from: str, valid_until: str, other_from: str, other_until: str
|
||||
) -> bool:
|
||||
"""Month ranges ('YYYY-MM', empty valid_until/other_until = open-ended) overlap
|
||||
if each range starts no later than the other one ends."""
|
||||
starts_before_other_ends = not other_until or valid_from <= other_until
|
||||
other_starts_before_ends = not valid_until or other_from <= valid_until
|
||||
return starts_before_other_ends and other_starts_before_ends
|
||||
|
||||
|
||||
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
|
||||
if str(claim.get("status", "")) == "cancelled":
|
||||
return "cancelled"
|
||||
|
||||
@@ -78,6 +78,38 @@ def normalize_date_input(value: str, field_name: str) -> str:
|
||||
return parsed.isoformat() if parsed else ""
|
||||
|
||||
|
||||
def month_input_hint() -> str:
|
||||
return "MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM"
|
||||
|
||||
|
||||
def parse_month_input(value: str, field_name: str, *, allow_empty: bool = True) -> str:
|
||||
"""Parses a month-only value (no day) and normalizes it to 'YYYY-MM', which also
|
||||
sorts and compares correctly as a plain string."""
|
||||
text = value.strip()
|
||||
if not text:
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise DateValidationError(f"{field_name} ist erforderlich.")
|
||||
for pattern, expected in (("%m.%Y", r"\d{2}\.\d{4}"), ("%Y-%m", r"\d{4}-\d{2}")):
|
||||
if not re.fullmatch(expected, text):
|
||||
continue
|
||||
try:
|
||||
parsed = datetime.strptime(text, pattern)
|
||||
except ValueError:
|
||||
continue
|
||||
return f"{parsed.year:04d}-{parsed.month:02d}"
|
||||
raise DateValidationError(f"{field_name} muss ein gültiger Monat im Format {month_input_hint()} sein.")
|
||||
|
||||
|
||||
def format_month_for_display(value: str) -> str:
|
||||
text = value.strip()
|
||||
match = re.fullmatch(r"(\d{4})-(\d{2})", text)
|
||||
if not match:
|
||||
return text
|
||||
year, month = match.groups()
|
||||
return f"{month}.{year}" if system_date_pattern() == "%d.%m.%Y" else f"{year}-{month}"
|
||||
|
||||
|
||||
def format_date_for_display(value: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
@@ -117,18 +149,24 @@ def validate_member_dates(
|
||||
birth_date: str,
|
||||
accepted_at: str = "",
|
||||
membership_started_at: str = "",
|
||||
membership_ended_at: str = "",
|
||||
today: date | None = None,
|
||||
) -> None:
|
||||
reference = today or date.today()
|
||||
birth = validate_birth_date(birth_date, today=reference)
|
||||
accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference)
|
||||
started = _validate_not_future(membership_started_at, "Mitglied seit", reference)
|
||||
ended = parse_iso_date(membership_ended_at, "Austrittsdatum")
|
||||
if birth and accepted and accepted < birth:
|
||||
raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.")
|
||||
if birth and started and started < birth:
|
||||
raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.")
|
||||
if accepted and started and started < accepted:
|
||||
raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.")
|
||||
if started and ended and ended < started:
|
||||
raise DateValidationError("Austrittsdatum darf nicht vor dem Mitgliedschaftsbeginn liegen.")
|
||||
if accepted and ended and ended < accepted:
|
||||
raise DateValidationError("Austrittsdatum darf nicht vor dem Aufnahmebeschluss liegen.")
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, on_date: date | None = None) -> int:
|
||||
|
||||
@@ -20,6 +20,13 @@ MEMBERSHIP_STATUS_LABELS = {
|
||||
"ended": "BEENDET",
|
||||
}
|
||||
|
||||
PAYMENT_FREQUENCY_LABELS = {
|
||||
"monthly": "MONATLICH",
|
||||
"quarterly": "QUARTALSWEISE",
|
||||
"semiannual": "HALBJÄHRLICH",
|
||||
"annual": "JÄHRLICH",
|
||||
}
|
||||
|
||||
ASSET_STATUS_LABELS = {
|
||||
"available": "VERFUEGBAR",
|
||||
"issued": "AUSGEGEBEN",
|
||||
@@ -27,6 +34,19 @@ ASSET_STATUS_LABELS = {
|
||||
"retired": "AUSGEMUSTERT",
|
||||
}
|
||||
|
||||
ASSET_OWNER_TYPE_LABELS = {
|
||||
"club": "VEREIN",
|
||||
"member": "MITGLIED",
|
||||
"external": "EXTERN",
|
||||
}
|
||||
|
||||
ASSET_CUSTODY_TYPE_LABELS = {
|
||||
"club": "VEREIN",
|
||||
"member": "MITGLIED",
|
||||
"external": "EXTERN",
|
||||
"unknown": "UNBEKANNT",
|
||||
}
|
||||
|
||||
HOUSEKEEPER_MEMBER_FIELD_LABELS = {
|
||||
"nickname": "Nickname",
|
||||
"email": "E-Mail-Adresse",
|
||||
@@ -75,9 +95,11 @@ class Member:
|
||||
mandate_active: bool = False
|
||||
mandate_revoked_at: str = ""
|
||||
status: str = "application"
|
||||
application_date: str = ""
|
||||
accepted_at: str = ""
|
||||
membership_started_at: str = ""
|
||||
payment_frequency: str = "annual"
|
||||
membership_ended_at: str = ""
|
||||
payment_frequency: str = "semiannual"
|
||||
contribution_rule_id: str = "standard-2022"
|
||||
honorary: bool = False
|
||||
notes: str = ""
|
||||
@@ -120,8 +142,10 @@ class Member:
|
||||
},
|
||||
"membership": {
|
||||
"status": self.status,
|
||||
"application_date": self.application_date,
|
||||
"accepted_at": self.accepted_at,
|
||||
"started_at": self.membership_started_at,
|
||||
"ended_at": self.membership_ended_at,
|
||||
"honorary": self.honorary,
|
||||
},
|
||||
"contribution_profile": {
|
||||
@@ -163,11 +187,13 @@ class Member:
|
||||
mandate_active=bool(banking.get("mandate_active", False)),
|
||||
mandate_revoked_at=str(banking.get("mandate_revoked_at", "")),
|
||||
status=str(membership.get("status", "application")),
|
||||
application_date=str(membership.get("application_date", "")),
|
||||
accepted_at=str(membership.get("accepted_at", "")),
|
||||
membership_started_at=str(membership.get("started_at", "")),
|
||||
membership_ended_at=str(membership.get("ended_at", "")),
|
||||
honorary=bool(membership.get("honorary", False)),
|
||||
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
|
||||
payment_frequency=str(contribution.get("payment_frequency", "annual")),
|
||||
payment_frequency=str(contribution.get("payment_frequency", "semiannual")),
|
||||
notes=str(data.get("notes", "")),
|
||||
created_at=str(data.get("created_at", _iso_now())),
|
||||
updated_at=str(data.get("updated_at", _iso_now())),
|
||||
@@ -183,6 +209,14 @@ class Asset:
|
||||
serial_number: str = ""
|
||||
status: str = "available"
|
||||
current_holder_member_id: str = ""
|
||||
owner_type: str = "club"
|
||||
owner_member_id: str = ""
|
||||
owner_name: str = ""
|
||||
custody_type: str = "club"
|
||||
location: str = ""
|
||||
handed_over_at: str = ""
|
||||
condition: str = ""
|
||||
estimated_value: str = ""
|
||||
deposit_amount_default: str = "0.00"
|
||||
notes: str = ""
|
||||
created_at: str = field(default_factory=_iso_now)
|
||||
@@ -199,6 +233,14 @@ class Asset:
|
||||
"serial_number": self.serial_number,
|
||||
"status": self.status,
|
||||
"current_holder_member_id": self.current_holder_member_id,
|
||||
"owner_type": self.owner_type,
|
||||
"owner_member_id": self.owner_member_id,
|
||||
"owner_name": self.owner_name,
|
||||
"custody_type": self.custody_type,
|
||||
"location": self.location,
|
||||
"handed_over_at": self.handed_over_at,
|
||||
"condition": self.condition,
|
||||
"estimated_value": self.estimated_value,
|
||||
"deposit_amount_default": self.deposit_amount_default,
|
||||
"notes": self.notes,
|
||||
"created_at": self.created_at,
|
||||
@@ -216,6 +258,19 @@ class Asset:
|
||||
serial_number=str(data.get("serial_number", "")),
|
||||
status=str(data.get("status", "available")),
|
||||
current_holder_member_id=str(data.get("current_holder_member_id", "")),
|
||||
owner_type=str(data.get("owner_type", "club")),
|
||||
owner_member_id=str(data.get("owner_member_id", "")),
|
||||
owner_name=str(data.get("owner_name", "")),
|
||||
custody_type=str(
|
||||
data.get(
|
||||
"custody_type",
|
||||
"member" if data.get("current_holder_member_id") else "club",
|
||||
)
|
||||
),
|
||||
location=str(data.get("location", "")),
|
||||
handed_over_at=str(data.get("handed_over_at", "")),
|
||||
condition=str(data.get("condition", "")),
|
||||
estimated_value=str(data.get("estimated_value", "")),
|
||||
deposit_amount_default=str(data.get("deposit_amount_default", "0.00")),
|
||||
notes=str(data.get("notes", "")),
|
||||
created_at=str(data.get("created_at", _iso_now())),
|
||||
@@ -268,6 +323,8 @@ class ContributionData:
|
||||
credits: list[dict[str, Any]] = field(default_factory=list)
|
||||
allocations: list[dict[str, Any]] = field(default_factory=list)
|
||||
reminders: list[dict[str, Any]] = field(default_factory=list)
|
||||
donations: list[dict[str, Any]] = field(default_factory=list)
|
||||
contribution_overrides: list[dict[str, Any]] = field(default_factory=list)
|
||||
schema_version: int = 1
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -278,6 +335,8 @@ class ContributionData:
|
||||
"credits": self.credits,
|
||||
"allocations": self.allocations,
|
||||
"reminders": self.reminders,
|
||||
"donations": self.donations,
|
||||
"contribution_overrides": self.contribution_overrides,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -289,6 +348,8 @@ class ContributionData:
|
||||
credits=list(data.get("credits") or []),
|
||||
allocations=list(data.get("allocations") or []),
|
||||
reminders=list(data.get("reminders") or []),
|
||||
donations=list(data.get("donations") or []),
|
||||
contribution_overrides=list(data.get("contribution_overrides") or []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ def evaluate(context: RuleContext):
|
||||
birth_date=member.birth_date,
|
||||
accepted_at=member.accepted_at,
|
||||
membership_started_at=member.membership_started_at,
|
||||
membership_ended_at=member.membership_ended_at,
|
||||
today=context.today,
|
||||
)
|
||||
except DateValidationError as exc:
|
||||
|
||||
@@ -8,6 +8,8 @@ ORDER = 20
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
birth_date = validate_birth_date(member.birth_date, today=context.today)
|
||||
except DateValidationError:
|
||||
|
||||
@@ -6,6 +6,11 @@ from ccma.rules.api import RuleContext, task
|
||||
RULE_ID = "claim-status"
|
||||
ORDER = 50
|
||||
|
||||
# Overdue claims (delta < 0) are handled entirely by reminder-due: it owns the whole
|
||||
# overdue lifecycle (grace period, dunning escalation, SEPA follow-up) so there is
|
||||
# exactly one finding per overdue claim instead of this rule and that one both firing
|
||||
# for the same problem.
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
actions = []
|
||||
@@ -21,33 +26,21 @@ def evaluate(context: RuleContext):
|
||||
except ValueError:
|
||||
continue
|
||||
delta = (due - context.today).days
|
||||
if delta < 0 or delta > 14:
|
||||
continue
|
||||
claim_key = str(claim.get("claim_key") or claim.get("claim_id") or "unknown")
|
||||
title = str(claim.get("title") or "Beitragsforderung")
|
||||
claim_type = str(claim.get("type", ""))
|
||||
if delta < 0:
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"overdue:{claim_key}",
|
||||
severity="error",
|
||||
title=f"{context.member.display_name}: {title} überfällig",
|
||||
detail=f"Fälligkeit war vor {-delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
|
||||
)
|
||||
)
|
||||
elif delta <= 14:
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"due-soon:{claim_key}",
|
||||
severity="info",
|
||||
title=f"{context.member.display_name}: {title} bald fällig",
|
||||
detail=f"Fälligkeit in {delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
|
||||
)
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"due-soon:{claim_key}",
|
||||
severity="info",
|
||||
title=f"{context.member.display_name}: {title} bald fällig",
|
||||
detail=f"Fälligkeit in {delta} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
|
||||
)
|
||||
)
|
||||
return actions
|
||||
|
||||
@@ -2,6 +2,7 @@ import calendar
|
||||
from datetime import date, timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from ccma.domain.contributions import contribution_override_covers_month
|
||||
from ccma.domain.dates import DateValidationError, parse_iso_date
|
||||
from ccma.rules.api import RuleContext, create_claim
|
||||
from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
|
||||
@@ -9,6 +10,21 @@ from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
|
||||
RULE_ID = "contribution-claims"
|
||||
ORDER = 40
|
||||
CENT = Decimal("0.01")
|
||||
MONTH_NAMES = (
|
||||
"",
|
||||
"Januar",
|
||||
"Februar",
|
||||
"März",
|
||||
"April",
|
||||
"Mai",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"August",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Dezember",
|
||||
)
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
@@ -18,6 +34,7 @@ def evaluate(context: RuleContext):
|
||||
try:
|
||||
accepted_at = parse_iso_date(member.accepted_at, "Aufnahmebeschluss")
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") or accepted_at
|
||||
ended_at = parse_iso_date(member.membership_ended_at, "Austrittsdatum")
|
||||
except DateValidationError:
|
||||
return []
|
||||
if not accepted_at or not started_at:
|
||||
@@ -29,20 +46,22 @@ def evaluate(context: RuleContext):
|
||||
admission_fee = Decimal(str(admission_rule.get("admission_fee", "0")))
|
||||
if admission_fee > 0:
|
||||
due_days = int(admission_rule.get("first_payment_due_days_after_acceptance", 28))
|
||||
actions.append(
|
||||
create_claim(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
claim_key="admission-fee",
|
||||
payload={
|
||||
"type": "admission_fee",
|
||||
"title": "Aufnahmegebühr",
|
||||
"amount": _money(admission_fee),
|
||||
"due_date": (accepted_at + timedelta(days=due_days)).isoformat(),
|
||||
"calculation": {"rule_id": admission_rule.get("rule_id", "")},
|
||||
},
|
||||
admission_due = accepted_at + timedelta(days=due_days)
|
||||
if not ended_at or admission_due <= ended_at:
|
||||
actions.append(
|
||||
create_claim(
|
||||
rule_id=RULE_ID,
|
||||
member=member,
|
||||
claim_key="admission-fee",
|
||||
payload={
|
||||
"type": "admission_fee",
|
||||
"title": "Aufnahmegebühr",
|
||||
"amount": _money(admission_fee),
|
||||
"due_date": admission_due.isoformat(),
|
||||
"calculation": {"rule_id": admission_rule.get("rule_id", "")},
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
year_from = (
|
||||
started_at.year
|
||||
@@ -50,11 +69,17 @@ def evaluate(context: RuleContext):
|
||||
else context.today.year
|
||||
)
|
||||
for year in range(year_from, context.today.year + 2):
|
||||
actions.extend(_membership_claims(context, started_at, accepted_at, year))
|
||||
actions.extend(_membership_claims(context, started_at, accepted_at, ended_at, year))
|
||||
return actions
|
||||
|
||||
|
||||
def _membership_claims(context: RuleContext, started_at: date, accepted_at: date, year: int):
|
||||
def _membership_claims(
|
||||
context: RuleContext,
|
||||
started_at: date,
|
||||
accepted_at: date,
|
||||
ended_at: date | None,
|
||||
year: int,
|
||||
):
|
||||
member = context.member
|
||||
period_start = max(started_at, date(year, 1, 1))
|
||||
if period_start.year > year:
|
||||
@@ -76,11 +101,31 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
("first-half", 1, 6, _due_date(year, configured_due_dates[0], "01-31")),
|
||||
("second-half", 7, 12, _due_date(year, configured_due_dates[1], "07-31")),
|
||||
]
|
||||
elif member.payment_frequency == "quarterly":
|
||||
periods = [
|
||||
(
|
||||
f"quarter-{quarter}",
|
||||
first_month,
|
||||
first_month + 2,
|
||||
_recurring_due_date(year, first_month, rule.get("annual_due"), "01-31"),
|
||||
)
|
||||
for quarter, first_month in enumerate((1, 4, 7, 10), start=1)
|
||||
]
|
||||
elif member.payment_frequency == "monthly":
|
||||
periods = [
|
||||
(
|
||||
f"month-{month:02d}",
|
||||
month,
|
||||
month,
|
||||
_recurring_due_date(year, month, rule.get("annual_due"), "01-31"),
|
||||
)
|
||||
for month in range(1, 13)
|
||||
]
|
||||
else:
|
||||
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
|
||||
|
||||
actions = []
|
||||
monthly_amount = annual_amount / Decimal(12)
|
||||
overrides = context.contributions.contribution_overrides
|
||||
for suffix, first_month, last_month, regular_due in periods:
|
||||
# The entry year is intentionally billed from the entry month onward,
|
||||
# even when retroactive claims create old membership-fee claims.
|
||||
@@ -88,7 +133,17 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
months = max(0, last_month - charged_from + 1)
|
||||
if months == 0:
|
||||
continue
|
||||
amount = (monthly_amount * months).quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
# Blended month by month rather than a single rate for the whole period, so a
|
||||
# contribution override that starts or ends mid-period (e.g. a student discount
|
||||
# beginning in March for a member who pays semiannually) is billed correctly.
|
||||
month_amounts = [
|
||||
_monthly_amount(context.repository_config, overrides, year, month)
|
||||
for month in range(charged_from, last_month + 1)
|
||||
]
|
||||
amount = sum((value for value, _override_id in month_amounts), Decimal("0")).quantize(
|
||||
CENT, rounding=ROUND_HALF_UP
|
||||
)
|
||||
applied_overrides = sorted({override_id for _value, override_id in month_amounts if override_id})
|
||||
entry_year = started_at.year == year
|
||||
issue_date = regular_due - timedelta(days=issue_days)
|
||||
if not entry_year and context.today < issue_date:
|
||||
@@ -96,7 +151,14 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
due_date = regular_due
|
||||
if entry_year and regular_due < accepted_at + timedelta(days=due_days_after_entry):
|
||||
due_date = accepted_at + timedelta(days=due_days_after_entry)
|
||||
if ended_at and due_date > ended_at:
|
||||
continue
|
||||
claim_key = f"membership-fee:{year}:{suffix}"
|
||||
full_period_months = last_month - first_month + 1
|
||||
description = _title(year, suffix)
|
||||
if months < full_period_months:
|
||||
unit = "Monat" if months == 1 else "Monate"
|
||||
description = f"{description} ({months} {unit})"
|
||||
actions.append(
|
||||
create_claim(
|
||||
rule_id=RULE_ID,
|
||||
@@ -105,6 +167,7 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
payload={
|
||||
"type": "membership_fee",
|
||||
"title": _title(year, suffix),
|
||||
"description": description,
|
||||
"amount": _money(amount),
|
||||
"due_date": due_date.isoformat(),
|
||||
"service_period": {
|
||||
@@ -116,6 +179,7 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
"annual_amount": _money(annual_amount),
|
||||
"months": months,
|
||||
"formula": "annual_amount * months / 12",
|
||||
"contribution_override_ids": applied_overrides,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -123,6 +187,28 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
|
||||
return actions
|
||||
|
||||
|
||||
def _monthly_amount(config, overrides, year: int, month: int) -> tuple[Decimal, str]:
|
||||
"""Returns the effective monthly rate for a single calendar month, and the
|
||||
override's id if one applied (empty string otherwise)."""
|
||||
rule = _rule_for(config, date(year, month, 1))
|
||||
base_annual = Decimal(str(rule.get("annual_amount", "0"))) if rule else Decimal("0")
|
||||
month_key = f"{year:04d}-{month:02d}"
|
||||
override = next(
|
||||
(item for item in overrides if contribution_override_covers_month(item, month_key)), None
|
||||
)
|
||||
if override is None:
|
||||
return base_annual / Decimal(12), ""
|
||||
kind = str(override.get("kind", ""))
|
||||
value = Decimal(str(override.get("value", "0")))
|
||||
if kind == "amount":
|
||||
annual = value
|
||||
elif kind == "percent":
|
||||
annual = base_annual * (Decimal(100) - value) / Decimal(100)
|
||||
else:
|
||||
annual = base_annual
|
||||
return annual / Decimal(12), str(override.get("override_id", ""))
|
||||
|
||||
|
||||
def _rule_for(config, target: date):
|
||||
selected = None
|
||||
for rule in config.get("contribution_rules", []):
|
||||
@@ -151,7 +237,21 @@ def _due_date(year: int, value, fallback: str) -> date:
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def _recurring_due_date(year: int, month: int, value, fallback: str) -> date:
|
||||
try:
|
||||
_configured_month, day = (int(part) for part in str(value or fallback).split("-", 1))
|
||||
except (TypeError, ValueError):
|
||||
day = int(fallback.split("-", 1)[1])
|
||||
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def _title(year: int, suffix: str) -> str:
|
||||
if suffix.startswith("month-"):
|
||||
month = int(suffix.removeprefix("month-"))
|
||||
return f"Mitgliedsbeitrag {MONTH_NAMES[month]} {year}"
|
||||
if suffix.startswith("quarter-"):
|
||||
quarter = int(suffix.removeprefix("quarter-"))
|
||||
return f"Mitgliedsbeitrag {quarter}. Quartal {year}"
|
||||
if suffix == "first-half":
|
||||
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
|
||||
if suffix == "second-half":
|
||||
|
||||
@@ -8,6 +8,8 @@ ORDER = 30
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
member = context.member
|
||||
if member.status == "ended":
|
||||
return []
|
||||
try:
|
||||
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit")
|
||||
except DateValidationError:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
||||
from ccma.rules.api import RuleContext, task
|
||||
from ccma.rules.api import RuleAction, RuleContext, task
|
||||
|
||||
RULE_ID = "reminder-due"
|
||||
ORDER = 60
|
||||
@@ -16,10 +16,18 @@ DEFAULT_POLICY = {
|
||||
}
|
||||
|
||||
|
||||
def evaluate(context: RuleContext):
|
||||
def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
|
||||
grace period, then either the postal dunning escalation or -- for members paying by
|
||||
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
|
||||
"check the direct debit" notice. Falls back to the plain overdue notice whenever no
|
||||
escalation applies (dunning hold active, or between reminder levels), so there is
|
||||
always exactly one finding for an overdue claim, never both an overdue notice and a
|
||||
reminder notice at once."""
|
||||
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
||||
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
||||
actions = []
|
||||
grace_days = int(policy.get("grace_days_after_due", 7))
|
||||
actions: list[RuleAction] = []
|
||||
for claim in context.contributions.claims:
|
||||
claim_id = str(claim.get("claim_id", ""))
|
||||
if not claim_id or claim_status(context.contributions, claim, today=context.today) not in {
|
||||
@@ -28,54 +36,180 @@ def evaluate(context: RuleContext):
|
||||
"overdue",
|
||||
}:
|
||||
continue
|
||||
if claim_balance(context.contributions, claim) <= 0 or _hold_is_active(claim, context.today):
|
||||
if claim_balance(context.contributions, claim) <= 0:
|
||||
continue
|
||||
reminders = [
|
||||
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
||||
]
|
||||
sent_levels = {
|
||||
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
|
||||
}
|
||||
next_level = next(
|
||||
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
|
||||
None,
|
||||
)
|
||||
if not next_level:
|
||||
try:
|
||||
due = date.fromisoformat(str(claim.get("due_date", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
level = int(next_level.get("level", 0))
|
||||
trigger_date = _trigger_date(claim, sent_levels, level, policy)
|
||||
if not trigger_date or context.today < trigger_date:
|
||||
continue
|
||||
draft_exists = any(
|
||||
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
||||
for item in reminders
|
||||
)
|
||||
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
title = (
|
||||
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
|
||||
if draft_exists
|
||||
else f"{context.member.display_name}: {name} fällig"
|
||||
)
|
||||
detail = (
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
|
||||
)
|
||||
actions.append(
|
||||
task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:level-{level}",
|
||||
severity="warning",
|
||||
code="reminder_due",
|
||||
title=title,
|
||||
detail=detail,
|
||||
due_date=trigger_date,
|
||||
)
|
||||
)
|
||||
days_overdue = (context.today - due).days
|
||||
if days_overdue < 0:
|
||||
continue # not yet due; claim-status handles the "due soon" notice
|
||||
|
||||
escalation = None
|
||||
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
|
||||
if context.member.mandate_active:
|
||||
escalation = _sepa_escalation(context, claim, claim_id, days_overdue)
|
||||
else:
|
||||
escalation = _reminder_action(context, claim, claim_id, levels, policy)
|
||||
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
|
||||
return actions
|
||||
|
||||
|
||||
def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_overdue: int) -> RuleAction:
|
||||
title = str(claim.get("title") or "Beitragsforderung")
|
||||
claim_type = str(claim.get("type", ""))
|
||||
claim_key = str(claim.get("claim_key") or claim_id)
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"overdue:{claim_key}",
|
||||
severity="error",
|
||||
title=f"{context.member.display_name}: {title} überfällig",
|
||||
detail=f"Fälligkeit war vor {days_overdue} Tagen.",
|
||||
due_date=due,
|
||||
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
|
||||
)
|
||||
|
||||
|
||||
def _sepa_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
||||
pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today)
|
||||
if pending:
|
||||
return _sepa_awaiting_action(context, claim, claim_id, *pending)
|
||||
return _sepa_action(context, claim, claim_id, days_overdue)
|
||||
|
||||
|
||||
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:sepa",
|
||||
severity="warning",
|
||||
code="sepa_debit_overdue",
|
||||
title=f"{context.member.display_name}: Lastschrift überfällig – Einzug prüfen",
|
||||
detail=(
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR, seit "
|
||||
f"{days_overdue} Tagen überfällig. Das Mitglied zahlt per SEPA-Lastschriftmandat, eine "
|
||||
"postalische Mahnung ist hier nicht vorgesehen -- bitte den Lastschrifteinzug prüfen "
|
||||
"bzw. erneut anstoßen."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _sepa_awaiting_action(
|
||||
context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date
|
||||
) -> RuleAction:
|
||||
"""A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA
|
||||
claim and its own deadline hasn't expired yet -- staff already engaged, so this
|
||||
must not keep nagging "check the direct debit" as if nothing had happened."""
|
||||
name = str(reminder.get("name") or "Rücklastschrift")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:sepa-awaiting",
|
||||
severity="info",
|
||||
code="sepa_debit_awaiting_deadline",
|
||||
title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft",
|
||||
detail=(
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||||
),
|
||||
due_date=trigger_date,
|
||||
)
|
||||
|
||||
|
||||
def _latest_pending_reminder(
|
||||
reminders: list[dict], claim_id: str, today: date
|
||||
) -> tuple[dict, date] | None:
|
||||
best: tuple[dict, date] | None = None
|
||||
for item in reminders:
|
||||
if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent":
|
||||
continue
|
||||
try:
|
||||
deadline = date.fromisoformat(str(item.get("payment_deadline", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
if today > deadline:
|
||||
continue
|
||||
if best is None or deadline > best[1]:
|
||||
best = (item, deadline)
|
||||
return best
|
||||
|
||||
|
||||
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
|
||||
reminders = [
|
||||
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
||||
]
|
||||
sent_levels = {
|
||||
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
|
||||
}
|
||||
next_level = next(
|
||||
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
|
||||
None,
|
||||
)
|
||||
if not next_level:
|
||||
return None
|
||||
level = int(next_level.get("level", 0))
|
||||
trigger_date = _trigger_date(claim, sent_levels, level, policy)
|
||||
if not trigger_date:
|
||||
return None
|
||||
if context.today < trigger_date:
|
||||
# A reminder for the previous level was already sent and its own payment
|
||||
# deadline hasn't expired yet -- this is being handled, not neglected, so it
|
||||
# must not show up as the same "error" severity as a plain unaddressed overdue
|
||||
# claim (that fallback is what the caller uses when this returns None).
|
||||
return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date)
|
||||
draft_exists = any(
|
||||
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
|
||||
for item in reminders
|
||||
)
|
||||
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
title = (
|
||||
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
|
||||
if draft_exists
|
||||
else f"{context.member.display_name}: {name} fällig"
|
||||
)
|
||||
detail = (
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
|
||||
)
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:level-{level}",
|
||||
severity="warning",
|
||||
code="reminder_due",
|
||||
title=title,
|
||||
detail=detail,
|
||||
due_date=trigger_date,
|
||||
)
|
||||
|
||||
|
||||
def _awaiting_deadline_action(
|
||||
context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date
|
||||
) -> RuleAction:
|
||||
previous_level = level - 1
|
||||
previous = sent_levels.get(previous_level) or {}
|
||||
name = str(previous.get("name") or f"Mahnstufe {previous_level}")
|
||||
balance = money_text(claim_balance(context.contributions, claim))
|
||||
return task(
|
||||
rule_id=RULE_ID,
|
||||
member=context.member,
|
||||
key_suffix=f"{claim_id}:level-{previous_level}:awaiting",
|
||||
severity="info",
|
||||
code="reminder_awaiting_deadline",
|
||||
title=f"{context.member.display_name}: {name} versandt, Frist läuft noch",
|
||||
detail=(
|
||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
||||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
||||
),
|
||||
due_date=trigger_date,
|
||||
)
|
||||
|
||||
|
||||
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
||||
if level == 1:
|
||||
try:
|
||||
|
||||
@@ -21,6 +21,7 @@ from ccma.domain.contributions import (
|
||||
claim_status,
|
||||
claim_total,
|
||||
money_text,
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
|
||||
@@ -211,6 +212,7 @@ def _template_values(
|
||||
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
|
||||
"member.accepted_at": format_date_for_display(member.accepted_at),
|
||||
"member.started_at": format_date_for_display(member.membership_started_at),
|
||||
"member.ended_at": format_date_for_display(member.membership_ended_at),
|
||||
"member.street": member.street,
|
||||
"member.address_addition": member.address_addition,
|
||||
"member.postal_code": member.postal_code,
|
||||
@@ -292,6 +294,10 @@ def _template_values(
|
||||
channel_labels = {"email": "E-Mail", "letter": "Brief", "personal": "Persönlich"}
|
||||
status = str(reminder.get("status", "draft"))
|
||||
channel = str(reminder.get("channel", ""))
|
||||
reminder_items = reminder.get("items") or []
|
||||
item_lines = [
|
||||
f"{item.get('description', '')}: {item.get('amount', '0.00')} EUR" for item in reminder_items
|
||||
]
|
||||
values.update(
|
||||
{
|
||||
"reminder.id": str(reminder.get("reminder_id", "")),
|
||||
@@ -304,11 +310,19 @@ def _template_values(
|
||||
str(reminder.get("payment_deadline") or "")
|
||||
),
|
||||
"reminder.payment_deadline_days": str(reminder.get("payment_deadline_days", "")),
|
||||
"reminder.fee": f"{reminder.get('fee', '0.00')} EUR",
|
||||
"reminder.fee": f"{money_text(reminder_items_total(reminder))} EUR",
|
||||
"reminder.items": "; ".join(item_lines),
|
||||
"reminder.detail": str(reminder.get("detail", "")),
|
||||
"reminder.channel": channel_labels.get(channel, channel),
|
||||
}
|
||||
)
|
||||
repeats["reminder.items"] = [
|
||||
{
|
||||
"item.description": str(item.get("description", "")),
|
||||
"item.amount": str(item.get("amount", "")),
|
||||
}
|
||||
for item in reminder_items
|
||||
]
|
||||
return values, repeats
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
NS = {
|
||||
"gnc": "http://www.gnucash.org/XML/gnc",
|
||||
"act": "http://www.gnucash.org/XML/act",
|
||||
"trn": "http://www.gnucash.org/XML/trn",
|
||||
"split": "http://www.gnucash.org/XML/split",
|
||||
"ts": "http://www.gnucash.org/XML/ts",
|
||||
}
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
class GnuCashImportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GnuCashAccount:
|
||||
guid: str
|
||||
name: str
|
||||
full_name: str
|
||||
account_type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GnuCashTransaction:
|
||||
guid: str
|
||||
date: date
|
||||
description: str
|
||||
memo: str
|
||||
amount: Decimal
|
||||
|
||||
|
||||
def _read_root(path: Path) -> ET.Element:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise GnuCashImportError(f"GnuCash-Datei konnte nicht gelesen werden: {exc}") from exc
|
||||
if raw[:2] == b"\x1f\x8b":
|
||||
try:
|
||||
raw = gzip.decompress(raw)
|
||||
except OSError as exc:
|
||||
raise GnuCashImportError(f"GnuCash-Datei konnte nicht entpackt werden: {exc}") from exc
|
||||
try:
|
||||
return ET.fromstring(raw)
|
||||
except ET.ParseError as exc:
|
||||
raise GnuCashImportError(f"GnuCash-Datei ist kein gültiges XML: {exc}") from exc
|
||||
|
||||
|
||||
def _book(root: ET.Element) -> ET.Element:
|
||||
book = root.find("gnc:book", NS)
|
||||
if book is None:
|
||||
raise GnuCashImportError("Keine GnuCash-Buchführung (gnc:book) in der Datei gefunden.")
|
||||
return book
|
||||
|
||||
|
||||
def _text(element: ET.Element, path: str) -> str:
|
||||
return (element.findtext(path, default="", namespaces=NS) or "").strip()
|
||||
|
||||
|
||||
def list_accounts(path: str | Path) -> list[GnuCashAccount]:
|
||||
root = _read_root(Path(path))
|
||||
book = _book(root)
|
||||
by_guid: dict[str, ET.Element] = {}
|
||||
parent_of: dict[str, str] = {}
|
||||
for account_el in book.findall("gnc:account", NS):
|
||||
guid = _text(account_el, "act:id")
|
||||
if not guid:
|
||||
continue
|
||||
by_guid[guid] = account_el
|
||||
parent_of[guid] = _text(account_el, "act:parent")
|
||||
if not by_guid:
|
||||
raise GnuCashImportError("Die Datei enthält keine Konten.")
|
||||
|
||||
def full_name(guid: str, seen: frozenset[str]) -> str:
|
||||
account_el = by_guid.get(guid)
|
||||
if account_el is None or guid in seen or _text(account_el, "act:type") == "ROOT":
|
||||
return ""
|
||||
name = _text(account_el, "act:name")
|
||||
parent = parent_of.get(guid, "")
|
||||
if not parent:
|
||||
return name
|
||||
parent_name = full_name(parent, seen | {guid})
|
||||
return f"{parent_name}:{name}" if parent_name else name
|
||||
|
||||
accounts = []
|
||||
for guid, account_el in by_guid.items():
|
||||
account_type = _text(account_el, "act:type")
|
||||
if account_type in {"ROOT", ""}:
|
||||
continue
|
||||
accounts.append(
|
||||
GnuCashAccount(
|
||||
guid=guid,
|
||||
name=_text(account_el, "act:name"),
|
||||
full_name=full_name(guid, frozenset()) or _text(account_el, "act:name"),
|
||||
account_type=account_type,
|
||||
)
|
||||
)
|
||||
accounts.sort(key=lambda item: item.full_name.casefold())
|
||||
return accounts
|
||||
|
||||
|
||||
def _parse_fraction(text: str) -> Decimal | None:
|
||||
text = text.strip()
|
||||
if "/" not in text:
|
||||
return None
|
||||
numerator, _, denominator = text.partition("/")
|
||||
try:
|
||||
denom = Decimal(denominator)
|
||||
if denom == 0:
|
||||
return None
|
||||
return (Decimal(numerator) / denom).quantize(CENT)
|
||||
except InvalidOperation:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posted_date(text: str) -> date | None:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def list_transactions(path: str | Path, account_guid: str) -> list[GnuCashTransaction]:
|
||||
"""Return every booking that has a split on the given account, one row per
|
||||
matching split (a transaction with two splits on the same account, e.g. a
|
||||
transfer to itself, yields two rows -- rare in practice, harmless here)."""
|
||||
root = _read_root(Path(path))
|
||||
book = _book(root)
|
||||
results: list[GnuCashTransaction] = []
|
||||
for transaction_el in book.findall("gnc:transaction", NS):
|
||||
guid = _text(transaction_el, "trn:id")
|
||||
posted_date = _parse_posted_date(_text(transaction_el, "trn:date-posted/ts:date"))
|
||||
if not guid or posted_date is None:
|
||||
continue
|
||||
description = _text(transaction_el, "trn:description")
|
||||
splits_el = transaction_el.find("trn:splits", NS)
|
||||
if splits_el is None:
|
||||
continue
|
||||
for split_el in splits_el.findall("trn:split", NS):
|
||||
if _text(split_el, "split:account") != account_guid:
|
||||
continue
|
||||
amount = _parse_fraction(_text(split_el, "split:value"))
|
||||
if amount is None:
|
||||
continue
|
||||
results.append(
|
||||
GnuCashTransaction(
|
||||
guid=guid,
|
||||
date=posted_date,
|
||||
description=description,
|
||||
memo=_text(split_el, "split:memo"),
|
||||
amount=amount,
|
||||
)
|
||||
)
|
||||
results.sort(key=lambda item: (item.date, item.description.casefold()))
|
||||
return results
|
||||
@@ -6,7 +6,7 @@ import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -67,9 +67,23 @@ class Housekeeper:
|
||||
today: date | None = None,
|
||||
*,
|
||||
member_delay: float = 0.0,
|
||||
member_id: str | None = None,
|
||||
retroactive_claims: bool | None = None,
|
||||
) -> list[HousekeeperFinding]:
|
||||
"""Run all rules. With `member_id`, only that member is (re-)evaluated --
|
||||
asset checks are skipped and every other member's tasks are left untouched
|
||||
(their rules never ran this pass, so they fall outside `successful_scopes`
|
||||
and `_resolve_stale_tasks` can't touch them). `retroactive_claims`, if given,
|
||||
overrides the configured setting for just this run without changing it
|
||||
permanently -- e.g. to recompute one member's full claim history on demand
|
||||
after a contribution change, without retroactively billing everyone else."""
|
||||
current_date = today or date.today()
|
||||
delay = _non_negative_delay(member_delay)
|
||||
effective_settings = (
|
||||
self.settings
|
||||
if retroactive_claims is None
|
||||
else replace(self.settings, retroactive_claims=retroactive_claims)
|
||||
)
|
||||
with _exclusive_lock(self.lock_path):
|
||||
original = self._load_state()
|
||||
working = copy.deepcopy(original)
|
||||
@@ -78,32 +92,39 @@ class Housekeeper:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
items = _items_by_key(working)
|
||||
successful_scopes: set[tuple[str, str]] = set()
|
||||
member_ids = set(self.repository.list_member_ids())
|
||||
all_member_ids = set(self.repository.list_member_ids())
|
||||
asset_ids = set(self.repository.list_asset_ids())
|
||||
_remove_orphaned_member_items(items, member_ids)
|
||||
_remove_orphaned_member_items(items, all_member_ids)
|
||||
_remove_orphaned_asset_items(items, asset_ids)
|
||||
|
||||
if member_id is not None:
|
||||
if member_id not in all_member_ids:
|
||||
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
|
||||
selected_member_ids = {member_id}
|
||||
else:
|
||||
selected_member_ids = all_member_ids
|
||||
|
||||
rules = load_rules(self.repository.root)
|
||||
repository_config = self.repository.get_configuration()
|
||||
for index, member_id in enumerate(sorted(member_ids)):
|
||||
for index, current_member_id in enumerate(sorted(selected_member_ids)):
|
||||
if index and delay:
|
||||
time.sleep(delay)
|
||||
try:
|
||||
member, contributions = self.repository.preflight_member_record(member_id)
|
||||
member, contributions = self.repository.preflight_member_record(current_member_id)
|
||||
except RepositoryError as exc:
|
||||
self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
|
||||
successful_scopes.add(("member-record-check", member_id))
|
||||
self._refresh_record_integrity_task(items, current_member_id, str(exc), run_id, now)
|
||||
successful_scopes.add(("member-record-check", current_member_id))
|
||||
continue
|
||||
successful_scopes.add(("member-record-check", member_id))
|
||||
successful_scopes.add(("member-record-check", current_member_id))
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="member",
|
||||
target_id=member_id,
|
||||
warnings=self.repository.member_hash_warnings(member_id),
|
||||
target_id=current_member_id,
|
||||
warnings=self.repository.member_hash_warnings(current_member_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("member-hash-check", member_id))
|
||||
successful_scopes.add(("member-hash-check", current_member_id))
|
||||
for rule in rules:
|
||||
scope = (rule.rule_id, member.member_id)
|
||||
try:
|
||||
@@ -111,7 +132,7 @@ class Housekeeper:
|
||||
member=member,
|
||||
contributions=contributions,
|
||||
today=current_date,
|
||||
settings=self.settings,
|
||||
settings=effective_settings,
|
||||
repository_config=repository_config,
|
||||
)
|
||||
actions = rule.evaluate(context)
|
||||
@@ -141,16 +162,17 @@ class Housekeeper:
|
||||
else:
|
||||
successful_scopes.add(scope)
|
||||
|
||||
for asset_id in sorted(asset_ids):
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="asset",
|
||||
target_id=asset_id,
|
||||
warnings=self.repository.asset_hash_warnings(asset_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("asset-hash-check", asset_id))
|
||||
if member_id is None:
|
||||
for asset_id in sorted(asset_ids):
|
||||
self._refresh_hash_integrity_tasks(
|
||||
items,
|
||||
target_type="asset",
|
||||
target_id=asset_id,
|
||||
warnings=self.repository.asset_hash_warnings(asset_id),
|
||||
run_id=run_id,
|
||||
now=now,
|
||||
)
|
||||
successful_scopes.add(("asset-hash-check", asset_id))
|
||||
|
||||
self._resolve_stale_tasks(items, successful_scopes, run_id, now)
|
||||
working.update(
|
||||
@@ -324,7 +346,9 @@ class Housekeeper:
|
||||
{
|
||||
"item_id": str(uuid4()),
|
||||
"type": "base",
|
||||
"description": str(claim.get("title", claim_key)),
|
||||
"description": str(
|
||||
claim.get("description") or claim.get("title", claim_key)
|
||||
),
|
||||
"quantity": "1.00",
|
||||
"unit_price": str(claim.get("amount", "0.00")),
|
||||
"amount": str(claim.get("amount", "0.00")),
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default as email_default_policy
|
||||
|
||||
from ccma.storage.repository import RepositoryError
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def _smtp_client(settings: dict) -> smtplib.SMTP:
|
||||
host = str(settings.get("smtp_host", "")).strip()
|
||||
if not host:
|
||||
raise RepositoryError("Kein SMTP-Server konfiguriert.")
|
||||
port = int(settings.get("smtp_port", 587))
|
||||
security = str(settings.get("smtp_security", "starttls"))
|
||||
client = (
|
||||
smtplib.SMTP_SSL(host, port, context=_ssl_context(), timeout=30)
|
||||
if security == "ssl"
|
||||
else smtplib.SMTP(host, port, timeout=30)
|
||||
)
|
||||
if security == "starttls":
|
||||
client.starttls(context=_ssl_context())
|
||||
username = str(settings.get("smtp_username", "")).strip()
|
||||
if username:
|
||||
client.login(username, str(settings.get("smtp_password", "")))
|
||||
return client
|
||||
|
||||
|
||||
def _imap_client(settings: dict) -> imaplib.IMAP4:
|
||||
host = str(settings.get("imap_host", "")).strip()
|
||||
if not host:
|
||||
raise RepositoryError("Kein IMAP-Server konfiguriert.")
|
||||
port = int(settings.get("imap_port", 993))
|
||||
security = str(settings.get("imap_security", "ssl"))
|
||||
client = (
|
||||
imaplib.IMAP4_SSL(host, port, timeout=30)
|
||||
if security == "ssl"
|
||||
else imaplib.IMAP4(host, port, timeout=30)
|
||||
)
|
||||
if security == "starttls":
|
||||
client.starttls(_ssl_context())
|
||||
username = str(settings.get("imap_username", "")).strip()
|
||||
if username:
|
||||
client.login(username, str(settings.get("imap_password", "")))
|
||||
return client
|
||||
|
||||
|
||||
@contextmanager
|
||||
def smtp_session(settings: dict) -> Iterator[smtplib.SMTP]:
|
||||
"""Opens one authenticated SMTP connection to reuse across several sends (a SEPA
|
||||
info-mail batch may cover dozens of members -- reconnecting/re-authenticating per
|
||||
message would be slow and can trigger provider rate limits)."""
|
||||
try:
|
||||
client = _smtp_client(settings)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise RepositoryError(f"SMTP-Verbindung fehlgeschlagen: {exc}") from exc
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with suppress(OSError, smtplib.SMTPException):
|
||||
client.quit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def imap_session(settings: dict) -> Iterator[imaplib.IMAP4]:
|
||||
try:
|
||||
client = _imap_client(settings)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Verbindung fehlgeschlagen: {exc}") from exc
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
with suppress(OSError, imaplib.IMAP4.error):
|
||||
client.logout()
|
||||
|
||||
|
||||
def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
|
||||
message = BytesParser(policy=email_default_policy).parsebytes(message_bytes)
|
||||
try:
|
||||
client.send_message(message)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc
|
||||
|
||||
|
||||
def append_message(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str, flags: str) -> None:
|
||||
try:
|
||||
status, response = client.append(_encode_imap_utf7(folder), flags, None, message_bytes)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"E-Mail konnte nicht in „{folder}“ abgelegt werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
|
||||
raise RepositoryError(f"IMAP-Server hat die Ablage im Ordner „{folder}“ abgelehnt: {detail}")
|
||||
|
||||
|
||||
def ensure_imap_folder(client: imaplib.IMAP4, folder: str) -> None:
|
||||
"""Creates the folder on the server if it doesn't exist yet -- lets someone type
|
||||
a not-yet-existing Entwürfe/Gesendet folder name and have it just appear, instead
|
||||
of every append failing until it's created by hand in a separate mail client."""
|
||||
encoded = _encode_imap_utf7(folder)
|
||||
try:
|
||||
status, _response = client.select(encoded, readonly=True)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht geprüft werden: {exc}") from exc
|
||||
if status == "OK":
|
||||
return
|
||||
try:
|
||||
status, response = client.create(encoded)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {detail}")
|
||||
|
||||
|
||||
_LIST_RESPONSE_RE = re.compile(
|
||||
rb'^\((?P<flags>[^)]*)\)\s+(?:"(?P<qdelim>[^"]*)"|(?P<delim>\S+))\s+(?P<name>.+)$'
|
||||
)
|
||||
|
||||
|
||||
def list_imap_folders(settings: dict) -> list[str]:
|
||||
"""Human-readable (decoded) folder names for the Options dialog's folder picker
|
||||
-- fetched live from the server, so it only works once credentials are entered."""
|
||||
with imap_session(settings) as client:
|
||||
try:
|
||||
status, response = client.list()
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"Ordnerliste konnte nicht abgerufen werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
raise RepositoryError("Ordnerliste konnte nicht abgerufen werden.")
|
||||
folders = []
|
||||
for raw in response or []:
|
||||
if not raw:
|
||||
continue
|
||||
name = _parse_list_response(raw)
|
||||
if name:
|
||||
folders.append(name)
|
||||
return folders
|
||||
|
||||
|
||||
def _parse_list_response(raw: bytes) -> str | None:
|
||||
match = _LIST_RESPONSE_RE.match(raw)
|
||||
if not match:
|
||||
return None
|
||||
name = match.group("name").decode("utf-8", "replace").strip()
|
||||
if name.startswith('"') and name.endswith('"') and len(name) >= 2:
|
||||
name = name[1:-1]
|
||||
return _decode_imap_utf7(name)
|
||||
|
||||
|
||||
def _decode_imap_utf7(value: str) -> str:
|
||||
# Modified UTF-7 (RFC 3501 5.1.3): "&" takes the role of "+", and "," takes the
|
||||
# role of "/" inside the base64 run; "&-" is a literal ampersand.
|
||||
if "&" not in value:
|
||||
return value
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
length = len(value)
|
||||
while index < length:
|
||||
char = value[index]
|
||||
if char != "&":
|
||||
result.append(char)
|
||||
index += 1
|
||||
continue
|
||||
end = value.find("-", index + 1)
|
||||
if end == -1:
|
||||
end = length
|
||||
chunk = value[index + 1 : end]
|
||||
if chunk == "":
|
||||
result.append("&")
|
||||
else:
|
||||
base64_chunk = chunk.replace(",", "/")
|
||||
base64_chunk += "=" * (-len(base64_chunk) % 4)
|
||||
try:
|
||||
result.append(base64.b64decode(base64_chunk).decode("utf-16-be"))
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
result.append("&" + chunk + "-")
|
||||
index = end + 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _encode_imap_utf7(value: str) -> str:
|
||||
if "&" not in value and all(32 <= ord(char) <= 126 for char in value):
|
||||
return value
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
length = len(value)
|
||||
while index < length:
|
||||
char = value[index]
|
||||
if char == "&":
|
||||
result.append("&-")
|
||||
index += 1
|
||||
continue
|
||||
if 32 <= ord(char) <= 126:
|
||||
result.append(char)
|
||||
index += 1
|
||||
continue
|
||||
start = index
|
||||
while index < length and not (32 <= ord(value[index]) <= 126):
|
||||
index += 1
|
||||
chunk = value[start:index]
|
||||
encoded = base64.b64encode(chunk.encode("utf-16-be")).decode("ascii").rstrip("=")
|
||||
result.append("&" + encoded.replace("/", ",") + "-")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def test_smtp_connection(settings: dict) -> None:
|
||||
with smtp_session(settings):
|
||||
pass
|
||||
|
||||
|
||||
def test_imap_connection(settings: dict) -> None:
|
||||
with imap_session(settings):
|
||||
pass
|
||||
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedReminderMail:
|
||||
# None for "send"/"drafts" delivery -- there is no local file, the mail was
|
||||
# handed straight to the mail server. Always set for "local" delivery.
|
||||
export_path: Path | None
|
||||
archive_path: Path
|
||||
sha256: str
|
||||
|
||||
|
||||
def _safe_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
||||
return cleaned or "Mahnung"
|
||||
|
||||
|
||||
def _available_path(directory: Path, filename: str) -> Path:
|
||||
candidate = directory / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
index = 2
|
||||
while (directory / f"{stem}-{index}{suffix}").exists():
|
||||
index += 1
|
||||
return directory / f"{stem}-{index}{suffix}"
|
||||
|
||||
|
||||
def reminder_mail_bytes(
|
||||
*,
|
||||
recipient: str,
|
||||
first_name: str,
|
||||
reminder: dict,
|
||||
claim: dict,
|
||||
balance: str,
|
||||
organization: dict,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not recipient.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
message = EmailMessage(policy=SMTP)
|
||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||||
message["Date"] = format_datetime(timestamp)
|
||||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||||
message["To"] = recipient.strip()
|
||||
message["Subject"] = f"{reminder.get('name', 'Zahlungserinnerung')} – {claim.get('title', '')}"
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
)
|
||||
iban = str(organization.get("iban", "")).strip()
|
||||
bic = str(organization.get("bic", "")).strip()
|
||||
payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "")
|
||||
detail = str(reminder.get("detail", "")).strip()
|
||||
fee_items = reminder.get("items") or []
|
||||
fee_lines = (
|
||||
"\n".join(
|
||||
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
|
||||
for item in fee_items
|
||||
)
|
||||
if fee_items
|
||||
else "Mahngebühr: 0.00 Euro"
|
||||
)
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
f"Hallo {first_name.strip() or 'Mitglied'},",
|
||||
"",
|
||||
f"zu der Forderung „{claim.get('title', 'Offene Forderung')}“ "
|
||||
"ist noch ein Betrag offen.",
|
||||
"",
|
||||
f"Offener Betrag: {balance} Euro",
|
||||
f"Ursprünglich fällig: "
|
||||
f"{format_date_for_display(str(claim.get('due_date', '')))}",
|
||||
f"Neue Zahlungsfrist: "
|
||||
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
|
||||
fee_lines,
|
||||
f"Hinweis: {detail}" if detail else "",
|
||||
"",
|
||||
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
|
||||
payment_details,
|
||||
"",
|
||||
"Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, "
|
||||
"betrachte sie bitte als gegenstandslos.",
|
||||
"",
|
||||
"Viele Grüße",
|
||||
signature.strip(),
|
||||
)
|
||||
),
|
||||
charset="utf-8",
|
||||
)
|
||||
return message.as_bytes()
|
||||
|
||||
|
||||
def generate_and_send_reminder_mail(
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
claim_id: str,
|
||||
reminder_id: str,
|
||||
*,
|
||||
delivery_mode: str,
|
||||
output_path: Path | str | None = None,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> GeneratedReminderMail:
|
||||
if delivery_mode not in {"local", "send", "drafts"}:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
member = repository.get_member(member_id)
|
||||
data, claim = repository.get_claim(member_id, claim_id)
|
||||
reminder = repository._find_reminder(data, claim_id, reminder_id)
|
||||
if str(reminder.get("status", "")) not in {"draft", "generated"}:
|
||||
raise RepositoryError("Nur ein Mahnungsentwurf kann als E-Mail erzeugt werden.")
|
||||
if not member.email.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
||||
target: Path | None = None
|
||||
if delivery_mode == "local":
|
||||
if not output_path:
|
||||
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
|
||||
target = Path(output_path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||
|
||||
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
|
||||
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
content = reminder_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
reminder=sent_reminder,
|
||||
claim=updated_claim,
|
||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||
organization=organization,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = (
|
||||
f"{datetime.now().date().isoformat()}-"
|
||||
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
|
||||
)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||
export_path: Path | None = None
|
||||
try:
|
||||
archive_temp.write_bytes(content)
|
||||
if delivery_mode == "local":
|
||||
export_temp = target.with_name(f".{target.name}.tmp")
|
||||
try:
|
||||
export_temp.write_bytes(content)
|
||||
os.replace(export_temp, target)
|
||||
finally:
|
||||
export_temp.unlink(missing_ok=True)
|
||||
export_path = target
|
||||
elif delivery_mode == "send":
|
||||
settings = repository.get_email_settings()
|
||||
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
||||
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||
send_via_smtp(smtp_client, content)
|
||||
if imap_client is not None:
|
||||
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||
append_message(
|
||||
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||
)
|
||||
else:
|
||||
settings = repository.get_email_settings()
|
||||
with imap_session(settings) as client:
|
||||
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
||||
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
|
||||
os.replace(archive_temp, archive_path)
|
||||
finally:
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member_id / "files"
|
||||
).as_posix()
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
repository.append_event(
|
||||
member_id,
|
||||
event_type="reminder_email_sent",
|
||||
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={
|
||||
"claim_id": claim_id,
|
||||
"reminder_id": reminder_id,
|
||||
"document": relative_path,
|
||||
},
|
||||
data={
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
|
||||
"delivery_mode": delivery_mode,
|
||||
},
|
||||
)
|
||||
return GeneratedReminderMail(export_path, archive_path, digest)
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import unicodedata
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
|
||||
|
||||
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
|
||||
SEPA_CHARACTERS = frozenset(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
"':?,-(+.)/ ÄäÖöÜüß&*$%"
|
||||
)
|
||||
ET.register_namespace("", PAIN_NAMESPACE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirectDebit:
|
||||
member_id: str
|
||||
member_number: str
|
||||
account_holder: str
|
||||
iban: str
|
||||
bic: str
|
||||
mandate_reference: str
|
||||
mandate_signed_at: str
|
||||
amount: Decimal
|
||||
purpose: str
|
||||
claim_ids: tuple[str, ...]
|
||||
|
||||
|
||||
def _safe(value: Any, maximum: int) -> str:
|
||||
replacements = str.maketrans({"’": "'", "‘": "'", "´": "'", "`": "'", "–": "-", "—": "-"})
|
||||
source = str(value).translate(replacements)
|
||||
normalized: list[str] = []
|
||||
for character in source:
|
||||
if character in SEPA_CHARACTERS:
|
||||
normalized.append(character)
|
||||
continue
|
||||
decomposed = unicodedata.normalize("NFKD", character)
|
||||
replacement = "".join(
|
||||
part for part in decomposed if not unicodedata.combining(part) and part in SEPA_CHARACTERS
|
||||
)
|
||||
normalized.append(replacement or " ")
|
||||
return re.sub(r"\s+", " ", "".join(normalized)).strip()[:maximum]
|
||||
|
||||
|
||||
def pending_direct_debits(
|
||||
repository: MemberRepository, *, due_until: date, due_from: date | None = None
|
||||
) -> tuple[list[DirectDebit], list[str]]:
|
||||
if due_from and due_from > due_until:
|
||||
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
|
||||
debits: list[DirectDebit] = []
|
||||
warnings: list[str] = []
|
||||
for member in repository.list_members():
|
||||
if not member.mandate_active:
|
||||
continue
|
||||
missing = [
|
||||
label
|
||||
for value, label in (
|
||||
(member.iban, "IBAN"),
|
||||
(member.mandate_reference, "Mandatsreferenz"),
|
||||
(member.mandate_signed_at, "Mandatsdatum"),
|
||||
)
|
||||
if not str(value).strip()
|
||||
]
|
||||
if missing:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
|
||||
continue
|
||||
try:
|
||||
validate_iban(member.iban)
|
||||
date.fromisoformat(member.mandate_signed_at)
|
||||
except (RepositoryError, ValueError) as exc:
|
||||
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
||||
continue
|
||||
data = repository.get_contributions(member.member_id)
|
||||
selected = []
|
||||
amount = Decimal("0")
|
||||
for claim in data.claims:
|
||||
if str(claim.get("status", "")) == "cancelled":
|
||||
continue
|
||||
try:
|
||||
claim_due = date.fromisoformat(str(claim.get("due_date", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
balance = claim_balance(data, claim)
|
||||
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
|
||||
selected.append(claim)
|
||||
amount += balance
|
||||
if not selected:
|
||||
continue
|
||||
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
|
||||
purpose = _safe(f"{member.member_number} " + ", ".join(dict.fromkeys(titles)), 140)
|
||||
debits.append(
|
||||
DirectDebit(
|
||||
member_id=member.member_id,
|
||||
member_number=member.member_number,
|
||||
account_holder=member.account_holder.strip() or member.display_name,
|
||||
iban=member.iban.replace(" ", "").upper(),
|
||||
bic=member.bic.replace(" ", "").upper(),
|
||||
mandate_reference=_safe(member.mandate_reference, 35),
|
||||
mandate_signed_at=member.mandate_signed_at,
|
||||
amount=amount.quantize(Decimal("0.01")),
|
||||
purpose=purpose,
|
||||
claim_ids=tuple(str(claim.get("claim_id", "")) for claim in selected),
|
||||
)
|
||||
)
|
||||
return sorted(debits, key=lambda item: (item.member_number, item.account_holder)), warnings
|
||||
|
||||
|
||||
def csv_text(debits: list[DirectDebit], *, collection_date: date) -> str:
|
||||
target = io.StringIO(newline="")
|
||||
writer = csv.writer(target, delimiter=";", lineterminator="\r\n")
|
||||
writer.writerow(
|
||||
["Einzugsdatum", "Mitgliedsnummer", "Kontoinhaber", "IBAN", "BIC", "Betrag",
|
||||
"Waehrung", "Mandatsreferenz", "Mandatsdatum", "Verwendungszweck"]
|
||||
)
|
||||
for debit in debits:
|
||||
writer.writerow(
|
||||
[collection_date.isoformat(), debit.member_number, debit.account_holder, debit.iban,
|
||||
debit.bic, f"{debit.amount:.2f}".replace(".", ","), "EUR",
|
||||
debit.mandate_reference, debit.mandate_signed_at, debit.purpose]
|
||||
)
|
||||
return target.getvalue()
|
||||
|
||||
|
||||
def write_csv(path: Path | str, debits: list[DirectDebit], *, collection_date: date) -> None:
|
||||
Path(path).write_text(csv_text(debits, collection_date=collection_date), encoding="utf-8-sig")
|
||||
|
||||
|
||||
def _el(parent: ET.Element, name: str, text: Any | None = None) -> ET.Element:
|
||||
child = ET.SubElement(parent, f"{{{PAIN_NAMESPACE}}}{name}")
|
||||
if text is not None:
|
||||
child.text = str(text)
|
||||
return child
|
||||
|
||||
|
||||
def pain008_bytes(
|
||||
debits: list[DirectDebit], *, collection_date: date, organization: dict[str, Any],
|
||||
message_id: str | None = None, created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not debits:
|
||||
raise RepositoryError("Es gibt keine Lastschriften für den Export.")
|
||||
creditor_name = _safe(organization.get("name", ""), 70)
|
||||
creditor_iban = str(organization.get("iban", "")).replace(" ", "").upper()
|
||||
creditor_bic = str(organization.get("bic", "")).replace(" ", "").upper()
|
||||
creditor_id = _safe(organization.get("creditor_id", ""), 35)
|
||||
missing = [label for value, label in (
|
||||
(creditor_name, "Vereinsname"), (creditor_iban, "Vereins-IBAN"),
|
||||
(creditor_bic, "Vereins-BIC"), (creditor_id, "Gläubiger-ID"),
|
||||
) if not value]
|
||||
if missing:
|
||||
raise RepositoryError("Für den SEPA-XML-Export fehlen in den Optionen: " + ", ".join(missing) + ".")
|
||||
validate_iban(creditor_iban)
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
msg_id = _safe(message_id or f"CCMA-{timestamp:%Y%m%d%H%M%S}-{uuid4().hex[:8]}", 35)
|
||||
total = sum((item.amount for item in debits), Decimal("0"))
|
||||
document = ET.Element(f"{{{PAIN_NAMESPACE}}}Document")
|
||||
initiation = _el(document, "CstmrDrctDbtInitn")
|
||||
header = _el(initiation, "GrpHdr")
|
||||
for name, value in (("MsgId", msg_id), ("CreDtTm", timestamp.isoformat(timespec="seconds")),
|
||||
("NbOfTxs", len(debits)), ("CtrlSum", f"{total:.2f}")):
|
||||
_el(header, name, value)
|
||||
_el(_el(header, "InitgPty"), "Nm", creditor_name)
|
||||
payment = _el(initiation, "PmtInf")
|
||||
for name, value in (("PmtInfId", _safe(f"{msg_id}-1", 35)), ("PmtMtd", "DD"),
|
||||
("BtchBookg", "true"), ("NbOfTxs", len(debits)),
|
||||
("CtrlSum", f"{total:.2f}")):
|
||||
_el(payment, name, value)
|
||||
payment_type = _el(payment, "PmtTpInf")
|
||||
_el(_el(payment_type, "SvcLvl"), "Cd", "SEPA")
|
||||
_el(_el(payment_type, "LclInstrm"), "Cd", "CORE")
|
||||
_el(payment_type, "SeqTp", "RCUR")
|
||||
_el(payment, "ReqdColltnDt", collection_date.isoformat())
|
||||
_el(_el(payment, "Cdtr"), "Nm", creditor_name)
|
||||
_el(_el(_el(payment, "CdtrAcct"), "Id"), "IBAN", creditor_iban)
|
||||
_el(_el(_el(payment, "CdtrAgt"), "FinInstnId"), "BICFI", creditor_bic)
|
||||
_el(payment, "ChrgBr", "SLEV")
|
||||
for index, debit in enumerate(debits, start=1):
|
||||
transaction = _el(payment, "DrctDbtTxInf")
|
||||
_el(_el(transaction, "PmtId"), "EndToEndId", _safe(f"{debit.member_number}-{index}", 35))
|
||||
amount = _el(transaction, "InstdAmt", f"{debit.amount:.2f}")
|
||||
amount.set("Ccy", "EUR")
|
||||
direct_debit = _el(transaction, "DrctDbtTx")
|
||||
mandate = _el(direct_debit, "MndtRltdInf")
|
||||
_el(mandate, "MndtId", debit.mandate_reference)
|
||||
_el(mandate, "DtOfSgntr", debit.mandate_signed_at)
|
||||
other = _el(_el(_el(_el(direct_debit, "CdtrSchmeId"), "Id"), "PrvtId"), "Othr")
|
||||
_el(other, "Id", creditor_id)
|
||||
_el(_el(other, "SchmeNm"), "Prtry", "SEPA")
|
||||
financial = _el(_el(transaction, "DbtrAgt"), "FinInstnId")
|
||||
if debit.bic:
|
||||
_el(financial, "BICFI", debit.bic)
|
||||
else:
|
||||
_el(_el(financial, "Othr"), "Id", "NOTPROVIDED")
|
||||
_el(_el(transaction, "Dbtr"), "Nm", _safe(debit.account_holder, 70))
|
||||
_el(_el(_el(transaction, "DbtrAcct"), "Id"), "IBAN", debit.iban)
|
||||
_el(_el(transaction, "RmtInf"), "Ustrd", debit.purpose)
|
||||
ET.indent(document, space=" ")
|
||||
return ET.tostring(document, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def write_pain008(
|
||||
path: Path | str, debits: list[DirectDebit], *, collection_date: date,
|
||||
organization: dict[str, Any],
|
||||
) -> None:
|
||||
Path(path).write_bytes(
|
||||
pain008_bytes(debits, collection_date=collection_date, organization=organization)
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.services.sepa import DirectDebit
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedDebitMail:
|
||||
member_id: str
|
||||
recipient: str
|
||||
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
||||
export_path: Path | None
|
||||
archive_path: Path
|
||||
|
||||
|
||||
def _safe_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
||||
return cleaned or "Mitglied"
|
||||
|
||||
|
||||
def _available_path(directory: Path, filename: str) -> Path:
|
||||
candidate = directory / filename
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||
index = 2
|
||||
while (directory / f"{stem}-{index}{suffix}").exists():
|
||||
index += 1
|
||||
return directory / f"{stem}-{index}{suffix}"
|
||||
|
||||
|
||||
def debit_mail_bytes(
|
||||
*,
|
||||
recipient: str,
|
||||
first_name: str,
|
||||
debit: DirectDebit,
|
||||
collection_date: date,
|
||||
creditor_id: str,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> bytes:
|
||||
if not recipient.strip():
|
||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||
if not sender_email.strip() or "@" not in sender_email:
|
||||
raise RepositoryError("Für die Info-Mails ist eine gültige Absenderadresse erforderlich.")
|
||||
timestamp = created_at or datetime.now().astimezone()
|
||||
message = EmailMessage(policy=SMTP)
|
||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||||
message["Date"] = format_datetime(timestamp)
|
||||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||||
message["To"] = recipient.strip()
|
||||
message["Subject"] = f"SEPA-Lastschrift C3MA – {collection_date.strftime('%d.%m.%Y')}"
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
)
|
||||
greeting = first_name.strip() or "Mitglied"
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
f"Hallo {greeting},",
|
||||
"",
|
||||
"hiermit informieren wir dich, dass wir den folgenden Betrag per "
|
||||
"SEPA-Lastschrift einziehen werden:",
|
||||
"",
|
||||
f"Betrag: {debit.amount:.2f} Euro",
|
||||
f"Einzugsdatum: {collection_date.strftime('%d.%m.%Y')}",
|
||||
f"Verwendungszweck: {debit.purpose}",
|
||||
f"Mandatsreferenz: {debit.mandate_reference}",
|
||||
f"Gläubiger-Identifikationsnummer: {creditor_id}",
|
||||
"",
|
||||
"Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.",
|
||||
"",
|
||||
"Viele Grüße",
|
||||
signature.strip(),
|
||||
)
|
||||
),
|
||||
charset="utf-8",
|
||||
)
|
||||
return message.as_bytes()
|
||||
|
||||
|
||||
def generate_debit_mails(
|
||||
repository: MemberRepository,
|
||||
debits: list[DirectDebit],
|
||||
*,
|
||||
collection_date: date,
|
||||
delivery_mode: str,
|
||||
output_directory: Path | str | None = None,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> tuple[list[GeneratedDebitMail], list[str]]:
|
||||
output: Path | None = None
|
||||
email_settings: dict | None = None
|
||||
smtp_ctx = nullcontext(None)
|
||||
imap_ctx = nullcontext(None)
|
||||
if delivery_mode == "local":
|
||||
if not output_directory:
|
||||
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
|
||||
output = Path(output_directory)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
elif delivery_mode == "send":
|
||||
email_settings = repository.get_email_settings()
|
||||
smtp_ctx = smtp_session(email_settings)
|
||||
if email_settings["imap_sent_enabled"]:
|
||||
imap_ctx = imap_session(email_settings)
|
||||
elif delivery_mode == "drafts":
|
||||
email_settings = repository.get_email_settings()
|
||||
imap_ctx = imap_session(email_settings)
|
||||
else:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
creditor_id = str(organization.get("creditor_id", "")).strip()
|
||||
if not creditor_id:
|
||||
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
|
||||
generated: list[GeneratedDebitMail] = []
|
||||
warnings: list[str] = []
|
||||
# One connection (or two, for "send" with a Sent-folder copy) is opened for the
|
||||
# whole batch -- a run can cover dozens of members -- instead of reconnecting/
|
||||
# re-authenticating per recipient.
|
||||
with smtp_ctx as smtp_client, imap_ctx as imap_client:
|
||||
if delivery_mode == "drafts":
|
||||
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
|
||||
elif delivery_mode == "send" and imap_client is not None:
|
||||
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
|
||||
for debit in debits:
|
||||
member = repository.get_member(debit.member_id)
|
||||
if not member.email.strip():
|
||||
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
||||
continue
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
debit=debit,
|
||||
collection_date=collection_date,
|
||||
creditor_id=creditor_id,
|
||||
sender_name=sender_name,
|
||||
sender_email=sender_email,
|
||||
signature=signature,
|
||||
)
|
||||
filename = (
|
||||
f"SEPA-Info-{collection_date.isoformat()}-"
|
||||
f"{_safe_filename(member.member_number or member.display_name)}.eml"
|
||||
)
|
||||
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = _available_path(archive_dir, filename)
|
||||
export_path: Path | None = None
|
||||
if delivery_mode == "local":
|
||||
export_path = _available_path(output, filename)
|
||||
export_path.write_bytes(content)
|
||||
elif delivery_mode == "send":
|
||||
send_via_smtp(smtp_client, content)
|
||||
if imap_client is not None:
|
||||
append_message(
|
||||
imap_client, content, folder=email_settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||
)
|
||||
else:
|
||||
append_message(
|
||||
imap_client, content, folder=email_settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||
)
|
||||
try:
|
||||
archive_path.write_bytes(content)
|
||||
except OSError:
|
||||
if export_path is not None:
|
||||
export_path.unlink(missing_ok=True)
|
||||
raise
|
||||
relative_path = archive_path.relative_to(
|
||||
repository.members_root / member.member_id / "files"
|
||||
).as_posix()
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
repository.append_event(
|
||||
member.member_id,
|
||||
event_type="sepa_notification_generated",
|
||||
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
references={"document": relative_path},
|
||||
data={
|
||||
"sha256": digest,
|
||||
"recipient": member.email,
|
||||
"collection_date": collection_date.isoformat(),
|
||||
"amount": f"{debit.amount:.2f}",
|
||||
"delivery_mode": delivery_mode,
|
||||
},
|
||||
)
|
||||
generated.append(
|
||||
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
||||
)
|
||||
return generated, warnings
|
||||
+1573
-60
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,10 @@ class AssetTab(ttk.Frame):
|
||||
("Inventarnummer", "inventory_number"),
|
||||
("Seriennummer", "serial_number"),
|
||||
("Kaution (EUR)", "deposit_amount_default"),
|
||||
("Standort", "location"),
|
||||
("Übergabedatum", "handed_over_at"),
|
||||
("Zustand bei Übergabe", "condition"),
|
||||
("Wert / Kaufpreis", "estimated_value"),
|
||||
]
|
||||
for row, (label, key) in enumerate(fields):
|
||||
self.variables[key] = tk.StringVar()
|
||||
@@ -151,15 +155,22 @@ class AssetTab(ttk.Frame):
|
||||
)
|
||||
self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5)
|
||||
self.holder_label.bind("<Button-1>", lambda _event: self._open_holder_member(), add="+")
|
||||
self.owner_var = tk.StringVar()
|
||||
ttk.Label(data_tab, text="Eigentümer").grid(
|
||||
row=holder_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
self.owner_label = ttk.Label(data_tab, textvariable=self.owner_var, style="TimelineHeader.TLabel")
|
||||
self.owner_label.grid(row=holder_row + 2, column=1, sticky="w", pady=5)
|
||||
self.owner_label.bind("<Button-1>", lambda _event: self._open_owner_member(), add="+")
|
||||
ttk.Label(data_tab, text="Interne Notiz").grid(
|
||||
row=holder_row + 2,
|
||||
row=holder_row + 3,
|
||||
column=0,
|
||||
sticky="nw",
|
||||
pady=5,
|
||||
padx=(0, 12),
|
||||
)
|
||||
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
|
||||
self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5)
|
||||
self.notes_text.grid(row=holder_row + 3, column=1, sticky="ew", pady=5)
|
||||
data_tab.columnconfigure(1, weight=1)
|
||||
self.issue_button = ttk.Button(
|
||||
action_buttons,
|
||||
@@ -276,13 +287,19 @@ class AssetTab(ttk.Frame):
|
||||
self.variables["inventory_number"].set(self.asset.inventory_number)
|
||||
self.variables["serial_number"].set(self.asset.serial_number)
|
||||
self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default)
|
||||
self.variables["location"].set(self.asset.location)
|
||||
self.variables["handed_over_at"].set(self.asset.handed_over_at)
|
||||
self.variables["condition"].set(self.asset.condition)
|
||||
self.variables["estimated_value"].set(self.asset.estimated_value)
|
||||
self.variables["status"].set(ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper()))
|
||||
if self.notes_text is not None:
|
||||
self.notes_text.delete("1.0", "end")
|
||||
self.notes_text.insert("1.0", self.asset.notes)
|
||||
self.holder_var.set(holder)
|
||||
self.owner_var.set(self._owner_label())
|
||||
issued = bool(self.asset.current_holder_member_id)
|
||||
self.holder_label.configure(cursor="hand2" if issued else "")
|
||||
self.owner_label.configure(cursor="hand2" if self.asset.owner_type == "member" else "")
|
||||
self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled")
|
||||
self.return_button.configure(state="normal" if issued else "disabled")
|
||||
self.status_box.configure(state="disabled" if issued else "readonly")
|
||||
@@ -300,6 +317,21 @@ class AssetTab(ttk.Frame):
|
||||
return self.asset.current_holder_member_id
|
||||
return f"{member.member_number or member.member_id} · {member.display_name}"
|
||||
|
||||
def _owner_label(self) -> str:
|
||||
if self.asset.owner_type == "club":
|
||||
return "Verein"
|
||||
if self.asset.owner_type == "external":
|
||||
return self.asset.owner_name or "Extern"
|
||||
try:
|
||||
member = self.repository.get_member(self.asset.owner_member_id)
|
||||
except RepositoryError:
|
||||
return self.asset.owner_member_id or "—"
|
||||
return f"{member.member_number or member.member_id} · {member.display_name}"
|
||||
|
||||
def _open_owner_member(self) -> None:
|
||||
if self.asset.owner_type == "member" and self.asset.owner_member_id:
|
||||
self.on_open_member(self.asset.owner_member_id)
|
||||
|
||||
def _refresh_events(self) -> None:
|
||||
self.timeline.delete(*self.timeline.get_children())
|
||||
try:
|
||||
@@ -352,6 +384,10 @@ class AssetTab(ttk.Frame):
|
||||
self.asset.inventory_number = self.variables["inventory_number"].get().strip()
|
||||
self.asset.serial_number = self.variables["serial_number"].get().strip()
|
||||
self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else ""
|
||||
self.asset.location = self.variables["location"].get().strip()
|
||||
self.asset.handed_over_at = self.variables["handed_over_at"].get().strip()
|
||||
self.asset.condition = self.variables["condition"].get().strip()
|
||||
self.asset.estimated_value = self.variables["estimated_value"].get().strip()
|
||||
if not self.asset.current_holder_member_id:
|
||||
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip()
|
||||
self.asset.status = _asset_status_key(self.variables["status"].get())
|
||||
|
||||
+553
-51
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
CLAIM_STATUS_LABELS,
|
||||
@@ -19,10 +19,13 @@ from ccma.domain.contributions import (
|
||||
decimal_value,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||
from ccma.ui.labels import (
|
||||
CLAIM_ITEM_TYPE_LABELS,
|
||||
REMINDER_CHANNEL_LABELS,
|
||||
@@ -88,8 +91,12 @@ class ClaimTab(ttk.Frame):
|
||||
footer.columnconfigure(0, weight=1)
|
||||
self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold)
|
||||
self.hold_button.grid(row=0, column=0, sticky="w")
|
||||
self.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim)
|
||||
self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8))
|
||||
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
|
||||
self.cancel_button.grid(row=0, column=1, sticky="e")
|
||||
self.cancel_button.grid(row=0, column=2, sticky="e", padx=(0, 8))
|
||||
self.delete_button = ttk.Button(footer, text="Forderung löschen", command=self._delete_claim)
|
||||
self.delete_button.grid(row=0, column=3, sticky="e")
|
||||
|
||||
def _build_ledger(self) -> None:
|
||||
ledger = ttk.Frame(self, padding=12)
|
||||
@@ -154,6 +161,17 @@ class ClaimTab(ttk.Frame):
|
||||
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
|
||||
)
|
||||
self.send_reminder_button.pack(side="right", padx=(8, 0))
|
||||
self.email_reminder_button = ttk.Button(
|
||||
buttons,
|
||||
text="E-Mail erzeugen & versandt",
|
||||
command=self._email_reminder,
|
||||
state="disabled",
|
||||
)
|
||||
self.email_reminder_button.pack(side="right", padx=(8, 0))
|
||||
self.edit_reminder_button = ttk.Button(
|
||||
buttons, text="Mahnung bearbeiten", command=self._edit_reminder, state="disabled"
|
||||
)
|
||||
self.edit_reminder_button.pack(side="right", padx=(8, 0))
|
||||
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
|
||||
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
|
||||
side="right", padx=(0, 8)
|
||||
@@ -185,6 +203,7 @@ class ClaimTab(ttk.Frame):
|
||||
self.summary_vars["balance"].set(f"{money_text(balance)} EUR")
|
||||
self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper()))
|
||||
self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal")
|
||||
self.edit_button.configure(state="disabled" if status == "cancelled" else "normal")
|
||||
self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen")
|
||||
self._render_ledger()
|
||||
|
||||
@@ -335,7 +354,7 @@ class ClaimTab(ttk.Frame):
|
||||
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
|
||||
"",
|
||||
"",
|
||||
f"{reminder.get('fee', '0.00')} EUR",
|
||||
f"{money_text(reminder_items_total(reminder))} EUR",
|
||||
display_label(REMINDER_STATUS_LABELS, status),
|
||||
" · ".join(
|
||||
part
|
||||
@@ -422,7 +441,24 @@ class ClaimTab(ttk.Frame):
|
||||
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
|
||||
state = "normal" if editable else "disabled"
|
||||
self.send_reminder_button.configure(state=state)
|
||||
self.email_reminder_button.configure(state=state)
|
||||
self.discard_reminder_button.configure(state=state)
|
||||
# Sent reminders can also be edited (revert-then-edit), just not cancelled ones.
|
||||
can_edit = bool(reminder and str(reminder.get("status", "draft")) != "cancelled")
|
||||
self.edit_reminder_button.configure(state="normal" if can_edit else "disabled")
|
||||
|
||||
def _email_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if reminder:
|
||||
ReminderMailDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
str(reminder["reminder_id"]),
|
||||
str(reminder.get("name", "Mahnung")),
|
||||
self._changed,
|
||||
)
|
||||
|
||||
def _send_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
@@ -439,6 +475,32 @@ class ClaimTab(ttk.Frame):
|
||||
return
|
||||
self._changed()
|
||||
|
||||
def _edit_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder:
|
||||
return
|
||||
if str(reminder.get("status", "")) == "sent":
|
||||
if not messagebox.askyesno(
|
||||
"Versand zurücksetzen",
|
||||
"Diese Mahnung wurde bereits versandt. Zum Bearbeiten wird der Versand "
|
||||
"zurückgesetzt (eine bereits gebuchte Mahngebühr wird von der Forderung "
|
||||
"entfernt und die Zahlungsfrist gelöscht) -- anschließend muss die Mahnung "
|
||||
"erneut versandt werden. Fortfahren?",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
reminder = self.repository.revert_reminder_sent(
|
||||
self.member_id, self.claim_id, str(reminder["reminder_id"])
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Versand konnte nicht zurückgesetzt werden", str(exc), parent=self)
|
||||
return
|
||||
self.refresh()
|
||||
ReminderDialog(
|
||||
self, self.repository, self.member_id, self.claim_id, self._changed, reminder=reminder
|
||||
)
|
||||
|
||||
def _discard_reminder(self) -> None:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder or not messagebox.askyesno(
|
||||
@@ -478,6 +540,34 @@ class ClaimTab(ttk.Frame):
|
||||
return
|
||||
self._changed()
|
||||
|
||||
def _delete_claim(self) -> None:
|
||||
allocated = allocated_total(self.data, self.claim_id)
|
||||
detail = "Diese Forderung wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
|
||||
if allocated:
|
||||
detail += (
|
||||
f"\n\nZugeordnete Zahlungen/Gutschriften in Höhe von {money_text(allocated)} EUR "
|
||||
"werden dabei gelöst und stehen danach wieder frei zur Verfügung."
|
||||
)
|
||||
if not messagebox.askyesno("Forderung löschen", detail, parent=self):
|
||||
return
|
||||
try:
|
||||
self.repository.delete_claim(self.member_id, self.claim_id)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
self.on_changed()
|
||||
self.on_close()
|
||||
|
||||
def _edit_claim(self) -> None:
|
||||
ClaimEditDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
self.claim,
|
||||
self._changed,
|
||||
)
|
||||
|
||||
def _changed(self) -> None:
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
@@ -502,6 +592,68 @@ class _Dialog(tk.Toplevel):
|
||||
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
|
||||
|
||||
|
||||
class ClaimEditDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, claim, on_saved):
|
||||
super().__init__(master, "Forderung bearbeiten", on_saved)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.claim_id = claim_id
|
||||
items = claim_items(claim)
|
||||
base_item = next(
|
||||
(item for item in items if str(item.get("type", "")) == "base"),
|
||||
items[0] if items else {},
|
||||
)
|
||||
self.variables = {
|
||||
"title": tk.StringVar(value=str(claim.get("title", ""))),
|
||||
"due_date": tk.StringVar(
|
||||
value=format_date_for_display(str(claim.get("due_date", "")))
|
||||
),
|
||||
"base_amount": tk.StringVar(
|
||||
value=str(base_item.get("amount", claim.get("amount", "")))
|
||||
),
|
||||
"description": tk.StringVar(value=str(base_item.get("description", ""))),
|
||||
}
|
||||
fields = (
|
||||
("Titel", "title"),
|
||||
(f"Fällig am ({date_input_hint()})", "due_date"),
|
||||
("Grundbetrag", "base_amount"),
|
||||
("Beschreibung der Grundposition", "description"),
|
||||
)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(self.frame, text=label).grid(
|
||||
row=row, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables[key], width=52).grid(
|
||||
row=row, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(
|
||||
self.frame,
|
||||
text=(
|
||||
"Der Grundbetrag ändert nur die Grundposition. Weitere Positionen, "
|
||||
"zum Beispiel Mahngebühren, bleiben erhalten."
|
||||
),
|
||||
style="Mono.TLabel",
|
||||
wraplength=520,
|
||||
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||
self._buttons(len(fields) + 1, self._save)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.repository.update_claim(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
title=self.variables["title"].get(),
|
||||
due_date=self.variables["due_date"].get(),
|
||||
base_amount=self.variables["base_amount"].get(),
|
||||
description=self.variables["description"].get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Forderung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class ItemDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Forderungsposition hinzufügen", on_saved)
|
||||
@@ -634,7 +786,11 @@ class AllocatePaymentDialog(_Dialog):
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
data = repository.get_contributions(member_id)
|
||||
self.payment_by_label = {}
|
||||
for payment in data.payments:
|
||||
for payment in sorted(
|
||||
data.payments,
|
||||
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
||||
reverse=True,
|
||||
):
|
||||
payment_id = str(payment.get("payment_id", ""))
|
||||
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
|
||||
if available <= 0:
|
||||
@@ -655,10 +811,23 @@ class AllocatePaymentDialog(_Dialog):
|
||||
width=60,
|
||||
)
|
||||
combo.grid(row=0, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5)
|
||||
if not self.payment_by_label:
|
||||
ttk.Label(
|
||||
self.frame,
|
||||
text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.",
|
||||
style="Mono.TLabel",
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5))
|
||||
amount_row = 2
|
||||
else:
|
||||
amount_row = 1
|
||||
ttk.Label(self.frame, text="Betrag").grid(
|
||||
row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.amount_var).grid(
|
||||
row=amount_row, column=1, sticky="ew", pady=5
|
||||
)
|
||||
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
|
||||
self._buttons(2, self._save)
|
||||
self._buttons(amount_row + 1, self._save)
|
||||
|
||||
def _select(self, balance):
|
||||
_payment_id, available = self.payment_by_label[self.payment_var.get()]
|
||||
@@ -733,67 +902,297 @@ class AllocateCreditDialog(_Dialog):
|
||||
|
||||
|
||||
class ReminderDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnung vorbereiten", on_saved)
|
||||
"""Drafts the next reminder in the sequence for a claim. The level is computed
|
||||
automatically (first not-yet-used level), and a "Vorlage" picks sensible defaults
|
||||
for name/deadline/fee items -- the matching dunning-policy level if one exists,
|
||||
a "Rücklastschrift" preset for a bounced direct debit (which occupies the same
|
||||
level slot as a regular reminder, since it's still the member's own failure to
|
||||
provide cover/valid bank details), or a blank template for manual entry. The fee
|
||||
items table itself is always free to edit no matter which preset was picked."""
|
||||
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved, *, reminder: dict | None = None):
|
||||
super().__init__(master, "Mahnung bearbeiten" if reminder else "Mahnung vorbereiten", on_saved)
|
||||
# Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table
|
||||
# of freely-editable fee items whose content varies a lot -- make it resizable
|
||||
# so a long/multi-item Mahnung doesn't get cramped.
|
||||
self.resizable(True, True)
|
||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||
policy = repository.get_configuration().get("reminder_policy") or {}
|
||||
levels = policy.get("levels") or [
|
||||
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
|
||||
]
|
||||
self.definition_by_label = {
|
||||
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
|
||||
}
|
||||
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
|
||||
self.detail_var = tk.StringVar()
|
||||
self.fee_var = tk.StringVar()
|
||||
self.deadline_var = tk.StringVar()
|
||||
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
||||
self.editing_reminder = reminder
|
||||
if reminder:
|
||||
self.level = int(reminder.get("level", 1))
|
||||
else:
|
||||
data, _claim = repository.get_claim(member_id, claim_id)
|
||||
existing_levels = {
|
||||
int(item.get("level", 0))
|
||||
for item in data.reminders
|
||||
if str(item.get("claim_id", "")) == claim_id
|
||||
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
||||
}
|
||||
self.level = max(existing_levels, default=0) + 1
|
||||
self.policy = repository.get_reminder_policy()
|
||||
self.items: list[dict[str, str]] = []
|
||||
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
self.frame.rowconfigure(6, weight=1)
|
||||
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
level_combo = ttk.Combobox(
|
||||
ttk.Label(self.frame, text=str(self.level), style="Mono.TLabel").grid(
|
||||
row=0, column=1, sticky="w", pady=5
|
||||
)
|
||||
|
||||
self.preset_by_label = self._build_presets()
|
||||
default_preset = (
|
||||
next(reversed(self.preset_by_label)) if reminder else next(iter(self.preset_by_label))
|
||||
)
|
||||
self.preset_var = tk.StringVar(value=default_preset)
|
||||
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
preset_combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.level_var,
|
||||
values=list(self.definition_by_label),
|
||||
textvariable=self.preset_var,
|
||||
values=list(self.preset_by_label),
|
||||
state="readonly",
|
||||
width=38,
|
||||
width=55,
|
||||
)
|
||||
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
|
||||
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
|
||||
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
|
||||
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
|
||||
preset_combo.grid(row=1, column=1, sticky="ew", pady=5)
|
||||
preset_combo.bind("<<ComboboxSelected>>", lambda _event: self._apply_preset())
|
||||
|
||||
self.name_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Bezeichnung").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.name_var, width=64).grid(
|
||||
row=2, column=1, sticky="ew", pady=5
|
||||
)
|
||||
|
||||
self.detail_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Details").grid(row=3, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.detail_var, width=64).grid(
|
||||
row=3, column=1, sticky="ew", pady=5
|
||||
)
|
||||
|
||||
self.deadline_var = tk.StringVar()
|
||||
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
|
||||
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
row=4, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=4, column=1, sticky="ew", pady=5)
|
||||
|
||||
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
|
||||
ttk.Label(self.frame, text="Versandweg").grid(row=5, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.channel_var,
|
||||
values=list(REMINDER_CHANNEL_LABELS.values()),
|
||||
state="readonly",
|
||||
).grid(row=4, column=1, sticky="ew", pady=5)
|
||||
self._load_definition()
|
||||
self._buttons(5, self._save)
|
||||
).grid(row=5, column=1, sticky="ew", pady=5)
|
||||
|
||||
def _load_definition(self) -> None:
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
self.fee_var.set(str(definition.get("fee", "0.00")))
|
||||
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
|
||||
self._build_items_table(row=6)
|
||||
if reminder:
|
||||
self.name_var.set(str(reminder.get("name", "")))
|
||||
self.detail_var.set(str(reminder.get("detail", "")))
|
||||
self.deadline_var.set(str(reminder.get("payment_deadline_days", 14)))
|
||||
self.channel_var.set(
|
||||
display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "email")))
|
||||
)
|
||||
self.items = [dict(item) for item in reminder.get("items") or []]
|
||||
self._refresh_items()
|
||||
else:
|
||||
self._apply_preset()
|
||||
self._buttons(7, self._save)
|
||||
# Size to fit the fully built content (incl. the preset-populated items table)
|
||||
# instead of whatever size Tk guessed before everything was in place -- the
|
||||
# base dialog sets resizable(False, False) before this subclass's content
|
||||
# exists, so an explicit geometry() is needed or the window stays clipped.
|
||||
# The floor of 700 keeps the dialog comfortably wide even for a short name/
|
||||
# detail; the user can still grow it further since it's resizable now.
|
||||
self.update_idletasks()
|
||||
width = max(700, self.winfo_reqwidth())
|
||||
height = self.winfo_reqheight()
|
||||
self.geometry(f"{width}x{height}")
|
||||
self.minsize(width, height)
|
||||
|
||||
def _build_presets(self) -> dict[str, dict]:
|
||||
presets: dict[str, dict] = {}
|
||||
matching = next((lvl for lvl in self.policy["levels"] if lvl["level"] == self.level), None)
|
||||
if matching:
|
||||
fee = decimal_value(matching["fee"])
|
||||
presets[f"Stufe {self.level}: {matching['name']}"] = {
|
||||
"name": matching["name"],
|
||||
"detail": "",
|
||||
"payment_deadline_days": matching["payment_deadline_days"],
|
||||
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
|
||||
}
|
||||
failed_debit_amount = self._default_amount_for("Rücklastschrift")
|
||||
presets[f"Stufe {self.level}: Rücklastschrift"] = {
|
||||
"name": "Rücklastschrift",
|
||||
# Prefilled so the member sees why they're being charged; the board can
|
||||
# still append/edit a concrete reason (mangels Deckung, Konto ungültig, ...)
|
||||
# right here since "Details" goes straight into the reminder email as-is.
|
||||
"detail": "Leider konnte die Lastschrift von Ihrem Konto nicht eingelöst werden.",
|
||||
"payment_deadline_days": 14,
|
||||
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
|
||||
}
|
||||
presets["Frei (eigene Eingabe)"] = {
|
||||
"name": "", "detail": "", "payment_deadline_days": 14, "items": []
|
||||
}
|
||||
return presets
|
||||
|
||||
def _default_amount_for(self, needle: str) -> str:
|
||||
for entry in self.policy["standard_fee_items"]:
|
||||
if needle.casefold() in str(entry.get("description", "")).casefold():
|
||||
return str(entry.get("default_amount", "0.00"))
|
||||
return "0.00"
|
||||
|
||||
def _apply_preset(self) -> None:
|
||||
preset = self.preset_by_label[self.preset_var.get()]
|
||||
self.name_var.set(preset["name"])
|
||||
self.detail_var.set(preset["detail"])
|
||||
self.deadline_var.set(str(preset["payment_deadline_days"]))
|
||||
self.items = [dict(item) for item in preset["items"]]
|
||||
self._refresh_items()
|
||||
|
||||
def _build_items_table(self, row: int) -> None:
|
||||
table_frame = ttk.LabelFrame(self.frame, text="Positionen", padding=8)
|
||||
table_frame.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=(8, 0))
|
||||
table_frame.columnconfigure(0, weight=1)
|
||||
table_frame.rowconfigure(0, weight=1)
|
||||
amount_column_width = 100
|
||||
self.items_tree = ttk.Treeview(
|
||||
table_frame, columns=("description", "amount"), show="headings", height=4, selectmode="browse"
|
||||
)
|
||||
self.items_tree.heading("description", text="Beschreibung")
|
||||
self.items_tree.heading("amount", text="Betrag")
|
||||
self.items_tree.column("description", width=320, anchor="w")
|
||||
self.items_tree.column("amount", width=amount_column_width, anchor="w", stretch=False)
|
||||
self.items_tree.grid(row=0, column=0, sticky="nsew")
|
||||
self.items_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_item())
|
||||
|
||||
# Edit row lines up under the table's own columns: "Beschreibung" stretches to
|
||||
# fill remaining space just like that Treeview column does, while "Betrag" sits
|
||||
# in a frame pinned to the exact same pixel width as the fixed "amount" column
|
||||
# above it (Entry's width= is in characters, not pixels, so a fixed-size frame
|
||||
# is what gets a genuinely exact match instead of an approximate ratio).
|
||||
edit_row = ttk.Frame(table_frame)
|
||||
edit_row.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
edit_row.columnconfigure(0, weight=1)
|
||||
edit_row.columnconfigure(1, weight=0)
|
||||
self.item_description_var = tk.StringVar()
|
||||
description_combo = ttk.Combobox(
|
||||
edit_row,
|
||||
textvariable=self.item_description_var,
|
||||
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
|
||||
)
|
||||
description_combo.grid(row=0, column=0, sticky="ew", padx=(0, 8))
|
||||
description_combo.bind("<<ComboboxSelected>>", lambda _event: self._prefill_item_amount())
|
||||
amount_frame = ttk.Frame(edit_row, width=amount_column_width)
|
||||
amount_frame.grid(row=0, column=1, sticky="ns")
|
||||
amount_frame.pack_propagate(False)
|
||||
self.item_amount_var = tk.StringVar()
|
||||
ttk.Entry(amount_frame, textvariable=self.item_amount_var).pack(fill="both", expand=True)
|
||||
|
||||
buttons_row = ttk.Frame(table_frame)
|
||||
buttons_row.grid(row=2, column=0, sticky="w", pady=(8, 0))
|
||||
ttk.Button(buttons_row, text="Position hinzufügen", command=self._add_item).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(buttons_row, text="Aktualisieren", command=self._update_item).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(buttons_row, text="Position entfernen", command=self._remove_item).pack(side="left")
|
||||
|
||||
def _prefill_item_amount(self) -> None:
|
||||
description = self.item_description_var.get()
|
||||
for entry in self.policy["standard_fee_items"]:
|
||||
if str(entry.get("description", "")) == description:
|
||||
self.item_amount_var.set(str(entry.get("default_amount", "0.00")))
|
||||
return
|
||||
|
||||
def _refresh_items(self) -> None:
|
||||
self.items_tree.delete(*self.items_tree.get_children())
|
||||
for index, item in enumerate(self.items):
|
||||
self.items_tree.insert(
|
||||
"", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR")
|
||||
)
|
||||
|
||||
def _load_selected_item(self) -> None:
|
||||
selected = self.items_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
item = self.items[int(selected[0])]
|
||||
self.item_description_var.set(item["description"])
|
||||
self.item_amount_var.set(item["amount"])
|
||||
|
||||
def _add_item(self) -> None:
|
||||
description = self.item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.item_amount_var.get(), "Betrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.items.append({"description": description, "amount": money_text(amount)})
|
||||
self.item_description_var.set("")
|
||||
self.item_amount_var.set("")
|
||||
self._refresh_items()
|
||||
|
||||
def _update_item(self) -> None:
|
||||
selected = self.items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror(
|
||||
"Auswahl fehlt", "Bitte die zu ändernde Position auswählen.", parent=self
|
||||
)
|
||||
return
|
||||
description = self.item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.item_amount_var.get(), "Betrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.items[int(selected[0])] = {"description": description, "amount": money_text(amount)}
|
||||
self._refresh_items()
|
||||
|
||||
def _remove_item(self) -> None:
|
||||
selected = self.items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
del self.items[int(selected[0])]
|
||||
self.item_description_var.set("")
|
||||
self.item_amount_var.set("")
|
||||
self._refresh_items()
|
||||
|
||||
def _save(self):
|
||||
definition = self.definition_by_label[self.level_var.get()]
|
||||
try:
|
||||
self.repository.create_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
level=int(definition.get("level", 1)),
|
||||
name=str(definition.get("name", "Mahnung")),
|
||||
payment_deadline_days=int(self.deadline_var.get()),
|
||||
detail=self.detail_var.get(),
|
||||
fee=self.fee_var.get(),
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
deadline_days = int(self.deadline_var.get())
|
||||
except ValueError:
|
||||
messagebox.showerror(
|
||||
"Ungültige Frist", "Die Zahlungsfrist muss eine ganze Zahl sein.", parent=self
|
||||
)
|
||||
return
|
||||
try:
|
||||
if self.editing_reminder:
|
||||
self.repository.edit_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
str(self.editing_reminder["reminder_id"]),
|
||||
name=self.name_var.get(),
|
||||
payment_deadline_days=deadline_days,
|
||||
detail=self.detail_var.get(),
|
||||
items=self.items,
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
)
|
||||
else:
|
||||
self.repository.create_reminder_draft(
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
level=self.level,
|
||||
name=self.name_var.get(),
|
||||
payment_deadline_days=deadline_days,
|
||||
detail=self.detail_var.get(),
|
||||
items=self.items,
|
||||
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||
)
|
||||
except (ValueError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
@@ -801,6 +1200,109 @@ class ReminderDialog(_Dialog):
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class ReminderMailDialog(tk.Toplevel):
|
||||
def __init__(
|
||||
self,
|
||||
master,
|
||||
repository,
|
||||
member_id,
|
||||
claim_id,
|
||||
reminder_id,
|
||||
reminder_name,
|
||||
on_saved,
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.claim_id = claim_id
|
||||
self.reminder_id = reminder_id
|
||||
self.reminder_name = reminder_name
|
||||
self.on_saved = on_saved
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
self.sender_name_var = tk.StringVar(
|
||||
value=str(organization.get("name") or "Verwaltung C3MA")
|
||||
)
|
||||
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
||||
self.title("Mahnung als E-Mail erzeugen")
|
||||
self.transient(master)
|
||||
self.resizable(True, False)
|
||||
self.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung "
|
||||
"sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam."
|
||||
),
|
||||
wraplength=620,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
|
||||
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
|
||||
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
|
||||
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
|
||||
self.signature = tk.Text(self, width=55, height=5, wrap="word")
|
||||
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
|
||||
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
||||
buttons = ttk.Frame(self, padding=14)
|
||||
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="EML speichern und als versandt buchen",
|
||||
style="Accent.TButton",
|
||||
command=self._generate,
|
||||
).pack(side="left", padx=(8, 0))
|
||||
self.grab_set()
|
||||
|
||||
def _generate(self) -> None:
|
||||
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||
if delivery_mode is None:
|
||||
return
|
||||
output_path = None
|
||||
if delivery_mode == "local":
|
||||
selected = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="Mahnung als Thunderbird-Entwurf speichern",
|
||||
defaultextension=".eml",
|
||||
initialfile=f"{self.reminder_name}.eml",
|
||||
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
output_path = selected
|
||||
try:
|
||||
result = generate_and_send_reminder_mail(
|
||||
self.repository,
|
||||
self.member_id,
|
||||
self.claim_id,
|
||||
self.reminder_id,
|
||||
delivery_mode=delivery_mode,
|
||||
output_path=output_path,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
outcome = {
|
||||
"local": f"gespeichert:\n{result.export_path}",
|
||||
"send": "direkt versandt.",
|
||||
"drafts": "als Entwurf im Postfach abgelegt.",
|
||||
}[delivery_mode]
|
||||
messagebox.showinfo(
|
||||
"Mahnung erzeugt",
|
||||
f"Die E-Mail wurde {outcome}\n\nDie Mahnung wurde als versandt gebucht.",
|
||||
parent=self,
|
||||
)
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class DunningHoldDialog(_Dialog):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnsperre setzen", on_saved)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import CONTRIBUTION_OVERRIDE_KIND_LABELS
|
||||
from ccma.domain.dates import format_month_for_display, month_input_hint
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.labels import storage_key
|
||||
|
||||
|
||||
class _Dialog(tk.Toplevel):
|
||||
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
|
||||
super().__init__(master)
|
||||
self.on_saved = on_saved
|
||||
self.title(title)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
self.frame = ttk.Frame(self, padding=18)
|
||||
self.frame.pack(fill="both", expand=True)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _buttons(self, row: int, command: Callable[[], None]) -> None:
|
||||
buttons = ttk.Frame(self.frame)
|
||||
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
|
||||
|
||||
|
||||
class ContributionOverrideEditDialog(_Dialog):
|
||||
"""Records an individually agreed membership fee for a member -- e.g. a reduced
|
||||
student rate -- for a month-granular date range, either as a fixed annual amount
|
||||
or as a percentage discount off whichever base rate is in effect at the time."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
on_saved: Callable[[], None],
|
||||
override: dict | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
master,
|
||||
"Abweichenden Beitrag bearbeiten" if override else "Abweichenden Beitrag anlegen",
|
||||
on_saved,
|
||||
)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.override_id = str(override.get("override_id")) if override else None
|
||||
kind_label = CONTRIBUTION_OVERRIDE_KIND_LABELS.get(
|
||||
str(override.get("kind", "")) if override else "amount",
|
||||
CONTRIBUTION_OVERRIDE_KIND_LABELS["amount"],
|
||||
)
|
||||
self.variables = {
|
||||
"valid_from": tk.StringVar(
|
||||
value=format_month_for_display(str(override.get("valid_from", ""))) if override else ""
|
||||
),
|
||||
"valid_until": tk.StringVar(
|
||||
value=format_month_for_display(str(override.get("valid_until", ""))) if override else ""
|
||||
),
|
||||
"kind": tk.StringVar(value=kind_label),
|
||||
"value": tk.StringVar(value=str(override.get("value", "")) if override else ""),
|
||||
}
|
||||
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
ttk.Label(self.frame, text=f"Ab ({month_input_hint()})").grid(
|
||||
row=0, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables["valid_from"], width=42).grid(
|
||||
row=0, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(self.frame, text=f"Bis ({month_input_hint()}, leer = unbefristet)").grid(
|
||||
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables["valid_until"], width=42).grid(
|
||||
row=1, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(self.frame, text="Art").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
kind_combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.variables["kind"],
|
||||
values=list(CONTRIBUTION_OVERRIDE_KIND_LABELS.values()),
|
||||
state="readonly",
|
||||
width=39,
|
||||
)
|
||||
kind_combo.grid(row=2, column=1, sticky="ew", pady=5)
|
||||
kind_combo.bind("<<ComboboxSelected>>", lambda _event: self._update_value_label())
|
||||
self.value_label_var = tk.StringVar()
|
||||
ttk.Label(self.frame, textvariable=self.value_label_var).grid(
|
||||
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(self.frame, textvariable=self.variables["value"], width=42).grid(
|
||||
row=3, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(self.frame, text="Begründung").grid(row=4, column=0, sticky="nw", pady=5, padx=(0, 12))
|
||||
self.reason_text = tk.Text(self.frame, width=42, height=4, wrap="word")
|
||||
self.reason_text.grid(row=4, column=1, sticky="ew", pady=5)
|
||||
if override:
|
||||
self.reason_text.insert("1.0", str(override.get("reason", "")))
|
||||
self._update_value_label()
|
||||
self._buttons(5, self._save)
|
||||
|
||||
def _update_value_label(self) -> None:
|
||||
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
|
||||
self.value_label_var.set("Jahresbetrag (EUR)" if kind == "amount" else "Ermäßigung (%)")
|
||||
|
||||
def _save(self) -> None:
|
||||
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
|
||||
reason = self.reason_text.get("1.0", "end-1c")
|
||||
try:
|
||||
if self.override_id:
|
||||
self.repository.update_contribution_override(
|
||||
self.member_id,
|
||||
self.override_id,
|
||||
valid_from=self.variables["valid_from"].get(),
|
||||
valid_until=self.variables["valid_until"].get(),
|
||||
kind=kind,
|
||||
value=self.variables["value"].get(),
|
||||
reason=reason,
|
||||
)
|
||||
else:
|
||||
self.repository.record_contribution_override(
|
||||
self.member_id,
|
||||
valid_from=self.variables["valid_from"].get(),
|
||||
valid_until=self.variables["valid_until"].get(),
|
||||
kind=kind,
|
||||
value=self.variables["value"].get(),
|
||||
reason=reason,
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror(
|
||||
"Abweichender Beitrag konnte nicht gespeichert werden", str(exc), parent=self
|
||||
)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
+141
-19
@@ -41,6 +41,7 @@ class NewMemberDialog(tk.Toplevel):
|
||||
"email",
|
||||
"phone",
|
||||
"birth_date",
|
||||
"application_date",
|
||||
"member_number",
|
||||
)
|
||||
}
|
||||
@@ -63,6 +64,7 @@ class NewMemberDialog(tk.Toplevel):
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
(f"Antragsdatum ({date_input_hint()})", "application_date"),
|
||||
]
|
||||
if self.number_policy["mode"] == "manual":
|
||||
fields.append(("Mitgliedsnummer *", "member_number"))
|
||||
@@ -124,13 +126,31 @@ class NewAssetDialog(tk.Toplevel):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.on_created = on_created
|
||||
self.members = repository.list_members()
|
||||
self.member_labels = {
|
||||
f"{member.member_number or member.member_id} · {member.display_name}": member.member_id
|
||||
for member in self.members
|
||||
}
|
||||
self.title("Neues Asset")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
self.variables = {
|
||||
name: tk.StringVar()
|
||||
for name in ("label", "category", "inventory_number", "serial_number", "deposit_amount_default")
|
||||
for name in (
|
||||
"label",
|
||||
"category",
|
||||
"inventory_number",
|
||||
"serial_number",
|
||||
"deposit_amount_default",
|
||||
"location",
|
||||
"handed_over_at",
|
||||
"condition",
|
||||
"estimated_value",
|
||||
"owner_name",
|
||||
)
|
||||
}
|
||||
self.owner_type_var = tk.StringVar(value="VEREIN")
|
||||
self.owner_member_var = tk.StringVar()
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.bind("<Return>", lambda _event: self._create())
|
||||
@@ -145,6 +165,10 @@ class NewAssetDialog(tk.Toplevel):
|
||||
("Inventarnummer", "inventory_number"),
|
||||
("Seriennummer", "serial_number"),
|
||||
("Kaution (EUR)", "deposit_amount_default"),
|
||||
("Standort", "location"),
|
||||
("Übergabedatum", "handed_over_at"),
|
||||
("Zustand bei Übergabe", "condition"),
|
||||
("Wert / Kaufpreis", "estimated_value"),
|
||||
]
|
||||
self.entries: dict[str, ttk.Entry] = {}
|
||||
for row, (label, key) in enumerate(fields):
|
||||
@@ -152,17 +176,42 @@ class NewAssetDialog(tk.Toplevel):
|
||||
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38)
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=5)
|
||||
self.entries[key] = entry
|
||||
owner_row = len(fields)
|
||||
ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_type_var,
|
||||
values=("VEREIN", "MITGLIED", "EXTERN"),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Eigentümer-Mitglied").grid(
|
||||
row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_member_var,
|
||||
values=list(self.member_labels),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row + 1, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Externer Eigentümer").grid(
|
||||
row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid(
|
||||
row=owner_row + 2, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(frame, text="Interne Notiz").grid(
|
||||
row=len(fields),
|
||||
row=owner_row + 3,
|
||||
column=0,
|
||||
sticky="nw",
|
||||
pady=5,
|
||||
padx=(0, 12),
|
||||
)
|
||||
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
|
||||
self.notes_text.grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||
self.notes_text.grid(row=owner_row + 3, column=1, sticky="ew", pady=5)
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
buttons.grid(row=owner_row + 4, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(
|
||||
buttons,
|
||||
@@ -176,9 +225,15 @@ class NewAssetDialog(tk.Toplevel):
|
||||
_activate_modal_window(self, self.entries.get("label"))
|
||||
|
||||
def _create(self) -> None:
|
||||
owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[self.owner_type_var.get()]
|
||||
try:
|
||||
asset = self.repository.create_asset(
|
||||
**{key: variable.get() for key, variable in self.variables.items()},
|
||||
**{key: variable.get() for key, variable in self.variables.items() if key != "owner_name"},
|
||||
owner_type=owner_type,
|
||||
owner_member_id=(
|
||||
self.member_labels.get(self.owner_member_var.get(), "") if owner_type == "member" else ""
|
||||
),
|
||||
owner_name=self.variables["owner_name"].get() if owner_type == "external" else "",
|
||||
notes=self.notes_text.get("1.0", "end-1c"),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
@@ -201,6 +256,19 @@ class EditAssetDialog(tk.Toplevel):
|
||||
self.asset_id = asset_id
|
||||
self.on_saved = on_saved
|
||||
self.asset = repository.get_asset(asset_id)
|
||||
self.members = repository.list_members()
|
||||
self.member_labels = {
|
||||
f"{member.member_number or member.member_id} · {member.display_name}": member.member_id
|
||||
for member in self.members
|
||||
}
|
||||
owner_member_label = next(
|
||||
(
|
||||
label
|
||||
for label, member_id in self.member_labels.items()
|
||||
if member_id == self.asset.owner_member_id
|
||||
),
|
||||
"",
|
||||
)
|
||||
self.title("Asset bearbeiten")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
@@ -211,7 +279,18 @@ class EditAssetDialog(tk.Toplevel):
|
||||
"serial_number": tk.StringVar(value=self.asset.serial_number),
|
||||
"deposit_amount_default": tk.StringVar(value=self.asset.deposit_amount_default),
|
||||
"status": tk.StringVar(value=self.asset.status),
|
||||
"location": tk.StringVar(value=self.asset.location),
|
||||
"handed_over_at": tk.StringVar(value=self.asset.handed_over_at),
|
||||
"condition": tk.StringVar(value=self.asset.condition),
|
||||
"estimated_value": tk.StringVar(value=self.asset.estimated_value),
|
||||
"owner_name": tk.StringVar(value=self.asset.owner_name),
|
||||
}
|
||||
self.owner_type_var = tk.StringVar(
|
||||
value={"club": "VEREIN", "member": "MITGLIED", "external": "EXTERN"}.get(
|
||||
self.asset.owner_type, "VEREIN"
|
||||
)
|
||||
)
|
||||
self.owner_member_var = tk.StringVar(value=owner_member_label)
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.bind("<Return>", lambda _event: self._save())
|
||||
@@ -227,6 +306,10 @@ class EditAssetDialog(tk.Toplevel):
|
||||
("Inventarnummer", "inventory_number"),
|
||||
("Seriennummer", "serial_number"),
|
||||
("Kaution (EUR)", "deposit_amount_default"),
|
||||
("Standort", "location"),
|
||||
("Übergabedatum", "handed_over_at"),
|
||||
("Zustand bei Übergabe", "condition"),
|
||||
("Wert / Kaufpreis", "estimated_value"),
|
||||
]
|
||||
self.entries: dict[str, ttk.Entry] = {}
|
||||
for row, (label, key) in enumerate(fields):
|
||||
@@ -235,12 +318,40 @@ class EditAssetDialog(tk.Toplevel):
|
||||
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38, state=state)
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=5)
|
||||
self.entries[key] = entry
|
||||
ttk.Label(frame, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
status_values = [value for key, value in (
|
||||
("available", "VERFUEGBAR"),
|
||||
("lost", "VERLOREN"),
|
||||
("retired", "AUSGEMUSTERT"),
|
||||
)]
|
||||
owner_row = len(fields)
|
||||
ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_type_var,
|
||||
values=("VEREIN", "MITGLIED", "EXTERN"),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Eigentümer-Mitglied").grid(
|
||||
row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Combobox(
|
||||
frame,
|
||||
textvariable=self.owner_member_var,
|
||||
values=list(self.member_labels),
|
||||
state="readonly",
|
||||
width=35,
|
||||
).grid(row=owner_row + 1, column=1, sticky="ew", pady=5)
|
||||
ttk.Label(frame, text="Externer Eigentümer").grid(
|
||||
row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||
)
|
||||
ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid(
|
||||
row=owner_row + 2, column=1, sticky="ew", pady=5
|
||||
)
|
||||
ttk.Label(frame, text="Status").grid(row=owner_row + 3, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
status_values = [
|
||||
value
|
||||
for key, value in (
|
||||
("available", "VERFUEGBAR"),
|
||||
("lost", "VERLOREN"),
|
||||
("retired", "AUSGEMUSTERT"),
|
||||
)
|
||||
]
|
||||
self.status_map = {
|
||||
"VERFUEGBAR": "available",
|
||||
"VERLOREN": "lost",
|
||||
@@ -260,8 +371,8 @@ class EditAssetDialog(tk.Toplevel):
|
||||
state="readonly" if not issued else "disabled",
|
||||
width=35,
|
||||
)
|
||||
self.status_box.grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||
note_row = len(fields) + 1
|
||||
self.status_box.grid(row=owner_row + 3, column=1, sticky="ew", pady=5)
|
||||
note_row = owner_row + 4
|
||||
ttk.Label(frame, text="Interne Notiz").grid(row=note_row, column=0, sticky="nw", pady=5, padx=(0, 12))
|
||||
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
|
||||
self.notes_text.grid(row=note_row, column=1, sticky="ew", pady=5)
|
||||
@@ -290,6 +401,21 @@ class EditAssetDialog(tk.Toplevel):
|
||||
self.asset.inventory_number = self.variables["inventory_number"].get()
|
||||
self.asset.serial_number = self.variables["serial_number"].get()
|
||||
self.asset.notes = self.notes_text.get("1.0", "end-1c")
|
||||
self.asset.location = self.variables["location"].get()
|
||||
self.asset.handed_over_at = self.variables["handed_over_at"].get()
|
||||
self.asset.condition = self.variables["condition"].get()
|
||||
self.asset.estimated_value = self.variables["estimated_value"].get()
|
||||
self.asset.owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[
|
||||
self.owner_type_var.get()
|
||||
]
|
||||
self.asset.owner_member_id = (
|
||||
self.member_labels.get(self.owner_member_var.get(), "")
|
||||
if self.asset.owner_type == "member"
|
||||
else ""
|
||||
)
|
||||
self.asset.owner_name = (
|
||||
self.variables["owner_name"].get() if self.asset.owner_type == "external" else ""
|
||||
)
|
||||
if not self.asset.current_holder_member_id:
|
||||
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get()
|
||||
self.asset.status = self.status_map.get(self.status_var.get(), self.asset.status)
|
||||
@@ -394,9 +520,7 @@ class IssueAssetDialog(tk.Toplevel):
|
||||
self.member_tree.delete(*self.member_tree.get_children())
|
||||
query = self.search_var.get().strip().casefold()
|
||||
filtered = [
|
||||
member
|
||||
for member in self.members
|
||||
if not query or query in self._member_search_text(member)
|
||||
member for member in self.members if not query or query in self._member_search_text(member)
|
||||
]
|
||||
for member in filtered:
|
||||
self.member_tree.insert(
|
||||
@@ -599,9 +723,7 @@ class IntegrityWarningDialog(tk.Toplevel):
|
||||
text="\n".join(f"• {item}" for item in self.warnings),
|
||||
style="Warning.TLabel",
|
||||
justify="left",
|
||||
).grid(
|
||||
row=1, column=0, sticky="w", pady=(12, 0)
|
||||
)
|
||||
).grid(row=1, column=0, sticky="w", pady=(12, 0))
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.grid(row=2, column=0, sticky="e", pady=(18, 0))
|
||||
ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
decimal_value,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
class _Dialog(tk.Toplevel):
|
||||
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
|
||||
super().__init__(master)
|
||||
self.on_saved = on_saved
|
||||
self.title(title)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
self.frame = ttk.Frame(self, padding=18)
|
||||
self.frame.pack(fill="both", expand=True)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _buttons(self, row: int, command: Callable[[], None]) -> None:
|
||||
buttons = ttk.Frame(self.frame)
|
||||
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
|
||||
|
||||
|
||||
class DonationEditDialog(_Dialog):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
on_saved: Callable[[], None],
|
||||
donation: dict | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
master, "Spende bearbeiten" if donation else "Spende anlegen", on_saved
|
||||
)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.donation_id = str(donation.get("donation_id")) if donation else None
|
||||
initial_date = str(donation.get("date", "")) if donation else date.today().isoformat()
|
||||
self.variables = {
|
||||
"date": tk.StringVar(value=format_date_for_display(initial_date)),
|
||||
"amount": tk.StringVar(value=str(donation.get("amount", "")) if donation else ""),
|
||||
"reference": tk.StringVar(value=str(donation.get("reference", "")) if donation else ""),
|
||||
"purpose": tk.StringVar(value=str(donation.get("purpose", "")) if donation else ""),
|
||||
}
|
||||
fields = (
|
||||
(f"Spendendatum ({date_input_hint()})", "date"),
|
||||
("Betrag", "amount"),
|
||||
("Referenz", "reference"),
|
||||
("Verwendungszweck", "purpose"),
|
||||
)
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.variables[key], width=42).grid(
|
||||
row=row, column=1, sticky="ew", pady=5
|
||||
)
|
||||
self._buttons(len(fields), self._save)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
if self.donation_id:
|
||||
self.repository.update_donation(
|
||||
self.member_id,
|
||||
self.donation_id,
|
||||
donation_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
reference=self.variables["reference"].get(),
|
||||
purpose=self.variables["purpose"].get(),
|
||||
)
|
||||
else:
|
||||
self.repository.record_donation(
|
||||
self.member_id,
|
||||
donation_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
reference=self.variables["reference"].get(),
|
||||
purpose=self.variables["purpose"].get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Spende konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class DonationPaymentDialog(_Dialog):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
donation_id: str,
|
||||
balance: Decimal,
|
||||
on_saved: Callable[[], None],
|
||||
):
|
||||
super().__init__(master, "Zahlung für Spende erfassen", on_saved)
|
||||
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
|
||||
initial = money_text(max(balance, Decimal("0")))
|
||||
self.variables = {
|
||||
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
|
||||
"amount": tk.StringVar(value=initial),
|
||||
"allocation": tk.StringVar(value=initial),
|
||||
"gnucash": tk.StringVar(),
|
||||
"reference": tk.StringVar(),
|
||||
}
|
||||
fields = (
|
||||
(f"Zahlungsdatum ({date_input_hint()})", "date"),
|
||||
("Zahlungsbetrag", "amount"),
|
||||
("Dieser Spende zuordnen", "allocation"),
|
||||
("GnuCash-ID (optional)", "gnucash"),
|
||||
("Referenz", "reference"),
|
||||
)
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.variables[key], width=38).grid(row=row, column=1, pady=5)
|
||||
self._buttons(len(fields), self._save)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.repository.record_donation_payment(
|
||||
self.member_id,
|
||||
self.donation_id,
|
||||
payment_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
allocation_amount=self.variables["allocation"].get(),
|
||||
gnucash_transaction_id=self.variables["gnucash"].get(),
|
||||
reference=self.variables["reference"].get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class AllocateDonationPaymentDialog(_Dialog):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
donation_id: str,
|
||||
balance: Decimal,
|
||||
on_saved: Callable[[], None],
|
||||
):
|
||||
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
|
||||
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
|
||||
data = repository.get_contributions(member_id)
|
||||
self.payment_by_label = {}
|
||||
for payment in sorted(
|
||||
data.payments,
|
||||
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
||||
reverse=True,
|
||||
):
|
||||
payment_id = str(payment.get("payment_id", ""))
|
||||
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
|
||||
if available <= 0:
|
||||
continue
|
||||
label = (
|
||||
f"{payment.get('date', '')} · {money_text(available)} EUR frei · "
|
||||
f"{payment.get('reference', '')}"
|
||||
)
|
||||
self.payment_by_label[label] = (payment_id, available)
|
||||
self.payment_var = tk.StringVar()
|
||||
self.amount_var = tk.StringVar(value=money_text(max(balance, Decimal("0"))))
|
||||
self.frame.columnconfigure(1, weight=1)
|
||||
ttk.Label(self.frame, text="Zahlung").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
combo = ttk.Combobox(
|
||||
self.frame,
|
||||
textvariable=self.payment_var,
|
||||
values=list(self.payment_by_label),
|
||||
state="readonly",
|
||||
width=60,
|
||||
)
|
||||
combo.grid(row=0, column=1, pady=5)
|
||||
if not self.payment_by_label:
|
||||
ttk.Label(
|
||||
self.frame,
|
||||
text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.",
|
||||
style="Mono.TLabel",
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5))
|
||||
amount_row = 2
|
||||
else:
|
||||
amount_row = 1
|
||||
ttk.Label(self.frame, text="Betrag").grid(row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||
ttk.Entry(self.frame, textvariable=self.amount_var).grid(
|
||||
row=amount_row, column=1, sticky="ew", pady=5
|
||||
)
|
||||
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
|
||||
self._buttons(amount_row + 1, self._save)
|
||||
|
||||
def _select(self, balance: Decimal) -> None:
|
||||
_payment_id, available = self.payment_by_label[self.payment_var.get()]
|
||||
self.amount_var.set(money_text(min(available, max(balance, Decimal("0")))))
|
||||
|
||||
def _save(self) -> None:
|
||||
selected = self.payment_by_label.get(self.payment_var.get())
|
||||
if not selected:
|
||||
messagebox.showerror("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
||||
return
|
||||
try:
|
||||
self.repository.allocate_payment_to_donation(
|
||||
self.member_id, self.donation_id, payment_id=selected[0], amount=self.amount_var.get()
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from ccma.storage.repository import MemberRepository
|
||||
|
||||
|
||||
class EmailDeliveryChoiceDialog(tk.Toplevel):
|
||||
"""Shown once per generation action (not per individual e-mail) when the
|
||||
repository's delivery mode is "ask" -- lets the board decide per-batch whether
|
||||
to send immediately or park the e-mail(s) as a draft for later review."""
|
||||
|
||||
def __init__(self, master: tk.Misc, *, can_send: bool, can_draft: bool):
|
||||
super().__init__(master)
|
||||
self.result: str | None = None
|
||||
self.title("E-Mail-Versand")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
frame = ttk.Frame(self, padding=18)
|
||||
frame.pack(fill="both", expand=True)
|
||||
ttk.Label(
|
||||
frame,
|
||||
text="Wie soll mit dieser E-Mail verfahren werden?",
|
||||
wraplength=360,
|
||||
justify="left",
|
||||
).pack(anchor="w", pady=(0, 14))
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.pack(anchor="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self._cancel).pack(side="left", padx=(0, 8))
|
||||
send_button = ttk.Button(buttons, text="Senden", command=self._choose_send)
|
||||
send_button.pack(side="left", padx=(0, 8))
|
||||
send_button.configure(state="normal" if can_send else "disabled")
|
||||
draft_button = ttk.Button(
|
||||
buttons, text="Als Entwurf ablegen", style="Accent.TButton", command=self._choose_drafts
|
||||
)
|
||||
draft_button.pack(side="left")
|
||||
draft_button.configure(state="normal" if can_draft else "disabled")
|
||||
self.bind("<Escape>", lambda _event: self._cancel())
|
||||
self.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _choose_send(self) -> None:
|
||||
self.result = "send"
|
||||
self.destroy()
|
||||
|
||||
def _choose_drafts(self) -> None:
|
||||
self.result = "drafts"
|
||||
self.destroy()
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
|
||||
def resolve_delivery_mode(master: tk.Misc, repository: MemberRepository) -> str | None:
|
||||
"""Returns the delivery mode to use for the next generated e-mail(s): "local",
|
||||
"send" or "drafts" -- or None if the board cancelled out of the "ask every time"
|
||||
prompt, in which case the caller should abort without generating anything."""
|
||||
settings = repository.get_email_settings()
|
||||
mode = settings["delivery_mode"]
|
||||
if mode in {"local", "send", "drafts"}:
|
||||
return mode
|
||||
dialog = EmailDeliveryChoiceDialog(
|
||||
master, can_send=bool(settings["smtp_host"]), can_draft=bool(settings["imap_host"])
|
||||
)
|
||||
master.wait_window(dialog)
|
||||
return dialog.result
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.contributions import money_text
|
||||
from ccma.domain.dates import (
|
||||
DateValidationError,
|
||||
date_input_hint,
|
||||
format_date_for_display,
|
||||
normalize_date_input,
|
||||
)
|
||||
from ccma.services.gnucash_import import (
|
||||
GnuCashAccount,
|
||||
GnuCashImportError,
|
||||
GnuCashTransaction,
|
||||
list_accounts,
|
||||
list_transactions,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.monitors import centered_geometry, preferred_monitor
|
||||
|
||||
|
||||
class GnuCashImportDialog(tk.Toplevel):
|
||||
"""Lets the board point at a GnuCash file, pick one account, narrow its bookings
|
||||
down with filters, and select (multi-select: click, Ctrl+click, Shift+click for
|
||||
ranges) which ones to import as payments for this member.
|
||||
|
||||
Only bookings with a positive amount on the selected account are offered (money
|
||||
coming in). A booking whose date + amount already match an existing payment for
|
||||
this member is flagged, but can still be selected -- at import time, the board is
|
||||
asked whether to skip those or instead adopt the booking's description onto the
|
||||
already-recorded payment, rather than silently blocking them."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
config: AppConfig,
|
||||
on_imported: Callable[[], None],
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.config = config
|
||||
self.on_imported = on_imported
|
||||
|
||||
self.accounts: list[GnuCashAccount] = []
|
||||
self.account_by_label: dict[str, GnuCashAccount] = {}
|
||||
self.transactions: list[GnuCashTransaction] = []
|
||||
self.existing_payment_by_key: dict[tuple[str, str], str] = {}
|
||||
|
||||
self.title("Zahlungen aus GnuCash importieren")
|
||||
self.transient(master.winfo_toplevel())
|
||||
monitor = preferred_monitor(master.winfo_toplevel())
|
||||
self.geometry(centered_geometry(round(monitor.width * 0.8), round(monitor.height * 0.8), monitor))
|
||||
self.minsize(780, 480)
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self._build_ui()
|
||||
self._load_existing_payment_keys()
|
||||
self.after_idle(self._activate)
|
||||
if self.file_var.get().strip():
|
||||
self._load_file()
|
||||
|
||||
def _activate(self) -> None:
|
||||
try:
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
return
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(2, weight=1)
|
||||
|
||||
file_frame = ttk.Frame(self, padding=(16, 16, 16, 8))
|
||||
file_frame.grid(row=0, column=0, sticky="ew")
|
||||
file_frame.columnconfigure(1, weight=1)
|
||||
self.file_var = tk.StringVar(value=self.config.gnucash_path)
|
||||
ttk.Label(file_frame, text="GnuCash-Datei").grid(row=0, column=0, sticky="w", padx=(0, 12))
|
||||
ttk.Entry(file_frame, textvariable=self.file_var).grid(row=0, column=1, sticky="ew")
|
||||
ttk.Button(file_frame, text="Durchsuchen…", command=self._browse_file).grid(
|
||||
row=0, column=2, padx=(8, 0)
|
||||
)
|
||||
ttk.Label(file_frame, text="Konto").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=(8, 0))
|
||||
self.account_var = tk.StringVar()
|
||||
self.account_combo = ttk.Combobox(
|
||||
file_frame, textvariable=self.account_var, state="readonly"
|
||||
)
|
||||
self.account_combo.grid(row=1, column=1, sticky="ew", pady=(8, 0))
|
||||
self.account_combo.bind("<<ComboboxSelected>>", lambda _event: self._account_selected())
|
||||
ttk.Button(file_frame, text="Datei laden", command=self._load_file).grid(
|
||||
row=1, column=2, padx=(8, 0), pady=(8, 0)
|
||||
)
|
||||
|
||||
filter_frame = ttk.Frame(self, padding=(16, 0, 16, 8))
|
||||
filter_frame.grid(row=1, column=0, sticky="ew")
|
||||
ttk.Label(filter_frame, text="Beschreibung enthält").pack(side="left")
|
||||
self.description_filter_var = tk.StringVar()
|
||||
ttk.Entry(filter_frame, textvariable=self.description_filter_var, width=26).pack(
|
||||
side="left", padx=(8, 16)
|
||||
)
|
||||
self.description_filter_var.trace_add("write", lambda *_args: self._render_transactions())
|
||||
ttk.Label(filter_frame, text=f"Von ({date_input_hint()})").pack(side="left")
|
||||
self.date_from_var = tk.StringVar()
|
||||
ttk.Entry(filter_frame, textvariable=self.date_from_var, width=12).pack(side="left", padx=(8, 16))
|
||||
self.date_from_var.trace_add("write", lambda *_args: self._render_transactions())
|
||||
ttk.Label(filter_frame, text=f"Bis ({date_input_hint()})").pack(side="left")
|
||||
self.date_to_var = tk.StringVar()
|
||||
ttk.Entry(filter_frame, textvariable=self.date_to_var, width=12).pack(side="left", padx=(8, 0))
|
||||
self.date_to_var.trace_add("write", lambda *_args: self._render_transactions())
|
||||
|
||||
tree_frame = ttk.Frame(self, padding=(16, 0, 16, 8))
|
||||
tree_frame.grid(row=2, column=0, sticky="nsew")
|
||||
tree_frame.columnconfigure(0, weight=1)
|
||||
tree_frame.rowconfigure(0, weight=1)
|
||||
self.tree = ttk.Treeview(
|
||||
tree_frame,
|
||||
columns=("date", "description", "memo", "amount", "status"),
|
||||
show="headings",
|
||||
selectmode="extended",
|
||||
)
|
||||
for key, title, width in (
|
||||
("date", "Datum", 100),
|
||||
("description", "Beschreibung", 280),
|
||||
("memo", "Memo", 180),
|
||||
("amount", "Betrag", 100),
|
||||
("status", "Status", 220),
|
||||
):
|
||||
self.tree.heading(key, text=title)
|
||||
self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"})
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
self.tree.tag_configure("duplicate", background="#7a2323", foreground="#ffffff")
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Es werden nur Buchungen mit positivem Betrag auf dem gewählten Konto angezeigt "
|
||||
"(eingehende Zahlungen). Mehrfachauswahl per Klick, Strg+Klick oder Umschalt+Klick. "
|
||||
"Rot markierte Buchungen haben Datum und Betrag einer bereits vorhandenen Zahlung "
|
||||
"dieses Mitglieds; beim Import kann gewählt werden, ob diese übersprungen werden "
|
||||
"oder ob die Beschreibung der vorhandenen Zahlung ersetzt wird."
|
||||
),
|
||||
style="Mono.TLabel",
|
||||
wraplength=900,
|
||||
).grid(row=3, column=0, sticky="w", padx=16)
|
||||
|
||||
summary_frame = ttk.Frame(self, padding=(16, 8, 16, 8))
|
||||
summary_frame.grid(row=4, column=0, sticky="ew")
|
||||
self.summary_var = tk.StringVar(value="Keine Datei geladen.")
|
||||
ttk.Label(summary_frame, textvariable=self.summary_var, style="Mono.TLabel").pack(side="left")
|
||||
|
||||
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
||||
buttons.grid(row=5, column=0, sticky="e")
|
||||
ttk.Button(buttons, text="Schließen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
self.import_button = ttk.Button(
|
||||
buttons,
|
||||
text="Ausgewählte importieren",
|
||||
style="Accent.TButton",
|
||||
command=self._import_selected,
|
||||
)
|
||||
self.import_button.pack(side="left")
|
||||
|
||||
def _load_existing_payment_keys(self) -> None:
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
self.existing_payment_by_key = {}
|
||||
for payment in data.payments:
|
||||
key = (str(payment.get("date", "")), money_text(payment.get("amount", "0")))
|
||||
self.existing_payment_by_key.setdefault(key, str(payment.get("payment_id", "")))
|
||||
|
||||
def _browse_file(self) -> None:
|
||||
current_text = self.file_var.get().strip()
|
||||
current = Path(current_text).expanduser() if current_text else Path.home()
|
||||
initial_dir = current.parent if current.is_file() else current
|
||||
selected = filedialog.askopenfilename(
|
||||
parent=self,
|
||||
title="GnuCash-Datei auswählen",
|
||||
initialdir=str(initial_dir) if initial_dir.is_dir() else str(Path.home()),
|
||||
filetypes=[("GnuCash-Dateien", "*.gnucash *.xac"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
if selected:
|
||||
self.file_var.set(selected)
|
||||
self._load_file()
|
||||
|
||||
def _load_file(self) -> None:
|
||||
path_text = self.file_var.get().strip()
|
||||
if not path_text:
|
||||
messagebox.showinfo("GnuCash-Datei", "Bitte zuerst eine GnuCash-Datei auswählen.", parent=self)
|
||||
return
|
||||
path = Path(path_text).expanduser()
|
||||
if not path.is_file():
|
||||
messagebox.showerror("GnuCash-Datei", "Die ausgewählte Datei existiert nicht.", parent=self)
|
||||
return
|
||||
try:
|
||||
self.accounts = list_accounts(path)
|
||||
except GnuCashImportError as exc:
|
||||
messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self)
|
||||
return
|
||||
self.account_by_label = {
|
||||
f"{account.full_name} ({account.account_type})": account for account in self.accounts
|
||||
}
|
||||
self.account_combo.configure(values=list(self.account_by_label))
|
||||
if self.account_by_label and self.account_var.get() not in self.account_by_label:
|
||||
self.account_var.set(self._preferred_account_label() or next(iter(self.account_by_label)))
|
||||
if self.account_var.get():
|
||||
self._account_selected()
|
||||
else:
|
||||
self.transactions = []
|
||||
self._render_transactions()
|
||||
if path_text != self.config.gnucash_path:
|
||||
self.config.gnucash_path = path_text
|
||||
self.config.save()
|
||||
|
||||
def _current_file_key(self) -> str | None:
|
||||
path_text = self.file_var.get().strip()
|
||||
if not path_text:
|
||||
return None
|
||||
path = Path(path_text).expanduser()
|
||||
if not path.is_file():
|
||||
return None
|
||||
return str(path.resolve())
|
||||
|
||||
def _preferred_account_label(self) -> str | None:
|
||||
file_key = self._current_file_key()
|
||||
if file_key is None:
|
||||
return None
|
||||
last_guid = self.config.gnucash_last_accounts.get(file_key)
|
||||
if not last_guid:
|
||||
return None
|
||||
return next(
|
||||
(label for label, account in self.account_by_label.items() if account.guid == last_guid),
|
||||
None,
|
||||
)
|
||||
|
||||
def _account_selected(self) -> None:
|
||||
account = self.account_by_label.get(self.account_var.get())
|
||||
if account is None:
|
||||
return
|
||||
path = Path(self.file_var.get().strip()).expanduser()
|
||||
try:
|
||||
all_transactions = list_transactions(path, account.guid)
|
||||
except GnuCashImportError as exc:
|
||||
messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self)
|
||||
self.transactions = []
|
||||
self._render_transactions()
|
||||
return
|
||||
self.transactions = [item for item in all_transactions if item.amount > 0]
|
||||
self._render_transactions()
|
||||
file_key = self._current_file_key()
|
||||
if file_key is not None and self.config.gnucash_last_accounts.get(file_key) != account.guid:
|
||||
self.config.gnucash_last_accounts[file_key] = account.guid
|
||||
self.config.save()
|
||||
|
||||
def _filtered_transactions(self) -> list[GnuCashTransaction]:
|
||||
needle = self.description_filter_var.get().strip().casefold()
|
||||
try:
|
||||
date_from = normalize_date_input(self.date_from_var.get(), "Von") or None
|
||||
except DateValidationError:
|
||||
date_from = None
|
||||
try:
|
||||
date_to = normalize_date_input(self.date_to_var.get(), "Bis") or None
|
||||
except DateValidationError:
|
||||
date_to = None
|
||||
result = []
|
||||
for item in self.transactions:
|
||||
if needle and needle not in item.description.casefold() and needle not in item.memo.casefold():
|
||||
continue
|
||||
iso = item.date.isoformat()
|
||||
if date_from and iso < date_from:
|
||||
continue
|
||||
if date_to and iso > date_to:
|
||||
continue
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def _duplicate_payment_id(self, item: GnuCashTransaction) -> str | None:
|
||||
return self.existing_payment_by_key.get((item.date.isoformat(), money_text(item.amount)))
|
||||
|
||||
def _is_duplicate(self, item: GnuCashTransaction) -> bool:
|
||||
return self._duplicate_payment_id(item) is not None
|
||||
|
||||
def _render_transactions(self) -> None:
|
||||
previously_selected = set(self.tree.selection())
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
for item in self._filtered_transactions():
|
||||
duplicate = self._is_duplicate(item)
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=item.guid,
|
||||
values=(
|
||||
format_date_for_display(item.date.isoformat()),
|
||||
item.description,
|
||||
item.memo,
|
||||
f"{money_text(item.amount)} EUR",
|
||||
"Bereits vorhanden" if duplicate else "",
|
||||
),
|
||||
tags=("duplicate",) if duplicate else (),
|
||||
)
|
||||
still_present = [guid for guid in previously_selected if self.tree.exists(guid)]
|
||||
if still_present:
|
||||
self.tree.selection_set(still_present)
|
||||
self._update_summary()
|
||||
|
||||
def _update_summary(self) -> None:
|
||||
filtered = self._filtered_transactions()
|
||||
selected_guids = set(self.tree.selection())
|
||||
selected_items = [item for item in filtered if item.guid in selected_guids]
|
||||
total = sum((item.amount for item in selected_items), Decimal("0"))
|
||||
self.summary_var.set(
|
||||
f"{len(filtered)} Buchungen gefunden · {len(selected_items)} ausgewählt · "
|
||||
f"{money_text(total)} EUR"
|
||||
)
|
||||
|
||||
def _import_selected(self) -> None:
|
||||
selected_guids = set(self.tree.selection())
|
||||
selected_items = [item for item in self.transactions if item.guid in selected_guids]
|
||||
if not selected_items:
|
||||
messagebox.showinfo("Import", "Bitte mindestens eine Buchung auswählen.", parent=self)
|
||||
return
|
||||
|
||||
new_items = [item for item in selected_items if not self._is_duplicate(item)]
|
||||
duplicate_items = [item for item in selected_items if self._is_duplicate(item)]
|
||||
|
||||
replace_description = False
|
||||
if duplicate_items:
|
||||
response = messagebox.askyesnocancel(
|
||||
"Bereits vorhandene Buchungen ausgewählt",
|
||||
(
|
||||
f"{len(duplicate_items)} der ausgewählten Buchungen stimmen in Datum und Betrag "
|
||||
"mit bereits vorhandenen Zahlungen dieses Mitglieds überein.\n\n"
|
||||
"Ja: Diese werden beim Import übersprungen.\n"
|
||||
"Nein: Bei diesen wird stattdessen die Beschreibung der vorhandenen Zahlung "
|
||||
"durch die Beschreibung aus GnuCash ersetzt.\n"
|
||||
"Abbrechen: Es wird nichts importiert."
|
||||
),
|
||||
parent=self,
|
||||
)
|
||||
if response is None:
|
||||
return
|
||||
replace_description = not response
|
||||
|
||||
imported = 0
|
||||
updated = 0
|
||||
processed_guids: set[str] = set()
|
||||
errors: list[str] = []
|
||||
for item in new_items:
|
||||
try:
|
||||
self.repository.create_payment(
|
||||
self.member_id,
|
||||
payment_date=item.date.isoformat(),
|
||||
amount=money_text(item.amount),
|
||||
reference=item.description,
|
||||
gnucash_transaction_id=item.guid,
|
||||
)
|
||||
imported += 1
|
||||
processed_guids.add(item.guid)
|
||||
except RepositoryError as exc:
|
||||
errors.append(
|
||||
f"{format_date_for_display(item.date.isoformat())} · {money_text(item.amount)} EUR: {exc}"
|
||||
)
|
||||
if replace_description:
|
||||
for item in duplicate_items:
|
||||
payment_id = self._duplicate_payment_id(item)
|
||||
if not payment_id:
|
||||
continue
|
||||
try:
|
||||
self.repository.update_payment_reference(
|
||||
self.member_id,
|
||||
payment_id,
|
||||
reference=item.description,
|
||||
gnucash_transaction_id=item.guid,
|
||||
)
|
||||
updated += 1
|
||||
processed_guids.add(item.guid)
|
||||
except RepositoryError as exc:
|
||||
errors.append(
|
||||
f"{format_date_for_display(item.date.isoformat())} · "
|
||||
f"{money_text(item.amount)} EUR: {exc}"
|
||||
)
|
||||
|
||||
if imported or updated:
|
||||
self._load_existing_payment_keys()
|
||||
self._render_transactions()
|
||||
still_selected = set(self.tree.selection()) - processed_guids
|
||||
self.tree.selection_set(list(still_selected))
|
||||
|
||||
summary_parts = []
|
||||
if imported:
|
||||
summary_parts.append(f"{imported} Zahlung(en) importiert")
|
||||
if updated:
|
||||
summary_parts.append(f"{updated} Beschreibung(en) aktualisiert")
|
||||
skipped = len(duplicate_items) if not replace_description else 0
|
||||
if skipped:
|
||||
summary_parts.append(f"{skipped} übersprungen (bereits vorhanden)")
|
||||
summary = ", ".join(summary_parts) or "Keine Buchung verarbeitet."
|
||||
|
||||
if errors:
|
||||
messagebox.showwarning(
|
||||
"Import teilweise fehlgeschlagen",
|
||||
f"{summary}.\n\nFehler:\n" + "\n".join(errors),
|
||||
parent=self,
|
||||
)
|
||||
else:
|
||||
messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self)
|
||||
if imported or updated:
|
||||
self.on_imported()
|
||||
@@ -30,6 +30,19 @@ REMINDER_CHANNEL_LABELS = {
|
||||
"personal": "Persönlich",
|
||||
}
|
||||
|
||||
EMAIL_DELIVERY_MODE_LABELS = {
|
||||
"local": "Lokal speichern (bisheriges Verhalten)",
|
||||
"send": "Direkt versenden",
|
||||
"drafts": "Als Entwurf im Postfach ablegen",
|
||||
"ask": "Jedes Mal fragen",
|
||||
}
|
||||
|
||||
EMAIL_SECURITY_LABELS = {
|
||||
"starttls": "STARTTLS",
|
||||
"ssl": "SSL/TLS",
|
||||
"none": "Keine",
|
||||
}
|
||||
|
||||
|
||||
def display_label(labels: Mapping[str, str], key: str) -> str:
|
||||
return labels.get(key, key)
|
||||
|
||||
@@ -14,6 +14,7 @@ from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, N
|
||||
from ccma.ui.icons import IconStore
|
||||
from ccma.ui.member_tab import MemberTab
|
||||
from ccma.ui.options_dialog import OptionsDialog
|
||||
from ccma.ui.sepa_dialog import SepaExportDialog
|
||||
from ccma.ui.theme import load_theme
|
||||
from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab
|
||||
|
||||
@@ -187,6 +188,13 @@ class MainWindow(ttk.Frame):
|
||||
)
|
||||
assets_button.image = assets_icon
|
||||
assets_button.pack(side="left", padx=(0, 6))
|
||||
sepa_icon = self.icons.get("bank-transfer", 24) or self.icons.get("bank", 24)
|
||||
sepa_button = ttk.Button(
|
||||
actions, text="SEPA", image=sepa_icon, compound="top", width=14,
|
||||
command=self.open_sepa_export,
|
||||
)
|
||||
sepa_button.image = sepa_icon
|
||||
sepa_button.pack(side="left", padx=(0, 6))
|
||||
new_icon = self.icons.get("account-plus", 24)
|
||||
new_button = ttk.Button(
|
||||
actions,
|
||||
@@ -287,12 +295,14 @@ class MainWindow(ttk.Frame):
|
||||
self.notebook,
|
||||
self.repository,
|
||||
member_id,
|
||||
self.config,
|
||||
on_close=lambda: self.tabs.close(key),
|
||||
on_changed=self.refresh_overview,
|
||||
on_open_claim=self.open_claim,
|
||||
on_open_assets=self.open_assets,
|
||||
on_open_asset=self.open_asset,
|
||||
on_return_asset=self.return_asset,
|
||||
on_run_housekeeper=self.run_housekeeper_for_member,
|
||||
)
|
||||
self.tabs.add(
|
||||
key,
|
||||
@@ -368,6 +378,7 @@ class MainWindow(ttk.Frame):
|
||||
tab = MembersTab(
|
||||
self.notebook,
|
||||
self.repository.list_members(),
|
||||
self.findings,
|
||||
self.open_member,
|
||||
lambda: self.tabs.close(key),
|
||||
)
|
||||
@@ -402,6 +413,9 @@ class MainWindow(ttk.Frame):
|
||||
icon_name="key-variant",
|
||||
)
|
||||
|
||||
def open_sepa_export(self) -> None:
|
||||
SepaExportDialog(self, self.repository)
|
||||
|
||||
def new_member(self) -> None:
|
||||
NewMemberDialog(self, self.repository, self._member_created)
|
||||
|
||||
@@ -490,6 +504,16 @@ class MainWindow(ttk.Frame):
|
||||
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
|
||||
return self.findings
|
||||
|
||||
def run_housekeeper_for_member(
|
||||
self, member_id: str, retroactive_claims: bool = False
|
||||
) -> list[HousekeeperFinding]:
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run(
|
||||
member_id=member_id, retroactive_claims=retroactive_claims or None
|
||||
)
|
||||
self.refresh_overview(run_housekeeper=False)
|
||||
self.status_var.set(f"Hausmeisterlauf für Mitglied beendet: {len(self.findings)} Vorgänge insgesamt.")
|
||||
return self.findings
|
||||
|
||||
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]:
|
||||
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
|
||||
self.refresh_overview(run_housekeeper=False)
|
||||
@@ -502,7 +526,7 @@ class MainWindow(ttk.Frame):
|
||||
self.dashboard.update_data(self.repository.member_count(), self.findings)
|
||||
members_tab = self.tabs.tabs.get("members")
|
||||
if isinstance(members_tab, MembersTab) and members_tab.winfo_exists():
|
||||
members_tab.refresh(self.repository.list_members())
|
||||
members_tab.refresh(self.repository.list_members(), self.findings)
|
||||
assets_tab = self.tabs.tabs.get("assets")
|
||||
if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists():
|
||||
assets_tab.refresh(self.repository.list_assets())
|
||||
|
||||
+564
-22
@@ -3,26 +3,54 @@ from __future__ import annotations
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text
|
||||
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, Event
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.contributions import (
|
||||
CLAIM_STATUS_LABELS,
|
||||
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
||||
DONATION_STATUS_LABELS,
|
||||
claim_status,
|
||||
claim_total,
|
||||
donation_allocated_total,
|
||||
donation_balance,
|
||||
donation_status,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
|
||||
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
|
||||
from ccma.ui.dialogs import IntegrityWarningDialog
|
||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||
from ccma.ui.donation_dialog import (
|
||||
AllocateDonationPaymentDialog,
|
||||
DonationEditDialog,
|
||||
DonationPaymentDialog,
|
||||
)
|
||||
from ccma.ui.file_open import open_path
|
||||
from ccma.ui.gnucash_import_dialog import GnuCashImportDialog
|
||||
from ccma.ui.labels import display_label, storage_key
|
||||
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
||||
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
||||
from ccma.ui.scrolling import ScrollableFrame
|
||||
|
||||
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
|
||||
# and amount-like columns get the same fixed width, status columns get a third more
|
||||
# (they tend to hold longer label text), and only the trailing description/reference
|
||||
# column stretches to take up whatever space remains.
|
||||
FIXED_COLUMN_WIDTH = 90
|
||||
STATUS_COLUMN_WIDTH = round(FIXED_COLUMN_WIDTH * 4 / 3)
|
||||
|
||||
CLAIM_TABLE_COLUMNS = (
|
||||
("due", "Fällig", FIXED_COLUMN_WIDTH),
|
||||
("amount", "Betrag", FIXED_COLUMN_WIDTH),
|
||||
("status", "Status", STATUS_COLUMN_WIDTH),
|
||||
("title", "Forderung", 220),
|
||||
("due", "Fällig", 100),
|
||||
("amount", "Betrag", 90),
|
||||
("status", "Status", 110),
|
||||
)
|
||||
|
||||
|
||||
@@ -39,28 +67,75 @@ def _claim_sort_value(data, claim: dict, column: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class _RunHousekeeperDialog(tk.Toplevel):
|
||||
"""Confirms a housekeeper run scoped to a single member, with the option to
|
||||
force retroactive claim creation for just this run without touching the
|
||||
application-wide default setting."""
|
||||
|
||||
def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
|
||||
super().__init__(master)
|
||||
self.on_confirm = on_confirm
|
||||
self.title("Hausmeister für dieses Mitglied ausführen")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(False, False)
|
||||
frame = ttk.Frame(self, padding=18)
|
||||
frame.pack(fill="both", expand=True)
|
||||
ttk.Label(
|
||||
frame,
|
||||
text=(
|
||||
f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
|
||||
"Andere Mitglieder bleiben unberührt."
|
||||
),
|
||||
wraplength=380,
|
||||
justify="left",
|
||||
).pack(anchor="w", pady=(0, 12))
|
||||
self.retroactive_var = tk.BooleanVar(value=False)
|
||||
ttk.Checkbutton(
|
||||
frame,
|
||||
text="Rückwirkende Beiträge/Forderungen erstellen",
|
||||
variable=self.retroactive_var,
|
||||
).pack(anchor="w", pady=(0, 14))
|
||||
buttons = ttk.Frame(frame)
|
||||
buttons.pack(anchor="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
|
||||
side="left"
|
||||
)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self.after_idle(self.grab_set)
|
||||
|
||||
def _confirm(self) -> None:
|
||||
retroactive = self.retroactive_var.get()
|
||||
self.destroy()
|
||||
self.on_confirm(retroactive)
|
||||
|
||||
|
||||
class MemberTab(ttk.Frame):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
config: AppConfig,
|
||||
on_close: Callable[[], None],
|
||||
on_changed: Callable[[], None],
|
||||
on_open_claim: Callable[[str, str], None],
|
||||
on_open_assets: Callable[[], None],
|
||||
on_open_asset: Callable[[str], None],
|
||||
on_return_asset: Callable[[str], None],
|
||||
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.config = config
|
||||
self.on_close = on_close
|
||||
self.on_changed = on_changed
|
||||
self.on_open_claim = on_open_claim
|
||||
self.on_open_assets = on_open_assets
|
||||
self.on_open_asset = on_open_asset
|
||||
self.on_return_asset = on_return_asset
|
||||
self.on_run_housekeeper = on_run_housekeeper
|
||||
self.member = repository.get_member(member_id)
|
||||
self.variables: dict[str, tk.Variable] = {}
|
||||
self._field_sections: dict[str, str] = {}
|
||||
@@ -94,9 +169,14 @@ class MemberTab(ttk.Frame):
|
||||
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
|
||||
row=2, column=0, sticky="w", pady=(3, 0)
|
||||
)
|
||||
ttk.Button(header, text="Tab schließen", command=self._close).grid(
|
||||
row=0, column=1, sticky="ne", padx=(12, 0)
|
||||
)
|
||||
header_actions = ttk.Frame(header)
|
||||
header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
|
||||
ttk.Button(
|
||||
header_actions,
|
||||
text="Hausmeister für dieses Mitglied",
|
||||
command=self._run_housekeeper_for_member,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
|
||||
self.messages = MessageBannerList(self)
|
||||
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||
self.messages.grid_remove()
|
||||
@@ -106,7 +186,7 @@ class MemberTab(ttk.Frame):
|
||||
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
|
||||
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
|
||||
self.pane.add(self.details_pane, weight=2)
|
||||
self.pane.add(self.timeline_pane, weight=3)
|
||||
self.pane.add(self.timeline_pane, weight=1)
|
||||
self._build_details(self.details_pane)
|
||||
self._build_timeline(self.timeline_pane)
|
||||
self._pane_position_initialized = False
|
||||
@@ -118,7 +198,8 @@ class MemberTab(ttk.Frame):
|
||||
try:
|
||||
width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
|
||||
if width > 1:
|
||||
self.pane.sashpos(0, max(360, int(width * 0.4)))
|
||||
# Chronik (timeline_pane) defaults to a third of the available width.
|
||||
self.pane.sashpos(0, max(360, round(width * 2 / 3)))
|
||||
self._pane_position_initialized = True
|
||||
except tk.TclError:
|
||||
return
|
||||
@@ -145,9 +226,15 @@ class MemberTab(ttk.Frame):
|
||||
command=self._save,
|
||||
).grid(row=0, column=0, sticky="e")
|
||||
contribution_tab = ttk.Frame(notebook, padding=16)
|
||||
contribution_override_tab = ttk.Frame(notebook, padding=16)
|
||||
payments_tab = ttk.Frame(notebook, padding=16)
|
||||
donations_tab = ttk.Frame(notebook, padding=16)
|
||||
assets_tab = ttk.Frame(notebook, padding=16)
|
||||
documents_tab = ttk.Frame(notebook, padding=16)
|
||||
notebook.add(contribution_tab, text="Forderungen")
|
||||
notebook.add(contribution_override_tab, text="Beitrag")
|
||||
notebook.add(payments_tab, text="Zahlungen")
|
||||
notebook.add(donations_tab, text="Spenden")
|
||||
notebook.add(assets_tab, text="Assets")
|
||||
notebook.add(documents_tab, text="Dokumente")
|
||||
|
||||
@@ -160,8 +247,10 @@ class MemberTab(ttk.Frame):
|
||||
("E-Mail-Adresse", "email"),
|
||||
("Telefonnummer", "phone"),
|
||||
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
|
||||
(f"Antragsdatum ({date_input_hint()})", "application_date"),
|
||||
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
|
||||
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
|
||||
(f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"),
|
||||
]
|
||||
for row, (label, key) in enumerate(fields):
|
||||
variable = tk.StringVar()
|
||||
@@ -225,7 +314,18 @@ class MemberTab(ttk.Frame):
|
||||
(f"Mandat widerrufen am ({date_input_hint()})", "mandate_revoked_at"),
|
||||
)
|
||||
banking_tab.columnconfigure(1, weight=1)
|
||||
for row, (label, key) in enumerate(banking_fields):
|
||||
self._add_variable("payment_frequency", tk.StringVar(), "banking")
|
||||
ttk.Label(banking_tab, text="Zahlweise").grid(
|
||||
row=0, column=0, sticky="w", padx=(0, 12), pady=5
|
||||
)
|
||||
ttk.Combobox(
|
||||
banking_tab,
|
||||
textvariable=self.variables["payment_frequency"],
|
||||
values=list(PAYMENT_FREQUENCY_LABELS.values()),
|
||||
state="readonly",
|
||||
width=39,
|
||||
).grid(row=0, column=1, sticky="ew", pady=5)
|
||||
for row, (label, key) in enumerate(banking_fields, start=1):
|
||||
self._add_variable(key, tk.StringVar(), "banking")
|
||||
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
|
||||
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
|
||||
@@ -237,7 +337,7 @@ class MemberTab(ttk.Frame):
|
||||
text="SEPA-Lastschriftmandat ist aktiv",
|
||||
variable=self.variables["mandate_active"],
|
||||
style="Switch",
|
||||
).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5))
|
||||
).grid(row=len(banking_fields) + 1, column=0, columnspan=2, sticky="w", pady=(12, 5))
|
||||
contribution_tab.columnconfigure(0, weight=1)
|
||||
contribution_tab.rowconfigure(1, weight=1)
|
||||
self.contribution_summary = tk.StringVar()
|
||||
@@ -245,17 +345,153 @@ class MemberTab(ttk.Frame):
|
||||
row=0, column=0, sticky="w", pady=(0, 10)
|
||||
)
|
||||
self.claims = ttk.Treeview(
|
||||
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
|
||||
contribution_tab, columns=("due", "amount", "status", "title"), show="headings"
|
||||
)
|
||||
self.claim_sort_column = "due"
|
||||
self.claim_sort_descending = False
|
||||
for key, title, width in CLAIM_TABLE_COLUMNS:
|
||||
self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
|
||||
self.claims.column(key, width=width, anchor="w")
|
||||
self.claims.column(key, width=width, anchor="w", stretch=key == "title")
|
||||
self.claims.grid(row=1, column=0, sticky="nsew")
|
||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
||||
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
||||
|
||||
contribution_override_tab.columnconfigure(0, weight=1)
|
||||
contribution_override_tab.rowconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
contribution_override_tab,
|
||||
text=(
|
||||
"Individuell vereinbarte Beiträge (z. B. ermäßigter Beitrag für Schüler), die von "
|
||||
"den regulären Beitragssätzen abweichen. Wirkt sich erst auf künftig erzeugte "
|
||||
"Forderungen aus, bereits erzeugte Forderungen bleiben unverändert."
|
||||
),
|
||||
style="Mono.TLabel",
|
||||
wraplength=700,
|
||||
).grid(row=0, column=0, sticky="w", pady=(0, 10))
|
||||
self.contribution_overrides = ttk.Treeview(
|
||||
contribution_override_tab,
|
||||
columns=("valid_from", "valid_until", "kind", "value", "reason"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("valid_from", "Ab", FIXED_COLUMN_WIDTH),
|
||||
("valid_until", "Bis", FIXED_COLUMN_WIDTH),
|
||||
("kind", "Art", FIXED_COLUMN_WIDTH),
|
||||
("value", "Wert", FIXED_COLUMN_WIDTH),
|
||||
("reason", "Begründung", 320),
|
||||
):
|
||||
self.contribution_overrides.heading(key, text=title)
|
||||
self.contribution_overrides.column(key, width=width, anchor="w", stretch=key == "reason")
|
||||
self.contribution_overrides.grid(row=1, column=0, sticky="nsew")
|
||||
self.contribution_overrides.bind(
|
||||
"<Double-1>", lambda _event: self._edit_selected_contribution_override()
|
||||
)
|
||||
self.contribution_overrides.bind(
|
||||
"<Return>", lambda _event: self._edit_selected_contribution_override()
|
||||
)
|
||||
contribution_override_actions = ttk.Frame(contribution_override_tab)
|
||||
contribution_override_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||
ttk.Button(
|
||||
contribution_override_actions,
|
||||
text="Abweichenden Beitrag anlegen",
|
||||
command=self._create_contribution_override,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(
|
||||
contribution_override_actions,
|
||||
text="Bearbeiten",
|
||||
command=self._edit_selected_contribution_override,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(
|
||||
contribution_override_actions,
|
||||
text="Löschen",
|
||||
command=self._delete_selected_contribution_override,
|
||||
).pack(side="left")
|
||||
|
||||
payments_tab.columnconfigure(0, weight=1)
|
||||
payments_tab.rowconfigure(1, weight=1)
|
||||
self.payment_summary = tk.StringVar()
|
||||
ttk.Label(payments_tab, textvariable=self.payment_summary, style="Mono.TLabel").grid(
|
||||
row=0, column=0, sticky="w", pady=(0, 10)
|
||||
)
|
||||
self.payments = ttk.Treeview(
|
||||
payments_tab,
|
||||
columns=("date", "amount", "allocated", "available", "reference"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("date", "Datum", FIXED_COLUMN_WIDTH),
|
||||
("amount", "Betrag", FIXED_COLUMN_WIDTH),
|
||||
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
|
||||
("available", "Frei", FIXED_COLUMN_WIDTH),
|
||||
("reference", "Referenz", 320),
|
||||
):
|
||||
self.payments.heading(key, text=title)
|
||||
self.payments.column(key, width=width, anchor="w", stretch=key == "reference")
|
||||
self.payments.grid(row=1, column=0, sticky="nsew")
|
||||
self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
|
||||
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
|
||||
payment_actions = ttk.Frame(payments_tab)
|
||||
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||
ttk.Button(payment_actions, text="Zahlung anlegen", command=self._create_payment).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack(
|
||||
side="left"
|
||||
)
|
||||
ttk.Separator(payment_actions, orient="vertical").pack(side="left", fill="y", padx=10)
|
||||
ttk.Button(
|
||||
payment_actions, text="Zahlungen importieren", command=self._import_payments
|
||||
).pack(side="left")
|
||||
|
||||
donations_tab.columnconfigure(0, weight=1)
|
||||
donations_tab.rowconfigure(1, weight=1)
|
||||
self.donation_summary = tk.StringVar()
|
||||
ttk.Label(donations_tab, textvariable=self.donation_summary, style="Mono.TLabel").grid(
|
||||
row=0, column=0, sticky="w", pady=(0, 10)
|
||||
)
|
||||
self.donations = ttk.Treeview(
|
||||
donations_tab,
|
||||
columns=("date", "amount", "allocated", "balance", "status", "reference"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("date", "Datum", FIXED_COLUMN_WIDTH),
|
||||
("amount", "Betrag", FIXED_COLUMN_WIDTH),
|
||||
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
|
||||
("balance", "Offen", FIXED_COLUMN_WIDTH),
|
||||
("status", "Status", STATUS_COLUMN_WIDTH),
|
||||
("reference", "Referenz / Zweck", 260),
|
||||
):
|
||||
self.donations.heading(key, text=title)
|
||||
self.donations.column(key, width=width, anchor="w", stretch=key == "reference")
|
||||
self.donations.grid(row=1, column=0, sticky="nsew")
|
||||
self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
|
||||
self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
|
||||
donation_actions = ttk.Frame(donations_tab)
|
||||
donation_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||
ttk.Button(donation_actions, text="Spende anlegen", command=self._create_donation).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(donation_actions, text="Spende bearbeiten", command=self._edit_selected_donation).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(donation_actions, text="Spende löschen", command=self._delete_selected_donation).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Separator(donation_actions, orient="vertical").pack(side="left", fill="y", padx=(0, 8))
|
||||
ttk.Button(
|
||||
donation_actions, text="Vorhandene Zahlung zuordnen", command=self._allocate_donation_payment
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(donation_actions, text="Zahlung erfassen", command=self._record_donation_payment).pack(
|
||||
side="left"
|
||||
)
|
||||
|
||||
assets_tab.columnconfigure(0, weight=1)
|
||||
assets_tab.rowconfigure(1, weight=1)
|
||||
self.assets_summary = tk.StringVar()
|
||||
@@ -304,9 +540,7 @@ class MemberTab(ttk.Frame):
|
||||
style="Accent.TButton",
|
||||
command=self._create_document,
|
||||
).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(
|
||||
side="left"
|
||||
)
|
||||
ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(side="left")
|
||||
self.documents = ttk.Treeview(
|
||||
documents_tab,
|
||||
columns=("name", "type", "modified", "size"),
|
||||
@@ -379,6 +613,25 @@ class MemberTab(ttk.Frame):
|
||||
return
|
||||
self.on_close()
|
||||
|
||||
def _run_housekeeper_for_member(self) -> None:
|
||||
_RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)
|
||||
|
||||
def _execute_housekeeper_run(self, retroactive: bool) -> None:
|
||||
try:
|
||||
findings = self.on_run_housekeeper(self.member_id, retroactive)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
member_findings = [item for item in findings if item.member_id == self.member_id]
|
||||
messagebox.showinfo(
|
||||
"Hausmeister ausgeführt",
|
||||
f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
|
||||
f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
|
||||
parent=self,
|
||||
)
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _build_timeline(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(1, weight=1)
|
||||
@@ -422,8 +675,10 @@ class MemberTab(ttk.Frame):
|
||||
)
|
||||
date_fields = {
|
||||
"birth_date",
|
||||
"application_date",
|
||||
"accepted_at",
|
||||
"membership_started_at",
|
||||
"membership_ended_at",
|
||||
"mandate_signed_at",
|
||||
"mandate_revoked_at",
|
||||
}
|
||||
@@ -431,6 +686,8 @@ class MemberTab(ttk.Frame):
|
||||
value = getattr(self.member, key)
|
||||
if key == "status":
|
||||
variable.set(display_label(STATUS_LABELS, str(value)))
|
||||
elif key == "payment_frequency":
|
||||
variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
|
||||
else:
|
||||
variable.set(format_date_for_display(value) if key in date_fields else value)
|
||||
if self.notes_text is not None:
|
||||
@@ -441,6 +698,8 @@ class MemberTab(ttk.Frame):
|
||||
self._clear_dirty()
|
||||
self._refresh_events()
|
||||
self._refresh_contributions()
|
||||
self._refresh_contribution_overrides()
|
||||
self._refresh_donations()
|
||||
self._refresh_assets()
|
||||
self._refresh_documents()
|
||||
|
||||
@@ -456,10 +715,12 @@ class MemberTab(ttk.Frame):
|
||||
|
||||
def _refresh_contributions(self) -> None:
|
||||
self.claims.delete(*self.claims.get_children())
|
||||
self.payments.delete(*self.payments.get_children())
|
||||
try:
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
except RepositoryError as exc:
|
||||
self.contribution_summary.set(f"FEHLER: {exc}")
|
||||
self.payment_summary.set(f"FEHLER: {exc}")
|
||||
return
|
||||
claims = sorted(
|
||||
data.claims,
|
||||
@@ -475,13 +736,106 @@ class MemberTab(ttk.Frame):
|
||||
"end",
|
||||
iid=claim_id,
|
||||
values=(
|
||||
claim.get("title", "Beitrag"),
|
||||
format_date_for_display(str(claim.get("due_date", ""))),
|
||||
money_text(claim_total(claim)),
|
||||
CLAIM_STATUS_LABELS.get(status, status.upper()),
|
||||
claim.get("title", "Beitrag"),
|
||||
),
|
||||
)
|
||||
for payment in sorted(
|
||||
data.payments,
|
||||
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
||||
reverse=True,
|
||||
):
|
||||
payment_id = str(payment.get("payment_id", ""))
|
||||
amount = payment.get("amount", "0")
|
||||
allocated = payment_allocated_total(data, payment_id)
|
||||
available = max(Decimal(str(amount)) - allocated, Decimal("0"))
|
||||
self.payments.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=payment_id,
|
||||
values=(
|
||||
format_date_for_display(str(payment.get("date", ""))),
|
||||
f"{money_text(amount)} EUR",
|
||||
f"{money_text(allocated)} EUR",
|
||||
f"{money_text(available)} EUR",
|
||||
payment.get("reference", ""),
|
||||
),
|
||||
)
|
||||
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
|
||||
total_amount = sum((Decimal(str(item.get("amount", "0"))) for item in data.payments), Decimal("0"))
|
||||
total_allocated = sum(
|
||||
(payment_allocated_total(data, str(item.get("payment_id", ""))) for item in data.payments),
|
||||
Decimal("0"),
|
||||
)
|
||||
self.payment_summary.set(
|
||||
f"{len(data.payments)} Zahlungen · Gesamt {money_text(total_amount)} EUR · "
|
||||
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
|
||||
)
|
||||
|
||||
def _refresh_donations(self) -> None:
|
||||
self.donations.delete(*self.donations.get_children())
|
||||
try:
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
except RepositoryError as exc:
|
||||
self.donation_summary.set(f"FEHLER: {exc}")
|
||||
return
|
||||
for donation in sorted(
|
||||
data.donations,
|
||||
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
||||
reverse=True,
|
||||
):
|
||||
donation_id = str(donation.get("donation_id", ""))
|
||||
amount = donation.get("amount", "0")
|
||||
allocated = donation_allocated_total(data, donation_id)
|
||||
balance = donation_balance(data, donation)
|
||||
status = donation_status(data, donation)
|
||||
reference = " · ".join(
|
||||
part for part in (donation.get("reference", ""), donation.get("purpose", "")) if part
|
||||
)
|
||||
self.donations.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=donation_id,
|
||||
values=(
|
||||
format_date_for_display(str(donation.get("date", ""))),
|
||||
f"{money_text(amount)} EUR",
|
||||
f"{money_text(allocated)} EUR",
|
||||
f"{money_text(balance)} EUR",
|
||||
DONATION_STATUS_LABELS.get(status, status.upper()),
|
||||
reference,
|
||||
),
|
||||
)
|
||||
total_amount = sum(
|
||||
(Decimal(str(item.get("amount", "0"))) for item in data.donations), Decimal("0")
|
||||
)
|
||||
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
|
||||
|
||||
def _refresh_contribution_overrides(self) -> None:
|
||||
self.contribution_overrides.delete(*self.contribution_overrides.get_children())
|
||||
try:
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
except RepositoryError:
|
||||
return
|
||||
for override in sorted(
|
||||
data.contribution_overrides, key=lambda item: str(item.get("valid_from", "")), reverse=True
|
||||
):
|
||||
kind = str(override.get("kind", ""))
|
||||
value = str(override.get("value", ""))
|
||||
value_text = f"{value} %" if kind == "percent" else f"{value} EUR"
|
||||
self.contribution_overrides.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(override.get("override_id", "")),
|
||||
values=(
|
||||
format_month_for_display(str(override.get("valid_from", ""))),
|
||||
format_month_for_display(str(override.get("valid_until", ""))) or "unbefristet",
|
||||
CONTRIBUTION_OVERRIDE_KIND_LABELS.get(kind, kind.upper()),
|
||||
value_text,
|
||||
override.get("reason", ""),
|
||||
),
|
||||
)
|
||||
|
||||
def _toggle_claim_sort(self, column: str) -> None:
|
||||
if self.claim_sort_column == column:
|
||||
@@ -507,6 +861,183 @@ class MemberTab(ttk.Frame):
|
||||
if selected and not selected[0].startswith("missing-id-"):
|
||||
self.on_open_claim(self.member_id, selected[0])
|
||||
|
||||
def _edit_selected_payment(self) -> None:
|
||||
selected = self.payments.selection()
|
||||
if not selected:
|
||||
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
||||
return
|
||||
try:
|
||||
PaymentEditDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
selected[0],
|
||||
self._payment_changed,
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht geöffnet werden", str(exc), parent=self)
|
||||
|
||||
def _delete_selected_payment(self) -> None:
|
||||
selected = self.payments.selection()
|
||||
if not selected:
|
||||
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
||||
return
|
||||
values = self.payments.item(selected[0], "values")
|
||||
label = f"{values[0]} · {values[1]}" if values else selected[0]
|
||||
if not messagebox.askyesno(
|
||||
"Zahlung wirklich löschen?",
|
||||
f"Die Zahlung {label} und alle ihre Zuordnungen werden endgültig gelöscht.",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
self.repository.delete_payment(self.member_id, selected[0])
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht gelöscht werden", str(exc), parent=self)
|
||||
return
|
||||
self._payment_changed()
|
||||
|
||||
def _create_payment(self) -> None:
|
||||
PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed)
|
||||
|
||||
def _import_payments(self) -> None:
|
||||
GnuCashImportDialog(
|
||||
self, self.repository, self.member_id, self.config, self._payment_changed
|
||||
)
|
||||
|
||||
def _payment_changed(self) -> None:
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _selected_donation(self) -> dict | None:
|
||||
selected = self.donations.selection()
|
||||
if not selected:
|
||||
return None
|
||||
try:
|
||||
_data, donation = self.repository.get_donation(self.member_id, selected[0])
|
||||
except RepositoryError:
|
||||
return None
|
||||
return donation
|
||||
|
||||
def _create_donation(self) -> None:
|
||||
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed)
|
||||
|
||||
def _edit_selected_donation(self) -> None:
|
||||
donation = self._selected_donation()
|
||||
if not donation:
|
||||
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||
return
|
||||
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed, donation)
|
||||
|
||||
def _delete_selected_donation(self) -> None:
|
||||
donation = self._selected_donation()
|
||||
if not donation:
|
||||
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||
return
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
|
||||
detail = "Diese Spende wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
|
||||
if allocated:
|
||||
detail += (
|
||||
f"\n\nZugeordnete Zahlungen in Höhe von {money_text(allocated)} EUR werden dabei "
|
||||
"gelöst und stehen danach wieder frei zur Verfügung."
|
||||
)
|
||||
if not messagebox.askyesno("Spende löschen", detail, parent=self):
|
||||
return
|
||||
try:
|
||||
self.repository.delete_donation(self.member_id, str(donation.get("donation_id", "")))
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
self._donation_changed()
|
||||
|
||||
def _record_donation_payment(self) -> None:
|
||||
donation = self._selected_donation()
|
||||
if not donation:
|
||||
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||
return
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
DonationPaymentDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
str(donation.get("donation_id", "")),
|
||||
donation_balance(data, donation),
|
||||
self._donation_changed,
|
||||
)
|
||||
|
||||
def _allocate_donation_payment(self) -> None:
|
||||
donation = self._selected_donation()
|
||||
if not donation:
|
||||
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||
return
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
AllocateDonationPaymentDialog(
|
||||
self,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
str(donation.get("donation_id", "")),
|
||||
donation_balance(data, donation),
|
||||
self._donation_changed,
|
||||
)
|
||||
|
||||
def _donation_changed(self) -> None:
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _selected_contribution_override(self) -> dict | None:
|
||||
selected = self.contribution_overrides.selection()
|
||||
if not selected:
|
||||
return None
|
||||
try:
|
||||
_data, override = self.repository.get_contribution_override(self.member_id, selected[0])
|
||||
except RepositoryError:
|
||||
return None
|
||||
return override
|
||||
|
||||
def _create_contribution_override(self) -> None:
|
||||
ContributionOverrideEditDialog(
|
||||
self, self.repository, self.member_id, self._contribution_override_changed
|
||||
)
|
||||
|
||||
def _edit_selected_contribution_override(self) -> None:
|
||||
override = self._selected_contribution_override()
|
||||
if not override:
|
||||
messagebox.showinfo(
|
||||
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
|
||||
)
|
||||
return
|
||||
ContributionOverrideEditDialog(
|
||||
self, self.repository, self.member_id, self._contribution_override_changed, override
|
||||
)
|
||||
|
||||
def _delete_selected_contribution_override(self) -> None:
|
||||
override = self._selected_contribution_override()
|
||||
if not override:
|
||||
messagebox.showinfo(
|
||||
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
|
||||
)
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Abweichenden Beitrag löschen",
|
||||
"Diesen abweichenden Beitrag wirklich löschen? Bereits erzeugte Forderungen bleiben "
|
||||
"davon unberührt.",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
try:
|
||||
self.repository.delete_contribution_override(
|
||||
self.member_id, str(override.get("override_id", ""))
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
self._contribution_override_changed()
|
||||
|
||||
def _contribution_override_changed(self) -> None:
|
||||
self.refresh()
|
||||
self.on_changed()
|
||||
|
||||
def _refresh_documents(self) -> None:
|
||||
self.documents.delete(*self.documents.get_children())
|
||||
self.document_paths.clear()
|
||||
@@ -536,8 +1067,11 @@ class MemberTab(ttk.Frame):
|
||||
|
||||
def _refresh_assets(self) -> None:
|
||||
self.assets_tree.delete(*self.assets_tree.get_children())
|
||||
assets = self.repository.list_member_assets(self.member_id)
|
||||
self.assets_summary.set(f"{len(assets)} ausgegebene Assets")
|
||||
held_assets = self.repository.list_member_assets(self.member_id)
|
||||
owned_assets = self.repository.list_member_owned_assets(self.member_id)
|
||||
assets_by_id = {asset.asset_id: asset for asset in [*held_assets, *owned_assets]}
|
||||
assets = list(assets_by_id.values())
|
||||
self.assets_summary.set(f"{len(owned_assets)} im Eigentum · {len(held_assets)} vom Verein erhalten")
|
||||
for asset in assets:
|
||||
self.assets_tree.insert(
|
||||
"",
|
||||
@@ -547,7 +1081,13 @@ class MemberTab(ttk.Frame):
|
||||
asset.label,
|
||||
asset.category,
|
||||
asset.inventory_number,
|
||||
ASSET_STATUS_LABELS.get(asset.status, asset.status),
|
||||
(
|
||||
"EIGENTUM · DEM VEREIN ÜBERLASSEN"
|
||||
if asset.owner_member_id == self.member_id and asset.custody_type == "club"
|
||||
else "EIGENTUM"
|
||||
if asset.owner_member_id == self.member_id
|
||||
else ASSET_STATUS_LABELS.get(asset.status, asset.status)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -564,6 +1104,8 @@ class MemberTab(ttk.Frame):
|
||||
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
||||
if key == "status":
|
||||
value = storage_key(STATUS_LABELS, value)
|
||||
elif key == "payment_frequency":
|
||||
value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
|
||||
setattr(self.member, key, value)
|
||||
if self.notes_text is not None:
|
||||
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
|
||||
|
||||
+504
-10
@@ -6,15 +6,23 @@ from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.config import AppConfig
|
||||
from ccma.domain.contributions import decimal_value, money_text
|
||||
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||
from ccma.services.intervals import (
|
||||
IntervalValidationError,
|
||||
normalize_anniversary_intervals,
|
||||
)
|
||||
from ccma.services.mail_delivery import list_imap_folders, test_imap_connection, test_smtp_connection
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
|
||||
from ccma.ui.changelog_view import ChangelogView
|
||||
from ccma.ui.icons import IconStore
|
||||
from ccma.ui.labels import THEME_LABELS, display_label, storage_key
|
||||
from ccma.ui.labels import (
|
||||
EMAIL_DELIVERY_MODE_LABELS,
|
||||
EMAIL_SECURITY_LABELS,
|
||||
THEME_LABELS,
|
||||
display_label,
|
||||
storage_key,
|
||||
)
|
||||
|
||||
|
||||
class OptionsDialog(tk.Toplevel):
|
||||
@@ -47,6 +55,7 @@ class OptionsDialog(tk.Toplevel):
|
||||
number_policy = repository.get_member_number_policy()
|
||||
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
|
||||
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
|
||||
self.number_strategy_var = tk.StringVar(value=number_policy["allocation_strategy"])
|
||||
self.number_preview_var = tk.StringVar()
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
if not isinstance(organization, dict):
|
||||
@@ -59,13 +68,21 @@ class OptionsDialog(tk.Toplevel):
|
||||
)
|
||||
}
|
||||
self.title("Optionen")
|
||||
self.geometry("900x650")
|
||||
self.minsize(760, 540)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.grab_set()
|
||||
self.resizable(True, True)
|
||||
self._build_ui()
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
# A ttk.Notebook sizes itself to its tallest/widest tab, so once every tab
|
||||
# (including "Mahnungen" with its two tables) is built, the natural required
|
||||
# size reflects whichever one actually needs the most room -- open at that size
|
||||
# instead of a static guess that clips a table/buttons at the bottom. The floor
|
||||
# keeps the window from opening awkwardly small on short content.
|
||||
self.update_idletasks()
|
||||
width = max(900, self.winfo_reqwidth())
|
||||
height = max(650, self.winfo_reqheight())
|
||||
self.geometry(f"{width}x{height}")
|
||||
self.minsize(760, 540)
|
||||
self.after_idle(self._center_on_parent)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
@@ -83,18 +100,24 @@ class OptionsDialog(tk.Toplevel):
|
||||
member_numbers = ttk.Frame(notebook, padding=16)
|
||||
organization = ttk.Frame(notebook, padding=16)
|
||||
automation = ttk.Frame(notebook, padding=16)
|
||||
reminders = ttk.Frame(notebook, padding=16)
|
||||
email = ttk.Frame(notebook, padding=16)
|
||||
changelog = ChangelogView(notebook)
|
||||
notebook.add(paths, text="Pfade")
|
||||
notebook.add(appearance, text="Darstellung")
|
||||
notebook.add(member_numbers, text="Mitgliedsnummern")
|
||||
notebook.add(organization, text="Verein / Absender")
|
||||
notebook.add(automation, text="Hausmeister")
|
||||
notebook.add(reminders, text="Mahnungen")
|
||||
notebook.add(email, text="E-Mail-Versand")
|
||||
notebook.add(changelog, text="Changelog")
|
||||
self._build_paths(paths)
|
||||
self._build_appearance(appearance)
|
||||
self._build_member_numbers(member_numbers)
|
||||
self._build_organization(organization)
|
||||
self._build_automation(automation)
|
||||
self._build_reminders(reminders)
|
||||
self._build_email(email)
|
||||
|
||||
buttons = ttk.Frame(root)
|
||||
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
||||
@@ -280,19 +303,38 @@ class OptionsDialog(tk.Toplevel):
|
||||
text="Platzhalter: {number}, {number:04d} und optional {year}",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=2, column=1, sticky="w", pady=(0, 12))
|
||||
ttk.Label(parent, text="Nächste Nummer").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=6)
|
||||
ttk.Label(parent, text="Vergabestrategie").grid(
|
||||
row=3, column=0, sticky="nw", padx=(0, 12), pady=6
|
||||
)
|
||||
strategy_frame = ttk.Frame(parent)
|
||||
strategy_frame.grid(row=3, column=1, sticky="w", pady=6)
|
||||
ttk.Radiobutton(
|
||||
strategy_frame,
|
||||
text="Lücken füllen (nächste freie Nummer)",
|
||||
variable=self.number_strategy_var,
|
||||
value="next_free",
|
||||
command=self._update_number_preview,
|
||||
).pack(anchor="w")
|
||||
ttk.Radiobutton(
|
||||
strategy_frame,
|
||||
text="Immer Max++ (höchste Nummer + 1)",
|
||||
variable=self.number_strategy_var,
|
||||
value="max_plus_one",
|
||||
command=self._update_number_preview,
|
||||
).pack(anchor="w", pady=(4, 0))
|
||||
ttk.Label(parent, text="Nächste Nummer").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=6)
|
||||
ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid(
|
||||
row=3, column=1, sticky="w", pady=6
|
||||
row=4, column=1, sticky="w", pady=6
|
||||
)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Der Zähler wird pro Pattern im Mitglieder-Store geführt. Bereits vergebene Nummern "
|
||||
"werden niemals doppelt erzeugt."
|
||||
"Die Strategie wird im Mitglieder-Store gespeichert. Die Vergabe ist gesperrt, "
|
||||
"solange ein neues Mitglied angelegt wird, damit keine Nummer doppelt entsteht."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=620,
|
||||
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(14, 0))
|
||||
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(14, 0))
|
||||
self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview())
|
||||
self._update_member_number_controls()
|
||||
|
||||
@@ -309,11 +351,443 @@ class OptionsDialog(tk.Toplevel):
|
||||
return
|
||||
pattern = self.number_pattern_var.get()
|
||||
try:
|
||||
preview = self.repository.preview_member_number(pattern)
|
||||
preview = self.repository.preview_member_number(
|
||||
pattern, self.number_strategy_var.get()
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
preview = f"UNGÜLTIG: {exc}"
|
||||
self.number_preview_var.set(preview)
|
||||
|
||||
def _build_reminders(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
policy = self.repository.get_reminder_policy()
|
||||
self.grace_days_var = tk.StringVar(value=str(policy["grace_days_after_due"]))
|
||||
ttk.Label(parent, text="Kulanzfrist nach Fälligkeit (Tage)").grid(
|
||||
row=0, column=0, sticky="w", pady=6
|
||||
)
|
||||
ttk.Spinbox(parent, from_=0, to=365, textvariable=self.grace_days_var, width=6).grid(
|
||||
row=0, column=1, sticky="w", pady=6
|
||||
)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Ab wann nach Fälligkeit die erste Mahnstufe fällig wird.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
levels_frame = ttk.LabelFrame(parent, text="Mahnstufen", padding=10)
|
||||
levels_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
|
||||
levels_frame.columnconfigure(0, weight=1)
|
||||
self.levels = [dict(level) for level in policy["levels"]]
|
||||
self.levels_tree = ttk.Treeview(
|
||||
levels_frame, columns=("level", "name", "fee", "deadline"), show="headings", height=5
|
||||
)
|
||||
for key, title, width in (
|
||||
("level", "Stufe", 55),
|
||||
("name", "Name", 220),
|
||||
("fee", "Gebühr", 90),
|
||||
("deadline", "Frist (Tage)", 100),
|
||||
):
|
||||
self.levels_tree.heading(key, text=title)
|
||||
self.levels_tree.column(key, width=width, anchor="w")
|
||||
self.levels_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.levels_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_level())
|
||||
|
||||
level_controls = ttk.Frame(levels_frame)
|
||||
level_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.level_name_var = tk.StringVar()
|
||||
self.level_fee_var = tk.StringVar()
|
||||
self.level_deadline_var = tk.StringVar()
|
||||
ttk.Entry(level_controls, textvariable=self.level_name_var, width=22).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_fee_var, width=8).pack(side="left", padx=(0, 6))
|
||||
ttk.Entry(level_controls, textvariable=self.level_deadline_var, width=8).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Übernehmen", command=self._apply_level).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(level_controls, text="Hinzufügen", command=self._add_level).pack(side="left", padx=(0, 6))
|
||||
ttk.Button(level_controls, text="Entfernen", command=self._remove_level).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Stufen werden immer nach ihrer Reihenfolge in der Liste 1, 2, 3, … nummeriert.",
|
||||
style="Muted.TLabel",
|
||||
).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
items_frame = ttk.LabelFrame(parent, text="Standardpositionen (Beschreibung & Betrag)", padding=10)
|
||||
items_frame.grid(row=4, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
|
||||
items_frame.columnconfigure(0, weight=1)
|
||||
self.standard_items = [dict(item) for item in policy["standard_fee_items"]]
|
||||
self.standard_items_tree = ttk.Treeview(
|
||||
items_frame, columns=("description", "amount"), show="headings", height=5
|
||||
)
|
||||
self.standard_items_tree.heading("description", text="Beschreibung")
|
||||
self.standard_items_tree.heading("amount", text="Standardbetrag")
|
||||
self.standard_items_tree.column("description", width=260, anchor="w")
|
||||
self.standard_items_tree.column("amount", width=120, anchor="w")
|
||||
self.standard_items_tree.grid(row=0, column=0, sticky="ew")
|
||||
self.standard_items_tree.bind(
|
||||
"<<TreeviewSelect>>", lambda _event: self._load_selected_standard_item()
|
||||
)
|
||||
|
||||
item_controls = ttk.Frame(items_frame)
|
||||
item_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
self.standard_item_description_var = tk.StringVar()
|
||||
self.standard_item_amount_var = tk.StringVar()
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_description_var, width=26).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Entry(item_controls, textvariable=self.standard_item_amount_var, width=10).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Übernehmen", command=self._apply_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Hinzufügen", command=self._add_standard_item).pack(
|
||||
side="left", padx=(0, 6)
|
||||
)
|
||||
ttk.Button(item_controls, text="Entfernen", command=self._remove_standard_item).pack(side="left")
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Diese Vorlagen stehen bei Mahnungen und Rücklastschriften als Positionen zur "
|
||||
"Auswahl, z. B. Mahngebühr, Rücklastschriftgebühr, Bankgebühr oder Porto."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=560,
|
||||
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
self._refresh_levels()
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _refresh_levels(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
self.levels_tree.delete(*self.levels_tree.get_children())
|
||||
for index, level in enumerate(self.levels):
|
||||
self.levels_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(
|
||||
index + 1,
|
||||
level.get("name", ""),
|
||||
f"{level.get('fee', '0.00')} EUR",
|
||||
level.get("payment_deadline_days", 14),
|
||||
),
|
||||
)
|
||||
if selected and self.levels_tree.exists(selected[0]):
|
||||
self.levels_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
level = self.levels[int(selected[0])]
|
||||
self.level_name_var.set(level.get("name", ""))
|
||||
self.level_fee_var.set(str(level.get("fee", "0.00")))
|
||||
self.level_deadline_var.set(str(level.get("payment_deadline_days", 14)))
|
||||
|
||||
def _apply_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
name = self.level_name_var.get().strip()
|
||||
if not name:
|
||||
messagebox.showerror("Name fehlt", "Bitte einen Namen angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get(), "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get())
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels[int(selected[0])] = {
|
||||
"name": name,
|
||||
"fee": money_text(fee),
|
||||
"payment_deadline_days": deadline,
|
||||
}
|
||||
self._refresh_levels()
|
||||
|
||||
def _add_level(self) -> None:
|
||||
name = self.level_name_var.get().strip() or "Neue Mahnstufe"
|
||||
try:
|
||||
fee = decimal_value(self.level_fee_var.get() or "0", "Gebühr")
|
||||
deadline = int(self.level_deadline_var.get() or "14")
|
||||
except (ValueError, TypeError) as exc:
|
||||
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
|
||||
return
|
||||
self.levels.append({"name": name, "fee": money_text(fee), "payment_deadline_days": deadline})
|
||||
self._refresh_levels()
|
||||
|
||||
def _remove_level(self) -> None:
|
||||
selected = self.levels_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
|
||||
return
|
||||
del self.levels[int(selected[0])]
|
||||
self._refresh_levels()
|
||||
|
||||
def _refresh_standard_items(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
self.standard_items_tree.delete(*self.standard_items_tree.get_children())
|
||||
for index, item in enumerate(self.standard_items):
|
||||
self.standard_items_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(item.get("description", ""), f"{item.get('default_amount', '0.00')} EUR"),
|
||||
)
|
||||
if selected and self.standard_items_tree.exists(selected[0]):
|
||||
self.standard_items_tree.selection_set(selected[0])
|
||||
|
||||
def _load_selected_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
item = self.standard_items[int(selected[0])]
|
||||
self.standard_item_description_var.set(item.get("description", ""))
|
||||
self.standard_item_amount_var.set(str(item.get("default_amount", "0.00")))
|
||||
|
||||
def _apply_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
description = self.standard_item_description_var.get().strip()
|
||||
if not description:
|
||||
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get(), "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items[int(selected[0])] = {
|
||||
"description": description,
|
||||
"default_amount": money_text(amount),
|
||||
}
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _add_standard_item(self) -> None:
|
||||
description = self.standard_item_description_var.get().strip() or "Neue Position"
|
||||
try:
|
||||
amount = decimal_value(self.standard_item_amount_var.get() or "0", "Standardbetrag")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
|
||||
return
|
||||
self.standard_items.append({"description": description, "default_amount": money_text(amount)})
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _remove_standard_item(self) -> None:
|
||||
selected = self.standard_items_tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
|
||||
return
|
||||
del self.standard_items[int(selected[0])]
|
||||
self._refresh_standard_items()
|
||||
|
||||
def _build_email(self, parent: ttk.Frame) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
email_settings = self.repository.get_email_settings()
|
||||
|
||||
self.email_delivery_mode_var = tk.StringVar(
|
||||
value=display_label(EMAIL_DELIVERY_MODE_LABELS, email_settings["delivery_mode"])
|
||||
)
|
||||
ttk.Label(parent, text="Versandmodus").grid(row=0, column=0, sticky="w", pady=6)
|
||||
ttk.Combobox(
|
||||
parent,
|
||||
textvariable=self.email_delivery_mode_var,
|
||||
values=list(EMAIL_DELIVERY_MODE_LABELS.values()),
|
||||
state="readonly",
|
||||
width=42,
|
||||
).grid(row=0, column=1, sticky="w", pady=6)
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Gilt für Mahnungs- und SEPA-Info-Mails. „Jedes Mal fragen“ zeigt vor jeder "
|
||||
"Erzeugung eine Auswahl zwischen Senden und Entwurf ablegen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=680,
|
||||
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||
|
||||
smtp_frame = ttk.LabelFrame(parent, text="SMTP (zum direkten Versenden)", padding=10)
|
||||
smtp_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(4, 0))
|
||||
smtp_frame.columnconfigure(1, weight=1)
|
||||
self.smtp_host_var = tk.StringVar(value=email_settings["smtp_host"])
|
||||
self.smtp_port_var = tk.StringVar(value=str(email_settings["smtp_port"]))
|
||||
self.smtp_security_var = tk.StringVar(
|
||||
value=display_label(EMAIL_SECURITY_LABELS, email_settings["smtp_security"])
|
||||
)
|
||||
self.smtp_username_var = tk.StringVar(value=email_settings["smtp_username"])
|
||||
self.smtp_password_var = tk.StringVar(value=email_settings["smtp_password"])
|
||||
ttk.Label(smtp_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_host_var).grid(
|
||||
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Label(smtp_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_port_var, width=8).grid(row=0, column=3, pady=4)
|
||||
ttk.Label(smtp_frame, text="Verschlüsselung").grid(
|
||||
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Combobox(
|
||||
smtp_frame,
|
||||
textvariable=self.smtp_security_var,
|
||||
values=list(EMAIL_SECURITY_LABELS.values()),
|
||||
state="readonly",
|
||||
width=14,
|
||||
).grid(row=1, column=1, sticky="w", pady=4)
|
||||
ttk.Label(smtp_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_username_var).grid(
|
||||
row=2, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Label(smtp_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(smtp_frame, textvariable=self.smtp_password_var, show="•").grid(
|
||||
row=3, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Button(smtp_frame, text="Verbindung testen", command=self._test_smtp_connection).grid(
|
||||
row=4, column=0, columnspan=4, sticky="w", pady=(8, 0)
|
||||
)
|
||||
|
||||
imap_frame = ttk.LabelFrame(parent, text="IMAP (für Entwürfe & Gesendet)", padding=10)
|
||||
imap_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(16, 0))
|
||||
imap_frame.columnconfigure(1, weight=1)
|
||||
self.imap_host_var = tk.StringVar(value=email_settings["imap_host"])
|
||||
self.imap_port_var = tk.StringVar(value=str(email_settings["imap_port"]))
|
||||
self.imap_security_var = tk.StringVar(
|
||||
value=display_label(EMAIL_SECURITY_LABELS, email_settings["imap_security"])
|
||||
)
|
||||
self.imap_username_var = tk.StringVar(value=email_settings["imap_username"])
|
||||
self.imap_password_var = tk.StringVar(value=email_settings["imap_password"])
|
||||
self.imap_drafts_folder_var = tk.StringVar(value=email_settings["imap_drafts_folder"])
|
||||
self.imap_sent_enabled_var = tk.BooleanVar(value=email_settings["imap_sent_enabled"])
|
||||
self.imap_sent_folder_var = tk.StringVar(value=email_settings["imap_sent_folder"])
|
||||
ttk.Label(imap_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_host_var).grid(
|
||||
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Label(imap_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_port_var, width=8).grid(row=0, column=3, pady=4)
|
||||
ttk.Label(imap_frame, text="Verschlüsselung").grid(
|
||||
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
|
||||
)
|
||||
ttk.Combobox(
|
||||
imap_frame,
|
||||
textvariable=self.imap_security_var,
|
||||
values=list(EMAIL_SECURITY_LABELS.values()),
|
||||
state="readonly",
|
||||
width=14,
|
||||
).grid(row=1, column=1, sticky="w", pady=4)
|
||||
ttk.Label(imap_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_username_var).grid(
|
||||
row=2, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Label(imap_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
ttk.Entry(imap_frame, textvariable=self.imap_password_var, show="•").grid(
|
||||
row=3, column=1, columnspan=3, sticky="ew", pady=4
|
||||
)
|
||||
ttk.Label(imap_frame, text="Entwürfe-Ordner").grid(row=4, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
self.imap_drafts_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_drafts_folder_var)
|
||||
self.imap_drafts_folder_combo.grid(row=4, column=1, columnspan=3, sticky="ew", pady=4)
|
||||
ttk.Checkbutton(
|
||||
imap_frame,
|
||||
text="Direkt versandte Mahnungen/Info-Mails zusätzlich in einen Gesendet-Ordner ablegen",
|
||||
variable=self.imap_sent_enabled_var,
|
||||
).grid(row=5, column=0, columnspan=4, sticky="w", pady=(8, 4))
|
||||
ttk.Label(imap_frame, text="Gesendet-Ordner").grid(row=6, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||
self.imap_sent_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_sent_folder_var)
|
||||
self.imap_sent_folder_combo.grid(row=6, column=1, columnspan=3, sticky="ew", pady=4)
|
||||
buttons_row = ttk.Frame(imap_frame)
|
||||
buttons_row.grid(row=7, column=0, columnspan=4, sticky="w", pady=(8, 0))
|
||||
ttk.Button(buttons_row, text="Verbindung testen", command=self._test_imap_connection).pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
ttk.Button(buttons_row, text="Ordnerliste laden", command=self._load_imap_folders).pack(side="left")
|
||||
ttk.Label(
|
||||
imap_frame,
|
||||
text=(
|
||||
"„Ordnerliste laden“ ruft die Ordner live vom Server ab (Zugangsdaten oben müssen "
|
||||
"dafür bereits ausgefüllt sein) und füllt beide Dropdowns; freie Eingabe bleibt "
|
||||
"möglich -- ein dabei angegebener, noch nicht vorhandener Ordner wird bei der "
|
||||
"nächsten Ablage automatisch angelegt."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=660,
|
||||
).grid(row=8, column=0, columnspan=4, sticky="w", pady=(6, 0))
|
||||
|
||||
ttk.Label(
|
||||
parent,
|
||||
text=(
|
||||
"Server, Zugangsdaten und Passwörter werden mit den übrigen Vereinseinstellungen "
|
||||
"in repository.json innerhalb dieses Mitglieder-Stores gespeichert -- pro Store "
|
||||
"lässt sich so ein eigenes Postfach hinterlegen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=680,
|
||||
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(12, 0))
|
||||
|
||||
def _collect_email_settings(self) -> dict:
|
||||
return {
|
||||
"delivery_mode": storage_key(EMAIL_DELIVERY_MODE_LABELS, self.email_delivery_mode_var.get()),
|
||||
"smtp_host": self.smtp_host_var.get(),
|
||||
"smtp_port": self.smtp_port_var.get(),
|
||||
"smtp_security": storage_key(EMAIL_SECURITY_LABELS, self.smtp_security_var.get()),
|
||||
"smtp_username": self.smtp_username_var.get(),
|
||||
"smtp_password": self.smtp_password_var.get(),
|
||||
"imap_host": self.imap_host_var.get(),
|
||||
"imap_port": self.imap_port_var.get(),
|
||||
"imap_security": storage_key(EMAIL_SECURITY_LABELS, self.imap_security_var.get()),
|
||||
"imap_username": self.imap_username_var.get(),
|
||||
"imap_password": self.imap_password_var.get(),
|
||||
"imap_drafts_folder": self.imap_drafts_folder_var.get(),
|
||||
"imap_sent_enabled": self.imap_sent_enabled_var.get(),
|
||||
"imap_sent_folder": self.imap_sent_folder_var.get(),
|
||||
}
|
||||
|
||||
def _test_smtp_connection(self) -> None:
|
||||
settings = self._collect_email_settings()
|
||||
try:
|
||||
settings["smtp_port"] = int(settings["smtp_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror("Ungültiger Port", "Der SMTP-Port muss eine ganze Zahl sein.", parent=self)
|
||||
return
|
||||
try:
|
||||
test_smtp_connection(settings)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("SMTP-Verbindung fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
messagebox.showinfo("SMTP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
|
||||
|
||||
def _test_imap_connection(self) -> None:
|
||||
settings = self._collect_email_settings()
|
||||
try:
|
||||
settings["imap_port"] = int(settings["imap_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
|
||||
return
|
||||
try:
|
||||
test_imap_connection(settings)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("IMAP-Verbindung fehlgeschlagen", str(exc), parent=self)
|
||||
return
|
||||
messagebox.showinfo("IMAP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
|
||||
|
||||
def _load_imap_folders(self) -> None:
|
||||
settings = self._collect_email_settings()
|
||||
try:
|
||||
settings["imap_port"] = int(settings["imap_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
|
||||
return
|
||||
try:
|
||||
folders = list_imap_folders(settings)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Ordnerliste konnte nicht geladen werden", str(exc), parent=self)
|
||||
return
|
||||
self.imap_drafts_folder_combo.configure(values=folders)
|
||||
self.imap_sent_folder_combo.configure(values=folders)
|
||||
messagebox.showinfo("Ordnerliste geladen", f"{len(folders)} Ordner gefunden.", parent=self)
|
||||
|
||||
def _choose_store(self) -> None:
|
||||
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
|
||||
initial = current if current.is_dir() else current.parent
|
||||
@@ -355,9 +829,19 @@ class OptionsDialog(tk.Toplevel):
|
||||
anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher")
|
||||
anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher")
|
||||
anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get())
|
||||
grace_days_after_due = _parse_day_window(self.grace_days_var.get(), "Kulanzfrist")
|
||||
except (ValueError, IntervalValidationError) as exc:
|
||||
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
|
||||
return
|
||||
email_settings = self._collect_email_settings()
|
||||
try:
|
||||
email_settings["smtp_port"] = int(email_settings["smtp_port"])
|
||||
email_settings["imap_port"] = int(email_settings["imap_port"])
|
||||
except ValueError:
|
||||
messagebox.showerror(
|
||||
"Ungültige E-Mail-Einstellung", "SMTP-/IMAP-Port müssen ganze Zahlen sein.", parent=self
|
||||
)
|
||||
return
|
||||
store_text = self.store_var.get().strip()
|
||||
if not store_text:
|
||||
messagebox.showerror(
|
||||
@@ -409,10 +893,20 @@ class OptionsDialog(tk.Toplevel):
|
||||
)
|
||||
try:
|
||||
self.config_obj.save()
|
||||
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
|
||||
self.repository.save_member_number_policy(
|
||||
mode=number_mode,
|
||||
pattern=number_pattern,
|
||||
allocation_strategy=self.number_strategy_var.get(),
|
||||
)
|
||||
self.repository.save_organization(
|
||||
{key: variable.get() for key, variable in self.organization_vars.items()}
|
||||
)
|
||||
self.repository.save_reminder_policy(
|
||||
grace_days_after_due=grace_days_after_due,
|
||||
levels=self.levels,
|
||||
standard_fee_items=self.standard_items,
|
||||
)
|
||||
self.repository.save_email_settings(**email_settings)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
claim_balance,
|
||||
claim_status,
|
||||
decimal_value,
|
||||
donation_balance,
|
||||
money_text,
|
||||
)
|
||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.donation_dialog import DonationEditDialog
|
||||
|
||||
|
||||
class _AllocationTable:
|
||||
"""Embeds a combined claims + donations allocation tree into a payment dialog.
|
||||
|
||||
Only claims/donations that are still open (or already linked to this payment) are
|
||||
listed, so fully-settled ones don't clutter the picture. A row's visibility is
|
||||
decided once, from a snapshot taken when the dialog opens (or when a new donation
|
||||
is created inline) -- it never disappears again for the rest of the editing
|
||||
session just because the user unassigned it. That way "Zuordnung lösen" followed
|
||||
by "Zuordnung setzen" always works on the same row instead of the target vanishing.
|
||||
|
||||
Mutates the ``claim_allocations``/``donation_allocations`` dicts it is given in
|
||||
place, so the owning dialog can read them back at save time. Nothing is written to
|
||||
the repository until the dialog's own Save button does so.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
*,
|
||||
claim_allocations: dict[str, str],
|
||||
donation_allocations: dict[str, str],
|
||||
on_change: Callable[[], None],
|
||||
get_payment_amount: Callable[[], Decimal],
|
||||
):
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.claim_allocations = claim_allocations
|
||||
self.donation_allocations = donation_allocations
|
||||
self.on_change = on_change
|
||||
self.get_payment_amount = get_payment_amount
|
||||
# Fixed snapshot of what this payment already covered when the dialog opened.
|
||||
# Capacities are computed against this baseline rather than the live, editable
|
||||
# dicts above -- otherwise "Maximal zuordenbar" would inflate every time the
|
||||
# user changes an amount, since claim_balance() reflects only persisted state.
|
||||
self._baseline_claim_allocations = dict(claim_allocations)
|
||||
self._baseline_donation_allocations = dict(donation_allocations)
|
||||
self._visible_claim_ids: set[str] = set(claim_allocations)
|
||||
self._visible_donation_ids: set[str] = set(donation_allocations)
|
||||
self._load_targets()
|
||||
self._build(parent)
|
||||
self._refresh()
|
||||
|
||||
def _load_targets(self) -> None:
|
||||
self.data = self.repository.get_contributions(self.member_id)
|
||||
self._visible_claim_ids |= {
|
||||
str(claim.get("claim_id", ""))
|
||||
for claim in self.data.claims
|
||||
if str(claim.get("claim_id", ""))
|
||||
and claim_status(self.data, claim) != "cancelled"
|
||||
and claim_balance(self.data, claim) > 0
|
||||
}
|
||||
self._visible_donation_ids |= {
|
||||
str(item.get("donation_id", ""))
|
||||
for item in self.data.donations
|
||||
if str(item.get("donation_id", "")) and donation_balance(self.data, item) > 0
|
||||
}
|
||||
self.claims_by_id = {
|
||||
str(claim.get("claim_id", "")): claim
|
||||
for claim in self.data.claims
|
||||
if str(claim.get("claim_id", "")) in self._visible_claim_ids
|
||||
}
|
||||
self.donations_by_id = {
|
||||
str(item.get("donation_id", "")): item
|
||||
for item in self.data.donations
|
||||
if str(item.get("donation_id", "")) in self._visible_donation_ids
|
||||
}
|
||||
|
||||
def _capacities(self) -> None:
|
||||
self.claim_capacity: dict[str, Decimal] = {}
|
||||
for claim_id, claim in self.claims_by_id.items():
|
||||
baseline = decimal_value(self._baseline_claim_allocations.get(claim_id, "0"))
|
||||
self.claim_capacity[claim_id] = max(claim_balance(self.data, claim) + baseline, Decimal("0"))
|
||||
self.donation_capacity: dict[str, Decimal] = {}
|
||||
for donation_id, donation in self.donations_by_id.items():
|
||||
baseline = decimal_value(self._baseline_donation_allocations.get(donation_id, "0"))
|
||||
self.donation_capacity[donation_id] = max(
|
||||
donation_balance(self.data, donation) + baseline, Decimal("0")
|
||||
)
|
||||
|
||||
def _build(self, parent: tk.Misc) -> None:
|
||||
parent.columnconfigure(0, weight=1)
|
||||
parent.rowconfigure(0, weight=1)
|
||||
self.tree = ttk.Treeview(
|
||||
parent,
|
||||
columns=("kind", "title", "date", "capacity", "allocated"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("kind", "Typ", 90),
|
||||
("title", "Forderung / Spende", 260),
|
||||
("date", "Fällig / Datum", 110),
|
||||
("capacity", "Maximal zuordenbar", 140),
|
||||
("allocated", "Aktuell zugeordnet", 140),
|
||||
):
|
||||
self.tree.heading(key, text=title)
|
||||
self.tree.column(key, width=width, anchor="w")
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
self.tree.bind("<<TreeviewSelect>>", self._select)
|
||||
|
||||
actions = ttk.Frame(parent)
|
||||
actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
||||
ttk.Label(actions, text="Betrag für Auswahl").pack(side="left")
|
||||
self.amount_var = tk.StringVar()
|
||||
ttk.Entry(actions, textvariable=self.amount_var, width=14).pack(side="left", padx=(8, 8))
|
||||
ttk.Button(actions, text="Zuordnung setzen", command=self._set).pack(side="left")
|
||||
ttk.Button(actions, text="Zuordnung lösen", command=self._remove).pack(side="left", padx=(8, 0))
|
||||
ttk.Separator(actions, orient="vertical").pack(side="left", fill="y", padx=10)
|
||||
ttk.Button(actions, text="Neue Spende anlegen", command=self._create_donation).pack(side="left")
|
||||
|
||||
def _refresh(self) -> None:
|
||||
selected = self.tree.selection()
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
self._capacities()
|
||||
for claim_id, claim in sorted(
|
||||
self.claims_by_id.items(),
|
||||
key=lambda item: (str(item[1].get("due_date", "")), str(item[1].get("title", "")).casefold()),
|
||||
):
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=f"claim:{claim_id}",
|
||||
values=(
|
||||
"Forderung",
|
||||
claim.get("title", "Forderung"),
|
||||
format_date_for_display(str(claim.get("due_date", ""))),
|
||||
f"{money_text(self.claim_capacity[claim_id])} EUR",
|
||||
f"{self.claim_allocations.get(claim_id, '0.00')} EUR",
|
||||
),
|
||||
)
|
||||
for donation_id, donation in sorted(
|
||||
self.donations_by_id.items(), key=lambda item: str(item[1].get("date", "")), reverse=True
|
||||
):
|
||||
label = donation.get("reference") or donation.get("purpose") or "Spende"
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=f"donation:{donation_id}",
|
||||
values=(
|
||||
"Spende",
|
||||
label,
|
||||
format_date_for_display(str(donation.get("date", ""))),
|
||||
f"{money_text(self.donation_capacity[donation_id])} EUR",
|
||||
f"{self.donation_allocations.get(donation_id, '0.00')} EUR",
|
||||
),
|
||||
)
|
||||
if selected and self.tree.exists(selected[0]):
|
||||
self.tree.selection_set(selected[0])
|
||||
self.on_change()
|
||||
|
||||
@staticmethod
|
||||
def _target(iid: str) -> tuple[str, str]:
|
||||
kind, target_id = iid.split(":", 1)
|
||||
return kind, target_id
|
||||
|
||||
def _select(self, _event: tk.Event | None = None) -> None:
|
||||
selected = self.tree.selection()
|
||||
if not selected:
|
||||
return
|
||||
kind, target_id = self._target(selected[0])
|
||||
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
|
||||
current = allocations.get(target_id)
|
||||
if current:
|
||||
# Already allocated: show the existing amount so it can be reviewed/edited.
|
||||
self.amount_var.set(current)
|
||||
return
|
||||
# Not yet allocated: suggest whichever is smaller -- what this claim/donation
|
||||
# still needs, or what's left of the payment -- so the common case is just
|
||||
# "click the row, then Zuordnung setzen".
|
||||
capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
|
||||
free = max(self._payment_amount() - self.total_allocated(), Decimal("0"))
|
||||
self.amount_var.set(money_text(min(capacity, free)))
|
||||
|
||||
def _payment_amount(self) -> Decimal:
|
||||
try:
|
||||
return self.get_payment_amount()
|
||||
except ValueError:
|
||||
return Decimal("0")
|
||||
|
||||
def _set(self) -> None:
|
||||
selected = self.tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror(
|
||||
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
|
||||
)
|
||||
return
|
||||
kind, target_id = self._target(selected[0])
|
||||
try:
|
||||
value = decimal_value(self.amount_var.get(), "Zuordnung")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self.tree)
|
||||
return
|
||||
capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
|
||||
if value < 0 or value > capacity:
|
||||
messagebox.showerror(
|
||||
"Ungültige Zuordnung",
|
||||
f"Es können höchstens {money_text(capacity)} EUR zugeordnet werden.",
|
||||
parent=self.tree,
|
||||
)
|
||||
return
|
||||
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
|
||||
if value:
|
||||
allocations[target_id] = money_text(value)
|
||||
else:
|
||||
allocations.pop(target_id, None)
|
||||
self._refresh()
|
||||
|
||||
def _remove(self) -> None:
|
||||
selected = self.tree.selection()
|
||||
if not selected:
|
||||
messagebox.showerror(
|
||||
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
|
||||
)
|
||||
return
|
||||
kind, target_id = self._target(selected[0])
|
||||
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
|
||||
allocations.pop(target_id, None)
|
||||
self.amount_var.set("0.00")
|
||||
self._refresh()
|
||||
|
||||
def _create_donation(self) -> None:
|
||||
DonationEditDialog(self.tree, self.repository, self.member_id, self._donation_created)
|
||||
|
||||
def _donation_created(self) -> None:
|
||||
self._load_targets()
|
||||
self._refresh()
|
||||
|
||||
def total_allocated(self) -> Decimal:
|
||||
return sum(
|
||||
(
|
||||
decimal_value(value)
|
||||
for value in (*self.claim_allocations.values(), *self.donation_allocations.values())
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
class PaymentCreateDialog(tk.Toplevel):
|
||||
"""Records a payment and lets the board allocate parts of it to open claims and/or
|
||||
donations right away — including creating a new donation on the fly — instead of
|
||||
having to save the bare payment first and assign it in a separate step."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
on_saved: Callable[[], None],
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.on_saved = on_saved
|
||||
self.claim_allocations: dict[str, str] = {}
|
||||
self.donation_allocations: dict[str, str] = {}
|
||||
self.allocation_table: _AllocationTable | None = None
|
||||
self.title("Zahlung anlegen")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.geometry("880x560")
|
||||
self.minsize(720, 460)
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self._build_ui()
|
||||
self.after_idle(self._activate)
|
||||
|
||||
def _activate(self) -> None:
|
||||
try:
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
return
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(1, weight=1)
|
||||
form = ttk.Frame(self, padding=16)
|
||||
form.grid(row=0, column=0, sticky="ew")
|
||||
form.columnconfigure(1, weight=1)
|
||||
self.variables = {
|
||||
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
|
||||
"amount": tk.StringVar(),
|
||||
"gnucash": tk.StringVar(),
|
||||
"reference": tk.StringVar(),
|
||||
}
|
||||
fields = (
|
||||
(f"Zahlungsdatum ({date_input_hint()})", "date"),
|
||||
("Zahlungsbetrag", "amount"),
|
||||
("GnuCash-ID (optional)", "gnucash"),
|
||||
("Referenz", "reference"),
|
||||
)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
|
||||
ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
|
||||
row=row, column=1, sticky="ew", pady=4
|
||||
)
|
||||
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
|
||||
self.total_var = tk.StringVar()
|
||||
|
||||
allocation_frame = ttk.LabelFrame(self, text="Sofort zuordnen (optional)", padding=12)
|
||||
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||
self.allocation_table = _AllocationTable(
|
||||
allocation_frame,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
claim_allocations=self.claim_allocations,
|
||||
donation_allocations=self.donation_allocations,
|
||||
on_change=self._refresh_totals,
|
||||
get_payment_amount=self._current_payment_amount,
|
||||
)
|
||||
|
||||
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
|
||||
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
|
||||
)
|
||||
|
||||
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
||||
buttons.grid(row=3, column=0, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(
|
||||
buttons, text="Speichern", style="Accent.TButton", command=self._save
|
||||
).pack(side="left")
|
||||
self._refresh_totals()
|
||||
|
||||
def _current_payment_amount(self) -> Decimal:
|
||||
return decimal_value(self.variables["amount"].get())
|
||||
|
||||
def _refresh_totals(self) -> None:
|
||||
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
|
||||
try:
|
||||
payment_amount = self._current_payment_amount()
|
||||
free = payment_amount - allocated
|
||||
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR")
|
||||
except (ValueError, InvalidOperation):
|
||||
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.repository.create_payment(
|
||||
self.member_id,
|
||||
payment_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
claim_allocations=self.claim_allocations,
|
||||
donation_allocations=self.donation_allocations,
|
||||
gnucash_transaction_id=self.variables["gnucash"].get(),
|
||||
reference=self.variables["reference"].get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
|
||||
|
||||
class PaymentEditDialog(tk.Toplevel):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
member_id: str,
|
||||
payment_id: str,
|
||||
on_saved: Callable[[], None],
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.member_id = member_id
|
||||
self.payment_id = payment_id
|
||||
self.on_saved = on_saved
|
||||
self.data = repository.get_contributions(member_id)
|
||||
self.payment = next(
|
||||
(
|
||||
payment
|
||||
for payment in self.data.payments
|
||||
if str(payment.get("payment_id", "")) == payment_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if self.payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
self.claim_allocations, self.donation_allocations = self._current_allocations()
|
||||
self.allocation_table: _AllocationTable | None = None
|
||||
|
||||
self.title("Zahlung bearbeiten")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.geometry("880x560")
|
||||
self.minsize(720, 460)
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self._build_ui()
|
||||
self.after_idle(self._activate)
|
||||
|
||||
def _current_allocations(self) -> tuple[dict[str, str], dict[str, str]]:
|
||||
claim_totals: dict[str, Decimal] = {}
|
||||
donation_totals: dict[str, Decimal] = {}
|
||||
for allocation in self.data.allocations:
|
||||
if str(allocation.get("payment_id", "")) != self.payment_id:
|
||||
continue
|
||||
amount = decimal_value(allocation.get("amount", "0"))
|
||||
donation_id = str(allocation.get("donation_id", ""))
|
||||
if donation_id:
|
||||
donation_totals[donation_id] = donation_totals.get(donation_id, Decimal("0")) + amount
|
||||
continue
|
||||
claim_id = str(allocation.get("claim_id", ""))
|
||||
if claim_id:
|
||||
claim_totals[claim_id] = claim_totals.get(claim_id, Decimal("0")) + amount
|
||||
return (
|
||||
{claim_id: money_text(value) for claim_id, value in claim_totals.items()},
|
||||
{donation_id: money_text(value) for donation_id, value in donation_totals.items()},
|
||||
)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(1, weight=1)
|
||||
form = ttk.Frame(self, padding=16)
|
||||
form.grid(row=0, column=0, sticky="ew")
|
||||
form.columnconfigure(1, weight=1)
|
||||
self.variables = {
|
||||
"date": tk.StringVar(
|
||||
value=format_date_for_display(str(self.payment.get("date", "")))
|
||||
),
|
||||
"amount": tk.StringVar(value=str(self.payment.get("amount", ""))),
|
||||
"gnucash": tk.StringVar(value=str(self.payment.get("gnucash_transaction_id", ""))),
|
||||
"reference": tk.StringVar(value=str(self.payment.get("reference", ""))),
|
||||
}
|
||||
fields = (
|
||||
(f"Zahlungsdatum ({date_input_hint()})", "date"),
|
||||
("Zahlungsbetrag", "amount"),
|
||||
("GnuCash-ID", "gnucash"),
|
||||
("Referenz", "reference"),
|
||||
)
|
||||
for row, (label, key) in enumerate(fields):
|
||||
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
|
||||
ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
|
||||
row=row, column=1, sticky="ew", pady=4
|
||||
)
|
||||
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
|
||||
self.total_var = tk.StringVar()
|
||||
|
||||
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen und Spenden", padding=12)
|
||||
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||
self.allocation_table = _AllocationTable(
|
||||
allocation_frame,
|
||||
self.repository,
|
||||
self.member_id,
|
||||
claim_allocations=self.claim_allocations,
|
||||
donation_allocations=self.donation_allocations,
|
||||
on_change=self._refresh_totals,
|
||||
get_payment_amount=self._current_payment_amount,
|
||||
)
|
||||
|
||||
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
|
||||
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
|
||||
)
|
||||
|
||||
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
||||
buttons.grid(row=3, column=0, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="Zahlung speichern",
|
||||
style="Accent.TButton",
|
||||
command=self._save,
|
||||
).pack(side="left")
|
||||
self._refresh_totals()
|
||||
|
||||
def _activate(self) -> None:
|
||||
try:
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
return
|
||||
|
||||
def _current_payment_amount(self) -> Decimal:
|
||||
return decimal_value(self.variables["amount"].get())
|
||||
|
||||
def _refresh_totals(self) -> None:
|
||||
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
|
||||
try:
|
||||
payment_amount = self._current_payment_amount()
|
||||
free = payment_amount - allocated
|
||||
self.total_var.set(
|
||||
f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR"
|
||||
)
|
||||
except (ValueError, InvalidOperation):
|
||||
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
self.repository.update_payment(
|
||||
self.member_id,
|
||||
self.payment_id,
|
||||
payment_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
allocations=self.claim_allocations,
|
||||
donation_allocations=self.donation_allocations,
|
||||
gnucash_transaction_id=self.variables["gnucash"].get(),
|
||||
reference=self.variables["reference"].get(),
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_saved()
|
||||
@@ -0,0 +1,324 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.domain.dates import DateValidationError, normalize_date_input
|
||||
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
|
||||
from ccma.services.sepa_mail import generate_debit_mails
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||
|
||||
|
||||
def _next_weekday(selected: date) -> date:
|
||||
while selected.weekday() >= 5:
|
||||
selected += timedelta(days=1)
|
||||
return selected
|
||||
|
||||
|
||||
class SepaExportDialog(tk.Toplevel):
|
||||
def __init__(self, master: tk.Misc, repository: MemberRepository):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.debits = []
|
||||
self.warnings: list[str] = []
|
||||
self.title("SEPA-Lastschriften")
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.geometry("1000x560")
|
||||
self.minsize(760, 420)
|
||||
self.from_var = tk.StringVar(value=date(date.today().year, 1, 1).isoformat())
|
||||
self.due_var = tk.StringVar(value=date.today().isoformat())
|
||||
self.collection_var = tk.StringVar(
|
||||
value=_next_weekday(date.today() + timedelta(days=2)).isoformat()
|
||||
)
|
||||
self.summary_var = tk.StringVar()
|
||||
self.warning_var = tk.StringVar()
|
||||
self._build_ui()
|
||||
self.refresh()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.columnconfigure(0, weight=1)
|
||||
self.rowconfigure(3, weight=1)
|
||||
filters = ttk.Frame(self, padding=12)
|
||||
filters.grid(row=0, column=0, sticky="ew")
|
||||
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
|
||||
ttk.Entry(filters, textvariable=self.from_var, width=14).grid(
|
||||
row=0, column=1, padx=(8, 20)
|
||||
)
|
||||
ttk.Label(filters, text="Fällig bis").grid(row=0, column=2)
|
||||
ttk.Entry(filters, textvariable=self.due_var, width=14).grid(
|
||||
row=0, column=3, padx=(8, 20)
|
||||
)
|
||||
ttk.Label(filters, text="Einzugsdatum").grid(row=0, column=4)
|
||||
ttk.Entry(filters, textvariable=self.collection_var, width=14).grid(
|
||||
row=0, column=5, padx=(8, 20)
|
||||
)
|
||||
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6)
|
||||
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||
row=1, column=0, sticky="w", padx=12, pady=(0, 2)
|
||||
)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Standardmäßig sind alle Lastschriften ausgewählt -- einzelne mit Strg+Klick "
|
||||
"abwählen (z. B. während eine Rücklastschrift geklärt wird), um sie erst bei "
|
||||
"einem späteren Einzug zu berücksichtigen."
|
||||
),
|
||||
style="Muted.TLabel",
|
||||
wraplength=960,
|
||||
).grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
|
||||
self.table = ttk.Treeview(
|
||||
self,
|
||||
columns=("number", "name", "iban", "mandate", "purpose", "amount"),
|
||||
show="headings",
|
||||
selectmode="extended",
|
||||
)
|
||||
for key, label, width, anchor in (
|
||||
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
|
||||
("iban", "IBAN", 190, "w"), ("mandate", "Mandat", 130, "w"),
|
||||
("purpose", "Verwendungszweck", 280, "w"), ("amount", "Betrag", 90, "e"),
|
||||
):
|
||||
self.table.heading(key, text=label)
|
||||
self.table.column(key, width=width, anchor=anchor)
|
||||
self.table.grid(row=3, column=0, sticky="nsew", padx=12)
|
||||
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||
footer = ttk.Frame(self, padding=12)
|
||||
footer.grid(row=4, column=0, sticky="ew")
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w")
|
||||
ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
|
||||
row=0, column=1, padx=(8, 0)
|
||||
)
|
||||
ttk.Button(footer, text="Info-Mails erzeugen", command=self._open_mail_export).grid(
|
||||
row=0, column=2, padx=(8, 0)
|
||||
)
|
||||
ttk.Button(
|
||||
footer, text="SEPA-XML speichern", style="Accent.TButton", command=self._save_xml
|
||||
).grid(row=0, column=3, padx=(8, 0))
|
||||
ttk.Button(footer, text="Schließen", command=self.destroy).grid(
|
||||
row=0, column=4, padx=(8, 0)
|
||||
)
|
||||
|
||||
def _dates(self) -> tuple[date, date, date]:
|
||||
try:
|
||||
due_from = date.fromisoformat(
|
||||
normalize_date_input(self.from_var.get(), "Fällig ab")
|
||||
)
|
||||
due = date.fromisoformat(normalize_date_input(self.due_var.get(), "Fällig bis"))
|
||||
collection = date.fromisoformat(
|
||||
normalize_date_input(self.collection_var.get(), "Einzugsdatum")
|
||||
)
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if due_from > due:
|
||||
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
|
||||
if collection.weekday() >= 5:
|
||||
raise RepositoryError(
|
||||
"Das Einzugsdatum muss ein Bankarbeitstag von Montag bis Freitag sein."
|
||||
)
|
||||
return due_from, due, collection
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Preserve which members were deliberately deselected across a refresh (e.g.
|
||||
# _prepare() re-querying right before an export) by identity, not by row index
|
||||
# -- a debit newly appearing in the requeried list still defaults to selected.
|
||||
previously_deselected = {
|
||||
self.debits[int(iid)].member_id
|
||||
for iid in self.table.get_children()
|
||||
if iid not in self.table.selection()
|
||||
}
|
||||
try:
|
||||
due_from, due, _collection = self._dates()
|
||||
self.debits, self.warnings = pending_direct_debits(
|
||||
self.repository, due_from=due_from, due_until=due
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
|
||||
return
|
||||
self.table.delete(*self.table.get_children())
|
||||
for index, debit in enumerate(self.debits):
|
||||
self.table.insert(
|
||||
"", "end", iid=str(index),
|
||||
values=(debit.member_number, debit.account_holder, debit.iban,
|
||||
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
|
||||
)
|
||||
self.table.selection_set(
|
||||
[
|
||||
str(index)
|
||||
for index, debit in enumerate(self.debits)
|
||||
if debit.member_id not in previously_deselected
|
||||
]
|
||||
)
|
||||
self._update_summary()
|
||||
self.warning_var.set(
|
||||
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
|
||||
)
|
||||
|
||||
def _selected_debits(self) -> list:
|
||||
return [self.debits[int(iid)] for iid in sorted(self.table.selection(), key=int)]
|
||||
|
||||
def _update_summary(self) -> None:
|
||||
selected = self._selected_debits()
|
||||
total = sum((item.amount for item in self.debits), Decimal("0"))
|
||||
if len(selected) == len(self.debits):
|
||||
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
|
||||
return
|
||||
selected_total = sum((item.amount for item in selected), Decimal("0"))
|
||||
self.summary_var.set(
|
||||
f"{len(selected)} von {len(self.debits)} Lastschrift(en) ausgewählt · "
|
||||
f"Summe {selected_total:.2f} EUR (gesamt {total:.2f} EUR)"
|
||||
)
|
||||
|
||||
def _prepare(self) -> tuple[date, date, date] | None:
|
||||
self.refresh()
|
||||
if not self.debits:
|
||||
messagebox.showinfo(
|
||||
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
|
||||
)
|
||||
return None
|
||||
if not self._selected_debits():
|
||||
messagebox.showinfo(
|
||||
"Keine Auswahl", "Bitte mindestens eine Lastschrift auswählen.", parent=self
|
||||
)
|
||||
return None
|
||||
if self.warnings and not messagebox.askyesno(
|
||||
"Unvollständige Mandate",
|
||||
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
|
||||
parent=self,
|
||||
):
|
||||
return None
|
||||
return self._dates()
|
||||
|
||||
def _target(self, extension: str, title: str, collection: date) -> Path | None:
|
||||
selected = filedialog.asksaveasfilename(
|
||||
parent=self, title=title, defaultextension=extension,
|
||||
initialfile=f"SEPA-Lastschriften-{collection.isoformat()}{extension}",
|
||||
filetypes=[(title, f"*{extension}"), ("Alle Dateien", "*.*")],
|
||||
)
|
||||
return Path(selected) if selected else None
|
||||
|
||||
def _save_csv(self) -> None:
|
||||
dates = self._prepare()
|
||||
if not dates:
|
||||
return
|
||||
target = self._target(".csv", "CSV-Datei", dates[2])
|
||||
if target:
|
||||
write_csv(target, self._selected_debits(), collection_date=dates[2])
|
||||
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
|
||||
|
||||
def _save_xml(self) -> None:
|
||||
dates = self._prepare()
|
||||
if not dates:
|
||||
return
|
||||
target = self._target(".xml", "SEPA-XML", dates[2])
|
||||
if not target:
|
||||
return
|
||||
try:
|
||||
write_pain008(
|
||||
target, self._selected_debits(), collection_date=dates[2],
|
||||
organization=self.repository.get_configuration().get("organization") or {},
|
||||
)
|
||||
except RepositoryError as exc:
|
||||
messagebox.showerror("SEPA-XML konnte nicht erstellt werden", str(exc), parent=self)
|
||||
return
|
||||
messagebox.showinfo("Export gespeichert", f"SEPA-XML gespeichert:\n{target}", parent=self)
|
||||
|
||||
def _open_mail_export(self) -> None:
|
||||
dates = self._prepare()
|
||||
if dates:
|
||||
SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2])
|
||||
|
||||
|
||||
class SepaMailExportDialog(tk.Toplevel):
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Misc,
|
||||
repository: MemberRepository,
|
||||
debits: list,
|
||||
collection_date: date,
|
||||
):
|
||||
super().__init__(master)
|
||||
self.repository = repository
|
||||
self.debits = debits
|
||||
self.collection_date = collection_date
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
self.sender_name_var = tk.StringVar(
|
||||
value=str(organization.get("name") or "Verwaltung C3MA")
|
||||
)
|
||||
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
||||
self.title("SEPA-Info-Mails erzeugen")
|
||||
self.transient(master)
|
||||
self.resizable(True, False)
|
||||
self.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
f"Für {len(debits)} Lastschrift(en) werden Thunderbird-kompatible "
|
||||
".eml-Entwürfe erzeugt."
|
||||
),
|
||||
wraplength=620,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
|
||||
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
|
||||
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
|
||||
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
|
||||
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
|
||||
)
|
||||
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
|
||||
self.signature = tk.Text(self, width=55, height=5, wrap="word")
|
||||
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
|
||||
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
||||
buttons = ttk.Frame(self, padding=14)
|
||||
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
|
||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="Info-Mails erzeugen",
|
||||
style="Accent.TButton",
|
||||
command=self._generate,
|
||||
).pack(side="left", padx=(8, 0))
|
||||
self.grab_set()
|
||||
|
||||
def _generate(self) -> None:
|
||||
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||
if delivery_mode is None:
|
||||
return
|
||||
output_directory = None
|
||||
if delivery_mode == "local":
|
||||
selected = filedialog.askdirectory(
|
||||
parent=self,
|
||||
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
|
||||
mustexist=True,
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
output_directory = selected
|
||||
try:
|
||||
generated, warnings = generate_debit_mails(
|
||||
self.repository,
|
||||
self.debits,
|
||||
collection_date=self.collection_date,
|
||||
delivery_mode=delivery_mode,
|
||||
output_directory=output_directory,
|
||||
sender_name=self.sender_name_var.get(),
|
||||
sender_email=self.sender_email_var.get(),
|
||||
signature=self.signature.get("1.0", "end-1c"),
|
||||
)
|
||||
except (OSError, RepositoryError) as exc:
|
||||
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
|
||||
return
|
||||
location = {
|
||||
"local": f"in\n{output_directory}\nerzeugt",
|
||||
"send": "direkt versandt",
|
||||
"drafts": "als Entwurf im Postfach abgelegt",
|
||||
}[delivery_mode]
|
||||
detail = f"{len(generated)} Info-Mail(s) wurden {location} und in den Mitgliederakten archiviert."
|
||||
if warnings:
|
||||
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
|
||||
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
|
||||
self.destroy()
|
||||
+53
-12
@@ -16,8 +16,8 @@ from ccma.ui.sections import titled_frame
|
||||
|
||||
MEMBER_TABLE_COLUMNS = (
|
||||
("number", "Nummer", 110),
|
||||
("first_name", "Vorname", 160),
|
||||
("last_name", "Nachname", 180),
|
||||
("first_name", "Vorname", 160),
|
||||
("nickname", "Nickname", 160),
|
||||
("email", "E-Mail-Adresse", 270),
|
||||
("birth", "Geburtsdatum", 120),
|
||||
@@ -25,13 +25,21 @@ MEMBER_TABLE_COLUMNS = (
|
||||
)
|
||||
|
||||
STATUS_FILTER_ALL = "Alle"
|
||||
MEMBER_FINDING_COLORS = {
|
||||
"info": "#d9efff",
|
||||
"warning": "#fff3b0",
|
||||
"error": "#ffd0d0",
|
||||
}
|
||||
MEMBER_FINDING_PRIORITY = {"info": 1, "warning": 2, "error": 3}
|
||||
ASSET_FILTER_ALL = "Alle"
|
||||
ASSET_TABLE_COLUMNS = (
|
||||
("label", "Bezeichnung", 260),
|
||||
("category", "Kategorie", 140),
|
||||
("inventory_number", "Inventarnummer", 140),
|
||||
("status", "Status", 140),
|
||||
("holder", "Mitglied", 240),
|
||||
("owner", "Eigentümer", 240),
|
||||
("holder", "Gewahrsam", 240),
|
||||
("location", "Standort", 180),
|
||||
)
|
||||
|
||||
|
||||
@@ -73,11 +81,22 @@ def _selected_status_filter(label: str) -> str:
|
||||
return storage_key(MEMBERSHIP_STATUS_LABELS, label)
|
||||
|
||||
|
||||
def _member_finding_severities(findings: list[HousekeeperFinding]) -> dict[str, str]:
|
||||
severities: dict[str, str] = {}
|
||||
for finding in findings:
|
||||
if not finding.member_id or finding.severity not in MEMBER_FINDING_PRIORITY:
|
||||
continue
|
||||
current = severities.get(finding.member_id)
|
||||
if current is None or MEMBER_FINDING_PRIORITY[finding.severity] > MEMBER_FINDING_PRIORITY[current]:
|
||||
severities[finding.member_id] = finding.severity
|
||||
return severities
|
||||
|
||||
|
||||
def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame:
|
||||
return titled_frame(parent, "// FILTER")
|
||||
|
||||
|
||||
def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str:
|
||||
def _asset_table_value(asset: Asset, column: str, holder_label: str, owner_label: str = "") -> str:
|
||||
if column == "label":
|
||||
return asset.label
|
||||
if column == "category":
|
||||
@@ -88,6 +107,10 @@ def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str:
|
||||
return ASSET_STATUS_LABELS.get(asset.status, asset.status)
|
||||
if column == "holder":
|
||||
return holder_label
|
||||
if column == "owner":
|
||||
return owner_label
|
||||
if column == "location":
|
||||
return asset.location
|
||||
return ""
|
||||
|
||||
|
||||
@@ -219,11 +242,13 @@ class MembersTab(ttk.Frame):
|
||||
self,
|
||||
master: tk.Misc,
|
||||
members: list[Member],
|
||||
findings: list[HousekeeperFinding],
|
||||
on_open: Callable[[str], None],
|
||||
on_close: Callable[[], None],
|
||||
):
|
||||
super().__init__(master, padding=12)
|
||||
self.members = members
|
||||
self.findings = findings
|
||||
self.on_open = on_open
|
||||
self.on_close = on_close
|
||||
self._build_ui()
|
||||
@@ -244,12 +269,12 @@ class MembersTab(ttk.Frame):
|
||||
filter_row.grid(row=0, column=0, sticky="w")
|
||||
self.tree = ttk.Treeview(
|
||||
self,
|
||||
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"),
|
||||
columns=tuple(column for column, _title, _width in MEMBER_TABLE_COLUMNS),
|
||||
show="headings",
|
||||
)
|
||||
self.sort_column = "last_name"
|
||||
self.sort_descending = False
|
||||
self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL)
|
||||
self.status_filter_var = tk.StringVar(value=MEMBERSHIP_STATUS_LABELS["active"])
|
||||
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||||
self.status_filter = ttk.Combobox(
|
||||
filter_row,
|
||||
@@ -263,13 +288,16 @@ class MembersTab(ttk.Frame):
|
||||
for key, title, width in MEMBER_TABLE_COLUMNS:
|
||||
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
|
||||
self.tree.column(key, width=width, anchor="w")
|
||||
for severity, color in MEMBER_FINDING_COLORS.items():
|
||||
self.tree.tag_configure(f"finding-{severity}", background=color, foreground="#202020")
|
||||
self.tree.grid(row=2, column=0, sticky="nsew")
|
||||
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
|
||||
self.tree.bind("<Return>", lambda _event: self._open_selected())
|
||||
self.refresh(self.members)
|
||||
self.refresh(self.members, self.findings)
|
||||
|
||||
def refresh(self, members: list[Member]) -> None:
|
||||
def refresh(self, members: list[Member], findings: list[HousekeeperFinding]) -> None:
|
||||
self.members = members
|
||||
self.findings = findings
|
||||
self._render_members()
|
||||
|
||||
def _render_members(self) -> None:
|
||||
@@ -277,20 +305,23 @@ class MembersTab(ttk.Frame):
|
||||
status_filter = _selected_status_filter(self.status_filter_var.get())
|
||||
filtered_members = _filter_members(self.members, status_filter)
|
||||
sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending)
|
||||
finding_severities = _member_finding_severities(self.findings)
|
||||
if len(filtered_members) == len(self.members):
|
||||
self.count_var.set(f"{len(filtered_members)} Mitglieder")
|
||||
else:
|
||||
self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder")
|
||||
self._update_tree_headings()
|
||||
for member in sorted_members:
|
||||
severity = finding_severities.get(member.member_id)
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=member.member_id,
|
||||
tags=(f"finding-{severity}",) if severity else (),
|
||||
values=(
|
||||
member.member_number,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
member.first_name,
|
||||
member.nickname,
|
||||
member.email,
|
||||
format_date_for_display(member.birth_date),
|
||||
@@ -429,6 +460,7 @@ class AssetsTab(ttk.Frame):
|
||||
asset,
|
||||
self.sort_column,
|
||||
self.resolve_holder_label(asset.current_holder_member_id),
|
||||
self._owner_label(asset),
|
||||
).casefold(),
|
||||
reverse=self.sort_descending,
|
||||
)
|
||||
@@ -447,11 +479,20 @@ class AssetsTab(ttk.Frame):
|
||||
asset.category,
|
||||
asset.inventory_number,
|
||||
ASSET_STATUS_LABELS.get(asset.status, asset.status),
|
||||
self._owner_label(asset),
|
||||
self.resolve_holder_label(asset.current_holder_member_id),
|
||||
asset.location,
|
||||
),
|
||||
)
|
||||
self._update_actions()
|
||||
|
||||
def _owner_label(self, asset: Asset) -> str:
|
||||
if asset.owner_type == "club":
|
||||
return "Verein"
|
||||
if asset.owner_type == "member":
|
||||
return self.resolve_holder_label(asset.owner_member_id)
|
||||
return asset.owner_name or "Extern"
|
||||
|
||||
def _toggle_sort(self, column: str) -> None:
|
||||
if self.sort_column == column:
|
||||
self.sort_descending = not self.sort_descending
|
||||
@@ -556,15 +597,15 @@ class HousekeeperTab(ttk.Frame):
|
||||
row=0, column=1, rowspan=2, padx=(0, 8)
|
||||
)
|
||||
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2)
|
||||
self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings")
|
||||
self.tree = ttk.Treeview(self, columns=("severity", "due", "title", "detail"), show="headings")
|
||||
for key, title, width in (
|
||||
("severity", "Level", 90),
|
||||
("due", "Fällig", 100),
|
||||
("title", "Vorgang", 330),
|
||||
("detail", "Details", 390),
|
||||
("due", "Fällig", 110),
|
||||
):
|
||||
self.tree.heading(key, text=title)
|
||||
self.tree.column(key, width=width, anchor="w")
|
||||
self.tree.column(key, width=width, anchor="w", stretch=key == "detail")
|
||||
self.tree.grid(row=1, column=0, sticky="nsew")
|
||||
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._show_selected_details())
|
||||
@@ -608,7 +649,7 @@ class HousekeeperTab(ttk.Frame):
|
||||
"",
|
||||
"end",
|
||||
iid=str(index),
|
||||
values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""),
|
||||
values=(finding.severity.upper(), finding.due_date or "", finding.title, finding.detail),
|
||||
)
|
||||
|
||||
def _show_selected_details(self) -> None:
|
||||
|
||||
@@ -48,3 +48,27 @@ def test_store_path_can_be_overridden_from_ccma_environment(tmp_path, monkeypatc
|
||||
monkeypatch.setenv("CCMA_STORE", str(tmp_path / "store"))
|
||||
|
||||
assert load_config().store_path == str(tmp_path / "store")
|
||||
|
||||
|
||||
def test_gnucash_last_account_per_file_round_trips(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config"))
|
||||
first_file = str(tmp_path / "verein.gnucash")
|
||||
second_file = str(tmp_path / "spenden.gnucash")
|
||||
config = AppConfig(
|
||||
gnucash_last_accounts={first_file: "bank-guid", second_file: "cash-guid"}
|
||||
)
|
||||
config.save()
|
||||
|
||||
loaded = load_config()
|
||||
assert loaded.gnucash_last_accounts == {first_file: "bank-guid", second_file: "cash-guid"}
|
||||
|
||||
|
||||
def test_gnucash_last_accounts_defaults_to_empty_dict_for_malformed_value(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config"))
|
||||
config_path = tmp_path / "config" / "config.json"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
json.dumps({"schema_version": 1, "gnucash_last_accounts": "not-a-dict"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
assert load_config().gnucash_last_accounts == {}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def _member(tmp_path):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Override", last_name="Test", birth_date="2010-01-01")
|
||||
member.status = "active"
|
||||
member.accepted_at = "2026-01-01"
|
||||
member.membership_started_at = "2026-01-01"
|
||||
member.payment_frequency = "semiannual"
|
||||
repository.save_member(member)
|
||||
return repository, member
|
||||
|
||||
|
||||
def test_contribution_override_can_be_recorded_with_amount(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
|
||||
override = repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="03.2026",
|
||||
valid_until="",
|
||||
kind="amount",
|
||||
value="90.00",
|
||||
reason="Schüler laut Nachweis",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.contribution_overrides == [override]
|
||||
assert override["valid_from"] == "2026-03"
|
||||
assert override["valid_until"] == ""
|
||||
assert override["kind"] == "amount"
|
||||
assert override["value"] == "90.00"
|
||||
assert override["reason"] == "Schüler laut Nachweis"
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_recorded"
|
||||
|
||||
|
||||
def test_contribution_override_accepts_percent_kind(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
|
||||
override = repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-03",
|
||||
valid_until="2026-08",
|
||||
kind="percent",
|
||||
value="50",
|
||||
reason="Schüler laut Nachweis",
|
||||
)
|
||||
|
||||
assert override["kind"] == "percent"
|
||||
assert override["value"] == "50.00"
|
||||
assert override["valid_until"] == "2026-08"
|
||||
|
||||
|
||||
def test_contribution_override_requires_reason(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
|
||||
with pytest.raises(RepositoryError, match="Begründung"):
|
||||
repository.record_contribution_override(
|
||||
member.member_id, valid_from="03.2026", kind="amount", value="90.00", reason=" "
|
||||
)
|
||||
|
||||
|
||||
def test_contribution_override_rejects_invalid_percent(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
|
||||
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
|
||||
repository.record_contribution_override(
|
||||
member.member_id, valid_from="03.2026", kind="percent", value="150", reason="Test"
|
||||
)
|
||||
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
|
||||
repository.record_contribution_override(
|
||||
member.member_id, valid_from="03.2026", kind="percent", value="0", reason="Test"
|
||||
)
|
||||
|
||||
|
||||
def test_contribution_override_rejects_negative_amount(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
|
||||
with pytest.raises(RepositoryError, match="nicht negativ"):
|
||||
repository.record_contribution_override(
|
||||
member.member_id, valid_from="03.2026", kind="amount", value="-10", reason="Test"
|
||||
)
|
||||
|
||||
|
||||
def test_contribution_override_rejects_until_before_from(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
|
||||
with pytest.raises(RepositoryError, match="darf nicht vor"):
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="08.2026",
|
||||
valid_until="03.2026",
|
||||
kind="amount",
|
||||
value="90.00",
|
||||
reason="Test",
|
||||
)
|
||||
|
||||
|
||||
def test_overlapping_contribution_overrides_are_rejected(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-01",
|
||||
valid_until="2026-06",
|
||||
kind="amount",
|
||||
value="90.00",
|
||||
reason="Erstes",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="überschneidet sich"):
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-04",
|
||||
valid_until="2026-12",
|
||||
kind="amount",
|
||||
value="100.00",
|
||||
reason="Zweites",
|
||||
)
|
||||
|
||||
# Adjacent, non-overlapping range is fine.
|
||||
second = repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-07",
|
||||
valid_until="",
|
||||
kind="amount",
|
||||
value="100.00",
|
||||
reason="Zweites, ab Juli",
|
||||
)
|
||||
assert second["valid_from"] == "2026-07"
|
||||
|
||||
|
||||
def test_open_ended_override_overlaps_with_any_later_range(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
repository.record_contribution_override(
|
||||
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Unbefristet"
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="überschneidet sich"):
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2030-01",
|
||||
valid_until="2030-06",
|
||||
kind="amount",
|
||||
value="100.00",
|
||||
reason="Später",
|
||||
)
|
||||
|
||||
|
||||
def test_contribution_override_can_be_updated_and_deleted(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
override = repository.record_contribution_override(
|
||||
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Erstversion"
|
||||
)
|
||||
|
||||
updated = repository.update_contribution_override(
|
||||
member.member_id,
|
||||
override["override_id"],
|
||||
valid_from="2026-02",
|
||||
valid_until="2026-12",
|
||||
kind="percent",
|
||||
value="40",
|
||||
reason="Korrigiert",
|
||||
)
|
||||
assert updated["valid_from"] == "2026-02"
|
||||
assert updated["kind"] == "percent"
|
||||
assert updated["reason"] == "Korrigiert"
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_changed"
|
||||
|
||||
repository.delete_contribution_override(member.member_id, override["override_id"])
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.contribution_overrides == []
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_deleted"
|
||||
|
||||
|
||||
def test_updating_override_can_keep_its_own_range(tmp_path) -> None:
|
||||
repository, member = _member(tmp_path)
|
||||
override = repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-01",
|
||||
valid_until="2026-06",
|
||||
kind="amount",
|
||||
value="90.00",
|
||||
reason="Erstversion",
|
||||
)
|
||||
|
||||
# Saving again with the same range must not trip the overlap check against itself.
|
||||
updated = repository.update_contribution_override(
|
||||
member.member_id,
|
||||
override["override_id"],
|
||||
valid_from="2026-01",
|
||||
valid_until="2026-06",
|
||||
kind="amount",
|
||||
value="95.00",
|
||||
reason="Betrag korrigiert",
|
||||
)
|
||||
assert updated["value"] == "95.00"
|
||||
|
||||
|
||||
def _housekeeper_member(tmp_path, *, payment_frequency="annual", started_at="2025-01-01"):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Override", last_name="Claims", birth_date="1990-01-01")
|
||||
member.status = "active"
|
||||
member.accepted_at = started_at
|
||||
member.membership_started_at = started_at
|
||||
member.payment_frequency = payment_frequency
|
||||
repository.save_member(member)
|
||||
return repository, member
|
||||
|
||||
|
||||
def test_full_year_amount_override_replaces_the_annual_claim_amount(tmp_path) -> None:
|
||||
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-01",
|
||||
valid_until="2026-12",
|
||||
kind="amount",
|
||||
value="60.00",
|
||||
reason="Schüler laut Nachweis",
|
||||
)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
|
||||
claims_by_key = {
|
||||
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
|
||||
}
|
||||
claim = claims_by_key["membership-fee:2026:annual"]
|
||||
assert claim["amount"] == "60.00"
|
||||
assert claim["calculation"]["contribution_override_ids"] != []
|
||||
|
||||
|
||||
def test_full_year_percent_override_reduces_the_base_rate(tmp_path) -> None:
|
||||
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-01",
|
||||
valid_until="2026-12",
|
||||
kind="percent",
|
||||
value="50",
|
||||
reason="Schüler laut Nachweis",
|
||||
)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
|
||||
claim = next(
|
||||
claim
|
||||
for claim in repository.get_contributions(member.member_id).claims
|
||||
if claim["claim_key"] == "membership-fee:2026:annual"
|
||||
)
|
||||
assert claim["amount"] == "75.00"
|
||||
|
||||
|
||||
def test_override_starting_mid_period_blends_month_by_month(tmp_path) -> None:
|
||||
repository, member = _housekeeper_member(tmp_path, payment_frequency="semiannual")
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2026-03",
|
||||
valid_until="2026-08",
|
||||
kind="amount",
|
||||
value="60.00",
|
||||
reason="Schüler ab März",
|
||||
)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 12, 31))
|
||||
|
||||
claims_by_key = {
|
||||
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
|
||||
}
|
||||
# Jan+Feb at 12.50/month (full rate) + Mar-Jun at 5.00/month (override) = 45.00
|
||||
assert claims_by_key["membership-fee:2026:first-half"]["amount"] == "45.00"
|
||||
# Jul+Aug at 5.00/month (override) + Sep-Dec at 12.50/month (full rate) = 60.00
|
||||
assert claims_by_key["membership-fee:2026:second-half"]["amount"] == "60.00"
|
||||
|
||||
|
||||
def test_claim_without_applicable_override_is_unaffected(tmp_path) -> None:
|
||||
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
|
||||
repository.record_contribution_override(
|
||||
member.member_id,
|
||||
valid_from="2020-01",
|
||||
valid_until="2020-12",
|
||||
kind="amount",
|
||||
value="60.00",
|
||||
reason="Frueherer Zeitraum",
|
||||
)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
|
||||
claim = next(
|
||||
claim
|
||||
for claim in repository.get_contributions(member.member_id).claims
|
||||
if claim["claim_key"] == "membership-fee:2026:annual"
|
||||
)
|
||||
assert claim["amount"] == "150.00"
|
||||
assert claim["calculation"]["contribution_override_ids"] == []
|
||||
+437
-3
@@ -9,6 +9,9 @@ from ccma.domain.contributions import (
|
||||
claim_settled_total,
|
||||
claim_status,
|
||||
claim_total,
|
||||
donation_allocated_total,
|
||||
donation_balance,
|
||||
donation_status,
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.models import ContributionData
|
||||
@@ -59,6 +62,49 @@ def test_legacy_claim_becomes_itemized_when_position_is_added(tmp_path) -> None:
|
||||
assert claim_balance(data, claim) == Decimal("90.00")
|
||||
|
||||
|
||||
def test_claim_base_data_can_be_edited(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
|
||||
repository.update_claim(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
title="Korrigierter Beitrag",
|
||||
due_date="30.06.2026",
|
||||
base_amount="75,00",
|
||||
description="Beitrag für sechs Monate",
|
||||
)
|
||||
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim["title"] == "Korrigierter Beitrag"
|
||||
assert claim["due_date"] == "2026-06-30"
|
||||
assert claim["amount"] == "75.00"
|
||||
assert claim["items"][0]["description"] == "Beitrag für sechs Monate"
|
||||
assert claim["items"][0]["amount"] == "75.00"
|
||||
assert claim["calculation"]["manual_override"]["actor"] == "Vorstand"
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "claim_changed"
|
||||
|
||||
|
||||
def test_claim_cannot_be_reduced_below_allocated_payment(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="80.00",
|
||||
allocation_amount="80.00",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="bereits zugeordneten Betrag"):
|
||||
repository.update_claim(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
title="Zu klein",
|
||||
due_date="2026-06-30",
|
||||
base_amount="75.00",
|
||||
description="",
|
||||
)
|
||||
|
||||
|
||||
def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
@@ -104,6 +150,172 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_combined_dummy_payment_settles_multiple_claims(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
data.claims.append(
|
||||
{
|
||||
"claim_id": "claim-2",
|
||||
"title": "Zweite Forderung",
|
||||
"amount": "50.00",
|
||||
"due_date": "2019-12-31",
|
||||
"status": "open",
|
||||
}
|
||||
)
|
||||
repository.save_contributions(member.member_id, data)
|
||||
|
||||
payment = repository.record_combined_payment(
|
||||
member.member_id,
|
||||
payment_date="2019-12-31",
|
||||
allocations={"claim-1": "100.00", "claim-2": "50.00"},
|
||||
method="dummy",
|
||||
reference="Altbestand",
|
||||
)
|
||||
|
||||
changed = repository.get_contributions(member.member_id)
|
||||
assert payment["amount"] == "150.00"
|
||||
assert payment["method"] == "dummy"
|
||||
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("150.00")
|
||||
assert all(claim_balance(changed, claim) == 0 for claim in changed.claims)
|
||||
assert repository.get_events(member.member_id)[-1].data["method"] == "dummy"
|
||||
|
||||
|
||||
def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path, amount="12.50")
|
||||
|
||||
with pytest.raises(RepositoryError, match="nur noch 12.50 EUR offen"):
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="22.50",
|
||||
allocation_amount="22.50",
|
||||
)
|
||||
|
||||
|
||||
def test_payment_can_be_edited_and_reallocated_atomically(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path, amount="12.50")
|
||||
data = repository.get_contributions(member.member_id)
|
||||
data.claims.append(
|
||||
{
|
||||
"claim_id": "admission-fee",
|
||||
"claim_key": "admission-fee",
|
||||
"title": "Aufnahmegebühr",
|
||||
"amount": "10.00",
|
||||
"due_date": "2026-12-31",
|
||||
"status": "open",
|
||||
}
|
||||
)
|
||||
repository.save_contributions(member.member_id, data)
|
||||
payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="22.50",
|
||||
allocation_amount="12.50",
|
||||
reference="Vorher",
|
||||
)
|
||||
|
||||
repository.update_payment(
|
||||
member.member_id,
|
||||
payment["payment_id"],
|
||||
payment_date="22.06.2026",
|
||||
amount="22.50",
|
||||
allocations={"claim-1": "12.50", "admission-fee": "10.00"},
|
||||
reference="Korrigiert",
|
||||
)
|
||||
|
||||
changed = repository.get_contributions(member.member_id)
|
||||
assert changed.payments[0]["date"] == "2026-06-22"
|
||||
assert changed.payments[0]["reference"] == "Korrigiert"
|
||||
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("22.50")
|
||||
assert {item["claim_id"] for item in changed.allocations} == {"claim-1", "admission-fee"}
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
|
||||
|
||||
|
||||
def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
allocation_amount="10.00",
|
||||
)
|
||||
|
||||
repository.delete_payment(member.member_id, payment["payment_id"])
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.payments == []
|
||||
assert data.allocations == []
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
|
||||
|
||||
|
||||
def test_payment_reference_can_be_replaced_without_touching_amount_or_allocations(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
allocation_amount="10.00",
|
||||
reference="Alte Referenz",
|
||||
)
|
||||
|
||||
updated = repository.update_payment_reference(
|
||||
member.member_id,
|
||||
payment["payment_id"],
|
||||
reference="Neue Referenz aus GnuCash",
|
||||
gnucash_transaction_id="TX-99",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert updated["reference"] == "Neue Referenz aus GnuCash"
|
||||
assert updated["gnucash_transaction_id"] == "TX-99"
|
||||
assert data.payments[0]["amount"] == "10.00"
|
||||
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("10.00")
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
|
||||
|
||||
|
||||
def test_payment_reference_update_rejects_gnucash_id_already_used_elsewhere(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
allocation_amount="10.00",
|
||||
gnucash_transaction_id="TX-1",
|
||||
)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
data.claims.append(
|
||||
{
|
||||
"claim_id": "claim-2",
|
||||
"claim_key": "second-claim",
|
||||
"title": "Zweite Forderung",
|
||||
"amount": "20.00",
|
||||
"due_date": "2026-12-31",
|
||||
"status": "open",
|
||||
}
|
||||
)
|
||||
repository.save_contributions(member.member_id, data)
|
||||
second_payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-2",
|
||||
payment_date="2026-06-22",
|
||||
amount="20.00",
|
||||
allocation_amount="20.00",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="GnuCash-ID bereits verwendet"):
|
||||
repository.update_payment_reference(
|
||||
member.member_id,
|
||||
second_payment["payment_id"],
|
||||
reference="Duplikatversuch",
|
||||
gnucash_transaction_id="TX-1",
|
||||
)
|
||||
|
||||
|
||||
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
|
||||
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
|
||||
data = ContributionData(
|
||||
@@ -133,13 +345,13 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
detail="Per E-Mail versandt",
|
||||
fee="5.00",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
|
||||
assert claim_total(claim) == Decimal("100.00")
|
||||
assert reminder["status"] == "draft"
|
||||
assert reminder["fee_item_id"] is None
|
||||
assert reminder["fee_item_ids"] == []
|
||||
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", reminder["reminder_id"])
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
@@ -148,7 +360,7 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
|
||||
assert claim_total(claim) == Decimal("105.00")
|
||||
assert sent["status"] == "sent"
|
||||
assert sent["payment_deadline"]
|
||||
assert sent["fee_item_id"]
|
||||
assert sent["fee_item_ids"]
|
||||
assert data.reminders[0]["detail"] == "Per E-Mail versandt"
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent"
|
||||
|
||||
@@ -167,6 +379,228 @@ def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None:
|
||||
repository.cancel_claim(member.member_id, "claim-1")
|
||||
|
||||
|
||||
def test_claim_can_be_deleted_and_releases_allocated_payment(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="60.00",
|
||||
allocation_amount="60.00",
|
||||
)
|
||||
|
||||
repository.delete_claim(member.member_id, "claim-1")
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.claims == []
|
||||
assert data.allocations == []
|
||||
assert data.payments[0]["payment_id"] == payment["payment_id"]
|
||||
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "claim_deleted"
|
||||
|
||||
with pytest.raises(RepositoryError, match="nicht gefunden"):
|
||||
repository.get_claim(member.member_id, "claim-1")
|
||||
|
||||
|
||||
def test_claim_with_payment_can_be_deleted_even_though_it_cannot_be_cancelled(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
allocation_amount="10.00",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="Zahlungszuordnungen"):
|
||||
repository.cancel_claim(member.member_id, "claim-1")
|
||||
|
||||
repository.delete_claim(member.member_id, "claim-1")
|
||||
assert repository.get_contributions(member.member_id).claims == []
|
||||
|
||||
|
||||
def test_bare_payment_can_be_created_without_allocation(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Payment", last_name="Test")
|
||||
|
||||
payment = repository.create_payment(
|
||||
member.member_id,
|
||||
payment_date="2026-06-21",
|
||||
amount="42.00",
|
||||
reference="Überweisung ohne Zuordnung",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.payments == [payment]
|
||||
assert data.allocations == []
|
||||
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_recorded"
|
||||
|
||||
|
||||
def test_donation_can_be_recorded_paid_and_deleted_releases_payment(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Donation", last_name="Test")
|
||||
|
||||
donation = repository.record_donation(
|
||||
member.member_id,
|
||||
donation_date="2026-06-21",
|
||||
amount="30.00",
|
||||
reference="Sommerfest",
|
||||
purpose="Freiwillige Zusatzspende",
|
||||
)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert donation_status(data, donation) == "open"
|
||||
assert donation_balance(data, donation) == Decimal("30.00")
|
||||
|
||||
payment = repository.record_donation_payment(
|
||||
member.member_id,
|
||||
donation["donation_id"],
|
||||
payment_date="2026-06-22",
|
||||
amount="30.00",
|
||||
allocation_amount="30.00",
|
||||
)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert donation_allocated_total(data, donation["donation_id"]) == Decimal("30.00")
|
||||
assert donation_status(data, donation) == "allocated"
|
||||
|
||||
repository.delete_donation(member.member_id, donation["donation_id"])
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.donations == []
|
||||
assert data.allocations == []
|
||||
assert data.payments[0]["payment_id"] == payment["payment_id"]
|
||||
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "donation_deleted"
|
||||
|
||||
|
||||
def test_existing_free_payment_can_be_allocated_to_a_donation(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Donation", last_name="Allocate")
|
||||
|
||||
payment = repository.create_payment(
|
||||
member.member_id,
|
||||
payment_date="2026-06-21",
|
||||
amount="100.00",
|
||||
reference="Mitgliedsbeitrag plus Spende",
|
||||
)
|
||||
donation = repository.record_donation(
|
||||
member.member_id,
|
||||
donation_date="2026-06-21",
|
||||
amount="20.00",
|
||||
reference="Aufrundung",
|
||||
)
|
||||
|
||||
repository.allocate_payment_to_donation(
|
||||
member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="20.00"
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert donation_balance(data, donation) == Decimal("0.00")
|
||||
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("20.00")
|
||||
|
||||
with pytest.raises(RepositoryError, match="nur noch 0.00 EUR"):
|
||||
repository.allocate_payment_to_donation(
|
||||
member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="1.00"
|
||||
)
|
||||
|
||||
|
||||
def test_payment_can_be_created_with_immediate_claim_and_donation_allocation(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path, amount="30.00")
|
||||
donation = repository.record_donation(
|
||||
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Aufrundung"
|
||||
)
|
||||
|
||||
payment = repository.create_payment(
|
||||
member.member_id,
|
||||
payment_date="2026-06-21",
|
||||
amount="50.00",
|
||||
claim_allocations={"claim-1": "30.00"},
|
||||
donation_allocations={donation["donation_id"]: "20.00"},
|
||||
reference="Mitgliedsbeitrag plus Spende",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_balance(data, claim) == Decimal("0.00")
|
||||
assert donation_balance(data, donation) == Decimal("0.00")
|
||||
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("50.00")
|
||||
|
||||
|
||||
def test_payment_creation_rejects_allocations_exceeding_amount(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path, amount="30.00")
|
||||
|
||||
with pytest.raises(RepositoryError, match="übersteigen den"):
|
||||
repository.create_payment(
|
||||
member.member_id,
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
claim_allocations={"claim-1": "30.00"},
|
||||
)
|
||||
|
||||
|
||||
def test_updating_payment_without_donation_allocations_preserves_existing_ones(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Donation", last_name="Preserve")
|
||||
donation = repository.record_donation(
|
||||
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Sommerfest"
|
||||
)
|
||||
payment = repository.record_donation_payment(
|
||||
member.member_id,
|
||||
donation["donation_id"],
|
||||
payment_date="2026-06-21",
|
||||
amount="20.00",
|
||||
allocation_amount="20.00",
|
||||
)
|
||||
|
||||
repository.update_payment(
|
||||
member.member_id,
|
||||
payment["payment_id"],
|
||||
payment_date="22.06.2026",
|
||||
amount="20.00",
|
||||
allocations={},
|
||||
reference="Korrigierte Referenz",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert donation_allocated_total(data, donation["donation_id"]) == Decimal("20.00")
|
||||
assert data.payments[0]["reference"] == "Korrigierte Referenz"
|
||||
|
||||
|
||||
def test_updating_payment_with_donation_allocations_replaces_them(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Donation", last_name="Replace")
|
||||
first_donation = repository.record_donation(
|
||||
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Erste Spende"
|
||||
)
|
||||
second_donation = repository.record_donation(
|
||||
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Zweite Spende"
|
||||
)
|
||||
payment = repository.record_donation_payment(
|
||||
member.member_id,
|
||||
first_donation["donation_id"],
|
||||
payment_date="2026-06-21",
|
||||
amount="20.00",
|
||||
allocation_amount="20.00",
|
||||
)
|
||||
|
||||
repository.update_payment(
|
||||
member.member_id,
|
||||
payment["payment_id"],
|
||||
payment_date="2026-06-21",
|
||||
amount="20.00",
|
||||
allocations={},
|
||||
donation_allocations={second_donation["donation_id"]: "20.00"},
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert donation_allocated_total(data, first_donation["donation_id"]) == Decimal("0.00")
|
||||
assert donation_allocated_total(data, second_donation["donation_id"]) == Decimal("20.00")
|
||||
|
||||
|
||||
def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None:
|
||||
repository, first_member = _repository_with_claim(tmp_path / "store")
|
||||
repository.record_payment(
|
||||
|
||||
@@ -7,9 +7,11 @@ from ccma.domain.dates import (
|
||||
age_label,
|
||||
calculate_age,
|
||||
format_date_for_display,
|
||||
format_month_for_display,
|
||||
normalize_date_input,
|
||||
parse_date_input,
|
||||
parse_iso_date,
|
||||
parse_month_input,
|
||||
validate_birth_date,
|
||||
validate_member_dates,
|
||||
)
|
||||
@@ -58,6 +60,34 @@ def test_member_dates_must_be_chronological() -> None:
|
||||
today=date(2026, 6, 21),
|
||||
)
|
||||
|
||||
with pytest.raises(DateValidationError, match="Austrittsdatum"):
|
||||
validate_member_dates(
|
||||
birth_date="2000-01-01",
|
||||
accepted_at="2020-01-01",
|
||||
membership_started_at="2020-02-01",
|
||||
membership_ended_at="2020-01-31",
|
||||
today=date(2026, 6, 21),
|
||||
)
|
||||
|
||||
|
||||
def test_month_input_accepts_german_and_iso_formats() -> None:
|
||||
assert parse_month_input("03.2026", "Ab") == "2026-03"
|
||||
assert parse_month_input("2026-03", "Ab") == "2026-03"
|
||||
assert parse_month_input("", "Ab") == ""
|
||||
with pytest.raises(DateValidationError, match="erforderlich"):
|
||||
parse_month_input("", "Ab", allow_empty=False)
|
||||
with pytest.raises(DateValidationError):
|
||||
parse_month_input("13.2026", "Ab")
|
||||
with pytest.raises(DateValidationError):
|
||||
parse_month_input("2026-03-15", "Ab")
|
||||
|
||||
|
||||
def test_month_display_uses_system_pattern(monkeypatch) -> None:
|
||||
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%d.%m.%Y")
|
||||
assert format_month_for_display("2026-03") == "03.2026"
|
||||
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%Y-%m-%d")
|
||||
assert format_month_for_display("2026-03") == "2026-03"
|
||||
|
||||
|
||||
def test_age_calculation_and_label() -> None:
|
||||
today = date(2026, 6, 21)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import pytest
|
||||
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def test_email_settings_default_to_local_delivery_for_new_repositories(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
settings = repository.get_email_settings()
|
||||
|
||||
assert settings["delivery_mode"] == "local"
|
||||
assert settings["smtp_host"] == ""
|
||||
assert settings["imap_host"] == ""
|
||||
assert settings["imap_sent_enabled"] is False
|
||||
assert settings["imap_sent_folder"] == "INBOX.Sent"
|
||||
|
||||
|
||||
def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
repository.save_email_settings(
|
||||
delivery_mode="ask",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="board@example.org",
|
||||
smtp_password="secret",
|
||||
imap_host="mail.example.org",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="board@example.org",
|
||||
imap_password="secret",
|
||||
imap_drafts_folder="INBOX.Entwürfe",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="INBOX.Gesendet",
|
||||
)
|
||||
|
||||
settings = repository.get_email_settings()
|
||||
assert settings["delivery_mode"] == "ask"
|
||||
assert settings["smtp_host"] == "mail.example.org"
|
||||
assert settings["smtp_port"] == 587
|
||||
assert settings["smtp_password"] == "secret"
|
||||
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
|
||||
assert settings["imap_sent_enabled"] is True
|
||||
assert settings["imap_sent_folder"] == "INBOX.Gesendet"
|
||||
|
||||
|
||||
def test_email_settings_sent_copy_requires_imap_host_even_in_send_only_mode(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Gesendet-Ordner ist ein IMAP-Server erforderlich"):
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "match"),
|
||||
[
|
||||
({"delivery_mode": "carrier-pigeon"}, "Ungültiger Versandmodus"),
|
||||
({"smtp_security": "rot13"}, "Ungültige SMTP-Verschlüsselung"),
|
||||
({"imap_security": "rot13"}, "Ungültige IMAP-Verschlüsselung"),
|
||||
({"delivery_mode": "send", "smtp_host": ""}, "SMTP-Server erforderlich"),
|
||||
({"delivery_mode": "drafts", "imap_host": ""}, "IMAP-Server erforderlich"),
|
||||
({"delivery_mode": "ask", "imap_host": ""}, "IMAP-Server erforderlich"),
|
||||
({"smtp_port": 0}, "SMTP-Port"),
|
||||
({"smtp_port": 70000}, "SMTP-Port"),
|
||||
({"imap_port": 0}, "IMAP-Port"),
|
||||
],
|
||||
)
|
||||
def test_email_settings_rejects_invalid_input(tmp_path, overrides, match) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
values = {
|
||||
"delivery_mode": "send",
|
||||
"smtp_host": "mail.example.org",
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "starttls",
|
||||
"smtp_username": "",
|
||||
"smtp_password": "",
|
||||
"imap_host": "mail.example.org",
|
||||
"imap_port": 993,
|
||||
"imap_security": "ssl",
|
||||
"imap_username": "",
|
||||
"imap_password": "",
|
||||
"imap_drafts_folder": "",
|
||||
}
|
||||
values.update(overrides)
|
||||
|
||||
with pytest.raises(RepositoryError, match=match):
|
||||
repository.save_email_settings(**values)
|
||||
|
||||
|
||||
def test_email_settings_local_mode_needs_no_server(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
repository.save_email_settings(
|
||||
delivery_mode="local",
|
||||
smtp_host="",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
|
||||
settings = repository.get_email_settings()
|
||||
assert settings["delivery_mode"] == "local"
|
||||
# Blank folder falls back to a sensible default instead of staying empty.
|
||||
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
|
||||
@@ -0,0 +1,156 @@
|
||||
import gzip
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.services.gnucash_import import GnuCashImportError, list_accounts, list_transactions
|
||||
|
||||
SAMPLE_GNUCASH_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<gnc-v2
|
||||
xmlns:gnc="http://www.gnucash.org/XML/gnc"
|
||||
xmlns:act="http://www.gnucash.org/XML/act"
|
||||
xmlns:book="http://www.gnucash.org/XML/book"
|
||||
xmlns:trn="http://www.gnucash.org/XML/trn"
|
||||
xmlns:ts="http://www.gnucash.org/XML/ts"
|
||||
xmlns:split="http://www.gnucash.org/XML/split">
|
||||
<gnc:book version="2.0.0">
|
||||
<book:id type="guid">book-1</book:id>
|
||||
<gnc:account version="2.0.0">
|
||||
<act:name>Root Account</act:name>
|
||||
<act:id type="guid">root-guid</act:id>
|
||||
<act:type>ROOT</act:type>
|
||||
</gnc:account>
|
||||
<gnc:account version="2.0.0">
|
||||
<act:name>Aktiva</act:name>
|
||||
<act:id type="guid">assets-guid</act:id>
|
||||
<act:type>ASSET</act:type>
|
||||
<act:parent type="guid">root-guid</act:parent>
|
||||
</gnc:account>
|
||||
<gnc:account version="2.0.0">
|
||||
<act:name>Girokonto</act:name>
|
||||
<act:id type="guid">bank-guid</act:id>
|
||||
<act:type>BANK</act:type>
|
||||
<act:parent type="guid">assets-guid</act:parent>
|
||||
</gnc:account>
|
||||
<gnc:account version="2.0.0">
|
||||
<act:name>Beitraege</act:name>
|
||||
<act:id type="guid">income-guid</act:id>
|
||||
<act:type>INCOME</act:type>
|
||||
<act:parent type="guid">root-guid</act:parent>
|
||||
</gnc:account>
|
||||
<gnc:transaction version="2.0.0">
|
||||
<trn:id type="guid">tx-1</trn:id>
|
||||
<trn:date-posted><ts:date>2026-06-21 10:59:00 +0200</ts:date></trn:date-posted>
|
||||
<trn:description>Mitgliedsbeitrag Max Mustermann</trn:description>
|
||||
<trn:splits>
|
||||
<trn:split>
|
||||
<split:id type="guid">split-1a</split:id>
|
||||
<split:memo>Beitrag Juni</split:memo>
|
||||
<split:value>15000/100</split:value>
|
||||
<split:quantity>15000/100</split:quantity>
|
||||
<split:account type="guid">bank-guid</split:account>
|
||||
</trn:split>
|
||||
<trn:split>
|
||||
<split:id type="guid">split-1b</split:id>
|
||||
<split:memo></split:memo>
|
||||
<split:value>-15000/100</split:value>
|
||||
<split:quantity>-15000/100</split:quantity>
|
||||
<split:account type="guid">income-guid</split:account>
|
||||
</trn:split>
|
||||
</trn:splits>
|
||||
</gnc:transaction>
|
||||
<gnc:transaction version="2.0.0">
|
||||
<trn:id type="guid">tx-2</trn:id>
|
||||
<trn:date-posted><ts:date>2026-07-01 08:30:00 +0200</ts:date></trn:date-posted>
|
||||
<trn:description>Bankgebuehr</trn:description>
|
||||
<trn:splits>
|
||||
<trn:split>
|
||||
<split:id type="guid">split-2a</split:id>
|
||||
<split:memo></split:memo>
|
||||
<split:value>-500/100</split:value>
|
||||
<split:quantity>-500/100</split:quantity>
|
||||
<split:account type="guid">bank-guid</split:account>
|
||||
</trn:split>
|
||||
<trn:split>
|
||||
<split:id type="guid">split-2b</split:id>
|
||||
<split:memo></split:memo>
|
||||
<split:value>500/100</split:value>
|
||||
<split:quantity>500/100</split:quantity>
|
||||
<split:account type="guid">income-guid</split:account>
|
||||
</trn:split>
|
||||
</trn:splits>
|
||||
</gnc:transaction>
|
||||
</gnc:book>
|
||||
</gnc-v2>
|
||||
"""
|
||||
|
||||
|
||||
def _write_sample(tmp_path, *, gzipped: bool = False, suffix: str = ".gnucash"):
|
||||
path = tmp_path / f"test{suffix}"
|
||||
data = SAMPLE_GNUCASH_XML.encode("utf-8")
|
||||
if gzipped:
|
||||
data = gzip.compress(data)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def test_list_accounts_builds_hierarchical_full_names(tmp_path) -> None:
|
||||
path = _write_sample(tmp_path)
|
||||
accounts = list_accounts(path)
|
||||
by_guid = {account.guid: account for account in accounts}
|
||||
|
||||
assert "root-guid" not in by_guid
|
||||
assert by_guid["bank-guid"].full_name == "Aktiva:Girokonto"
|
||||
assert by_guid["bank-guid"].account_type == "BANK"
|
||||
assert by_guid["assets-guid"].full_name == "Aktiva"
|
||||
assert by_guid["income-guid"].full_name == "Beitraege"
|
||||
|
||||
|
||||
def test_list_transactions_returns_splits_for_selected_account(tmp_path) -> None:
|
||||
path = _write_sample(tmp_path)
|
||||
transactions = list_transactions(path, "bank-guid")
|
||||
|
||||
assert [item.guid for item in transactions] == ["tx-1", "tx-2"]
|
||||
first = transactions[0]
|
||||
assert first.date.isoformat() == "2026-06-21"
|
||||
assert first.description == "Mitgliedsbeitrag Max Mustermann"
|
||||
assert first.memo == "Beitrag Juni"
|
||||
assert first.amount == Decimal("150.00")
|
||||
second = transactions[1]
|
||||
assert second.amount == Decimal("-5.00")
|
||||
|
||||
|
||||
def test_list_transactions_only_includes_splits_on_the_requested_account(tmp_path) -> None:
|
||||
path = _write_sample(tmp_path)
|
||||
transactions = list_transactions(path, "income-guid")
|
||||
|
||||
assert [item.guid for item in transactions] == ["tx-1", "tx-2"]
|
||||
assert transactions[0].amount == Decimal("-150.00")
|
||||
assert transactions[1].amount == Decimal("5.00")
|
||||
|
||||
|
||||
def test_gzip_compressed_gnucash_file_is_read_transparently(tmp_path) -> None:
|
||||
path = _write_sample(tmp_path, gzipped=True, suffix=".gnucash")
|
||||
accounts = list_accounts(path)
|
||||
assert any(account.guid == "bank-guid" for account in accounts)
|
||||
transactions = list_transactions(path, "bank-guid")
|
||||
assert len(transactions) == 2
|
||||
|
||||
|
||||
def test_invalid_xml_raises_gnucash_import_error(tmp_path) -> None:
|
||||
path = tmp_path / "broken.gnucash"
|
||||
path.write_text("not xml at all <<<", encoding="utf-8")
|
||||
with pytest.raises(GnuCashImportError):
|
||||
list_accounts(path)
|
||||
|
||||
|
||||
def test_missing_file_raises_gnucash_import_error(tmp_path) -> None:
|
||||
with pytest.raises(GnuCashImportError):
|
||||
list_accounts(tmp_path / "does-not-exist.gnucash")
|
||||
|
||||
|
||||
def test_file_without_book_raises_gnucash_import_error(tmp_path) -> None:
|
||||
path = tmp_path / "empty.gnucash"
|
||||
path.write_text('<?xml version="1.0"?><gnc-v2></gnc-v2>', encoding="utf-8")
|
||||
with pytest.raises(GnuCashImportError, match="gnc:book"):
|
||||
list_accounts(path)
|
||||
@@ -29,11 +29,10 @@ def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
|
||||
)
|
||||
|
||||
findings = Housekeeper(repository).run(today=date(2026, 2, 10))
|
||||
assert {finding.code for finding in findings} == {
|
||||
"initial_payment_overdue",
|
||||
"claim_overdue",
|
||||
"reminder_due",
|
||||
}
|
||||
# Both claims are more than the default 7-day grace period overdue, so the dunning
|
||||
# escalation ("reminder_due") is the single active finding for each -- no separate,
|
||||
# redundant "overdue" finding alongside it.
|
||||
assert {finding.code for finding in findings} == {"reminder_due"}
|
||||
|
||||
|
||||
def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import pytest
|
||||
|
||||
from ccma.services import mail_delivery
|
||||
from ccma.storage.repository import RepositoryError
|
||||
|
||||
|
||||
class _FakeSmtpClient:
|
||||
instances: list["_FakeSmtpClient"] = []
|
||||
|
||||
def __init__(self, host, port, **kwargs):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.kwargs = kwargs
|
||||
self.calls: list[str] = []
|
||||
self.login_args: tuple | None = None
|
||||
self.sent_messages: list = []
|
||||
_FakeSmtpClient.instances.append(self)
|
||||
|
||||
def starttls(self, **kwargs):
|
||||
self.calls.append("starttls")
|
||||
|
||||
def login(self, username, password):
|
||||
self.login_args = (username, password)
|
||||
self.calls.append("login")
|
||||
|
||||
def send_message(self, message):
|
||||
self.sent_messages.append(message)
|
||||
self.calls.append("send_message")
|
||||
|
||||
def quit(self):
|
||||
self.calls.append("quit")
|
||||
|
||||
|
||||
class _FakeImapClient:
|
||||
instances: list["_FakeImapClient"] = []
|
||||
default_list_result: tuple = ("OK", [])
|
||||
error = Exception
|
||||
|
||||
def __init__(self, host, port, **kwargs):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.kwargs = kwargs
|
||||
self.calls: list[str] = []
|
||||
self.login_args: tuple | None = None
|
||||
self.appended: list[tuple] = []
|
||||
self.append_result = ("OK", [b"APPEND completed"])
|
||||
self.selected: list[str] = []
|
||||
self.select_result = ("OK", [b"1"])
|
||||
self.created: list[str] = []
|
||||
self.create_result = ("OK", [b"CREATE completed"])
|
||||
self.list_result = _FakeImapClient.default_list_result
|
||||
_FakeImapClient.instances.append(self)
|
||||
|
||||
def starttls(self, *args, **kwargs):
|
||||
self.calls.append("starttls")
|
||||
|
||||
def login(self, username, password):
|
||||
self.login_args = (username, password)
|
||||
self.calls.append("login")
|
||||
|
||||
def append(self, folder, flags, date_time, message_bytes):
|
||||
self.appended.append((folder, flags, date_time, message_bytes))
|
||||
self.calls.append("append")
|
||||
return self.append_result
|
||||
|
||||
def select(self, folder, readonly=False):
|
||||
self.selected.append(folder)
|
||||
self.calls.append("select")
|
||||
return self.select_result
|
||||
|
||||
def create(self, folder):
|
||||
self.created.append(folder)
|
||||
self.calls.append("create")
|
||||
return self.create_result
|
||||
|
||||
def list(self):
|
||||
self.calls.append("list")
|
||||
return self.list_result
|
||||
|
||||
def logout(self):
|
||||
self.calls.append("logout")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_fakes():
|
||||
_FakeSmtpClient.instances.clear()
|
||||
_FakeImapClient.instances.clear()
|
||||
_FakeImapClient.default_list_result = ("OK", [])
|
||||
yield
|
||||
|
||||
|
||||
def _patch_smtp(monkeypatch):
|
||||
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _FakeSmtpClient)
|
||||
monkeypatch.setattr(mail_delivery.smtplib, "SMTP_SSL", _FakeSmtpClient)
|
||||
|
||||
|
||||
def _patch_imap(monkeypatch):
|
||||
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4", _FakeImapClient)
|
||||
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4_SSL", _FakeImapClient)
|
||||
|
||||
|
||||
SMTP_SETTINGS = {
|
||||
"smtp_host": "mail.example.org",
|
||||
"smtp_port": 587,
|
||||
"smtp_security": "starttls",
|
||||
"smtp_username": "board@example.org",
|
||||
"smtp_password": "secret",
|
||||
}
|
||||
|
||||
IMAP_SETTINGS = {
|
||||
"imap_host": "mail.example.org",
|
||||
"imap_port": 993,
|
||||
"imap_security": "ssl",
|
||||
"imap_username": "board@example.org",
|
||||
"imap_password": "secret",
|
||||
}
|
||||
|
||||
MESSAGE = (
|
||||
b"From: Board <board@example.org>\r\n"
|
||||
b"To: member@example.org\r\n"
|
||||
b"Subject: Test\r\n"
|
||||
b"\r\n"
|
||||
b"Hello\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) -> None:
|
||||
_patch_smtp(monkeypatch)
|
||||
with mail_delivery.smtp_session(SMTP_SETTINGS) as client:
|
||||
mail_delivery.send_via_smtp(client, MESSAGE)
|
||||
mail_delivery.send_via_smtp(client, MESSAGE)
|
||||
|
||||
assert len(_FakeSmtpClient.instances) == 1
|
||||
fake = _FakeSmtpClient.instances[0]
|
||||
assert fake.host == "mail.example.org"
|
||||
assert fake.port == 587
|
||||
assert fake.login_args == ("board@example.org", "secret")
|
||||
assert fake.calls == ["starttls", "login", "send_message", "send_message", "quit"]
|
||||
assert len(fake.sent_messages) == 2
|
||||
|
||||
|
||||
def test_append_message_uses_configured_folder_and_flags(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
mail_delivery.append_message(client, MESSAGE, folder="INBOX.Entwürfe", flags=r"(\Draft)")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.login_args == ("board@example.org", "secret")
|
||||
# The folder name travels the wire in modified UTF-7, not raw UTF-8.
|
||||
assert fake.appended == [("INBOX.Entw&APw-rfe", r"(\Draft)", None, MESSAGE)]
|
||||
assert fake.calls == ["login", "append", "logout"]
|
||||
|
||||
|
||||
def test_append_message_raises_on_rejected_status(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.append_result = ("NO", [b"Mailbox does not exist"])
|
||||
with pytest.raises(RepositoryError, match="Mailbox does not exist"):
|
||||
mail_delivery.append_message(client, MESSAGE, folder="Missing", flags=r"(\Seen)")
|
||||
|
||||
|
||||
def test_ensure_imap_folder_skips_creation_when_folder_already_exists(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
mail_delivery.ensure_imap_folder(client, "INBOX.Sent")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.selected == ["INBOX.Sent"]
|
||||
assert fake.created == []
|
||||
|
||||
|
||||
def test_ensure_imap_folder_creates_missing_folder(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.select_result = ("NO", [b"Mailbox does not exist"])
|
||||
mail_delivery.ensure_imap_folder(client, "Archiv.Neu")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.selected == ["Archiv.Neu"]
|
||||
assert fake.created == ["Archiv.Neu"]
|
||||
|
||||
|
||||
def test_ensure_imap_folder_raises_when_creation_fails(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.select_result = ("NO", [b"no such mailbox"])
|
||||
client.create_result = ("NO", [b"Permission denied"])
|
||||
with pytest.raises(RepositoryError, match="Permission denied"):
|
||||
mail_delivery.ensure_imap_folder(client, "Verboten")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plain", "wire"),
|
||||
[
|
||||
("INBOX", "INBOX"),
|
||||
("Entwürfe", "Entw&APw-rfe"),
|
||||
("INBOX.Entwürfe", "INBOX.Entw&APw-rfe"),
|
||||
("Test & Foo", "Test &- Foo"),
|
||||
],
|
||||
)
|
||||
def test_imap_utf7_round_trips(plain, wire) -> None:
|
||||
assert mail_delivery._encode_imap_utf7(plain) == wire
|
||||
assert mail_delivery._decode_imap_utf7(wire) == plain
|
||||
|
||||
|
||||
def test_list_imap_folders_parses_and_decodes_list_response(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
_FakeImapClient.default_list_result = (
|
||||
"OK",
|
||||
[
|
||||
rb'(\HasNoChildren) "." INBOX',
|
||||
rb'(\HasNoChildren) "." INBOX.Entw&APw-rfe',
|
||||
rb'(\HasNoChildren) "." "INBOX.Gesendete Objekte"',
|
||||
],
|
||||
)
|
||||
|
||||
folders = mail_delivery.list_imap_folders(IMAP_SETTINGS)
|
||||
|
||||
assert folders == ["INBOX", "INBOX.Entwürfe", "INBOX.Gesendete Objekte"]
|
||||
assert _FakeImapClient.instances[0].calls == ["login", "list", "logout"]
|
||||
|
||||
|
||||
def test_smtp_session_wraps_connection_errors(monkeypatch) -> None:
|
||||
def _boom(*args, **kwargs):
|
||||
raise OSError("connection refused")
|
||||
|
||||
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _boom)
|
||||
with pytest.raises(RepositoryError, match="SMTP-Verbindung fehlgeschlagen"):
|
||||
with mail_delivery.smtp_session({**SMTP_SETTINGS, "smtp_security": "none"}):
|
||||
pass
|
||||
|
||||
|
||||
def test_smtp_session_requires_host() -> None:
|
||||
with pytest.raises(RepositoryError, match="Kein SMTP-Server konfiguriert"):
|
||||
with mail_delivery.smtp_session({}):
|
||||
pass
|
||||
|
||||
|
||||
def test_imap_session_requires_host() -> None:
|
||||
with pytest.raises(RepositoryError, match="Kein IMAP-Server konfiguriert"):
|
||||
with mail_delivery.imap_session({}):
|
||||
pass
|
||||
|
||||
|
||||
def test_test_smtp_connection_succeeds_and_closes(monkeypatch) -> None:
|
||||
_patch_smtp(monkeypatch)
|
||||
mail_delivery.test_smtp_connection(SMTP_SETTINGS)
|
||||
assert _FakeSmtpClient.instances[0].calls == ["starttls", "login", "quit"]
|
||||
|
||||
|
||||
def test_test_imap_connection_succeeds_and_closes(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
mail_delivery.test_imap_connection(IMAP_SETTINGS)
|
||||
assert _FakeImapClient.instances[0].calls == ["login", "logout"]
|
||||
@@ -0,0 +1,202 @@
|
||||
from contextlib import contextmanager
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
from test_reminders import _overdue_claim_repository
|
||||
|
||||
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
||||
|
||||
|
||||
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE98670505050038907751",
|
||||
"bic": "MANSDE66XXX",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
detail="Bitte Mitgliedsnummer angeben.",
|
||||
)
|
||||
export_path = tmp_path / "Zahlungserinnerung.eml"
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=export_path,
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
assert message["To"] == "reminder@example.org"
|
||||
assert "Zahlungserinnerung" in message["Subject"]
|
||||
assert "100.00 Euro" in message.get_content()
|
||||
assert "DE98670505050038907751" in message.get_content()
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.reminders[0]["status"] == "sent"
|
||||
assert data.reminders[0]["payment_deadline"]
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
|
||||
assert generated.archive_path.parent.name == "Mahnungen"
|
||||
|
||||
|
||||
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Mahnung.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
||||
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
||||
|
||||
|
||||
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
sent = []
|
||||
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
|
||||
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||
)
|
||||
|
||||
generated = generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated.export_path is None
|
||||
assert generated.archive_path.is_file()
|
||||
assert len(sent) == 1
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.reminders[0]["status"] == "sent"
|
||||
|
||||
|
||||
def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(tmp_path, monkeypatch):
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="mail.example.org",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="INBOX.Gesendet",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "imap_session", contextmanager(lambda settings: iter(["imap-client"]))
|
||||
)
|
||||
ensured = []
|
||||
appended = []
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append((client, folder))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module,
|
||||
"append_message",
|
||||
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
||||
)
|
||||
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
||||
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
|
||||
+376
-3
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.domain.contributions import claim_total
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
@@ -42,7 +45,7 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
fee="0.00",
|
||||
items=[],
|
||||
)
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
reminder_task = next(item for item in findings if item.code == "reminder_due")
|
||||
@@ -58,6 +61,35 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||
assert "Erste Mahnung" in next_task.title
|
||||
|
||||
|
||||
def test_overdue_claim_awaiting_reminder_deadline_is_not_reported_as_error(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
deadline = date.fromisoformat(sent["payment_deadline"])
|
||||
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
# A reminder was already sent and its own deadline hasn't expired yet -- this is
|
||||
# being handled, so it must not appear as an unaddressed "error"-severity overdue
|
||||
# claim, only as a low-priority note that the existing deadline is still running.
|
||||
assert not any(item.code == "claim_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Zahlungserinnerung" in awaiting.title
|
||||
|
||||
|
||||
def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
@@ -68,7 +100,11 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
||||
reason="Betrag wird geklärt",
|
||||
)
|
||||
|
||||
assert not any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
assert not any(item.code == "reminder_due" for item in findings)
|
||||
# The claim stays visible as a plain overdue notice instead of disappearing entirely
|
||||
# while the hold suppresses the dunning escalation.
|
||||
assert any(item.code == "claim_overdue" for item in findings)
|
||||
with pytest.raises(RepositoryError, match="Mahnsperre aktiv"):
|
||||
repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
@@ -81,6 +117,82 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
||||
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
|
||||
|
||||
|
||||
def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
# Still within the 7-day default grace period: only the plain overdue notice, no
|
||||
# dunning escalation yet.
|
||||
within_grace = housekeeper.run(today=date(2026, 2, 4))
|
||||
codes = {item.code for item in within_grace if item.member_id == member.member_id}
|
||||
assert codes == {"claim_overdue"}
|
||||
|
||||
# Past the grace period: the dunning escalation takes over as the single finding,
|
||||
# the redundant plain overdue notice disappears.
|
||||
past_grace = housekeeper.run(today=date(2026, 2, 10))
|
||||
codes = {item.code for item in past_grace if item.member_id == member.member_id}
|
||||
assert codes == {"reminder_due"}
|
||||
|
||||
|
||||
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
codes = {item.code for item in findings if item.member_id == member.member_id}
|
||||
|
||||
assert codes == {"sepa_debit_overdue"}
|
||||
sepa_finding = next(item for item in findings if item.code == "sepa_debit_overdue")
|
||||
assert "Lastschrift" in sepa_finding.title
|
||||
assert "SEPA" in sepa_finding.detail
|
||||
|
||||
|
||||
def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.mandate_reference = "MANDATE-1"
|
||||
member.mandate_signed_at = "2026-01-01"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
deadline = date.fromisoformat(sent["payment_deadline"])
|
||||
|
||||
findings = [
|
||||
item
|
||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
|
||||
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline")
|
||||
assert awaiting.severity == "info"
|
||||
assert "Rücklastschrift" in awaiting.title
|
||||
|
||||
# Once the reminder's own deadline has passed without resolution, the plain SEPA
|
||||
# follow-up notice returns so it doesn't just silently go quiet forever.
|
||||
findings = [
|
||||
item for item in housekeeper.run(today=deadline + timedelta(days=1))
|
||||
if item.member_id == member.member_id
|
||||
]
|
||||
assert any(item.code == "sepa_debit_overdue" for item in findings)
|
||||
assert not any(item.code == "sepa_debit_awaiting_deadline" for item in findings)
|
||||
|
||||
|
||||
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
@@ -116,7 +228,7 @@ def test_reminder_levels_cannot_be_skipped(tmp_path) -> None:
|
||||
level=2,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
fee="5.00",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
|
||||
@@ -134,3 +246,264 @@ def test_payment_resolves_open_reminder_task(tmp_path) -> None:
|
||||
|
||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||
assert not any(item.code == "reminder_due" for item in findings)
|
||||
|
||||
|
||||
def test_reminder_can_carry_multiple_fee_items(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[
|
||||
{"description": "Rücklastschriftgebühr", "amount": "5.00"},
|
||||
{"description": "Bankgebühr", "amount": "3.50"},
|
||||
],
|
||||
)
|
||||
assert draft["fee_item_ids"] == []
|
||||
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert len(sent["fee_item_ids"]) == 2
|
||||
|
||||
data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
descriptions = {item["description"] for item in claim["items"] if item["type"] == "fee"}
|
||||
assert descriptions == {"Rücklastschriftgebühr", "Bankgebühr"}
|
||||
assert claim_total(claim) == Decimal("108.50")
|
||||
|
||||
|
||||
def test_reminder_items_require_a_description(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
|
||||
with pytest.raises(RepositoryError, match="Beschreibung"):
|
||||
repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": " ", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
|
||||
def test_reminder_with_zero_amount_items_creates_no_claim_item(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "0.00"}],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert sent["fee_item_ids"] == []
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("100.00")
|
||||
|
||||
|
||||
def test_reminder_policy_round_trips_through_save_and_get(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=10,
|
||||
levels=[
|
||||
{"name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 10},
|
||||
{"name": "Mahnung", "fee": "7.50", "payment_deadline_days": 14},
|
||||
],
|
||||
standard_fee_items=[
|
||||
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"},
|
||||
],
|
||||
)
|
||||
|
||||
policy = repository.get_reminder_policy()
|
||||
assert policy["grace_days_after_due"] == 10
|
||||
assert [level["level"] for level in policy["levels"]] == [1, 2]
|
||||
assert policy["levels"][1]["name"] == "Mahnung"
|
||||
assert policy["levels"][1]["fee"] == "7.50"
|
||||
assert policy["standard_fee_items"] == [
|
||||
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"}
|
||||
]
|
||||
|
||||
|
||||
def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Mindestens eine Mahnstufe"):
|
||||
repository.save_reminder_policy(grace_days_after_due=7, levels=[], standard_fee_items=[])
|
||||
|
||||
with pytest.raises(RepositoryError, match="benötigt einen Namen"):
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=7,
|
||||
levels=[{"name": " ", "fee": "0.00", "payment_deadline_days": 14}],
|
||||
standard_fee_items=[],
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="nicht negativ"):
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=7,
|
||||
levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}],
|
||||
standard_fee_items=[],
|
||||
)
|
||||
|
||||
|
||||
def test_edit_reminder_draft_updates_fields_but_not_level(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
|
||||
edited = repository.edit_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
draft["reminder_id"],
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=10,
|
||||
detail="Bitte um Ausgleich.",
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
assert edited["level"] == 1
|
||||
assert edited["name"] == "Erste Mahnung"
|
||||
assert edited["payment_deadline_days"] == 10
|
||||
assert edited["detail"] == "Bitte um Ausgleich."
|
||||
assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}]
|
||||
|
||||
|
||||
def test_edit_reminder_draft_rejects_already_sent_reminder(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
|
||||
with pytest.raises(RepositoryError, match="Nur ein Entwurf"):
|
||||
repository.edit_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
draft["reminder_id"],
|
||||
name="Andere Bezeichnung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
|
||||
def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert len(sent["fee_item_ids"]) == 1
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("105.00")
|
||||
|
||||
reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
assert reverted["status"] == "draft"
|
||||
assert reverted["sent_at"] is None
|
||||
assert reverted["payment_deadline"] is None
|
||||
assert reverted["fee_item_ids"] == []
|
||||
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("100.00")
|
||||
descriptions = {item["description"] for item in claim["items"]}
|
||||
assert "Rücklastschriftgebühr" not in descriptions
|
||||
|
||||
# Now editable and re-sendable, e.g. after fixing the wording.
|
||||
repository.edit_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
draft["reminder_id"],
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||
assert claim_total(claim) == Decimal("107.50")
|
||||
|
||||
|
||||
def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
first = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
items=[],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
|
||||
repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=2,
|
||||
name="Erste Mahnung",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="höhere Mahnstufe"):
|
||||
repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
|
||||
|
||||
|
||||
def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Rücklastschrift",
|
||||
payment_deadline_days=14,
|
||||
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||
)
|
||||
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-02-10",
|
||||
amount="105.00",
|
||||
allocation_amount="105.00",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"):
|
||||
repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||
|
||||
|
||||
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
config_path = tmp_path / "repository.json"
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
# Simulate a repository created before "standard_fee_items" existed at all.
|
||||
del raw["reminder_policy"]["standard_fee_items"]
|
||||
config_path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
policy = repository.get_reminder_policy()
|
||||
assert policy["standard_fee_items"], "should fall back to built-in defaults"
|
||||
assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"])
|
||||
|
||||
# But once the board explicitly saves an empty list, that choice is respected.
|
||||
repository.save_reminder_policy(
|
||||
grace_days_after_due=policy["grace_days_after_due"],
|
||||
levels=policy["levels"],
|
||||
standard_fee_items=[],
|
||||
)
|
||||
assert repository.get_reminder_policy()["standard_fee_items"] == []
|
||||
|
||||
+133
-3
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -24,6 +25,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
||||
nickname="Enchantress",
|
||||
email="ada@example.org",
|
||||
birth_date="1990-12-10",
|
||||
application_date="30.07.2026",
|
||||
member_number="0042",
|
||||
)
|
||||
|
||||
@@ -37,10 +39,35 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
||||
raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8"))
|
||||
assert raw["person"]["first_name"] == "Ada"
|
||||
assert raw["person"]["nickname"] == "Enchantress"
|
||||
assert raw["membership"]["application_date"] == "2026-07-30"
|
||||
assert raw["schema_version"] == 1
|
||||
assert raw["membership"]["ended_at"] == ""
|
||||
assert raw["contribution_profile"]["payment_frequency"] == "semiannual"
|
||||
assert raw["content_hash"]
|
||||
|
||||
|
||||
def test_existing_member_without_payment_frequency_defaults_to_semiannual(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Legacy", last_name="Member")
|
||||
member_path = repository.members_root / member.member_id / "member.json"
|
||||
raw = json.loads(member_path.read_text(encoding="utf-8"))
|
||||
raw["contribution_profile"].pop("payment_frequency")
|
||||
member_path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
assert repository.get_member(member.member_id).payment_frequency == "semiannual"
|
||||
|
||||
|
||||
def test_repository_rejects_unknown_payment_frequency(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Invalid", last_name="Frequency")
|
||||
member.payment_frequency = "weekly"
|
||||
|
||||
with pytest.raises(RepositoryError, match="Ungültige Zahlweise"):
|
||||
repository.save_member(member)
|
||||
|
||||
|
||||
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -57,6 +84,22 @@ def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> Non
|
||||
assert [result.member_id for result in repository.search(query)] == [member.member_id]
|
||||
|
||||
|
||||
def test_past_membership_end_sets_status_to_ended(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Former", last_name="Member")
|
||||
member.status = "active"
|
||||
member.accepted_at = "2020-01-01"
|
||||
member.membership_started_at = "2020-01-01"
|
||||
member.membership_ended_at = "2020-12-31"
|
||||
|
||||
repository.save_member(member)
|
||||
|
||||
loaded = repository.get_member(member.member_id)
|
||||
assert loaded.membership_ended_at == "2020-12-31"
|
||||
assert loaded.status == "ended"
|
||||
|
||||
|
||||
def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -103,6 +146,18 @@ def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path)
|
||||
repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000")
|
||||
|
||||
|
||||
def test_existing_member_without_application_date_stays_empty(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Legacy", last_name="Member")
|
||||
path = repository.members_root / member.member_id / "member.json"
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw["membership"].pop("application_date")
|
||||
path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
assert repository.get_member(member.member_id).application_date == ""
|
||||
|
||||
|
||||
def test_repository_reports_empty_contributions_file(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -145,7 +200,6 @@ def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tm
|
||||
first = repository.create_member(first_name="First", last_name="Member")
|
||||
second = repository.create_member(first_name="Second", last_name="Member")
|
||||
|
||||
|
||||
assert first.member_number == "CCMA-0001"
|
||||
assert second.member_number == "CCMA-0002"
|
||||
assert repository.preview_member_number() == "CCMA-0003"
|
||||
@@ -168,6 +222,43 @@ def test_custom_pattern_and_manual_mode(tmp_path) -> None:
|
||||
repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7")
|
||||
|
||||
|
||||
def test_member_number_strategy_fills_gaps_or_uses_max_plus_one(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
repository.create_member(first_name="One", last_name="Member", member_number="MA-001")
|
||||
repository.create_member(first_name="Three", last_name="Member", member_number="MA-003")
|
||||
|
||||
repository.save_member_number_policy(
|
||||
mode="automatic",
|
||||
pattern="MA-{number:03d}",
|
||||
allocation_strategy="next_free",
|
||||
)
|
||||
assert repository.preview_member_number() == "MA-002"
|
||||
assert repository.get_member_number_policy()["allocation_strategy"] == "next_free"
|
||||
|
||||
repository.save_member_number_policy(
|
||||
mode="automatic",
|
||||
pattern="MA-{number:03d}",
|
||||
allocation_strategy="max_plus_one",
|
||||
)
|
||||
assert repository.preview_member_number() == "MA-004"
|
||||
created = repository.create_member(first_name="Four", last_name="Member")
|
||||
assert created.member_number == "MA-004"
|
||||
|
||||
|
||||
def test_concurrent_automatic_member_numbers_are_unique(tmp_path) -> None:
|
||||
MemberRepository(tmp_path).initialize()
|
||||
|
||||
def create(index: int) -> str:
|
||||
repository = MemberRepository(tmp_path)
|
||||
return repository.create_member(first_name=f"Member {index}", last_name="Concurrent").member_number
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
numbers = list(executor.map(create, range(16)))
|
||||
|
||||
assert len(numbers) == len(set(numbers)) == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"])
|
||||
def test_invalid_member_number_patterns_are_rejected(pattern) -> None:
|
||||
with pytest.raises(RepositoryError):
|
||||
@@ -286,6 +377,46 @@ def test_asset_can_be_assigned_and_returned_to_single_member(tmp_path) -> None:
|
||||
assert repository.list_member_assets(member.member_id) == []
|
||||
|
||||
|
||||
def test_member_owned_asset_is_kept_in_central_inventory(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
owner = repository.create_member(first_name="Ada", last_name="Lovelace")
|
||||
|
||||
asset = repository.create_asset(
|
||||
label="3D-Drucker",
|
||||
owner_type="member",
|
||||
owner_member_id=owner.member_id,
|
||||
custody_type="club",
|
||||
location="Clubraum / Werkstatt",
|
||||
handed_over_at="2026-07-22",
|
||||
condition="gebraucht, funktionsfähig",
|
||||
estimated_value="450.00",
|
||||
)
|
||||
|
||||
loaded = repository.get_asset(asset.asset_id)
|
||||
assert loaded.owner_member_id == owner.member_id
|
||||
assert loaded.custody_type == "club"
|
||||
assert loaded.location == "Clubraum / Werkstatt"
|
||||
assert [item.asset_id for item in repository.list_member_owned_assets(owner.member_id)] == [
|
||||
asset.asset_id
|
||||
]
|
||||
assert repository.list_member_assets(owner.member_id) == []
|
||||
assert repository.list_assets()[0].asset_id == asset.asset_id
|
||||
assert repository.get_events(owner.member_id)[-1].event_type == "member_asset_registered"
|
||||
|
||||
|
||||
def test_member_ownership_requires_existing_member(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Mitglied"):
|
||||
repository.create_asset(
|
||||
label="Privater Beamer",
|
||||
owner_type="member",
|
||||
owner_member_id="missing-member",
|
||||
)
|
||||
|
||||
|
||||
def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -423,6 +554,5 @@ def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None:
|
||||
findings = Housekeeper(repository).run()
|
||||
|
||||
assert any(
|
||||
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id
|
||||
for finding in findings
|
||||
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id for finding in findings
|
||||
)
|
||||
|
||||
+165
-1
@@ -1,13 +1,14 @@
|
||||
import json
|
||||
import shutil
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
import ccma.services.housekeeper as housekeeper_module
|
||||
from ccma.rules.loader import RuleLoadError
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.storage.repository import MemberRepository
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def test_housekeeper_optionally_waits_between_members(tmp_path, monkeypatch) -> None:
|
||||
@@ -87,10 +88,52 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
|
||||
amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims}
|
||||
assert amounts["membership-fee:2026:first-half"] == "37.50"
|
||||
assert amounts["membership-fee:2026:second-half"] == "75.00"
|
||||
descriptions = {
|
||||
claim["claim_key"]: claim["items"][0]["description"] for claim in first_claims
|
||||
}
|
||||
assert descriptions["membership-fee:2026:first-half"].endswith("(3 Monate)")
|
||||
assert descriptions["membership-fee:2026:second-half"] == "Mitgliedsbeitrag 2. Halbjahr 2026"
|
||||
assert state["run_counter"] == 2
|
||||
assert state["last_completed_run"] == "2026-04-15:000002"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frequency", "expected_suffixes", "expected_amount"),
|
||||
[
|
||||
("monthly", [f"month-{month:02d}" for month in range(1, 13)], "12.50"),
|
||||
("quarterly", [f"quarter-{quarter}" for quarter in range(1, 5)], "37.50"),
|
||||
("semiannual", ["first-half", "second-half"], "75.00"),
|
||||
("annual", ["annual"], "150.00"),
|
||||
],
|
||||
)
|
||||
def test_housekeeper_creates_claims_for_each_payment_frequency(
|
||||
tmp_path, frequency, expected_suffixes, expected_amount
|
||||
) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(
|
||||
first_name=frequency,
|
||||
last_name="Frequency",
|
||||
birth_date="1990-01-01",
|
||||
)
|
||||
member.status = "active"
|
||||
member.accepted_at = "2026-01-01"
|
||||
member.membership_started_at = "2026-01-01"
|
||||
member.payment_frequency = frequency
|
||||
repository.save_member(member)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 12, 31))
|
||||
|
||||
membership_claims = [
|
||||
claim
|
||||
for claim in repository.get_contributions(member.member_id).claims
|
||||
if claim.get("type") == "membership_fee"
|
||||
]
|
||||
assert [claim["claim_key"].rsplit(":", 1)[-1] for claim in membership_claims] == expected_suffixes
|
||||
assert {claim["amount"] for claim in membership_claims} == {expected_amount}
|
||||
assert sum(Decimal(claim["amount"]) for claim in membership_claims) == Decimal("150.00")
|
||||
|
||||
|
||||
def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -98,6 +141,7 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
|
||||
member.status = "active"
|
||||
member.accepted_at = "2024-04-15"
|
||||
member.membership_started_at = "2024-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
@@ -121,6 +165,9 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
|
||||
"membership-fee:2026:annual",
|
||||
}
|
||||
assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50"
|
||||
assert claims_by_key["membership-fee:2024:annual"]["items"][0]["description"].endswith(
|
||||
"(9 Monate)"
|
||||
)
|
||||
|
||||
|
||||
def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None:
|
||||
@@ -130,6 +177,7 @@ def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_pat
|
||||
member.status = "active"
|
||||
member.accepted_at = "2021-04-15"
|
||||
member.membership_started_at = "2021-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
@@ -157,6 +205,7 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
|
||||
member.status = "active"
|
||||
member.accepted_at = "2024-04-15"
|
||||
member.membership_started_at = "2024-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
@@ -169,6 +218,57 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
|
||||
}
|
||||
|
||||
|
||||
def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Former", last_name="Member", birth_date="1990-01-01")
|
||||
member.status = "resigned_end_of_year"
|
||||
member.accepted_at = "2024-01-01"
|
||||
member.membership_started_at = "2024-01-01"
|
||||
member.membership_ended_at = "2026-12-31"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||
|
||||
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
|
||||
assert "membership-fee:2026:annual" in claim_keys
|
||||
assert "membership-fee:2027:annual" not in claim_keys
|
||||
|
||||
|
||||
def test_housekeeper_skips_birthday_and_anniversary_for_ended_members(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
active = repository.create_member(first_name="Active", last_name="Member", birth_date="1990-06-21")
|
||||
active.status = "active"
|
||||
active.accepted_at = "2025-06-21"
|
||||
active.membership_started_at = "2025-06-21"
|
||||
repository.save_member(active)
|
||||
ended = repository.create_member(first_name="Ended", last_name="Member", birth_date="1990-06-21")
|
||||
ended.status = "ended"
|
||||
ended.accepted_at = "2025-06-21"
|
||||
ended.membership_started_at = "2025-06-21"
|
||||
ended.membership_ended_at = "2025-12-31"
|
||||
repository.save_member(ended)
|
||||
|
||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||
birthday_days_before=0,
|
||||
birthday_days_after=0,
|
||||
anniversary_days_before=0,
|
||||
anniversary_days_after=0,
|
||||
anniversary_intervals="1Y",
|
||||
)
|
||||
|
||||
findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21))
|
||||
codes_by_member = {}
|
||||
for item in findings:
|
||||
codes_by_member.setdefault(item.member_id, set()).add(item.code)
|
||||
|
||||
assert "birthday" in codes_by_member.get(active.member_id, set())
|
||||
assert "membership_anniversary" in codes_by_member.get(active.member_id, set())
|
||||
assert codes_by_member.get(ended.member_id, set()).isdisjoint({"birthday", "membership_anniversary"})
|
||||
|
||||
|
||||
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
@@ -314,3 +414,67 @@ def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> Non
|
||||
|
||||
assert findings == []
|
||||
assert not any(item.get("member_id") == member.member_id for item in state["items"])
|
||||
|
||||
|
||||
def test_housekeeper_run_can_target_a_single_member(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member_a = repository.create_member(first_name="Alpha", last_name="Member")
|
||||
member_b = repository.create_member(first_name="Beta", last_name="Member")
|
||||
housekeeper = Housekeeper(repository)
|
||||
housekeeper.run(today=date(2026, 6, 21))
|
||||
state_before = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
|
||||
b_task_before = next(
|
||||
item for item in state_before["items"] if item.get("member_id") == member_b.member_id
|
||||
)
|
||||
|
||||
member_a.birth_date = "1990-01-01"
|
||||
repository.save_member(member_a)
|
||||
findings = housekeeper.run(today=date(2026, 6, 21), member_id=member_a.member_id)
|
||||
|
||||
a_findings = [item for item in findings if item.member_id == member_a.member_id]
|
||||
assert not any(item.code == "missing_birth_date" for item in a_findings)
|
||||
state_after = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
|
||||
b_task_after = next(
|
||||
item for item in state_after["items"] if item.get("member_id") == member_b.member_id
|
||||
)
|
||||
assert b_task_after == b_task_before
|
||||
|
||||
|
||||
def test_housekeeper_run_rejects_unknown_member_id(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
with pytest.raises(RepositoryError, match="nicht gefunden"):
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id="does-not-exist")
|
||||
|
||||
|
||||
def test_housekeeper_run_retroactive_override_applies_only_to_that_run(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Retro", last_name="Member", birth_date="1990-01-01")
|
||||
member.status = "active"
|
||||
member.accepted_at = "2024-04-15"
|
||||
member.membership_started_at = "2024-04-15"
|
||||
member.payment_frequency = "annual"
|
||||
repository.save_member(member)
|
||||
housekeeper = Housekeeper(repository)
|
||||
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id=member.member_id, retroactive_claims=True)
|
||||
|
||||
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
|
||||
assert "membership-fee:2024:annual" in claim_keys
|
||||
assert housekeeper.settings.retroactive_claims is False
|
||||
|
||||
other = repository.create_member(first_name="NoRetro", last_name="Member", birth_date="1990-01-01")
|
||||
other.status = "active"
|
||||
other.accepted_at = "2024-04-15"
|
||||
other.membership_started_at = "2024-04-15"
|
||||
other.payment_frequency = "annual"
|
||||
repository.save_member(other)
|
||||
housekeeper.run(today=date(2026, 6, 21), member_id=other.member_id)
|
||||
|
||||
other_claim_keys = {claim["claim_key"] for claim in repository.get_contributions(other.member_id).claims}
|
||||
assert "membership-fee:2024:annual" not in other_claim_keys
|
||||
assert "membership-fee:2026:annual" in other_claim_keys
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.housekeeper import Housekeeper
|
||||
from ccma.services.sepa import PAIN_NAMESPACE, _safe, csv_text, pain008_bytes, pending_direct_debits
|
||||
from ccma.services.sepa_mail import debit_mail_bytes, generate_debit_mails
|
||||
from ccma.storage.repository import MemberRepository
|
||||
from ccma.ui.sepa_dialog import _next_weekday
|
||||
|
||||
|
||||
def _repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Ada", last_name="Lovelace", member_number="C3-42")
|
||||
member.status = "active"
|
||||
member.email = "ada@example.org"
|
||||
member.account_holder = "Ada Lovelace"
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.bic = "COBADEFFXXX"
|
||||
member.mandate_reference = "MANDAT-42"
|
||||
member.mandate_signed_at = "2025-01-10"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
repository.save_contributions(
|
||||
member.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
},
|
||||
{
|
||||
"claim_id": "future",
|
||||
"title": "Mitgliedsbeitrag 2027",
|
||||
"amount": "150.00",
|
||||
"due_date": "2027-01-31",
|
||||
"status": "open",
|
||||
},
|
||||
],
|
||||
payments=[{"payment_id": "payment", "amount": "25.00"}],
|
||||
allocations=[{"payment_id": "payment", "claim_id": "due", "amount": "25.00"}],
|
||||
),
|
||||
)
|
||||
return repository, member
|
||||
|
||||
|
||||
def test_pending_debits_select_due_open_balance(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
assert warnings == []
|
||||
assert len(debits) == 1
|
||||
assert debits[0].member_id == member.member_id
|
||||
assert debits[0].amount == Decimal("125.00")
|
||||
assert debits[0].claim_ids == ("due",)
|
||||
|
||||
|
||||
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
member.accepted_at = "2025-01-01"
|
||||
member.membership_started_at = "2025-01-01"
|
||||
member.payment_frequency = "monthly"
|
||||
repository.save_member(member)
|
||||
repository.save_contributions(member.member_id, ContributionData())
|
||||
|
||||
Housekeeper(repository).run(today=date(2026, 3, 31))
|
||||
debits, warnings = pending_direct_debits(
|
||||
repository,
|
||||
due_from=date(2026, 1, 1),
|
||||
due_until=date(2026, 3, 31),
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert len(debits) == 1
|
||||
assert debits[0].amount == Decimal("37.50")
|
||||
assert len(debits[0].claim_ids) == 3
|
||||
|
||||
|
||||
def test_pending_debits_exclude_claims_before_lower_date(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
|
||||
debits, warnings = pending_direct_debits(
|
||||
repository,
|
||||
due_from=date(2026, 2, 1),
|
||||
due_until=date(2026, 12, 31),
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert debits == []
|
||||
|
||||
|
||||
def test_next_weekday_moves_weekend_to_monday():
|
||||
assert _next_weekday(date(2026, 8, 1)) == date(2026, 8, 3)
|
||||
assert _next_weekday(date(2026, 8, 3)) == date(2026, 8, 3)
|
||||
|
||||
|
||||
def test_sepa_text_normalizes_unsupported_characters():
|
||||
assert _safe("Sébastien O’Connor – Müller", 70) == "Sebastien O'Connor - Müller"
|
||||
|
||||
|
||||
def test_csv_is_semicolon_separated_and_uses_decimal_comma(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
text = csv_text(debits, collection_date=date(2026, 8, 3))
|
||||
|
||||
assert "Einzugsdatum;Mitgliedsnummer" in text
|
||||
assert "125,00;EUR;MANDAT-42" in text
|
||||
|
||||
|
||||
def test_pain008_contains_control_sum_mandate_and_creditor(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
organization = {
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
|
||||
content = pain008_bytes(
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
organization=organization,
|
||||
message_id="CCMA-TEST",
|
||||
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
root = ET.fromstring(content)
|
||||
ns = {"p": PAIN_NAMESPACE}
|
||||
|
||||
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "125.00"
|
||||
assert root.findtext(".//p:MndtId", namespaces=ns) == "MANDAT-42"
|
||||
assert (
|
||||
root.findtext(".//p:DrctDbtTx/p:CdtrSchmeId//p:Othr/p:Id", namespaces=ns)
|
||||
== "DE98ZZZ09999999999"
|
||||
)
|
||||
assert root.find("p:CstmrDrctDbtInitn/p:PmtInf/p:CdtrSchmeId", ns) is None
|
||||
assert root.findtext(".//p:ReqdColltnDt", namespaces=ns) == "2026-08-03"
|
||||
|
||||
|
||||
def test_pain008_contains_one_logical_batch_with_multiple_transactions(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
organization = {
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
|
||||
root = ET.fromstring(
|
||||
pain008_bytes(
|
||||
[debit, debit],
|
||||
collection_date=date(2026, 8, 3),
|
||||
organization=organization,
|
||||
message_id="CCMA-BATCH-TEST",
|
||||
)
|
||||
)
|
||||
ns = {"p": PAIN_NAMESPACE}
|
||||
|
||||
assert len(root.findall("p:CstmrDrctDbtInitn", ns)) == 1
|
||||
assert len(root.findall(".//p:PmtInf", ns)) == 1
|
||||
assert len(root.findall(".//p:DrctDbtTxInf", ns)) == 2
|
||||
assert root.findtext(".//p:GrpHdr/p:NbOfTxs", namespaces=ns) == "2"
|
||||
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "250.00"
|
||||
|
||||
|
||||
def test_debit_mail_is_thunderbird_draft(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
debit=debit,
|
||||
collection_date=date(2026, 8, 3),
|
||||
creditor_id="DE98ZZZ09999999999",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
message = BytesParser(policy=default).parsebytes(content)
|
||||
|
||||
assert message["To"] == "ada@example.org"
|
||||
assert str(message["X-Mozilla-Draft-Info"]).strip().startswith("internal/draft")
|
||||
assert "125.00 Euro" in message.get_content()
|
||||
assert "MANDAT-42" in message.get_content()
|
||||
|
||||
|
||||
def test_generated_mail_is_exported_archived_and_logged(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="local",
|
||||
output_directory=tmp_path / "mail-export",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert generated[0].export_path.is_file()
|
||||
assert generated[0].archive_path.is_file()
|
||||
assert generated[0].archive_path.read_bytes() == generated[0].export_path.read_bytes()
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.event_type == "sepa_notification_generated"
|
||||
assert event.references["document"].startswith("documents/SEPA/")
|
||||
|
||||
|
||||
def test_generated_mail_send_mode_reuses_one_smtp_connection_for_batch(tmp_path, monkeypatch):
|
||||
import ccma.services.sepa_mail as sepa_mail_module
|
||||
|
||||
repository, member = _repository(tmp_path)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
connections_opened = []
|
||||
|
||||
@contextmanager
|
||||
def fake_smtp_session(settings):
|
||||
connections_opened.append(settings)
|
||||
yield object()
|
||||
|
||||
sent = []
|
||||
monkeypatch.setattr(sepa_mail_module, "smtp_session", fake_smtp_session)
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||
)
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert generated[0].export_path is None
|
||||
assert generated[0].archive_path.is_file()
|
||||
assert len(connections_opened) == 1, "one connection should be reused for the whole batch"
|
||||
assert len(sent) == len(debits)
|
||||
|
||||
|
||||
def test_generated_mail_send_mode_copies_to_sent_folder_reusing_one_imap_connection(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
import ccma.services.sepa_mail as sepa_mail_module
|
||||
|
||||
repository, ada = _repository(tmp_path)
|
||||
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
|
||||
second.status = "active"
|
||||
second.email = "grace@example.org"
|
||||
second.account_holder = "Grace Hopper"
|
||||
second.iban = "DE89370400440532013000"
|
||||
second.bic = "COBADEFFXXX"
|
||||
second.mandate_reference = "MANDAT-43"
|
||||
second.mandate_signed_at = "2025-01-10"
|
||||
second.mandate_active = True
|
||||
repository.save_member(second)
|
||||
repository.save_contributions(
|
||||
second.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="none",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="mail.example.org",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="INBOX.Gesendet",
|
||||
)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
assert len(debits) == 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: None)
|
||||
imap_connections = []
|
||||
|
||||
@contextmanager
|
||||
def fake_imap_session(settings):
|
||||
imap_connections.append(settings)
|
||||
yield "imap-client"
|
||||
|
||||
monkeypatch.setattr(sepa_mail_module, "imap_session", fake_imap_session)
|
||||
ensured = []
|
||||
appended = []
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append(folder)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sepa_mail_module,
|
||||
"append_message",
|
||||
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
||||
)
|
||||
|
||||
generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert len(imap_connections) == 1, "one IMAP connection should be reused for the whole batch"
|
||||
assert ensured == ["INBOX.Gesendet"]
|
||||
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")] * 2
|
||||
@@ -131,6 +131,20 @@ def test_member_table_sort_uses_display_values() -> None:
|
||||
assert [member.member_id for member in _sort_members(members, "status", False)] == ["2", "1", "3"]
|
||||
|
||||
|
||||
def test_member_finding_severity_uses_highest_level_per_member() -> None:
|
||||
from ccma.domain.models import HousekeeperFinding
|
||||
from ccma.ui.work_tabs import _member_finding_severities
|
||||
|
||||
findings = [
|
||||
HousekeeperFinding("info", "info", "Info", "", member_id="1"),
|
||||
HousekeeperFinding("warning", "warning", "Warning", "", member_id="1"),
|
||||
HousekeeperFinding("error", "error", "Error", "", member_id="2"),
|
||||
HousekeeperFinding("info", "global", "Global", ""),
|
||||
]
|
||||
|
||||
assert _member_finding_severities(findings) == {"1": "warning", "2": "error"}
|
||||
|
||||
|
||||
def test_asset_table_filter_and_sort_use_status_and_holder_label() -> None:
|
||||
from ccma.domain.models import Asset
|
||||
from ccma.ui.work_tabs import _asset_table_value, _filter_assets, _selected_asset_filter
|
||||
|
||||
@@ -5,4 +5,4 @@ from ccma import __version__
|
||||
|
||||
def test_ui_version_matches_version_file() -> None:
|
||||
expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert __version__ == expected == "0.0.1-dev1"
|
||||
assert __version__ == expected
|
||||
|
||||
Reference in New Issue
Block a user