mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
A Rücklastschrift is still the member's own failure to ensure cover or provide valid bank details, so it belongs in the same escalation ladder as a regular reminder -- it now occupies whatever level the claim is next due for, alongside a "Rücklastschrift" preset next to the existing Mahnstufe presets. Mahnung vorbereiten no longer lets the board pick a level (it's computed automatically from what's already been sent, since the sequencing was already server-enforced) and replaces the single fee field with a small items table (Beschreibung + Betrag), so a reminder can carry several charges at once -- e.g. Rücklastschriftgebühr plus Bankgebühr plus Porto -- each landing on the claim as its own line item instead of one lump sum under a generic label. The description field offers the configured standard texts but stays free-editable. Those standard texts and amounts, plus the dunning levels themselves (name/fee/deadline), are now configurable from Optionen -> Mahnungen instead of only being editable by hand-editing repository.json, which was the case for the whole reminder policy until now. Cleaned up reminder_fee/failed_debit_fee on contribution rules while at it -- both were unused leftovers superseded by this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
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=[],
|
|
)
|