feat: add scriptable housekeeper rule engine

This commit is contained in:
Marcel Peterkau
2026-06-21 17:43:04 +02:00
parent e63abbae81
commit 4bc1a8a200
18 changed files with 936 additions and 207 deletions
@@ -0,0 +1,152 @@
import calendar
from datetime import date, timedelta
from decimal import ROUND_HALF_UP, Decimal
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
RULE_ID = "contribution-claims"
ORDER = 40
CENT = Decimal("0.01")
def evaluate(context: RuleContext):
member = context.member
if member.honorary or member.status not in CONTRIBUTION_STATUSES:
return []
try:
accepted_at = parse_iso_date(member.accepted_at, "Aufnahmebeschluss")
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") or accepted_at
except DateValidationError:
return []
if not accepted_at or not started_at:
return []
actions = []
admission_rule = _rule_for(context.repository_config, accepted_at)
if admission_rule:
admission_fee = Decimal(str(admission_rule.get("admission_fee", "0")))
if admission_fee > 0:
due_days = int(admission_rule.get("first_payment_due_days_after_acceptance", 28))
actions.append(
create_claim(
rule_id=RULE_ID,
member=member,
claim_key="admission-fee",
payload={
"type": "admission_fee",
"title": "Aufnahmegebühr",
"amount": _money(admission_fee),
"due_date": (accepted_at + timedelta(days=due_days)).isoformat(),
"calculation": {"rule_id": admission_rule.get("rule_id", "")},
},
)
)
for year in (context.today.year, context.today.year + 1):
actions.extend(_membership_claims(context, started_at, accepted_at, year))
return actions
def _membership_claims(context: RuleContext, started_at: date, accepted_at: date, year: int):
member = context.member
period_start = max(started_at, date(year, 1, 1))
if period_start.year > year:
return []
rule = _rule_for(context.repository_config, period_start)
if not rule:
return []
annual_amount = Decimal(str(rule.get("annual_amount", "0")))
issue_days = int(rule.get("issue_days_before_due", 30))
due_days_after_entry = int(rule.get("first_payment_due_days_after_acceptance", 28))
if annual_amount <= 0:
return []
if member.payment_frequency == "semiannual":
configured_due_dates = list(rule.get("semiannual_due") or ["01-31", "07-31"])
while len(configured_due_dates) < 2:
configured_due_dates.append(("01-31", "07-31")[len(configured_due_dates)])
periods = [
("first-half", 1, 6, _due_date(year, configured_due_dates[0], "01-31")),
("second-half", 7, 12, _due_date(year, configured_due_dates[1], "07-31")),
]
else:
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
actions = []
monthly_amount = annual_amount / Decimal(12)
for suffix, first_month, last_month, regular_due in periods:
charged_from = max(first_month, period_start.month)
months = max(0, last_month - charged_from + 1)
if months == 0:
continue
amount = (monthly_amount * months).quantize(CENT, rounding=ROUND_HALF_UP)
entry_year = started_at.year == year
issue_date = regular_due - timedelta(days=issue_days)
if not entry_year and context.today < issue_date:
continue
due_date = regular_due
if entry_year and regular_due < accepted_at + timedelta(days=due_days_after_entry):
due_date = accepted_at + timedelta(days=due_days_after_entry)
claim_key = f"membership-fee:{year}:{suffix}"
actions.append(
create_claim(
rule_id=RULE_ID,
member=member,
claim_key=claim_key,
payload={
"type": "membership_fee",
"title": _title(year, suffix),
"amount": _money(amount),
"due_date": due_date.isoformat(),
"service_period": {
"from": date(year, charged_from, 1).isoformat(),
"until": date(year, last_month, calendar.monthrange(year, last_month)[1]).isoformat(),
},
"calculation": {
"rule_id": rule.get("rule_id", ""),
"annual_amount": _money(annual_amount),
"months": months,
"formula": "annual_amount * months / 12",
},
},
)
)
return actions
def _rule_for(config, target: date):
selected = None
for rule in config.get("contribution_rules", []):
try:
valid_from = date.fromisoformat(str(rule.get("valid_from", "")))
valid_until_raw = rule.get("valid_until")
valid_until = date.fromisoformat(str(valid_until_raw)) if valid_until_raw else None
except ValueError:
continue
if valid_from <= target and (not valid_until or target <= valid_until):
if selected is None or valid_from > selected[0]:
selected = (valid_from, rule)
return selected[1] if selected else None
def _money(value: Decimal) -> str:
return str(value.quantize(CENT, rounding=ROUND_HALF_UP))
def _due_date(year: int, value, fallback: str) -> date:
try:
month, day = (int(part) for part in str(value or fallback).split("-", 1))
return date(year, month, day)
except (TypeError, ValueError):
month, day = (int(part) for part in fallback.split("-", 1))
return date(year, month, day)
def _title(year: int, suffix: str) -> str:
if suffix == "first-half":
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
if suffix == "second-half":
return f"Mitgliedsbeitrag 2. Halbjahr {year}"
return f"Mitgliedsbeitrag {year}"