mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-05 19:20:48 +02:00
Compare commits
35
Commits
a61ea3cb57
...
7144d961c7
@@ -17,7 +17,19 @@
|
||||
"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."
|
||||
"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 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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+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,58 @@ 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 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:
|
||||
|
||||
@@ -323,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]:
|
||||
@@ -333,6 +335,8 @@ class ContributionData:
|
||||
"credits": self.credits,
|
||||
"allocations": self.allocations,
|
||||
"reminders": self.reminders,
|
||||
"donations": self.donations,
|
||||
"contribution_overrides": self.contribution_overrides,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -344,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 []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -124,7 +125,7 @@ def _membership_claims(
|
||||
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.
|
||||
@@ -132,7 +133,17 @@ def _membership_claims(
|
||||
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:
|
||||
@@ -168,6 +179,7 @@ def _membership_claims(
|
||||
"annual_amount": _money(annual_amount),
|
||||
"months": months,
|
||||
"formula": "annual_amount * months / 12",
|
||||
"contribution_override_ids": applied_overrides,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -175,6 +187,28 @@ def _membership_claims(
|
||||
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", []):
|
||||
|
||||
@@ -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,104 @@ 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_action(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_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 _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 or context.today < trigger_date:
|
||||
return None
|
||||
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 _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
|
||||
if level == 1:
|
||||
try:
|
||||
|
||||
@@ -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
|
||||
@@ -14,16 +14,24 @@ from string import Formatter
|
||||
from uuid import uuid4
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
||||
allocated_total,
|
||||
claim_balance,
|
||||
claim_total,
|
||||
contribution_override_ranges_overlap,
|
||||
credit_allocated_total,
|
||||
decimal_value,
|
||||
donation_balance,
|
||||
materialize_claim_items,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
)
|
||||
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
|
||||
from ccma.domain.dates import (
|
||||
DateValidationError,
|
||||
normalize_date_input,
|
||||
parse_month_input,
|
||||
validate_member_dates,
|
||||
)
|
||||
from ccma.domain.models import (
|
||||
ASSET_CUSTODY_TYPE_LABELS,
|
||||
ASSET_OWNER_TYPE_LABELS,
|
||||
@@ -810,7 +818,15 @@ class MemberRepository:
|
||||
raw = read_json(path)
|
||||
if not isinstance(raw, dict):
|
||||
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
|
||||
for field_name in ("claims", "payments", "credits", "allocations", "reminders"):
|
||||
for field_name in (
|
||||
"claims",
|
||||
"payments",
|
||||
"credits",
|
||||
"allocations",
|
||||
"reminders",
|
||||
"donations",
|
||||
"contribution_overrides",
|
||||
):
|
||||
if field_name in raw and not isinstance(raw[field_name], list):
|
||||
raise TypeError(f"{field_name} muss eine JSON-Liste sein")
|
||||
if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]):
|
||||
@@ -1099,6 +1115,129 @@ class MemberRepository:
|
||||
)
|
||||
return payment
|
||||
|
||||
def create_payment(
|
||||
self,
|
||||
member_id: str,
|
||||
*,
|
||||
payment_date: str,
|
||||
amount: str,
|
||||
claim_allocations: dict[str, str] | None = None,
|
||||
donation_allocations: dict[str, str] | None = None,
|
||||
gnucash_transaction_id: str = "",
|
||||
reference: str = "",
|
||||
method: str = "bank_transfer",
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
"""Record an incoming payment, optionally allocating parts of it immediately to
|
||||
open claims and/or donations. Called with no allocations, this just logs a bank
|
||||
transfer as soon as it arrives, to be assigned to claims or donations later."""
|
||||
self.get_member(member_id)
|
||||
try:
|
||||
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
|
||||
selected_amount = decimal_value(amount)
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_date:
|
||||
raise RepositoryError("Zahlungsdatum ist erforderlich.")
|
||||
if selected_amount <= 0:
|
||||
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
||||
gnucash_id = gnucash_transaction_id.strip()
|
||||
if gnucash_id:
|
||||
self._assert_gnucash_id_available(gnucash_id)
|
||||
|
||||
data = self.get_contributions(member_id)
|
||||
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
|
||||
donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}
|
||||
|
||||
selected_claim_allocations: dict[str, Decimal] = {}
|
||||
for claim_id, raw_amount in (claim_allocations or {}).items():
|
||||
claim = claims_by_id.get(claim_id)
|
||||
if claim is None:
|
||||
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
|
||||
if str(claim.get("status", "")) == "cancelled":
|
||||
raise RepositoryError("Eine stornierte Forderung kann nicht bezahlt werden.")
|
||||
try:
|
||||
selected = decimal_value(raw_amount, "Zuordnung")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if selected <= 0:
|
||||
continue
|
||||
available = max(claim_balance(data, claim), Decimal("0"))
|
||||
if selected > available:
|
||||
raise RepositoryError(
|
||||
f"{claim.get('title', 'Forderung')} hat nur {money_text(available)} EUR offen."
|
||||
)
|
||||
selected_claim_allocations[claim_id] = selected
|
||||
|
||||
selected_donation_allocations: dict[str, Decimal] = {}
|
||||
for donation_id, raw_amount in (donation_allocations or {}).items():
|
||||
donation = donations_by_id.get(donation_id)
|
||||
if donation is None:
|
||||
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
|
||||
try:
|
||||
selected = decimal_value(raw_amount, "Zuordnung")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if selected <= 0:
|
||||
continue
|
||||
available = max(donation_balance(data, donation), Decimal("0"))
|
||||
if selected > available:
|
||||
raise RepositoryError(f"Die Spende hat nur noch {money_text(available)} EUR offen.")
|
||||
selected_donation_allocations[donation_id] = selected
|
||||
|
||||
allocated_total_amount = sum(selected_claim_allocations.values(), Decimal("0")) + sum(
|
||||
selected_donation_allocations.values(), Decimal("0")
|
||||
)
|
||||
if allocated_total_amount > selected_amount:
|
||||
raise RepositoryError(
|
||||
f"Die Zuordnungen ({money_text(allocated_total_amount)} EUR) übersteigen den "
|
||||
f"Zahlungsbetrag ({money_text(selected_amount)} EUR)."
|
||||
)
|
||||
|
||||
payment = {
|
||||
"payment_id": str(uuid4()),
|
||||
"date": normalized_date,
|
||||
"amount": money_text(selected_amount),
|
||||
"method": method.strip() or "bank_transfer",
|
||||
"gnucash_transaction_id": gnucash_id,
|
||||
"reference": reference.strip(),
|
||||
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
for claim_id, claim_amount in selected_claim_allocations.items():
|
||||
data.allocations.append(
|
||||
{
|
||||
"allocation_id": str(uuid4()),
|
||||
"payment_id": payment["payment_id"],
|
||||
"claim_id": claim_id,
|
||||
"amount": money_text(claim_amount),
|
||||
}
|
||||
)
|
||||
for donation_id, donation_amount in selected_donation_allocations.items():
|
||||
data.allocations.append(
|
||||
{
|
||||
"allocation_id": str(uuid4()),
|
||||
"payment_id": payment["payment_id"],
|
||||
"donation_id": donation_id,
|
||||
"amount": money_text(donation_amount),
|
||||
}
|
||||
)
|
||||
data.payments.append(payment)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_recorded",
|
||||
summary=f"Zahlung erfasst: {payment['amount']} EUR",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"payment_id": str(payment["payment_id"])},
|
||||
data={
|
||||
"allocated_amount": money_text(allocated_total_amount),
|
||||
"claim_ids": list(selected_claim_allocations),
|
||||
"donation_ids": list(selected_donation_allocations),
|
||||
},
|
||||
)
|
||||
return payment
|
||||
|
||||
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
payment = next(
|
||||
@@ -1143,9 +1282,13 @@ class MemberRepository:
|
||||
payment_date: str,
|
||||
amount: str,
|
||||
allocations: dict[str, str],
|
||||
donation_allocations: dict[str, str] | None = None,
|
||||
gnucash_transaction_id: str = "",
|
||||
reference: str = "",
|
||||
) -> dict:
|
||||
"""`allocations` fully replaces the claim allocations of this payment, and
|
||||
`donation_allocations` (if given) fully replaces its donation allocations. Omit
|
||||
`donation_allocations` to leave any existing donation allocations untouched."""
|
||||
data = self.get_contributions(member_id)
|
||||
payment = next(
|
||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||
@@ -1164,10 +1307,15 @@ class MemberRepository:
|
||||
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
||||
|
||||
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
|
||||
donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}
|
||||
old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id]
|
||||
old_by_claim: dict[str, list[dict]] = {}
|
||||
old_by_donation: dict[str, list[dict]] = {}
|
||||
for allocation in old_allocations:
|
||||
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
|
||||
if str(allocation.get("donation_id", "")):
|
||||
old_by_donation.setdefault(str(allocation.get("donation_id", "")), []).append(allocation)
|
||||
else:
|
||||
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
|
||||
|
||||
selected_allocations: dict[str, Decimal] = {}
|
||||
for claim_id, raw_amount in allocations.items():
|
||||
@@ -1197,7 +1345,43 @@ class MemberRepository:
|
||||
)
|
||||
selected_allocations[claim_id] = allocation_amount
|
||||
|
||||
allocated_sum = sum(selected_allocations.values(), Decimal("0"))
|
||||
if donation_allocations is None:
|
||||
selected_donation_allocations = {
|
||||
donation_id: sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in items), Decimal("0")
|
||||
)
|
||||
for donation_id, items in old_by_donation.items()
|
||||
}
|
||||
else:
|
||||
selected_donation_allocations = {}
|
||||
for donation_id, raw_amount in donation_allocations.items():
|
||||
if donation_id not in donations_by_id:
|
||||
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
|
||||
try:
|
||||
donation_amount = decimal_value(raw_amount, "Zuordnung")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if donation_amount < 0:
|
||||
raise RepositoryError("Zuordnungen dürfen nicht negativ sein.")
|
||||
if donation_amount == 0:
|
||||
continue
|
||||
donation = donations_by_id[donation_id]
|
||||
currently_allocated = sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in old_by_donation.get(donation_id, [])),
|
||||
Decimal("0"),
|
||||
)
|
||||
available_balance = max(
|
||||
donation_balance(data, donation) + currently_allocated, Decimal("0")
|
||||
)
|
||||
if donation_amount > available_balance:
|
||||
raise RepositoryError(
|
||||
f"Die Spende hat nur noch {money_text(available_balance)} EUR offen."
|
||||
)
|
||||
selected_donation_allocations[donation_id] = donation_amount
|
||||
|
||||
allocated_sum = sum(selected_allocations.values(), Decimal("0")) + sum(
|
||||
selected_donation_allocations.values(), Decimal("0")
|
||||
)
|
||||
if allocated_sum > selected_amount:
|
||||
raise RepositoryError(
|
||||
f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den "
|
||||
@@ -1231,6 +1415,16 @@ class MemberRepository:
|
||||
"amount": money_text(allocation_amount),
|
||||
}
|
||||
)
|
||||
for donation_id, donation_amount in selected_donation_allocations.items():
|
||||
prior = old_by_donation.get(donation_id, [])
|
||||
new_allocations.append(
|
||||
{
|
||||
"allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())),
|
||||
"payment_id": payment_id,
|
||||
"donation_id": donation_id,
|
||||
"amount": money_text(donation_amount),
|
||||
}
|
||||
)
|
||||
data.allocations = retained_allocations + new_allocations
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
@@ -1247,6 +1441,44 @@ class MemberRepository:
|
||||
)
|
||||
return payment
|
||||
|
||||
def update_payment_reference(
|
||||
self,
|
||||
member_id: str,
|
||||
payment_id: str,
|
||||
*,
|
||||
reference: str,
|
||||
gnucash_transaction_id: str = "",
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
"""Relabel an existing payment without touching its date, amount, or
|
||||
allocations -- used e.g. when a GnuCash import recognizes a booking as
|
||||
matching an already-recorded payment and the board wants to adopt the
|
||||
(better) description from the statement instead of re-importing it."""
|
||||
data = self.get_contributions(member_id)
|
||||
payment = next(
|
||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||
None,
|
||||
)
|
||||
if payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
gnucash_id = gnucash_transaction_id.strip()
|
||||
if gnucash_id and gnucash_id != str(payment.get("gnucash_transaction_id", "")):
|
||||
self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id)
|
||||
payment["reference"] = reference.strip()
|
||||
if gnucash_id:
|
||||
payment["gnucash_transaction_id"] = gnucash_id
|
||||
payment["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_changed",
|
||||
summary=f"Zahlung geändert: Referenz aktualisiert ({payment['reference']})",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"payment_id": payment_id},
|
||||
)
|
||||
return payment
|
||||
|
||||
def delete_payment(self, member_id: str, payment_id: str) -> None:
|
||||
data = self.get_contributions(member_id)
|
||||
payment = next(
|
||||
@@ -1355,6 +1587,385 @@ class MemberRepository:
|
||||
)
|
||||
return allocation
|
||||
|
||||
def record_donation(
|
||||
self,
|
||||
member_id: str,
|
||||
*,
|
||||
donation_date: str,
|
||||
amount: str,
|
||||
reference: str = "",
|
||||
purpose: str = "",
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
self.get_member(member_id)
|
||||
try:
|
||||
normalized_date = normalize_date_input(donation_date, "Spendendatum")
|
||||
selected_amount = decimal_value(amount, "Spendenbetrag")
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_date:
|
||||
raise RepositoryError("Ein Spendendatum ist erforderlich.")
|
||||
if selected_amount <= 0:
|
||||
raise RepositoryError("Der Spendenbetrag muss größer als null sein.")
|
||||
donation = {
|
||||
"donation_id": str(uuid4()),
|
||||
"date": normalized_date,
|
||||
"amount": money_text(selected_amount),
|
||||
"reference": reference.strip(),
|
||||
"purpose": purpose.strip(),
|
||||
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
data = self.get_contributions(member_id)
|
||||
data.donations.append(donation)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="donation_recorded",
|
||||
summary=f"Spende erfasst: {donation['amount']} EUR",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"donation_id": donation["donation_id"]},
|
||||
)
|
||||
return donation
|
||||
|
||||
def get_donation(self, member_id: str, donation_id: str) -> tuple[ContributionData, dict]:
|
||||
data = self.get_contributions(member_id)
|
||||
donation = next(
|
||||
(item for item in data.donations if str(item.get("donation_id", "")) == donation_id),
|
||||
None,
|
||||
)
|
||||
if donation is None:
|
||||
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
|
||||
return data, donation
|
||||
|
||||
def update_donation(
|
||||
self,
|
||||
member_id: str,
|
||||
donation_id: str,
|
||||
*,
|
||||
donation_date: str,
|
||||
amount: str,
|
||||
reference: str = "",
|
||||
purpose: str = "",
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
try:
|
||||
normalized_date = normalize_date_input(donation_date, "Spendendatum")
|
||||
selected_amount = decimal_value(amount, "Spendenbetrag")
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_date:
|
||||
raise RepositoryError("Ein Spendendatum ist erforderlich.")
|
||||
if selected_amount <= 0:
|
||||
raise RepositoryError("Der Spendenbetrag muss größer als null sein.")
|
||||
data, donation = self.get_donation(member_id, donation_id)
|
||||
allocated = sum(
|
||||
(
|
||||
decimal_value(item.get("amount", "0"))
|
||||
for item in data.allocations
|
||||
if str(item.get("donation_id", "")) == donation_id
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
if selected_amount < allocated:
|
||||
raise RepositoryError(
|
||||
f"Der Betrag darf nicht unter den bereits zugeordneten "
|
||||
f"{money_text(allocated)} EUR liegen."
|
||||
)
|
||||
donation["date"] = normalized_date
|
||||
donation["amount"] = money_text(selected_amount)
|
||||
donation["reference"] = reference.strip()
|
||||
donation["purpose"] = purpose.strip()
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="donation_changed",
|
||||
summary=f"Spende geändert: {donation['amount']} EUR",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"donation_id": donation_id},
|
||||
)
|
||||
return donation
|
||||
|
||||
def delete_donation(self, member_id: str, donation_id: str, *, actor_name: str = "Vorstand") -> None:
|
||||
"""Permanently remove a donation. Payments allocated to it are released, not deleted."""
|
||||
data, donation = self.get_donation(member_id, donation_id)
|
||||
data.donations = [
|
||||
item for item in data.donations if str(item.get("donation_id", "")) != donation_id
|
||||
]
|
||||
data.allocations = [
|
||||
item for item in data.allocations if str(item.get("donation_id", "")) != donation_id
|
||||
]
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="donation_deleted",
|
||||
summary=f"Spende gelöscht: {donation.get('amount', '')} EUR",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"donation_id": donation_id},
|
||||
)
|
||||
|
||||
def record_donation_payment(
|
||||
self,
|
||||
member_id: str,
|
||||
donation_id: str,
|
||||
*,
|
||||
payment_date: str,
|
||||
amount: str,
|
||||
allocation_amount: str,
|
||||
gnucash_transaction_id: str = "",
|
||||
reference: str = "",
|
||||
) -> dict:
|
||||
try:
|
||||
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
|
||||
selected_amount = decimal_value(amount)
|
||||
selected_allocation = decimal_value(allocation_amount, "Zuordnung")
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_date:
|
||||
raise RepositoryError("Zahlungsdatum ist erforderlich.")
|
||||
if selected_amount <= 0:
|
||||
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
||||
if selected_allocation <= 0 or selected_allocation > selected_amount:
|
||||
raise RepositoryError(
|
||||
"Die Zuordnung muss größer als null und höchstens so hoch wie die Zahlung sein."
|
||||
)
|
||||
gnucash_id = gnucash_transaction_id.strip()
|
||||
if gnucash_id:
|
||||
self._assert_gnucash_id_available(gnucash_id)
|
||||
data, donation = self.get_donation(member_id, donation_id)
|
||||
available_balance = max(donation_balance(data, donation), Decimal("0"))
|
||||
if selected_allocation > available_balance:
|
||||
raise RepositoryError(f"Die Spende hat nur noch {money_text(available_balance)} EUR offen.")
|
||||
payment = {
|
||||
"payment_id": str(uuid4()),
|
||||
"date": normalized_date,
|
||||
"amount": money_text(selected_amount),
|
||||
"method": "bank_transfer",
|
||||
"gnucash_transaction_id": gnucash_id,
|
||||
"reference": reference.strip(),
|
||||
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
allocation = {
|
||||
"allocation_id": str(uuid4()),
|
||||
"payment_id": payment["payment_id"],
|
||||
"donation_id": donation_id,
|
||||
"amount": money_text(selected_allocation),
|
||||
}
|
||||
data.payments.append(payment)
|
||||
data.allocations.append(allocation)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_recorded",
|
||||
summary=f"Zahlung für Spende eingegangen: {payment['amount']} EUR",
|
||||
references={"donation_id": donation_id, "payment_id": str(payment["payment_id"])},
|
||||
data={"allocation_amount": allocation["amount"]},
|
||||
)
|
||||
return payment
|
||||
|
||||
def allocate_payment_to_donation(
|
||||
self, member_id: str, donation_id: str, *, payment_id: str, amount: str
|
||||
) -> dict:
|
||||
data, donation = self.get_donation(member_id, donation_id)
|
||||
payment = next(
|
||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||
None,
|
||||
)
|
||||
if payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
try:
|
||||
selected_amount = decimal_value(amount, "Zuordnung")
|
||||
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if selected_amount <= 0 or selected_amount > available:
|
||||
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.")
|
||||
available_balance = max(donation_balance(data, donation), Decimal("0"))
|
||||
if selected_amount > available_balance:
|
||||
raise RepositoryError(f"Die Spende hat nur noch {money_text(available_balance)} EUR offen.")
|
||||
allocation = {
|
||||
"allocation_id": str(uuid4()),
|
||||
"payment_id": payment_id,
|
||||
"donation_id": donation_id,
|
||||
"amount": money_text(selected_amount),
|
||||
}
|
||||
data.allocations.append(allocation)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_allocated",
|
||||
summary=f"Zahlung Spende zugeordnet: {allocation['amount']} EUR",
|
||||
references={"donation_id": donation_id, "payment_id": payment_id},
|
||||
)
|
||||
return allocation
|
||||
|
||||
def get_contribution_override(self, member_id: str, override_id: str) -> tuple[ContributionData, dict]:
|
||||
data = self.get_contributions(member_id)
|
||||
override = next(
|
||||
(
|
||||
item
|
||||
for item in data.contribution_overrides
|
||||
if str(item.get("override_id", "")) == override_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if override is None:
|
||||
raise RepositoryError(f"Abweichender Beitrag nicht gefunden: {override_id}")
|
||||
return data, override
|
||||
|
||||
def record_contribution_override(
|
||||
self,
|
||||
member_id: str,
|
||||
*,
|
||||
valid_from: str,
|
||||
valid_until: str = "",
|
||||
kind: str,
|
||||
value: str,
|
||||
reason: str,
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
self.get_member(member_id)
|
||||
normalized = self._normalize_contribution_override_input(
|
||||
valid_from=valid_from, valid_until=valid_until, kind=kind, value=value, reason=reason
|
||||
)
|
||||
normalized_from, normalized_until, normalized_kind, normalized_value, normalized_reason = normalized
|
||||
data = self.get_contributions(member_id)
|
||||
self._assert_contribution_override_range_free(data, normalized_from, normalized_until)
|
||||
override = {
|
||||
"override_id": str(uuid4()),
|
||||
"valid_from": normalized_from,
|
||||
"valid_until": normalized_until,
|
||||
"kind": normalized_kind,
|
||||
"value": normalized_value,
|
||||
"reason": normalized_reason,
|
||||
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"created_by": actor_name,
|
||||
}
|
||||
data.contribution_overrides.append(override)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="contribution_override_recorded",
|
||||
summary=f"Abweichender Mitgliedsbeitrag erfasst: {_contribution_override_summary(override)}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"override_id": override["override_id"]},
|
||||
)
|
||||
return override
|
||||
|
||||
def update_contribution_override(
|
||||
self,
|
||||
member_id: str,
|
||||
override_id: str,
|
||||
*,
|
||||
valid_from: str,
|
||||
valid_until: str = "",
|
||||
kind: str,
|
||||
value: str,
|
||||
reason: str,
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
normalized = self._normalize_contribution_override_input(
|
||||
valid_from=valid_from, valid_until=valid_until, kind=kind, value=value, reason=reason
|
||||
)
|
||||
normalized_from, normalized_until, normalized_kind, normalized_value, normalized_reason = normalized
|
||||
data, override = self.get_contribution_override(member_id, override_id)
|
||||
self._assert_contribution_override_range_free(
|
||||
data, normalized_from, normalized_until, exclude_override_id=override_id
|
||||
)
|
||||
override.update(
|
||||
{
|
||||
"valid_from": normalized_from,
|
||||
"valid_until": normalized_until,
|
||||
"kind": normalized_kind,
|
||||
"value": normalized_value,
|
||||
"reason": normalized_reason,
|
||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="contribution_override_changed",
|
||||
summary=f"Abweichender Mitgliedsbeitrag geändert: {_contribution_override_summary(override)}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"override_id": override_id},
|
||||
)
|
||||
return override
|
||||
|
||||
def delete_contribution_override(
|
||||
self, member_id: str, override_id: str, *, actor_name: str = "Vorstand"
|
||||
) -> None:
|
||||
data, override = self.get_contribution_override(member_id, override_id)
|
||||
data.contribution_overrides = [
|
||||
item
|
||||
for item in data.contribution_overrides
|
||||
if str(item.get("override_id", "")) != override_id
|
||||
]
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="contribution_override_deleted",
|
||||
summary=f"Abweichender Mitgliedsbeitrag gelöscht: {_contribution_override_summary(override)}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"override_id": override_id},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_contribution_override_input(
|
||||
*, valid_from: str, valid_until: str, kind: str, value: str, reason: str
|
||||
) -> tuple[str, str, str, str, str]:
|
||||
try:
|
||||
normalized_from = parse_month_input(valid_from, "Ab", allow_empty=False)
|
||||
normalized_until = parse_month_input(valid_until, "Bis")
|
||||
except DateValidationError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if normalized_until and normalized_until < normalized_from:
|
||||
raise RepositoryError("Der Zeitraum 'Bis' darf nicht vor dem Zeitraum 'Ab' liegen.")
|
||||
normalized_kind = kind.strip().lower()
|
||||
if normalized_kind not in CONTRIBUTION_OVERRIDE_KIND_LABELS:
|
||||
raise RepositoryError("Ungültige Art des abweichenden Beitrags.")
|
||||
try:
|
||||
parsed_value = decimal_value(value, "Wert")
|
||||
except ValueError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if normalized_kind == "percent":
|
||||
if parsed_value <= 0 or parsed_value > 100:
|
||||
raise RepositoryError("Die Ermäßigung muss zwischen 0 und 100 Prozent liegen.")
|
||||
elif parsed_value < 0:
|
||||
raise RepositoryError("Der Jahresbetrag darf nicht negativ sein.")
|
||||
normalized_reason = reason.strip()
|
||||
if not normalized_reason:
|
||||
raise RepositoryError("Eine Begründung ist erforderlich.")
|
||||
return normalized_from, normalized_until, normalized_kind, money_text(parsed_value), normalized_reason
|
||||
|
||||
@staticmethod
|
||||
def _assert_contribution_override_range_free(
|
||||
data: ContributionData,
|
||||
valid_from: str,
|
||||
valid_until: str,
|
||||
*,
|
||||
exclude_override_id: str | None = None,
|
||||
) -> None:
|
||||
for existing in data.contribution_overrides:
|
||||
if exclude_override_id and str(existing.get("override_id", "")) == exclude_override_id:
|
||||
continue
|
||||
if contribution_override_ranges_overlap(
|
||||
valid_from,
|
||||
valid_until,
|
||||
str(existing.get("valid_from", "")),
|
||||
str(existing.get("valid_until", "") or ""),
|
||||
):
|
||||
existing_until = str(existing.get("valid_until", "") or "") or "unbefristet"
|
||||
raise RepositoryError(
|
||||
"Der Zeitraum überschneidet sich mit einem bereits vorhandenen abweichenden "
|
||||
f"Beitrag ({existing.get('valid_from', '')} bis {existing_until})."
|
||||
)
|
||||
|
||||
def create_reminder_draft(
|
||||
self,
|
||||
member_id: str,
|
||||
@@ -1578,6 +2189,37 @@ class MemberRepository:
|
||||
references={"claim_id": claim_id},
|
||||
)
|
||||
|
||||
def delete_claim(self, member_id: str, claim_id: str, *, actor_name: str = "Vorstand") -> None:
|
||||
"""Permanently remove a claim. Any payments/credits allocated to it are released
|
||||
(kept intact, just unlinked) rather than deleted, so they can be reallocated."""
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
released_allocations = [
|
||||
allocation for allocation in data.allocations if str(allocation.get("claim_id", "")) == claim_id
|
||||
]
|
||||
released_total = sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in released_allocations), Decimal("0")
|
||||
)
|
||||
data.claims = [item for item in data.claims if str(item.get("claim_id", "")) != claim_id]
|
||||
data.allocations = [
|
||||
item for item in data.allocations if str(item.get("claim_id", "")) != claim_id
|
||||
]
|
||||
data.reminders = [
|
||||
item for item in data.reminders if str(item.get("claim_id", "")) != claim_id
|
||||
]
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="claim_deleted",
|
||||
summary=f"Forderung gelöscht: {claim.get('title', claim_id)}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"claim_id": claim_id},
|
||||
data={
|
||||
"amount": money_text(claim_total(claim)),
|
||||
"released_allocations": money_text(released_total),
|
||||
},
|
||||
)
|
||||
|
||||
def _assert_gnucash_id_available(
|
||||
self, transaction_id: str, *, exclude_payment_id: str | None = None
|
||||
) -> None:
|
||||
@@ -1993,6 +2635,15 @@ def _german_date(value: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _contribution_override_summary(override: dict) -> str:
|
||||
value = str(override.get("value", ""))
|
||||
value_text = f"{value}%" if str(override.get("kind", "")) == "percent" else f"{value} EUR"
|
||||
valid_from = str(override.get("valid_from", ""))
|
||||
valid_until = str(override.get("valid_until", "") or "")
|
||||
period = f"{valid_from} bis {valid_until}" if valid_until else f"ab {valid_from}"
|
||||
return f"{value_text} ({period})"
|
||||
|
||||
|
||||
def _dunning_hold_is_active(claim: dict) -> bool:
|
||||
hold = claim.get("dunning_hold") or {}
|
||||
if not hold.get("active"):
|
||||
|
||||
@@ -92,7 +92,9 @@ class ClaimTab(ttk.Frame):
|
||||
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=2, 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)
|
||||
@@ -503,6 +505,24 @@ 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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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,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()
|
||||
@@ -295,6 +295,7 @@ 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,
|
||||
|
||||
+347
-15
@@ -7,30 +7,50 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
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
|
||||
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
|
||||
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 PaymentEditDialog
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +73,7 @@ class MemberTab(ttk.Frame):
|
||||
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],
|
||||
@@ -63,6 +84,7 @@ class MemberTab(ttk.Frame):
|
||||
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
|
||||
@@ -114,7 +136,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
|
||||
@@ -126,7 +148,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
|
||||
@@ -153,11 +176,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")
|
||||
|
||||
@@ -268,17 +295,69 @@ 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()
|
||||
@@ -292,25 +371,76 @@ class MemberTab(ttk.Frame):
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("date", "Datum", 100),
|
||||
("amount", "Betrag", 90),
|
||||
("allocated", "Zugeordnet", 90),
|
||||
("available", "Frei", 90),
|
||||
("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")
|
||||
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)
|
||||
@@ -499,6 +629,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()
|
||||
|
||||
@@ -535,10 +667,10 @@ 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(
|
||||
@@ -573,6 +705,69 @@ class MemberTab(ttk.Frame):
|
||||
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:
|
||||
self.claim_sort_descending = not self.claim_sort_descending
|
||||
@@ -633,10 +828,147 @@ class MemberTab(ttk.Frame):
|
||||
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()
|
||||
|
||||
+401
-134
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -9,10 +10,371 @@ 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):
|
||||
@@ -40,17 +402,8 @@ class PaymentEditDialog(tk.Toplevel):
|
||||
)
|
||||
if self.payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
self.allocations = self._current_allocations()
|
||||
self.claims_by_id = {
|
||||
str(claim.get("claim_id", "")): claim
|
||||
for claim in self.data.claims
|
||||
if str(claim.get("claim_id", ""))
|
||||
and (
|
||||
claim_status(self.data, claim) != "cancelled"
|
||||
or str(claim.get("claim_id", "")) in self.allocations
|
||||
)
|
||||
}
|
||||
self.capacities = self._claim_capacities()
|
||||
self.claim_allocations, self.donation_allocations = self._current_allocations()
|
||||
self.allocation_table: _AllocationTable | None = None
|
||||
|
||||
self.title("Zahlung bearbeiten")
|
||||
self.transient(master.winfo_toplevel())
|
||||
@@ -59,26 +412,26 @@ class PaymentEditDialog(tk.Toplevel):
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
self.bind("<Escape>", lambda _event: self.destroy())
|
||||
self._build_ui()
|
||||
self._refresh_claims()
|
||||
self.after_idle(self._activate)
|
||||
|
||||
def _current_allocations(self) -> dict[str, str]:
|
||||
totals: dict[str, Decimal] = {}
|
||||
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", ""))
|
||||
totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value(
|
||||
allocation.get("amount", "0")
|
||||
)
|
||||
return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id}
|
||||
|
||||
def _claim_capacities(self) -> dict[str, Decimal]:
|
||||
capacities = {}
|
||||
for claim_id, claim in self.claims_by_id.items():
|
||||
current = decimal_value(self.allocations.get(claim_id, "0"))
|
||||
capacities[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0"))
|
||||
return capacities
|
||||
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)
|
||||
@@ -106,51 +459,26 @@ class PaymentEditDialog(tk.Toplevel):
|
||||
row=row, column=1, sticky="ew", pady=4
|
||||
)
|
||||
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
|
||||
|
||||
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12)
|
||||
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||
allocation_frame.columnconfigure(0, weight=1)
|
||||
allocation_frame.rowconfigure(0, weight=1)
|
||||
self.claims = ttk.Treeview(
|
||||
allocation_frame,
|
||||
columns=("title", "due", "available", "allocated"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
)
|
||||
for key, title, width in (
|
||||
("title", "Forderung", 300),
|
||||
("due", "Fällig", 110),
|
||||
("available", "Maximal zuordenbar", 150),
|
||||
("allocated", "Aktuell zugeordnet", 150),
|
||||
):
|
||||
self.claims.heading(key, text=title)
|
||||
self.claims.column(key, width=width, anchor="w")
|
||||
self.claims.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.claims.configure(yscrollcommand=scrollbar.set)
|
||||
self.claims.bind("<<TreeviewSelect>>", self._select_claim)
|
||||
|
||||
allocation_actions = ttk.Frame(allocation_frame)
|
||||
allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
||||
ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left")
|
||||
self.allocation_var = tk.StringVar()
|
||||
ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack(
|
||||
side="left", padx=(8, 8)
|
||||
)
|
||||
ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack(
|
||||
side="left"
|
||||
)
|
||||
ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack(
|
||||
side="left", padx=(8, 0)
|
||||
)
|
||||
self.total_var = tk.StringVar()
|
||||
ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack(
|
||||
side="right"
|
||||
|
||||
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=2, column=0, sticky="e")
|
||||
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,
|
||||
@@ -158,6 +486,7 @@ class PaymentEditDialog(tk.Toplevel):
|
||||
style="Accent.TButton",
|
||||
command=self._save,
|
||||
).pack(side="left")
|
||||
self._refresh_totals()
|
||||
|
||||
def _activate(self) -> None:
|
||||
try:
|
||||
@@ -168,76 +497,13 @@ class PaymentEditDialog(tk.Toplevel):
|
||||
except tk.TclError:
|
||||
return
|
||||
|
||||
def _refresh_claims(self) -> None:
|
||||
selected = self.claims.selection()
|
||||
self.claims.delete(*self.claims.get_children())
|
||||
ordered = sorted(
|
||||
self.claims_by_id.items(),
|
||||
key=lambda item: (
|
||||
str(item[1].get("due_date", "")),
|
||||
str(item[1].get("title", "")).casefold(),
|
||||
),
|
||||
)
|
||||
for claim_id, claim in ordered:
|
||||
self.claims.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=claim_id,
|
||||
values=(
|
||||
claim.get("title", "Forderung"),
|
||||
format_date_for_display(str(claim.get("due_date", ""))),
|
||||
f"{money_text(self.capacities[claim_id])} EUR",
|
||||
f"{self.allocations.get(claim_id, '0.00')} EUR",
|
||||
),
|
||||
)
|
||||
if selected and self.claims.exists(selected[0]):
|
||||
self.claims.selection_set(selected[0])
|
||||
self._refresh_totals()
|
||||
|
||||
def _select_claim(self, _event=None) -> None:
|
||||
selected = self.claims.selection()
|
||||
if selected:
|
||||
self.allocation_var.set(self.allocations.get(selected[0], "0.00"))
|
||||
|
||||
def _set_allocation(self) -> None:
|
||||
selected = self.claims.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
|
||||
return
|
||||
try:
|
||||
amount = decimal_value(self.allocation_var.get(), "Zuordnung")
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self)
|
||||
return
|
||||
capacity = self.capacities[selected[0]]
|
||||
if amount < 0 or amount > capacity:
|
||||
messagebox.showerror(
|
||||
"Ungültige Zuordnung",
|
||||
f"Für diese Forderung können höchstens {money_text(capacity)} EUR zugeordnet werden.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
if amount:
|
||||
self.allocations[selected[0]] = money_text(amount)
|
||||
else:
|
||||
self.allocations.pop(selected[0], None)
|
||||
self._refresh_claims()
|
||||
|
||||
def _remove_allocation(self) -> None:
|
||||
selected = self.claims.selection()
|
||||
if not selected:
|
||||
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
|
||||
return
|
||||
self.allocations.pop(selected[0], None)
|
||||
self.allocation_var.set("0.00")
|
||||
self._refresh_claims()
|
||||
def _current_payment_amount(self) -> Decimal:
|
||||
return decimal_value(self.variables["amount"].get())
|
||||
|
||||
def _refresh_totals(self) -> None:
|
||||
allocated = sum(
|
||||
(decimal_value(value) for value in self.allocations.values()), Decimal("0")
|
||||
)
|
||||
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
|
||||
try:
|
||||
payment_amount = decimal_value(self.variables["amount"].get())
|
||||
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"
|
||||
@@ -252,7 +518,8 @@ class PaymentEditDialog(tk.Toplevel):
|
||||
self.payment_id,
|
||||
payment_date=self.variables["date"].get(),
|
||||
amount=self.variables["amount"].get(),
|
||||
allocations=self.allocations,
|
||||
allocations=self.claim_allocations,
|
||||
donation_allocations=self.donation_allocations,
|
||||
gnucash_transaction_id=self.variables["gnucash"].get(),
|
||||
reference=self.variables["reference"].get(),
|
||||
)
|
||||
|
||||
@@ -597,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())
|
||||
@@ -649,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"] == []
|
||||
@@ -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
|
||||
@@ -248,6 +251,71 @@ def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
|
||||
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(
|
||||
@@ -311,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,
|
||||
)
|
||||
@@ -68,6 +70,25 @@ def test_member_dates_must_be_chronological() -> None:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
assert calculate_age(date(2000, 6, 21), today) == 26
|
||||
|
||||
@@ -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:
|
||||
|
||||
+40
-1
@@ -68,7 +68,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 +85,41 @@ 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_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
||||
repository, member = _overdue_claim_repository(tmp_path)
|
||||
draft = repository.create_reminder_draft(
|
||||
|
||||
Reference in New Issue
Block a user