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 RULE_ID = "contribution-claims" ORDER = 40 CENT = Decimal("0.01") MONTH_NAMES = ( "", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", ) 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 ended_at = parse_iso_date(member.membership_ended_at, "Austrittsdatum") 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)) admission_due = accepted_at + timedelta(days=due_days) if not ended_at or admission_due <= ended_at: 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": admission_due.isoformat(), "calculation": {"rule_id": admission_rule.get("rule_id", "")}, }, ) ) year_from = ( started_at.year if getattr(context.settings, "retroactive_claims", False) else context.today.year ) for year in range(year_from, context.today.year + 2): actions.extend(_membership_claims(context, started_at, accepted_at, ended_at, year)) return actions def _membership_claims( context: RuleContext, started_at: date, accepted_at: date, ended_at: date | None, 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")), ] elif member.payment_frequency == "quarterly": periods = [ ( f"quarter-{quarter}", first_month, first_month + 2, _recurring_due_date(year, first_month, rule.get("annual_due"), "01-31"), ) for quarter, first_month in enumerate((1, 4, 7, 10), start=1) ] elif member.payment_frequency == "monthly": periods = [ ( f"month-{month:02d}", month, month, _recurring_due_date(year, month, rule.get("annual_due"), "01-31"), ) for month in range(1, 13) ] else: periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))] actions = [] 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. charged_from = max(first_month, period_start.month) months = max(0, last_month - charged_from + 1) if months == 0: continue # 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: 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) if ended_at and due_date > ended_at: continue claim_key = f"membership-fee:{year}:{suffix}" full_period_months = last_month - first_month + 1 description = _title(year, suffix) if months < full_period_months: unit = "Monat" if months == 1 else "Monate" description = f"{description} ({months} {unit})" actions.append( create_claim( rule_id=RULE_ID, member=member, claim_key=claim_key, payload={ "type": "membership_fee", "title": _title(year, suffix), "description": description, "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", "contribution_override_ids": applied_overrides, }, }, ) ) 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", []): 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 _recurring_due_date(year: int, month: int, value, fallback: str) -> date: try: _configured_month, day = (int(part) for part in str(value or fallback).split("-", 1)) except (TypeError, ValueError): day = int(fallback.split("-", 1)[1]) return date(year, month, min(day, calendar.monthrange(year, month)[1])) def _title(year: int, suffix: str) -> str: if suffix.startswith("month-"): month = int(suffix.removeprefix("month-")) return f"Mitgliedsbeitrag {MONTH_NAMES[month]} {year}" if suffix.startswith("quarter-"): quarter = int(suffix.removeprefix("quarter-")) return f"Mitgliedsbeitrag {quarter}. Quartal {year}" if suffix == "first-half": return f"Mitgliedsbeitrag 1. Halbjahr {year}" if suffix == "second-half": return f"Mitgliedsbeitrag 2. Halbjahr {year}" return f"Mitgliedsbeitrag {year}"