mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
The dialog's base class sets resizable(False, False) before this subclass's content (including the preset-populated items table) exists, so its initial size stayed locked to a too-small guess and cut off the bottom. It now explicitly sizes to its actual content after everything, including the selected preset's items, has been built. The items table only supported add/remove -- there was no way to change an already-added row's amount (e.g. after picking the Rücklastschrift preset, its prefilled fee couldn't be adjusted). Selecting a row now loads it into the description/amount fields, and a new "Aktualisieren" button applies edits back to that row. The Rücklastschrift preset label was missing the "Stufe N:" prefix the other presets have, inconsistent for no reason. Also fixed a real gap: repositories created before standard_fee_items existed had no such key in repository.json at all, so Optionen showed an empty Standardpositionen table instead of the built-in defaults. get_reminder_policy() now backfills the defaults when the key is missing entirely, while still respecting a list the board intentionally emptied and saved. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
304 lines
12 KiB
Python
304 lines
12 KiB
Python
import json
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from ccma.domain.contributions import claim_total
|
|
from ccma.domain.models import ContributionData
|
|
from ccma.services.housekeeper import Housekeeper
|
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
|
|
|
|
|
def _overdue_claim_repository(tmp_path):
|
|
repository = MemberRepository(tmp_path)
|
|
repository.initialize()
|
|
member = repository.create_member(first_name="Reminder", last_name="Test", birth_date="1990-01-01")
|
|
repository.save_contributions(
|
|
member.member_id,
|
|
ContributionData(
|
|
claims=[
|
|
{
|
|
"claim_id": "claim-1",
|
|
"claim_key": "overdue-test",
|
|
"title": "Offene Forderung",
|
|
"amount": "100.00",
|
|
"due_date": "2026-01-31",
|
|
"status": "open",
|
|
}
|
|
]
|
|
),
|
|
)
|
|
return repository, member
|
|
|
|
|
|
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
housekeeper = Housekeeper(repository)
|
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
|
reminder_task = next(item for item in findings if item.code == "reminder_due")
|
|
assert "Zahlungserinnerung" in reminder_task.title
|
|
|
|
draft = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[],
|
|
)
|
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
|
reminder_task = next(item for item in findings if item.code == "reminder_due")
|
|
assert "wartet auf Versand" in reminder_task.title
|
|
|
|
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
|
deadline = date.fromisoformat(sent["payment_deadline"])
|
|
assert not any(
|
|
item.code == "reminder_due" for item in housekeeper.run(today=deadline - timedelta(days=1))
|
|
)
|
|
findings = housekeeper.run(today=deadline)
|
|
next_task = next(item for item in findings if item.code == "reminder_due")
|
|
assert "Erste Mahnung" in next_task.title
|
|
|
|
|
|
def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
housekeeper = Housekeeper(repository)
|
|
repository.set_dunning_hold(
|
|
member.member_id,
|
|
"claim-1",
|
|
active=True,
|
|
reason="Betrag wird geklärt",
|
|
)
|
|
|
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
|
assert not any(item.code == "reminder_due" for item in findings)
|
|
# The claim stays visible as a plain overdue notice instead of disappearing entirely
|
|
# while the hold suppresses the dunning escalation.
|
|
assert any(item.code == "claim_overdue" for item in findings)
|
|
with pytest.raises(RepositoryError, match="Mahnsperre aktiv"):
|
|
repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
repository.set_dunning_hold(member.member_id, "claim-1", active=False)
|
|
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
|
|
|
|
|
|
def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
housekeeper = Housekeeper(repository)
|
|
|
|
# Still within the 7-day default grace period: only the plain overdue notice, no
|
|
# dunning escalation yet.
|
|
within_grace = housekeeper.run(today=date(2026, 2, 4))
|
|
codes = {item.code for item in within_grace if item.member_id == member.member_id}
|
|
assert codes == {"claim_overdue"}
|
|
|
|
# Past the grace period: the dunning escalation takes over as the single finding,
|
|
# the redundant plain overdue notice disappears.
|
|
past_grace = housekeeper.run(today=date(2026, 2, 10))
|
|
codes = {item.code for item in past_grace if item.member_id == member.member_id}
|
|
assert codes == {"reminder_due"}
|
|
|
|
|
|
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
member.iban = "DE89370400440532013000"
|
|
member.mandate_reference = "MANDATE-1"
|
|
member.mandate_signed_at = "2026-01-01"
|
|
member.mandate_active = True
|
|
repository.save_member(member)
|
|
housekeeper = Housekeeper(repository)
|
|
|
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
|
codes = {item.code for item in findings if item.member_id == member.member_id}
|
|
|
|
assert codes == {"sepa_debit_overdue"}
|
|
sepa_finding = next(item for item in findings if item.code == "sepa_debit_overdue")
|
|
assert "Lastschrift" in sepa_finding.title
|
|
assert "SEPA" in sepa_finding.detail
|
|
|
|
|
|
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
draft = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
repository.cancel_reminder(member.member_id, "claim-1", draft["reminder_id"])
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "cancelled"
|
|
|
|
second = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
repository.mark_reminder_sent(member.member_id, "claim-1", second["reminder_id"])
|
|
with pytest.raises(RepositoryError, match="bereits versandte"):
|
|
repository.cancel_reminder(member.member_id, "claim-1", second["reminder_id"])
|
|
|
|
|
|
def test_reminder_levels_cannot_be_skipped(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
|
|
with pytest.raises(RepositoryError, match="Mahnstufe 1 wurde noch nicht versandt"):
|
|
repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=2,
|
|
name="Erste Mahnung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
|
|
def test_payment_resolves_open_reminder_task(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
housekeeper = Housekeeper(repository)
|
|
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
|
|
repository.record_payment(
|
|
member.member_id,
|
|
"claim-1",
|
|
payment_date="2026-02-10",
|
|
amount="100.00",
|
|
allocation_amount="100.00",
|
|
)
|
|
|
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
|
assert not any(item.code == "reminder_due" for item in findings)
|
|
|
|
|
|
def test_reminder_can_carry_multiple_fee_items(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
|
|
draft = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Rücklastschrift",
|
|
payment_deadline_days=14,
|
|
items=[
|
|
{"description": "Rücklastschriftgebühr", "amount": "5.00"},
|
|
{"description": "Bankgebühr", "amount": "3.50"},
|
|
],
|
|
)
|
|
assert draft["fee_item_ids"] == []
|
|
|
|
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
|
assert len(sent["fee_item_ids"]) == 2
|
|
|
|
data, claim = repository.get_claim(member.member_id, "claim-1")
|
|
descriptions = {item["description"] for item in claim["items"] if item["type"] == "fee"}
|
|
assert descriptions == {"Rücklastschriftgebühr", "Bankgebühr"}
|
|
assert claim_total(claim) == Decimal("108.50")
|
|
|
|
|
|
def test_reminder_items_require_a_description(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
|
|
with pytest.raises(RepositoryError, match="Beschreibung"):
|
|
repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Rücklastschrift",
|
|
payment_deadline_days=14,
|
|
items=[{"description": " ", "amount": "5.00"}],
|
|
)
|
|
|
|
|
|
def test_reminder_with_zero_amount_items_creates_no_claim_item(tmp_path) -> None:
|
|
repository, member = _overdue_claim_repository(tmp_path)
|
|
|
|
draft = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "0.00"}],
|
|
)
|
|
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
|
assert sent["fee_item_ids"] == []
|
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
|
assert claim_total(claim) == Decimal("100.00")
|
|
|
|
|
|
def test_reminder_policy_round_trips_through_save_and_get(tmp_path) -> None:
|
|
repository = MemberRepository(tmp_path)
|
|
repository.initialize()
|
|
|
|
repository.save_reminder_policy(
|
|
grace_days_after_due=10,
|
|
levels=[
|
|
{"name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 10},
|
|
{"name": "Mahnung", "fee": "7.50", "payment_deadline_days": 14},
|
|
],
|
|
standard_fee_items=[
|
|
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"},
|
|
],
|
|
)
|
|
|
|
policy = repository.get_reminder_policy()
|
|
assert policy["grace_days_after_due"] == 10
|
|
assert [level["level"] for level in policy["levels"]] == [1, 2]
|
|
assert policy["levels"][1]["name"] == "Mahnung"
|
|
assert policy["levels"][1]["fee"] == "7.50"
|
|
assert policy["standard_fee_items"] == [
|
|
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"}
|
|
]
|
|
|
|
|
|
def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
|
|
repository = MemberRepository(tmp_path)
|
|
repository.initialize()
|
|
|
|
with pytest.raises(RepositoryError, match="Mindestens eine Mahnstufe"):
|
|
repository.save_reminder_policy(grace_days_after_due=7, levels=[], standard_fee_items=[])
|
|
|
|
with pytest.raises(RepositoryError, match="benötigt einen Namen"):
|
|
repository.save_reminder_policy(
|
|
grace_days_after_due=7,
|
|
levels=[{"name": " ", "fee": "0.00", "payment_deadline_days": 14}],
|
|
standard_fee_items=[],
|
|
)
|
|
|
|
with pytest.raises(RepositoryError, match="nicht negativ"):
|
|
repository.save_reminder_policy(
|
|
grace_days_after_due=7,
|
|
levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}],
|
|
standard_fee_items=[],
|
|
)
|
|
|
|
|
|
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
|
|
repository = MemberRepository(tmp_path)
|
|
repository.initialize()
|
|
config_path = tmp_path / "repository.json"
|
|
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
|
# Simulate a repository created before "standard_fee_items" existed at all.
|
|
del raw["reminder_policy"]["standard_fee_items"]
|
|
config_path.write_text(json.dumps(raw), encoding="utf-8")
|
|
|
|
policy = repository.get_reminder_policy()
|
|
assert policy["standard_fee_items"], "should fall back to built-in defaults"
|
|
assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"])
|
|
|
|
# But once the board explicitly saves an empty list, that choice is respected.
|
|
repository.save_reminder_policy(
|
|
grace_days_after_due=policy["grace_days_after_due"],
|
|
levels=policy["levels"],
|
|
standard_fee_items=[],
|
|
)
|
|
assert repository.get_reminder_policy()["standard_fee_items"] == []
|