mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Add individually agreed membership fees (contribution overrides)
Members are sometimes given a fee that deviates from the regular schedule -- e.g. a reduced rate for students -- for a specific period. Add a per-member "Beitrag" tab where such deviations can be recorded with a month-granular date range (Ab/Bis), a mandatory reason, and either a fixed annual amount or a percentage discount off whichever base rate is in effect at the time. Data model: ContributionData gets a contribution_overrides list, each entry validated (month format, Bis >= Ab, non-overlapping ranges per member, reason required) and CRUD'd through the repository (record/update/delete/get_contribution_override), consistent with how donations already work. Integration: contribution_claims.py now computes each membership-fee claim's amount month by month instead of a single rate for the whole billing period, picking up whichever override (if any) covers each individual month. That handles an override starting or ending mid period correctly (e.g. a semiannual payer whose discount begins in March) without changing behavior for members without overrides. Already-created claims are never recalculated retroactively, matching how changes to the global contribution rates already behave. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
44228dd7dd
commit
2484a1631d
@@ -25,6 +25,11 @@ DONATION_STATUS_LABELS = {
|
||||
"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(",", ".")
|
||||
@@ -148,6 +153,26 @@ def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
|
||||
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:
|
||||
|
||||
@@ -324,6 +324,7 @@ class ContributionData:
|
||||
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]:
|
||||
@@ -335,6 +336,7 @@ class ContributionData:
|
||||
"allocations": self.allocations,
|
||||
"reminders": self.reminders,
|
||||
"donations": self.donations,
|
||||
"contribution_overrides": self.contribution_overrides,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -347,6 +349,7 @@ class ContributionData:
|
||||
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 []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user