Files
CCMA/tests/test_contribution_overrides.py
T
Marcel PeterkauandClaude Sonnet 5 2484a1631d 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>
2026-08-15 00:57:51 +02:00

301 lines
10 KiB
Python

from datetime import date
import pytest
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
def _member(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Override", last_name="Test", birth_date="2010-01-01")
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
member.payment_frequency = "semiannual"
repository.save_member(member)
return repository, member
def test_contribution_override_can_be_recorded_with_amount(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="03.2026",
valid_until="",
kind="amount",
value="90.00",
reason="Schüler laut Nachweis",
)
data = repository.get_contributions(member.member_id)
assert data.contribution_overrides == [override]
assert override["valid_from"] == "2026-03"
assert override["valid_until"] == ""
assert override["kind"] == "amount"
assert override["value"] == "90.00"
assert override["reason"] == "Schüler laut Nachweis"
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_recorded"
def test_contribution_override_accepts_percent_kind(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="2026-03",
valid_until="2026-08",
kind="percent",
value="50",
reason="Schüler laut Nachweis",
)
assert override["kind"] == "percent"
assert override["value"] == "50.00"
assert override["valid_until"] == "2026-08"
def test_contribution_override_requires_reason(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="Begründung"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="amount", value="90.00", reason=" "
)
def test_contribution_override_rejects_invalid_percent(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="percent", value="150", reason="Test"
)
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="percent", value="0", reason="Test"
)
def test_contribution_override_rejects_negative_amount(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="nicht negativ"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="amount", value="-10", reason="Test"
)
def test_contribution_override_rejects_until_before_from(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="darf nicht vor"):
repository.record_contribution_override(
member.member_id,
valid_from="08.2026",
valid_until="03.2026",
kind="amount",
value="90.00",
reason="Test",
)
def test_overlapping_contribution_overrides_are_rejected(tmp_path) -> None:
repository, member = _member(tmp_path)
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="90.00",
reason="Erstes",
)
with pytest.raises(RepositoryError, match="überschneidet sich"):
repository.record_contribution_override(
member.member_id,
valid_from="2026-04",
valid_until="2026-12",
kind="amount",
value="100.00",
reason="Zweites",
)
# Adjacent, non-overlapping range is fine.
second = repository.record_contribution_override(
member.member_id,
valid_from="2026-07",
valid_until="",
kind="amount",
value="100.00",
reason="Zweites, ab Juli",
)
assert second["valid_from"] == "2026-07"
def test_open_ended_override_overlaps_with_any_later_range(tmp_path) -> None:
repository, member = _member(tmp_path)
repository.record_contribution_override(
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Unbefristet"
)
with pytest.raises(RepositoryError, match="überschneidet sich"):
repository.record_contribution_override(
member.member_id,
valid_from="2030-01",
valid_until="2030-06",
kind="amount",
value="100.00",
reason="Später",
)
def test_contribution_override_can_be_updated_and_deleted(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Erstversion"
)
updated = repository.update_contribution_override(
member.member_id,
override["override_id"],
valid_from="2026-02",
valid_until="2026-12",
kind="percent",
value="40",
reason="Korrigiert",
)
assert updated["valid_from"] == "2026-02"
assert updated["kind"] == "percent"
assert updated["reason"] == "Korrigiert"
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_changed"
repository.delete_contribution_override(member.member_id, override["override_id"])
data = repository.get_contributions(member.member_id)
assert data.contribution_overrides == []
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_deleted"
def test_updating_override_can_keep_its_own_range(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="90.00",
reason="Erstversion",
)
# Saving again with the same range must not trip the overlap check against itself.
updated = repository.update_contribution_override(
member.member_id,
override["override_id"],
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="95.00",
reason="Betrag korrigiert",
)
assert updated["value"] == "95.00"
def _housekeeper_member(tmp_path, *, payment_frequency="annual", started_at="2025-01-01"):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Override", last_name="Claims", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = started_at
member.membership_started_at = started_at
member.payment_frequency = payment_frequency
repository.save_member(member)
return repository, member
def test_full_year_amount_override_replaces_the_annual_claim_amount(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-12",
kind="amount",
value="60.00",
reason="Schüler laut Nachweis",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claims_by_key = {
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
}
claim = claims_by_key["membership-fee:2026:annual"]
assert claim["amount"] == "60.00"
assert claim["calculation"]["contribution_override_ids"] != []
def test_full_year_percent_override_reduces_the_base_rate(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-12",
kind="percent",
value="50",
reason="Schüler laut Nachweis",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim = next(
claim
for claim in repository.get_contributions(member.member_id).claims
if claim["claim_key"] == "membership-fee:2026:annual"
)
assert claim["amount"] == "75.00"
def test_override_starting_mid_period_blends_month_by_month(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="semiannual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-03",
valid_until="2026-08",
kind="amount",
value="60.00",
reason="Schüler ab März",
)
Housekeeper(repository).run(today=date(2026, 12, 31))
claims_by_key = {
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
}
# Jan+Feb at 12.50/month (full rate) + Mar-Jun at 5.00/month (override) = 45.00
assert claims_by_key["membership-fee:2026:first-half"]["amount"] == "45.00"
# Jul+Aug at 5.00/month (override) + Sep-Dec at 12.50/month (full rate) = 60.00
assert claims_by_key["membership-fee:2026:second-half"]["amount"] == "60.00"
def test_claim_without_applicable_override_is_unaffected(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2020-01",
valid_until="2020-12",
kind="amount",
value="60.00",
reason="Frueherer Zeitraum",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim = next(
claim
for claim in repository.get_contributions(member.member_id).claims
if claim["claim_key"] == "membership-fee:2026:annual"
)
assert claim["amount"] == "150.00"
assert claim["calculation"]["contribution_override_ids"] == []