mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Merge pull request 'Keep dunning a bounced debit out of the direct-debit track' (#26) from feature/dunning-after-failed-debit into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/26 Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
This commit is contained in:
@@ -291,4 +291,13 @@ only confirming actual dispatch starts the new payment deadline and adds an
|
|||||||
optional fee line item. A claim-level dunning hold suppresses automatic and
|
optional fee line item. A claim-level dunning hold suppresses automatic and
|
||||||
manual reminder preparation until it is removed or expires.
|
manual reminder preparation until it is removed or expires.
|
||||||
|
|
||||||
|
An overdue claim of a member with an active mandate is not dunned but reported as
|
||||||
|
a direct debit to look into -- until a reminder for it has actually been sent. A
|
||||||
|
sent reminder (typically the "Rücklastschrift" preset for a bounced debit) takes
|
||||||
|
that claim off the direct-debit track for good: it is expected as a transfer by
|
||||||
|
the stated deadline, so the housekeeper continues it in the ordinary dunning
|
||||||
|
sequence, the SEPA run leaves it out and says so, and its dunning mail asks for a
|
||||||
|
transfer even though the mandate is still active. Reverting the sent reminder puts
|
||||||
|
the claim back into the direct-debit run.
|
||||||
|
|
||||||
Do not place a real member store inside the source repository.
|
Do not place a real member store inside the source repository.
|
||||||
|
|||||||
@@ -42,7 +42,8 @@
|
|||||||
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
|
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
|
||||||
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
|
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
|
||||||
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart.",
|
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart.",
|
||||||
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt. Die Mail nennt außerdem die Zahlweise und den Verwendungszweck, den das Mitglied bei Überweisungen angeben soll (Mitgliedsnummer und voller Name)."
|
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt. Die Mail nennt außerdem die Zahlweise und den Verwendungszweck, den das Mitglied bei Überweisungen angeben soll (Mitgliedsnummer und voller Name).",
|
||||||
|
"Eine Forderung, für die bereits eine Mahnung bzw. Rücklastschrift-Zahlungserinnerung versandt wurde, wird nicht mehr als Lastschrift behandelt: Der Hausmeister meldet nach Ablauf der Zahlungsfrist die nächste Mahnstufe statt weiterhin „Einzug prüfen, eine Mahnung ist hier nicht vorgesehen“, der SEPA-Lauf zieht die Forderung nicht erneut ein (und weist die übersprungene Position aus) und die Mahnungsmail bittet um Überweisung, auch wenn das Lastschriftmandat weiterhin aktiv ist. Wird der Versand einer Mahnung zurückgenommen, steht die Forderung wieder für den Einzug bereit."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -160,6 +160,22 @@ def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_was_dunned(data: ContributionData, claim: dict[str, Any]) -> bool:
|
||||||
|
"""True once a reminder for this claim has actually gone out. Such a claim has
|
||||||
|
left the direct-debit track: the collection failed (or never happened), the member
|
||||||
|
was asked in writing to pay it, and the money is expected as a transfer by the
|
||||||
|
stated deadline. Quietly collecting it a second time is exactly what the dunning
|
||||||
|
letter says will not happen, so both the SEPA run and the housekeeper ask this
|
||||||
|
before treating a claim as one the mandate still covers."""
|
||||||
|
claim_id = str(claim.get("claim_id", ""))
|
||||||
|
if not claim_id:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
str(reminder.get("claim_id", "")) == claim_id and str(reminder.get("status", "")) == "sent"
|
||||||
|
for reminder in data.reminders
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
|
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
|
||||||
valid_from = str(override.get("valid_from", ""))
|
valid_from = str(override.get("valid_from", ""))
|
||||||
valid_until = str(override.get("valid_until", "") or "")
|
valid_until = str(override.get("valid_until", "") or "")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from ccma.domain.contributions import claim_balance, claim_status, money_text
|
from ccma.domain.contributions import claim_balance, claim_status, claim_was_dunned, money_text
|
||||||
from ccma.rules.api import RuleAction, RuleContext, task
|
from ccma.rules.api import RuleAction, RuleContext, task
|
||||||
|
|
||||||
RULE_ID = "reminder-due"
|
RULE_ID = "reminder-due"
|
||||||
@@ -18,12 +18,12 @@ DEFAULT_POLICY = {
|
|||||||
|
|
||||||
def evaluate(context: RuleContext) -> list[RuleAction]:
|
def evaluate(context: RuleContext) -> list[RuleAction]:
|
||||||
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
|
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
|
||||||
grace period, then either the postal dunning escalation or -- for members paying by
|
grace period, then either the postal dunning escalation or -- for a claim the
|
||||||
SEPA direct debit, where sending a dunning letter makes no sense -- a distinct
|
member's mandate still covers, where sending a dunning letter makes no sense -- a
|
||||||
"check the direct debit" notice. Falls back to the plain overdue notice whenever no
|
distinct "check the direct debit" notice. Falls back to the plain overdue notice
|
||||||
escalation applies (dunning hold active, or between reminder levels), so there is
|
whenever no escalation applies (dunning hold active, or between reminder levels),
|
||||||
always exactly one finding for an overdue claim, never both an overdue notice and a
|
so there is always exactly one finding for an overdue claim, never both an overdue
|
||||||
reminder notice at once."""
|
notice and a reminder notice at once."""
|
||||||
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
|
||||||
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
|
||||||
grace_days = int(policy.get("grace_days_after_due", 7))
|
grace_days = int(policy.get("grace_days_after_due", 7))
|
||||||
@@ -48,8 +48,11 @@ def evaluate(context: RuleContext) -> list[RuleAction]:
|
|||||||
|
|
||||||
escalation = None
|
escalation = None
|
||||||
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
|
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
|
||||||
if context.member.mandate_active:
|
# A claim that was already dunned is no longer collected by direct debit --
|
||||||
escalation = _sepa_escalation(context, claim, claim_id, days_overdue)
|
# the member was asked to transfer it, so it continues in the ordinary
|
||||||
|
# dunning sequence even though the mandate itself is still active.
|
||||||
|
if context.member.mandate_active and not claim_was_dunned(context.contributions, claim):
|
||||||
|
escalation = _sepa_action(context, claim, claim_id, days_overdue)
|
||||||
else:
|
else:
|
||||||
escalation = _reminder_action(context, claim, claim_id, levels, policy)
|
escalation = _reminder_action(context, claim, claim_id, levels, policy)
|
||||||
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
|
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
|
||||||
@@ -72,13 +75,6 @@ def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sepa_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
|
||||||
pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today)
|
|
||||||
if pending:
|
|
||||||
return _sepa_awaiting_action(context, claim, claim_id, *pending)
|
|
||||||
return _sepa_action(context, claim, claim_id, days_overdue)
|
|
||||||
|
|
||||||
|
|
||||||
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
|
||||||
balance = money_text(claim_balance(context.contributions, claim))
|
balance = money_text(claim_balance(context.contributions, claim))
|
||||||
return task(
|
return task(
|
||||||
@@ -97,47 +93,6 @@ def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int)
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sepa_awaiting_action(
|
|
||||||
context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date
|
|
||||||
) -> RuleAction:
|
|
||||||
"""A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA
|
|
||||||
claim and its own deadline hasn't expired yet -- staff already engaged, so this
|
|
||||||
must not keep nagging "check the direct debit" as if nothing had happened."""
|
|
||||||
name = str(reminder.get("name") or "Rücklastschrift")
|
|
||||||
balance = money_text(claim_balance(context.contributions, claim))
|
|
||||||
return task(
|
|
||||||
rule_id=RULE_ID,
|
|
||||||
member=context.member,
|
|
||||||
key_suffix=f"{claim_id}:sepa-awaiting",
|
|
||||||
severity="info",
|
|
||||||
code="sepa_debit_awaiting_deadline",
|
|
||||||
title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft",
|
|
||||||
detail=(
|
|
||||||
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
|
|
||||||
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
|
|
||||||
),
|
|
||||||
due_date=trigger_date,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _latest_pending_reminder(
|
|
||||||
reminders: list[dict], claim_id: str, today: date
|
|
||||||
) -> tuple[dict, date] | None:
|
|
||||||
best: tuple[dict, date] | None = None
|
|
||||||
for item in reminders:
|
|
||||||
if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
deadline = date.fromisoformat(str(item.get("payment_deadline", "")))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if today > deadline:
|
|
||||||
continue
|
|
||||||
if best is None or deadline > best[1]:
|
|
||||||
best = (item, deadline)
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
|
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
|
||||||
reminders = [
|
reminders = [
|
||||||
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
|
||||||
|
|||||||
@@ -38,11 +38,14 @@ def payment_instructions(
|
|||||||
*,
|
*,
|
||||||
due_date: str,
|
due_date: str,
|
||||||
reference: str,
|
reference: str,
|
||||||
|
expect_transfer: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Ready-made payment paragraph: members with an active mandate are told the
|
"""Ready-made payment paragraph: members with an active mandate are told the
|
||||||
money is collected, everyone else gets the club's bank details."""
|
money is collected, everyone else gets the club's bank details. `expect_transfer`
|
||||||
|
overrides that for a claim the mandate no longer covers -- a dunned claim is
|
||||||
|
expected as a transfer even though the member still has a mandate."""
|
||||||
organization = organization if isinstance(organization, dict) else {}
|
organization = organization if isinstance(organization, dict) else {}
|
||||||
if member.mandate_active:
|
if member.mandate_active and not expect_transfer:
|
||||||
mandate = member.mandate_reference.strip()
|
mandate = member.mandate_reference.strip()
|
||||||
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
|
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -96,8 +96,16 @@ def reminder_mail_bytes(
|
|||||||
),
|
),
|
||||||
"reminder.fee_total": money_text(reminder_items_total(reminder)),
|
"reminder.fee_total": money_text(reminder_items_total(reminder)),
|
||||||
"payment.reference": reference,
|
"payment.reference": reference,
|
||||||
|
# A dunning mail only goes out because the money did not arrive, so it
|
||||||
|
# asks for a transfer even from a member with an active mandate: this
|
||||||
|
# claim has left the direct-debit run and telling them "we collect it,
|
||||||
|
# you need not do anything" would contradict the letter it carries.
|
||||||
"payment.instructions": payment_instructions(
|
"payment.instructions": payment_instructions(
|
||||||
member, organization, due_date=payment_deadline, reference=reference
|
member,
|
||||||
|
organization,
|
||||||
|
due_date=payment_deadline,
|
||||||
|
reference=reference,
|
||||||
|
expect_transfer=True,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from ccma.domain.contributions import claim_balance
|
from ccma.domain.contributions import claim_balance, claim_was_dunned
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
|
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
|
||||||
|
|
||||||
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
|
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
|
||||||
@@ -63,6 +63,7 @@ def pending_direct_debits(
|
|||||||
for member in repository.list_members():
|
for member in repository.list_members():
|
||||||
if not member.mandate_active:
|
if not member.mandate_active:
|
||||||
continue
|
continue
|
||||||
|
member_label = member.member_number or member.display_name
|
||||||
missing = [
|
missing = [
|
||||||
label
|
label
|
||||||
for value, label in (
|
for value, label in (
|
||||||
@@ -73,13 +74,13 @@ def pending_direct_debits(
|
|||||||
if not str(value).strip()
|
if not str(value).strip()
|
||||||
]
|
]
|
||||||
if missing:
|
if missing:
|
||||||
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
|
warnings.append(f"{member_label}: {', '.join(missing)} fehlt.")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
validate_iban(member.iban)
|
validate_iban(member.iban)
|
||||||
date.fromisoformat(member.mandate_signed_at)
|
date.fromisoformat(member.mandate_signed_at)
|
||||||
except (RepositoryError, ValueError) as exc:
|
except (RepositoryError, ValueError) as exc:
|
||||||
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
warnings.append(f"{member_label}: {exc}")
|
||||||
continue
|
continue
|
||||||
data = repository.get_contributions(member.member_id)
|
data = repository.get_contributions(member.member_id)
|
||||||
selected = []
|
selected = []
|
||||||
@@ -92,7 +93,20 @@ def pending_direct_debits(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
balance = claim_balance(data, claim)
|
balance = claim_balance(data, claim)
|
||||||
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
|
if not ((due_from is None or claim_due >= due_from) and claim_due <= due_until):
|
||||||
|
continue
|
||||||
|
if balance <= 0:
|
||||||
|
continue
|
||||||
|
if claim_was_dunned(data, claim):
|
||||||
|
# The debit for this claim already bounced and the member was dunned:
|
||||||
|
# the letter told them to transfer the money themselves, so collecting
|
||||||
|
# it behind their back would contradict it (and can bounce again, with
|
||||||
|
# another fee). Reverting the sent reminder puts it back in the run.
|
||||||
|
warnings.append(
|
||||||
|
f"{member_label}: „{claim.get('title') or 'Forderung'}“ wurde bereits "
|
||||||
|
"gemahnt – Zahlung per Überweisung erwartet, kein erneuter Einzug."
|
||||||
|
)
|
||||||
|
continue
|
||||||
selected.append(claim)
|
selected.append(claim)
|
||||||
amount += balance
|
amount += balance
|
||||||
if not selected:
|
if not selected:
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ class SepaExportDialog(tk.Toplevel):
|
|||||||
)
|
)
|
||||||
self._update_summary()
|
self._update_summary()
|
||||||
self.warning_var.set(
|
self.warning_var.set(
|
||||||
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
|
f"{len(self.warnings)} Position(en) übersprungen – Details beim Export."
|
||||||
|
if self.warnings
|
||||||
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
def _selected_debits(self) -> list:
|
def _selected_debits(self) -> list:
|
||||||
@@ -185,7 +187,7 @@ class SepaExportDialog(tk.Toplevel):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
if self.warnings and not messagebox.askyesno(
|
if self.warnings and not messagebox.askyesno(
|
||||||
"Unvollständige Mandate",
|
"Übersprungene Positionen",
|
||||||
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
|
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
|
||||||
parent=self,
|
parent=self,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -58,6 +58,59 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
|||||||
assert generated.archive_path.parent.name == "Mahnungen"
|
assert generated.archive_path.parent.name == "Mahnungen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dunning_mail_asks_for_a_transfer_even_with_an_active_mandate(tmp_path):
|
||||||
|
"""A member is only dunned once the money did not arrive, so this claim has left
|
||||||
|
the direct-debit run -- "wir ziehen den Betrag ein, du musst nichts tun" would
|
||||||
|
contradict the very letter it accompanies."""
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||||
|
member.email = "reminder@example.org"
|
||||||
|
member.account_holder = "Reminder Test"
|
||||||
|
member.iban = "DE89370400440532013000"
|
||||||
|
member.mandate_reference = "MANDAT-1"
|
||||||
|
member.mandate_signed_at = "2026-01-01"
|
||||||
|
member.mandate_active = True
|
||||||
|
repository.save_member(member)
|
||||||
|
organization = repository.get_configuration()["organization"]
|
||||||
|
organization.update(
|
||||||
|
{
|
||||||
|
"name": "Chaos Computer Club Mannheim e.V.",
|
||||||
|
"email": "verwaltung@example.org",
|
||||||
|
"iban": "DE98670505050038907751",
|
||||||
|
"bic": "MANSDE66XXX",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository.save_organization(organization)
|
||||||
|
# The shipped template spells the bank details out; this one uses the ready-made
|
||||||
|
# paragraph, which is what the placeholder chooser offers for a dunning mail.
|
||||||
|
repository.save_mail_template(
|
||||||
|
"reminder",
|
||||||
|
subject="Mahnung: {{claim.title}}",
|
||||||
|
body="Hallo {{member.first_name}},\n\n{{payment.instructions}}\n\n{{signature}}",
|
||||||
|
)
|
||||||
|
reminder = repository.create_reminder_draft(
|
||||||
|
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_reminder_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Mahnung.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = BytesParser(policy=default).parsebytes(
|
||||||
|
generated.export_path.read_bytes()
|
||||||
|
).get_content()
|
||||||
|
assert "Bitte überweise den Betrag" in content
|
||||||
|
assert "DE98670505050038907751" in content
|
||||||
|
assert "ziehen den Betrag" not in content
|
||||||
|
|
||||||
|
|
||||||
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
||||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||||
member.email = "reminder@example.org"
|
member.email = "reminder@example.org"
|
||||||
|
|||||||
+46
-18
@@ -32,6 +32,15 @@ def _overdue_claim_repository(tmp_path):
|
|||||||
return repository, member
|
return repository, member
|
||||||
|
|
||||||
|
|
||||||
|
def _sepa_member(repository, member):
|
||||||
|
member.iban = "DE89370400440532013000"
|
||||||
|
member.mandate_reference = "MANDATE-1"
|
||||||
|
member.mandate_signed_at = "2026-01-01"
|
||||||
|
member.mandate_active = True
|
||||||
|
repository.save_member(member)
|
||||||
|
return member
|
||||||
|
|
||||||
|
|
||||||
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
|
||||||
repository, member = _overdue_claim_repository(tmp_path)
|
repository, member = _overdue_claim_repository(tmp_path)
|
||||||
housekeeper = Housekeeper(repository)
|
housekeeper = Housekeeper(repository)
|
||||||
@@ -136,11 +145,7 @@ def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) ->
|
|||||||
|
|
||||||
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
||||||
repository, member = _overdue_claim_repository(tmp_path)
|
repository, member = _overdue_claim_repository(tmp_path)
|
||||||
member.iban = "DE89370400440532013000"
|
_sepa_member(repository, member)
|
||||||
member.mandate_reference = "MANDATE-1"
|
|
||||||
member.mandate_signed_at = "2026-01-01"
|
|
||||||
member.mandate_active = True
|
|
||||||
repository.save_member(member)
|
|
||||||
housekeeper = Housekeeper(repository)
|
housekeeper = Housekeeper(repository)
|
||||||
|
|
||||||
findings = housekeeper.run(today=date(2026, 2, 10))
|
findings = housekeeper.run(today=date(2026, 2, 10))
|
||||||
@@ -152,13 +157,12 @@ def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
|
|||||||
assert "SEPA" in sepa_finding.detail
|
assert "SEPA" in sepa_finding.detail
|
||||||
|
|
||||||
|
|
||||||
def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_path) -> None:
|
def test_a_dunned_sepa_claim_continues_in_the_normal_dunning_process(tmp_path) -> None:
|
||||||
|
"""A bounced direct debit is dunned like any other claim: the money is expected as
|
||||||
|
a transfer from then on, so the "check the direct debit" notice must not come back
|
||||||
|
once the Rücklastschrift deadline has passed."""
|
||||||
repository, member = _overdue_claim_repository(tmp_path)
|
repository, member = _overdue_claim_repository(tmp_path)
|
||||||
member.iban = "DE89370400440532013000"
|
_sepa_member(repository, member)
|
||||||
member.mandate_reference = "MANDATE-1"
|
|
||||||
member.mandate_signed_at = "2026-01-01"
|
|
||||||
member.mandate_active = True
|
|
||||||
repository.save_member(member)
|
|
||||||
housekeeper = Housekeeper(repository)
|
housekeeper = Housekeeper(repository)
|
||||||
|
|
||||||
draft = repository.create_reminder_draft(
|
draft = repository.create_reminder_draft(
|
||||||
@@ -177,20 +181,44 @@ def test_sepa_member_with_running_reminder_shows_awaiting_note_not_overdue(tmp_p
|
|||||||
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
for item in housekeeper.run(today=deadline - timedelta(days=1))
|
||||||
if item.member_id == member.member_id
|
if item.member_id == member.member_id
|
||||||
]
|
]
|
||||||
|
|
||||||
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||||
awaiting = next(item for item in findings if item.code == "sepa_debit_awaiting_deadline")
|
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
|
||||||
assert awaiting.severity == "info"
|
assert awaiting.severity == "info"
|
||||||
assert "Rücklastschrift" in awaiting.title
|
assert "Rücklastschrift" in awaiting.title
|
||||||
|
|
||||||
# Once the reminder's own deadline has passed without resolution, the plain SEPA
|
# Deadline gone by without payment: the next dunning level is due, not another
|
||||||
# follow-up notice returns so it doesn't just silently go quiet forever.
|
# "der Einzug muss geprüft werden".
|
||||||
findings = [
|
findings = [
|
||||||
item for item in housekeeper.run(today=deadline + timedelta(days=1))
|
item
|
||||||
|
for item in housekeeper.run(today=deadline + timedelta(days=1))
|
||||||
if item.member_id == member.member_id
|
if item.member_id == member.member_id
|
||||||
]
|
]
|
||||||
assert any(item.code == "sepa_debit_overdue" for item in findings)
|
assert not any(item.code == "sepa_debit_overdue" for item in findings)
|
||||||
assert not any(item.code == "sepa_debit_awaiting_deadline" for item in findings)
|
reminder_task = next(item for item in findings if item.code == "reminder_due")
|
||||||
|
assert "Erste Mahnung" in reminder_task.title
|
||||||
|
assert "Mahnstufe 2" in reminder_task.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_undunned_sepa_claim_still_asks_to_check_the_debit(tmp_path) -> None:
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path)
|
||||||
|
_sepa_member(repository, member)
|
||||||
|
housekeeper = Housekeeper(repository)
|
||||||
|
|
||||||
|
findings = [
|
||||||
|
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
|
||||||
|
]
|
||||||
|
|
||||||
|
# A draft alone changes nothing -- only a reminder that actually went out takes the
|
||||||
|
# claim out of the direct-debit run.
|
||||||
|
repository.create_reminder_draft(
|
||||||
|
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||||
|
)
|
||||||
|
still_sepa = [
|
||||||
|
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
|
||||||
|
]
|
||||||
|
|
||||||
|
assert {item.code for item in findings} == {"sepa_debit_overdue"}
|
||||||
|
assert {item.code for item in still_sepa} == {"sepa_debit_overdue"}
|
||||||
|
|
||||||
|
|
||||||
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
|
||||||
|
|||||||
@@ -63,6 +63,48 @@ def test_pending_debits_select_due_open_balance(tmp_path):
|
|||||||
assert debits[0].claim_ids == ("due",)
|
assert debits[0].claim_ids == ("due",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dunned_claim_is_not_collected_again(tmp_path):
|
||||||
|
"""The dunning letter asked the member to transfer the money -- pulling it by
|
||||||
|
direct debit anyway would contradict it (and can bounce a second time)."""
|
||||||
|
repository, member = _repository(tmp_path)
|
||||||
|
draft = repository.create_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"due",
|
||||||
|
level=1,
|
||||||
|
name="Rücklastschrift",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# A draft alone is not a dunning: nothing has gone out yet.
|
||||||
|
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||||
|
assert [debit.claim_ids for debit in debits] == [("due",)]
|
||||||
|
assert warnings == []
|
||||||
|
|
||||||
|
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
|
||||||
|
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||||
|
|
||||||
|
# Nothing else is due in the window, so the member drops out of the run entirely
|
||||||
|
# -- and says why instead of vanishing silently.
|
||||||
|
assert debits == []
|
||||||
|
assert warnings == ["C3-42: „Mitgliedsbeitrag 2026“ wurde bereits gemahnt – "
|
||||||
|
"Zahlung per Überweisung erwartet, kein erneuter Einzug."]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dunned_claim_does_not_hold_back_the_members_other_claims(tmp_path):
|
||||||
|
repository, member = _repository(tmp_path)
|
||||||
|
draft = repository.create_reminder_draft(
|
||||||
|
member.member_id, "due", level=1, name="Rücklastschrift", payment_deadline_days=14
|
||||||
|
)
|
||||||
|
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
|
||||||
|
|
||||||
|
debits, warnings = pending_direct_debits(repository, due_until=date(2027, 12, 31))
|
||||||
|
|
||||||
|
assert [debit.claim_ids for debit in debits] == [("future",)]
|
||||||
|
assert debits[0].amount == Decimal("150.00")
|
||||||
|
assert len(warnings) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
|
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
|
||||||
repository, member = _repository(tmp_path)
|
repository, member = _repository(tmp_path)
|
||||||
member.accepted_at = "2025-01-01"
|
member.accepted_at = "2025-01-01"
|
||||||
|
|||||||
Reference in New Issue
Block a user