mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +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
@@ -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", []):
|
||||
|
||||
Reference in New Issue
Block a user