mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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>
260 lines
10 KiB
Python
260 lines
10 KiB
Python
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}"
|