mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +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>
301 lines
10 KiB
Python
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"] == []
|