mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-26 23:35:19 +02:00
Allow editing reminders (incl. undoing "sent") and prefill a Rücklastschrift reason
A generated/sent Mahnung email couldn't be corrected before: sent reminders were
locked, so a wording mistake meant living with it. Add edit_reminder_draft() for
draft/generated reminders and revert_reminder_sent() to safely undo a "sent"
reminder (removing the fee items it booked, refusing if a payment already covers
them or a higher Mahnstufe already exists) so it can be edited and resent.
ReminderDialog now supports an edit mode (reminder=... prefills name/detail/
items/deadline/channel, level stays fixed) and claim_tab gained a "Mahnung
bearbeiten" button that reverts-then-edits for sent reminders automatically,
asking for confirmation first since it un-books the fee.
The Rücklastschrift preset now also prefills "Details" with a short explanation
("Leider konnte die Lastschrift ... nicht eingelöst werden.") since that field
goes straight into the reminder email -- the board can append the concrete
reason (mangels Deckung, Konto ungültig, ...) right there instead of the field
starting empty.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f3bdffd347
commit
186af530b0
@@ -17,6 +17,8 @@ from ccma.domain.contributions import (
|
|||||||
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
CONTRIBUTION_OVERRIDE_KIND_LABELS,
|
||||||
allocated_total,
|
allocated_total,
|
||||||
claim_balance,
|
claim_balance,
|
||||||
|
claim_items,
|
||||||
|
claim_settled_total,
|
||||||
claim_total,
|
claim_total,
|
||||||
contribution_override_ranges_overlap,
|
contribution_override_ranges_overlap,
|
||||||
credit_allocated_total,
|
credit_allocated_total,
|
||||||
@@ -2108,6 +2110,97 @@ class MemberRepository:
|
|||||||
references={"claim_id": claim_id, "reminder_id": reminder_id},
|
references={"claim_id": claim_id, "reminder_id": reminder_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def edit_reminder_draft(
|
||||||
|
self,
|
||||||
|
member_id: str,
|
||||||
|
claim_id: str,
|
||||||
|
reminder_id: str,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
payment_deadline_days: int,
|
||||||
|
detail: str = "",
|
||||||
|
items: list[dict[str, str]] | None = None,
|
||||||
|
channel: str = "email",
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
normalized_items = _normalize_reminder_items(items)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RepositoryError(str(exc)) from exc
|
||||||
|
if payment_deadline_days < 1 or payment_deadline_days > 365:
|
||||||
|
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
|
||||||
|
data, _claim = self.get_claim(member_id, claim_id)
|
||||||
|
reminder = self._find_reminder(data, claim_id, reminder_id)
|
||||||
|
if str(reminder.get("status", "draft")) not in {"draft", "generated"}:
|
||||||
|
raise RepositoryError("Nur ein Entwurf kann bearbeitet werden.")
|
||||||
|
reminder["name"] = name.strip() or reminder["name"]
|
||||||
|
reminder["detail"] = detail.strip()
|
||||||
|
reminder["channel"] = channel.strip() or "email"
|
||||||
|
reminder["payment_deadline_days"] = payment_deadline_days
|
||||||
|
reminder["items"] = normalized_items
|
||||||
|
self.save_contributions(member_id, data)
|
||||||
|
self.append_event(
|
||||||
|
member_id,
|
||||||
|
event_type="reminder_draft_edited",
|
||||||
|
summary=f"Mahnungsentwurf bearbeitet: {reminder['name']}",
|
||||||
|
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||||
|
data={"fee": money_text(reminder_items_total(reminder))},
|
||||||
|
)
|
||||||
|
return reminder
|
||||||
|
|
||||||
|
def revert_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict:
|
||||||
|
"""Undoes mark_reminder_sent: removes the fee items it booked onto the claim
|
||||||
|
and puts the reminder back into "draft" so its text/items can be edited and it
|
||||||
|
can be sent again -- e.g. after generating a Mahnung email and noticing the
|
||||||
|
wording needs a fix. Refuses if a later Mahnstufe already exists (would break
|
||||||
|
the level ordering) or if payments already booked against the claim would no
|
||||||
|
longer fit once the fee is removed."""
|
||||||
|
data, claim = self.get_claim(member_id, claim_id)
|
||||||
|
reminder = self._find_reminder(data, claim_id, reminder_id)
|
||||||
|
if str(reminder.get("status", "")) != "sent":
|
||||||
|
raise RepositoryError("Nur eine bereits versandte Mahnung kann zurückgesetzt werden.")
|
||||||
|
if any(
|
||||||
|
str(item.get("claim_id", "")) == claim_id
|
||||||
|
and int(item.get("level", 0)) > int(reminder.get("level", 0))
|
||||||
|
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
||||||
|
for item in data.reminders
|
||||||
|
):
|
||||||
|
raise RepositoryError(
|
||||||
|
"Es existiert bereits eine höhere Mahnstufe; diese Mahnung kann nicht "
|
||||||
|
"zurückgesetzt werden."
|
||||||
|
)
|
||||||
|
fee_item_ids = set(reminder.get("fee_item_ids") or [])
|
||||||
|
if fee_item_ids:
|
||||||
|
removed_total = sum(
|
||||||
|
(
|
||||||
|
decimal_value(item.get("amount", "0"))
|
||||||
|
for item in claim_items(claim)
|
||||||
|
if str(item.get("item_id", "")) in fee_item_ids
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
)
|
||||||
|
new_total = claim_total(claim) - removed_total
|
||||||
|
if claim_settled_total(data, claim) > new_total:
|
||||||
|
raise RepositoryError(
|
||||||
|
"Für diese Forderung wurden bereits Zahlungen verbucht, die die "
|
||||||
|
"Mahngebühr abdecken; der Versand kann nicht zurückgesetzt werden."
|
||||||
|
)
|
||||||
|
claim["items"] = [
|
||||||
|
item for item in claim_items(claim) if str(item.get("item_id", "")) not in fee_item_ids
|
||||||
|
]
|
||||||
|
claim["amount"] = money_text(claim_total(claim))
|
||||||
|
reminder["status"] = "draft"
|
||||||
|
reminder["sent_at"] = None
|
||||||
|
reminder["payment_deadline"] = None
|
||||||
|
reminder["fee_item_ids"] = []
|
||||||
|
self.save_contributions(member_id, data)
|
||||||
|
self.append_event(
|
||||||
|
member_id,
|
||||||
|
event_type="reminder_sent_reverted",
|
||||||
|
summary=f"Versand zurückgesetzt: {reminder.get('name', '')}",
|
||||||
|
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
||||||
|
)
|
||||||
|
return reminder
|
||||||
|
|
||||||
def register_reminder_document(
|
def register_reminder_document(
|
||||||
self,
|
self,
|
||||||
member_id: str,
|
member_id: str,
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ class ClaimTab(ttk.Frame):
|
|||||||
state="disabled",
|
state="disabled",
|
||||||
)
|
)
|
||||||
self.email_reminder_button.pack(side="right", padx=(8, 0))
|
self.email_reminder_button.pack(side="right", padx=(8, 0))
|
||||||
|
self.edit_reminder_button = ttk.Button(
|
||||||
|
buttons, text="Mahnung bearbeiten", command=self._edit_reminder, state="disabled"
|
||||||
|
)
|
||||||
|
self.edit_reminder_button.pack(side="right", padx=(8, 0))
|
||||||
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
|
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
|
||||||
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
|
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
|
||||||
side="right", padx=(0, 8)
|
side="right", padx=(0, 8)
|
||||||
@@ -438,6 +442,9 @@ class ClaimTab(ttk.Frame):
|
|||||||
self.send_reminder_button.configure(state=state)
|
self.send_reminder_button.configure(state=state)
|
||||||
self.email_reminder_button.configure(state=state)
|
self.email_reminder_button.configure(state=state)
|
||||||
self.discard_reminder_button.configure(state=state)
|
self.discard_reminder_button.configure(state=state)
|
||||||
|
# Sent reminders can also be edited (revert-then-edit), just not cancelled ones.
|
||||||
|
can_edit = bool(reminder and str(reminder.get("status", "draft")) != "cancelled")
|
||||||
|
self.edit_reminder_button.configure(state="normal" if can_edit else "disabled")
|
||||||
|
|
||||||
def _email_reminder(self) -> None:
|
def _email_reminder(self) -> None:
|
||||||
reminder = self._selected_reminder()
|
reminder = self._selected_reminder()
|
||||||
@@ -467,6 +474,32 @@ class ClaimTab(ttk.Frame):
|
|||||||
return
|
return
|
||||||
self._changed()
|
self._changed()
|
||||||
|
|
||||||
|
def _edit_reminder(self) -> None:
|
||||||
|
reminder = self._selected_reminder()
|
||||||
|
if not reminder:
|
||||||
|
return
|
||||||
|
if str(reminder.get("status", "")) == "sent":
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Versand zurücksetzen",
|
||||||
|
"Diese Mahnung wurde bereits versandt. Zum Bearbeiten wird der Versand "
|
||||||
|
"zurückgesetzt (eine bereits gebuchte Mahngebühr wird von der Forderung "
|
||||||
|
"entfernt und die Zahlungsfrist gelöscht) -- anschließend muss die Mahnung "
|
||||||
|
"erneut versandt werden. Fortfahren?",
|
||||||
|
parent=self,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
reminder = self.repository.revert_reminder_sent(
|
||||||
|
self.member_id, self.claim_id, str(reminder["reminder_id"])
|
||||||
|
)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("Versand konnte nicht zurückgesetzt werden", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
self.refresh()
|
||||||
|
ReminderDialog(
|
||||||
|
self, self.repository, self.member_id, self.claim_id, self._changed, reminder=reminder
|
||||||
|
)
|
||||||
|
|
||||||
def _discard_reminder(self) -> None:
|
def _discard_reminder(self) -> None:
|
||||||
reminder = self._selected_reminder()
|
reminder = self._selected_reminder()
|
||||||
if not reminder or not messagebox.askyesno(
|
if not reminder or not messagebox.askyesno(
|
||||||
@@ -876,13 +909,17 @@ class ReminderDialog(_Dialog):
|
|||||||
provide cover/valid bank details), or a blank template for manual entry. The fee
|
provide cover/valid bank details), or a blank template for manual entry. The fee
|
||||||
items table itself is always free to edit no matter which preset was picked."""
|
items table itself is always free to edit no matter which preset was picked."""
|
||||||
|
|
||||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
def __init__(self, master, repository, member_id, claim_id, on_saved, *, reminder: dict | None = None):
|
||||||
super().__init__(master, "Mahnung vorbereiten", on_saved)
|
super().__init__(master, "Mahnung bearbeiten" if reminder else "Mahnung vorbereiten", on_saved)
|
||||||
# Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table
|
# Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table
|
||||||
# of freely-editable fee items whose content varies a lot -- make it resizable
|
# of freely-editable fee items whose content varies a lot -- make it resizable
|
||||||
# so a long/multi-item Mahnung doesn't get cramped.
|
# so a long/multi-item Mahnung doesn't get cramped.
|
||||||
self.resizable(True, True)
|
self.resizable(True, True)
|
||||||
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
|
||||||
|
self.editing_reminder = reminder
|
||||||
|
if reminder:
|
||||||
|
self.level = int(reminder.get("level", 1))
|
||||||
|
else:
|
||||||
data, _claim = repository.get_claim(member_id, claim_id)
|
data, _claim = repository.get_claim(member_id, claim_id)
|
||||||
existing_levels = {
|
existing_levels = {
|
||||||
int(item.get("level", 0))
|
int(item.get("level", 0))
|
||||||
@@ -902,7 +939,10 @@ class ReminderDialog(_Dialog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.preset_by_label = self._build_presets()
|
self.preset_by_label = self._build_presets()
|
||||||
self.preset_var = tk.StringVar(value=next(iter(self.preset_by_label)))
|
default_preset = (
|
||||||
|
next(reversed(self.preset_by_label)) if reminder else next(iter(self.preset_by_label))
|
||||||
|
)
|
||||||
|
self.preset_var = tk.StringVar(value=default_preset)
|
||||||
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||||
preset_combo = ttk.Combobox(
|
preset_combo = ttk.Combobox(
|
||||||
self.frame,
|
self.frame,
|
||||||
@@ -942,6 +982,16 @@ class ReminderDialog(_Dialog):
|
|||||||
).grid(row=5, column=1, sticky="ew", pady=5)
|
).grid(row=5, column=1, sticky="ew", pady=5)
|
||||||
|
|
||||||
self._build_items_table(row=6)
|
self._build_items_table(row=6)
|
||||||
|
if reminder:
|
||||||
|
self.name_var.set(str(reminder.get("name", "")))
|
||||||
|
self.detail_var.set(str(reminder.get("detail", "")))
|
||||||
|
self.deadline_var.set(str(reminder.get("payment_deadline_days", 14)))
|
||||||
|
self.channel_var.set(
|
||||||
|
display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "email")))
|
||||||
|
)
|
||||||
|
self.items = [dict(item) for item in reminder.get("items") or []]
|
||||||
|
self._refresh_items()
|
||||||
|
else:
|
||||||
self._apply_preset()
|
self._apply_preset()
|
||||||
self._buttons(7, self._save)
|
self._buttons(7, self._save)
|
||||||
# Size to fit the fully built content (incl. the preset-populated items table)
|
# Size to fit the fully built content (incl. the preset-populated items table)
|
||||||
@@ -963,16 +1013,23 @@ class ReminderDialog(_Dialog):
|
|||||||
fee = decimal_value(matching["fee"])
|
fee = decimal_value(matching["fee"])
|
||||||
presets[f"Stufe {self.level}: {matching['name']}"] = {
|
presets[f"Stufe {self.level}: {matching['name']}"] = {
|
||||||
"name": matching["name"],
|
"name": matching["name"],
|
||||||
|
"detail": "",
|
||||||
"payment_deadline_days": matching["payment_deadline_days"],
|
"payment_deadline_days": matching["payment_deadline_days"],
|
||||||
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
|
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
|
||||||
}
|
}
|
||||||
failed_debit_amount = self._default_amount_for("Rücklastschrift")
|
failed_debit_amount = self._default_amount_for("Rücklastschrift")
|
||||||
presets[f"Stufe {self.level}: Rücklastschrift"] = {
|
presets[f"Stufe {self.level}: Rücklastschrift"] = {
|
||||||
"name": "Rücklastschrift",
|
"name": "Rücklastschrift",
|
||||||
|
# Prefilled so the member sees why they're being charged; the board can
|
||||||
|
# still append/edit a concrete reason (mangels Deckung, Konto ungültig, ...)
|
||||||
|
# right here since "Details" goes straight into the reminder email as-is.
|
||||||
|
"detail": "Leider konnte die Lastschrift von Ihrem Konto nicht eingelöst werden.",
|
||||||
"payment_deadline_days": 14,
|
"payment_deadline_days": 14,
|
||||||
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
|
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
|
||||||
}
|
}
|
||||||
presets["Frei (eigene Eingabe)"] = {"name": "", "payment_deadline_days": 14, "items": []}
|
presets["Frei (eigene Eingabe)"] = {
|
||||||
|
"name": "", "detail": "", "payment_deadline_days": 14, "items": []
|
||||||
|
}
|
||||||
return presets
|
return presets
|
||||||
|
|
||||||
def _default_amount_for(self, needle: str) -> str:
|
def _default_amount_for(self, needle: str) -> str:
|
||||||
@@ -984,6 +1041,7 @@ class ReminderDialog(_Dialog):
|
|||||||
def _apply_preset(self) -> None:
|
def _apply_preset(self) -> None:
|
||||||
preset = self.preset_by_label[self.preset_var.get()]
|
preset = self.preset_by_label[self.preset_var.get()]
|
||||||
self.name_var.set(preset["name"])
|
self.name_var.set(preset["name"])
|
||||||
|
self.detail_var.set(preset["detail"])
|
||||||
self.deadline_var.set(str(preset["payment_deadline_days"]))
|
self.deadline_var.set(str(preset["payment_deadline_days"]))
|
||||||
self.items = [dict(item) for item in preset["items"]]
|
self.items = [dict(item) for item in preset["items"]]
|
||||||
self._refresh_items()
|
self._refresh_items()
|
||||||
@@ -1112,6 +1170,18 @@ class ReminderDialog(_Dialog):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
if self.editing_reminder:
|
||||||
|
self.repository.edit_reminder_draft(
|
||||||
|
self.member_id,
|
||||||
|
self.claim_id,
|
||||||
|
str(self.editing_reminder["reminder_id"]),
|
||||||
|
name=self.name_var.get(),
|
||||||
|
payment_deadline_days=deadline_days,
|
||||||
|
detail=self.detail_var.get(),
|
||||||
|
items=self.items,
|
||||||
|
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.repository.create_reminder_draft(
|
self.repository.create_reminder_draft(
|
||||||
self.member_id,
|
self.member_id,
|
||||||
self.claim_id,
|
self.claim_id,
|
||||||
|
|||||||
@@ -281,6 +281,142 @@ def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_reminder_draft_updates_fields_but_not_level(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=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
edited = repository.edit_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
draft["reminder_id"],
|
||||||
|
name="Erste Mahnung",
|
||||||
|
payment_deadline_days=10,
|
||||||
|
detail="Bitte um Ausgleich.",
|
||||||
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
||||||
|
)
|
||||||
|
assert edited["level"] == 1
|
||||||
|
assert edited["name"] == "Erste Mahnung"
|
||||||
|
assert edited["payment_deadline_days"] == 10
|
||||||
|
assert edited["detail"] == "Bitte um Ausgleich."
|
||||||
|
assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_reminder_draft_rejects_already_sent_reminder(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=[],
|
||||||
|
)
|
||||||
|
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="Nur ein Entwurf"):
|
||||||
|
repository.edit_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
draft["reminder_id"],
|
||||||
|
name="Andere Bezeichnung",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(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"}],
|
||||||
|
)
|
||||||
|
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||||
|
assert len(sent["fee_item_ids"]) == 1
|
||||||
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||||
|
assert claim_total(claim) == Decimal("105.00")
|
||||||
|
|
||||||
|
reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||||
|
assert reverted["status"] == "draft"
|
||||||
|
assert reverted["sent_at"] is None
|
||||||
|
assert reverted["payment_deadline"] is None
|
||||||
|
assert reverted["fee_item_ids"] == []
|
||||||
|
|
||||||
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||||
|
assert claim_total(claim) == Decimal("100.00")
|
||||||
|
descriptions = {item["description"] for item in claim["items"]}
|
||||||
|
assert "Rücklastschriftgebühr" not in descriptions
|
||||||
|
|
||||||
|
# Now editable and re-sendable, e.g. after fixing the wording.
|
||||||
|
repository.edit_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
draft["reminder_id"],
|
||||||
|
name="Rücklastschrift",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}],
|
||||||
|
)
|
||||||
|
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||||
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||||
|
assert claim_total(claim) == Decimal("107.50")
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None:
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path)
|
||||||
|
first = repository.create_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
level=1,
|
||||||
|
name="Zahlungserinnerung",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
items=[],
|
||||||
|
)
|
||||||
|
repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
|
||||||
|
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"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="höhere Mahnstufe"):
|
||||||
|
repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(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"}],
|
||||||
|
)
|
||||||
|
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||||
|
repository.record_payment(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
payment_date="2026-02-10",
|
||||||
|
amount="105.00",
|
||||||
|
allocation_amount="105.00",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"):
|
||||||
|
repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
|
||||||
|
|
||||||
|
|
||||||
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
|
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path)
|
repository = MemberRepository(tmp_path)
|
||||||
repository.initialize()
|
repository.initialize()
|
||||||
|
|||||||
Reference in New Issue
Block a user