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:
Marcel Peterkau
2026-08-15 00:57:51 +02:00
co-authored by Claude Sonnet 5
parent 44228dd7dd
commit 2484a1631d
9 changed files with 883 additions and 5 deletions
+36 -2
View File
@@ -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", []):