mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-05 19:20:48 +02:00
Once the direct debit bounced and the board sent the Rücklastschrift reminder,
the claim is expected as a transfer by the deadline that letter states. Three
places still treated it as a claim the mandate covers, and the housekeeper's was
the one the board kept running into: after the reminder's deadline lapsed, the
finding went back to "Lastschrift überfällig -- Einzug prüfen, eine postalische
Mahnung ist hier nicht vorgesehen", for a claim that had just been dunned.
The rule now asks whether the claim was dunned before treating it as one for the
direct debit. If it was, it continues in the ordinary dunning sequence: the
running deadline shows as the usual "Frist läuft noch" note, and once that has
passed the next dunning level comes due. The SEPA-specific pending-reminder
detour that used to cover the deadline window is gone with it -- the ordinary
path reports the same thing.
The SEPA run now skips a dunned claim as well, instead of quietly collecting the
money the letter asked the member to transfer (which can bounce a second time,
with a second fee). The skip is reported like the incomplete mandates are, so
nothing disappears from the run without saying why; the dialog's wording is no
longer specific to mandates.
And a dunning mail asks for a transfer even from a member with an active
mandate. The shipped template spells the bank details out, but the ready-made
{{payment.instructions}} paragraph, offered by the template editor for exactly
this mail, told them "wir ziehen den Betrag ein, du musst nichts weiter tun" --
in the letter demanding payment.
Reverting the sent reminder is what puts the claim back into the direct-debit
run; the read of "dunned" is a sent reminder, not a draft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
469 lines
16 KiB
Python
469 lines
16 KiB
Python
from contextlib import contextmanager
|
|
from email.parser import BytesParser
|
|
from email.policy import default
|
|
|
|
import pytest
|
|
from test_reminders import _overdue_claim_repository
|
|
|
|
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
|
from ccma.storage.repository import RepositoryError
|
|
|
|
|
|
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
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)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
detail="Bitte Mitgliedsnummer angeben.",
|
|
)
|
|
export_path = tmp_path / "Zahlungserinnerung.eml"
|
|
|
|
generated = generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=export_path,
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
assert message["To"] == "reminder@example.org"
|
|
assert "Zahlungserinnerung" in message["Subject"]
|
|
assert "100.00 Euro" in message.get_content()
|
|
assert "DE98670505050038907751" in message.get_content()
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "sent"
|
|
assert data.reminders[0]["payment_deadline"]
|
|
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
|
|
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):
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Erste Mahnung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
|
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
|
|
|
|
|
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
repository.save_email_settings(
|
|
delivery_mode="send",
|
|
smtp_host="mail.example.org",
|
|
smtp_port=587,
|
|
smtp_security="none",
|
|
smtp_username="",
|
|
smtp_password="",
|
|
imap_host="",
|
|
imap_port=993,
|
|
imap_security="ssl",
|
|
imap_username="",
|
|
imap_password="",
|
|
imap_drafts_folder="",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
|
|
sent = []
|
|
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
|
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
|
)
|
|
|
|
generated = generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert generated.export_path is None
|
|
assert generated.archive_path.is_file()
|
|
assert len(sent) == 1
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "sent"
|
|
|
|
|
|
def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
repository.save_email_settings(
|
|
delivery_mode="send",
|
|
smtp_host="mail.example.org",
|
|
smtp_port=587,
|
|
smtp_security="none",
|
|
smtp_username="",
|
|
smtp_password="",
|
|
imap_host="mail.example.org",
|
|
imap_port=993,
|
|
imap_security="ssl",
|
|
imap_username="",
|
|
imap_password="",
|
|
imap_drafts_folder="",
|
|
imap_sent_enabled=True,
|
|
imap_sent_folder="INBOX.Gesendet",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
|
)
|
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "imap_session", contextmanager(lambda settings: iter(["imap-client"]))
|
|
)
|
|
ensured = []
|
|
appended = []
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append((client, folder))
|
|
)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module,
|
|
"append_message",
|
|
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
|
)
|
|
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
|
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
|
|
|
|
|
|
def test_reminder_mail_send_failure_reverts_sent_status(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
repository.save_email_settings(
|
|
delivery_mode="send",
|
|
smtp_host="mail.example.org",
|
|
smtp_port=587,
|
|
smtp_security="none",
|
|
smtp_username="",
|
|
smtp_password="",
|
|
imap_host="",
|
|
imap_port=993,
|
|
imap_security="ssl",
|
|
imap_username="",
|
|
imap_password="",
|
|
imap_drafts_folder="",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
@contextmanager
|
|
def _fake_smtp_session(settings):
|
|
yield "smtp-client"
|
|
|
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
|
|
|
def _failing_send(client, content):
|
|
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
|
|
|
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", _failing_send)
|
|
|
|
with pytest.raises(RepositoryError):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
stored_reminder = data.reminders[0]
|
|
assert stored_reminder["status"] == "draft"
|
|
assert not stored_reminder.get("fee_item_ids")
|
|
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent_reverted"
|
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
|
assert not any(
|
|
item.get("description") == "Mahngebühr" for item in claim.get("items", [])
|
|
)
|
|
|
|
|
|
def test_reminder_mail_local_write_failure_reverts_sent_status(tmp_path, monkeypatch):
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
def _boom(*args, **kwargs):
|
|
raise OSError("disk full")
|
|
|
|
monkeypatch.setattr(reminder_mail_module.os, "replace", _boom)
|
|
|
|
with pytest.raises(OSError):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Zahlungserinnerung.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "draft"
|
|
|
|
|
|
def test_reminder_mail_archiving_failure_after_smtp_success_does_not_revert(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
repository.save_email_settings(
|
|
delivery_mode="send",
|
|
smtp_host="mail.example.org",
|
|
smtp_port=587,
|
|
smtp_security="none",
|
|
smtp_username="",
|
|
smtp_password="",
|
|
imap_host="",
|
|
imap_port=993,
|
|
imap_security="ssl",
|
|
imap_username="",
|
|
imap_password="",
|
|
imap_drafts_folder="",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
@contextmanager
|
|
def _fake_smtp_session(settings):
|
|
yield "smtp-client"
|
|
|
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
|
|
|
real_replace = reminder_mail_module.os.replace
|
|
|
|
def _flaky_replace(src, dst):
|
|
# Only the final archive move (into the "Mahnungen" folder) should fail --
|
|
# the mail has already been handed to send_via_smtp by that point.
|
|
if str(dst).endswith(".eml") and "Mahnungen" in str(dst):
|
|
raise OSError("disk full")
|
|
return real_replace(src, dst)
|
|
|
|
monkeypatch.setattr(reminder_mail_module.os, "replace", _flaky_replace)
|
|
|
|
with pytest.raises(RepositoryError, match="zugestellt"):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
stored_reminder = data.reminders[0]
|
|
assert stored_reminder["status"] == "sent", "mail was already sent -- must not be un-sent"
|
|
assert stored_reminder.get("fee_item_ids")
|
|
events = repository.get_events(member.member_id)
|
|
assert events[-1].event_type == "reminder_email_sent"
|
|
assert events[-1].data["archive_error"]
|
|
assert "document" not in events[-1].references
|
|
|
|
|
|
def test_reminder_mail_archive_dir_creation_failure_reverts_sent_status(tmp_path, monkeypatch):
|
|
from pathlib import Path
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
original_mkdir = Path.mkdir
|
|
|
|
def _flaky_mkdir(self, *args, **kwargs):
|
|
if self.name == "Mahnungen":
|
|
raise OSError("permission denied")
|
|
return original_mkdir(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
|
|
|
|
with pytest.raises(OSError):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Zahlungserinnerung.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
stored_reminder = data.reminders[0]
|
|
assert stored_reminder["status"] == "draft"
|
|
assert not stored_reminder.get("fee_item_ids")
|