mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
Merge pull request 'Feature/member contribution overrides' (#17) from feature/member-contribution-overrides into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/17 Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
This commit is contained in:
@@ -27,7 +27,9 @@
|
|||||||
"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 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.",
|
"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.",
|
"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."
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ DONATION_STATUS_LABELS = {
|
|||||||
"overallocated": "ÜBERZUGEORDNET",
|
"overallocated": "ÜBERZUGEORDNET",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CONTRIBUTION_OVERRIDE_KIND_LABELS = {
|
||||||
|
"amount": "BETRAG",
|
||||||
|
"percent": "PROZENT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
|
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
|
||||||
text = str(value).strip().replace(",", ".")
|
text = str(value).strip().replace(",", ".")
|
||||||
@@ -148,6 +153,26 @@ def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
|
|||||||
return "open"
|
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:
|
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
|
||||||
if str(claim.get("status", "")) == "cancelled":
|
if str(claim.get("status", "")) == "cancelled":
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
|
|||||||
@@ -78,6 +78,38 @@ def normalize_date_input(value: str, field_name: str) -> str:
|
|||||||
return parsed.isoformat() if parsed else ""
|
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:
|
def format_date_for_display(value: str) -> str:
|
||||||
text = value.strip()
|
text = value.strip()
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ class ContributionData:
|
|||||||
allocations: 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)
|
reminders: list[dict[str, Any]] = field(default_factory=list)
|
||||||
donations: 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
|
schema_version: int = 1
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
@@ -335,6 +336,7 @@ class ContributionData:
|
|||||||
"allocations": self.allocations,
|
"allocations": self.allocations,
|
||||||
"reminders": self.reminders,
|
"reminders": self.reminders,
|
||||||
"donations": self.donations,
|
"donations": self.donations,
|
||||||
|
"contribution_overrides": self.contribution_overrides,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -347,6 +349,7 @@ class ContributionData:
|
|||||||
allocations=list(data.get("allocations") or []),
|
allocations=list(data.get("allocations") or []),
|
||||||
reminders=list(data.get("reminders") or []),
|
reminders=list(data.get("reminders") or []),
|
||||||
donations=list(data.get("donations") or []),
|
donations=list(data.get("donations") or []),
|
||||||
|
contribution_overrides=list(data.get("contribution_overrides") or []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import calendar
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
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.domain.dates import DateValidationError, parse_iso_date
|
||||||
from ccma.rules.api import RuleContext, create_claim
|
from ccma.rules.api import RuleContext, create_claim
|
||||||
from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
|
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"))]
|
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
|
||||||
|
|
||||||
actions = []
|
actions = []
|
||||||
monthly_amount = annual_amount / Decimal(12)
|
overrides = context.contributions.contribution_overrides
|
||||||
for suffix, first_month, last_month, regular_due in periods:
|
for suffix, first_month, last_month, regular_due in periods:
|
||||||
# The entry year is intentionally billed from the entry month onward,
|
# The entry year is intentionally billed from the entry month onward,
|
||||||
# even when retroactive claims create old membership-fee claims.
|
# even when retroactive claims create old membership-fee claims.
|
||||||
@@ -132,7 +133,17 @@ def _membership_claims(
|
|||||||
months = max(0, last_month - charged_from + 1)
|
months = max(0, last_month - charged_from + 1)
|
||||||
if months == 0:
|
if months == 0:
|
||||||
continue
|
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
|
entry_year = started_at.year == year
|
||||||
issue_date = regular_due - timedelta(days=issue_days)
|
issue_date = regular_due - timedelta(days=issue_days)
|
||||||
if not entry_year and context.today < issue_date:
|
if not entry_year and context.today < issue_date:
|
||||||
@@ -168,6 +179,7 @@ def _membership_claims(
|
|||||||
"annual_amount": _money(annual_amount),
|
"annual_amount": _money(annual_amount),
|
||||||
"months": months,
|
"months": months,
|
||||||
"formula": "annual_amount * months / 12",
|
"formula": "annual_amount * months / 12",
|
||||||
|
"contribution_override_ids": applied_overrides,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -175,6 +187,28 @@ def _membership_claims(
|
|||||||
return actions
|
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):
|
def _rule_for(config, target: date):
|
||||||
selected = None
|
selected = None
|
||||||
for rule in config.get("contribution_rules", []):
|
for rule in config.get("contribution_rules", []):
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ from string import Formatter
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from ccma.domain.contributions import (
|
from ccma.domain.contributions import (
|
||||||
|
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
||||||
allocated_total,
|
allocated_total,
|
||||||
claim_balance,
|
claim_balance,
|
||||||
claim_total,
|
claim_total,
|
||||||
|
contribution_override_ranges_overlap,
|
||||||
credit_allocated_total,
|
credit_allocated_total,
|
||||||
decimal_value,
|
decimal_value,
|
||||||
donation_balance,
|
donation_balance,
|
||||||
@@ -24,7 +26,12 @@ from ccma.domain.contributions import (
|
|||||||
money_text,
|
money_text,
|
||||||
payment_allocated_total,
|
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 (
|
from ccma.domain.models import (
|
||||||
ASSET_CUSTODY_TYPE_LABELS,
|
ASSET_CUSTODY_TYPE_LABELS,
|
||||||
ASSET_OWNER_TYPE_LABELS,
|
ASSET_OWNER_TYPE_LABELS,
|
||||||
@@ -811,7 +818,15 @@ class MemberRepository:
|
|||||||
raw = read_json(path)
|
raw = read_json(path)
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
|
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):
|
if field_name in raw and not isinstance(raw[field_name], list):
|
||||||
raise TypeError(f"{field_name} muss eine JSON-Liste sein")
|
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]):
|
if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]):
|
||||||
@@ -1786,6 +1801,171 @@ class MemberRepository:
|
|||||||
)
|
)
|
||||||
return allocation
|
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(
|
def create_reminder_draft(
|
||||||
self,
|
self,
|
||||||
member_id: str,
|
member_id: str,
|
||||||
@@ -2455,6 +2635,15 @@ def _german_date(value: str) -> str:
|
|||||||
return ""
|
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:
|
def _dunning_hold_is_active(claim: dict) -> bool:
|
||||||
hold = claim.get("dunning_hold") or {}
|
hold = claim.get("dunning_hold") or {}
|
||||||
if not hold.get("active"):
|
if not hold.get("active"):
|
||||||
|
|||||||
@@ -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()
|
||||||
+136
-1
@@ -10,6 +10,7 @@ from tkinter import messagebox, ttk
|
|||||||
from ccma.config import AppConfig
|
from ccma.config import AppConfig
|
||||||
from ccma.domain.contributions import (
|
from ccma.domain.contributions import (
|
||||||
CLAIM_STATUS_LABELS,
|
CLAIM_STATUS_LABELS,
|
||||||
|
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
||||||
DONATION_STATUS_LABELS,
|
DONATION_STATUS_LABELS,
|
||||||
claim_status,
|
claim_status,
|
||||||
claim_total,
|
claim_total,
|
||||||
@@ -19,10 +20,11 @@ from ccma.domain.contributions import (
|
|||||||
money_text,
|
money_text,
|
||||||
payment_allocated_total,
|
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 ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event
|
||||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
|
||||||
from ccma.ui.dialogs import IntegrityWarningDialog
|
from ccma.ui.dialogs import IntegrityWarningDialog
|
||||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||||
from ccma.ui.donation_dialog import (
|
from ccma.ui.donation_dialog import (
|
||||||
@@ -174,11 +176,13 @@ class MemberTab(ttk.Frame):
|
|||||||
command=self._save,
|
command=self._save,
|
||||||
).grid(row=0, column=0, sticky="e")
|
).grid(row=0, column=0, sticky="e")
|
||||||
contribution_tab = ttk.Frame(notebook, padding=16)
|
contribution_tab = ttk.Frame(notebook, padding=16)
|
||||||
|
contribution_override_tab = ttk.Frame(notebook, padding=16)
|
||||||
payments_tab = ttk.Frame(notebook, padding=16)
|
payments_tab = ttk.Frame(notebook, padding=16)
|
||||||
donations_tab = ttk.Frame(notebook, padding=16)
|
donations_tab = ttk.Frame(notebook, padding=16)
|
||||||
assets_tab = ttk.Frame(notebook, padding=16)
|
assets_tab = ttk.Frame(notebook, padding=16)
|
||||||
documents_tab = ttk.Frame(notebook, padding=16)
|
documents_tab = ttk.Frame(notebook, padding=16)
|
||||||
notebook.add(contribution_tab, text="Forderungen")
|
notebook.add(contribution_tab, text="Forderungen")
|
||||||
|
notebook.add(contribution_override_tab, text="Beitrag")
|
||||||
notebook.add(payments_tab, text="Zahlungen")
|
notebook.add(payments_tab, text="Zahlungen")
|
||||||
notebook.add(donations_tab, text="Spenden")
|
notebook.add(donations_tab, text="Spenden")
|
||||||
notebook.add(assets_tab, text="Assets")
|
notebook.add(assets_tab, text="Assets")
|
||||||
@@ -302,6 +306,58 @@ class MemberTab(ttk.Frame):
|
|||||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
||||||
self.claims.bind("<Return>", 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.columnconfigure(0, weight=1)
|
||||||
payments_tab.rowconfigure(1, weight=1)
|
payments_tab.rowconfigure(1, weight=1)
|
||||||
self.payment_summary = tk.StringVar()
|
self.payment_summary = tk.StringVar()
|
||||||
@@ -573,6 +629,7 @@ class MemberTab(ttk.Frame):
|
|||||||
self._clear_dirty()
|
self._clear_dirty()
|
||||||
self._refresh_events()
|
self._refresh_events()
|
||||||
self._refresh_contributions()
|
self._refresh_contributions()
|
||||||
|
self._refresh_contribution_overrides()
|
||||||
self._refresh_donations()
|
self._refresh_donations()
|
||||||
self._refresh_assets()
|
self._refresh_assets()
|
||||||
self._refresh_documents()
|
self._refresh_documents()
|
||||||
@@ -686,6 +743,31 @@ class MemberTab(ttk.Frame):
|
|||||||
)
|
)
|
||||||
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
|
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:
|
def _toggle_claim_sort(self, column: str) -> None:
|
||||||
if self.claim_sort_column == column:
|
if self.claim_sort_column == column:
|
||||||
self.claim_sort_descending = not self.claim_sort_descending
|
self.claim_sort_descending = not self.claim_sort_descending
|
||||||
@@ -834,6 +916,59 @@ class MemberTab(ttk.Frame):
|
|||||||
self.refresh()
|
self.refresh()
|
||||||
self.on_changed()
|
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:
|
def _refresh_documents(self) -> None:
|
||||||
self.documents.delete(*self.documents.get_children())
|
self.documents.delete(*self.documents.get_children())
|
||||||
self.document_paths.clear()
|
self.document_paths.clear()
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
@@ -7,9 +7,11 @@ from ccma.domain.dates import (
|
|||||||
age_label,
|
age_label,
|
||||||
calculate_age,
|
calculate_age,
|
||||||
format_date_for_display,
|
format_date_for_display,
|
||||||
|
format_month_for_display,
|
||||||
normalize_date_input,
|
normalize_date_input,
|
||||||
parse_date_input,
|
parse_date_input,
|
||||||
parse_iso_date,
|
parse_iso_date,
|
||||||
|
parse_month_input,
|
||||||
validate_birth_date,
|
validate_birth_date,
|
||||||
validate_member_dates,
|
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:
|
def test_age_calculation_and_label() -> None:
|
||||||
today = date(2026, 6, 21)
|
today = date(2026, 6, 21)
|
||||||
assert calculate_age(date(2000, 6, 21), today) == 26
|
assert calculate_age(date(2000, 6, 21), today) == 26
|
||||||
|
|||||||
Reference in New Issue
Block a user