mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Send new members a welcome mail that doubles as their first invoice
Accepting a member left the board writing the "welcome, you are a member from
the 1st, and this is what it costs" mail by hand. The member file now offers it
directly: it greets the member, names the membership number and the start of the
membership, and lists every claim that is still open -- admission fee and first
contribution -- with a payment hint that adapts to whether a direct debit
mandate is active.
"Mitglied seit" rejects future dates, so a member accepted this month cannot
carry their actual start date yet; {{membership.start_date}} therefore falls
back to the 1st of the month following the acceptance decision. Individual
claims can be deselected before sending, the finished mail can be previewed,
and the result is archived in the member file like every other generated mail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d6cd58a788
commit
aa0c16347b
@@ -0,0 +1,221 @@
|
||||
from contextlib import contextmanager
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def _new_member_repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
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)
|
||||
member = repository.create_member(
|
||||
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||
)
|
||||
member.email = "ada@example.org"
|
||||
member.accepted_at = "2026-08-20"
|
||||
repository.save_member(member)
|
||||
repository.create_manual_claim(
|
||||
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
|
||||
)
|
||||
repository.create_manual_claim(
|
||||
member.member_id,
|
||||
title="Mitgliedsbeitrag 2. Halbjahr 2026",
|
||||
amount="60.00",
|
||||
due_date="2026-09-17",
|
||||
)
|
||||
return repository, member
|
||||
|
||||
|
||||
def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
export_path = tmp_path / "Willkommen.eml"
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=export_path,
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
content = message.get_content()
|
||||
assert message["To"] == "ada@example.org"
|
||||
assert "Willkommen" in message["Subject"]
|
||||
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
||||
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content
|
||||
assert "Gesamtbetrag: 75.00 Euro" in content
|
||||
assert "DE98670505050038907751" in content
|
||||
assert content.rstrip().endswith("Der Vorstand")
|
||||
# No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September.
|
||||
assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content
|
||||
|
||||
|
||||
def test_welcome_mail_is_archived_and_logged(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert generated.archive_path.parent.name == "Willkommen"
|
||||
assert generated.archive_path.read_bytes() == generated.export_path.read_bytes()
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.event_type == "welcome_email_sent"
|
||||
assert event.data["recipient"] == "ada@example.org"
|
||||
assert len(event.data["claim_ids"]) == 2
|
||||
|
||||
|
||||
def test_only_selected_claims_are_billed(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
admission = next(
|
||||
claim for claim in open_claims(repository, member.member_id)
|
||||
if claim["title"] == "Aufnahmegebühr"
|
||||
)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
claim_ids=[admission["claim_id"]],
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.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 "Aufnahmegebühr" in content
|
||||
assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content
|
||||
assert "Gesamtbetrag: 15.00 Euro" in content
|
||||
|
||||
|
||||
def test_members_with_mandate_are_told_the_money_is_collected(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
member.account_holder = "Ada Lovelace"
|
||||
member.iban = "DE98670505050038907751"
|
||||
member.mandate_reference = "MANDAT-42"
|
||||
member.mandate_signed_at = "2026-08-20"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.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 "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content
|
||||
assert "Bitte überweise" not in content
|
||||
|
||||
|
||||
def test_edited_template_is_used_for_the_mail(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
repository.save_mail_template(
|
||||
"welcome",
|
||||
subject="Servus {{member.first_name}}",
|
||||
body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}",
|
||||
)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||
assert message["Subject"] == "Servus Ada"
|
||||
assert message.get_content().replace("\r\n", "\n").strip() == (
|
||||
"Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026"
|
||||
)
|
||||
|
||||
|
||||
def test_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
||||
import ccma.services.welcome_mail as welcome_mail_module
|
||||
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="smtp.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="verwaltung",
|
||||
smtp_password="secret",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="INBOX.Entwürfe",
|
||||
)
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||
)
|
||||
|
||||
generated = generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_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.read_bytes() == sent[0]
|
||||
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
|
||||
|
||||
|
||||
def test_member_without_email_is_refused(tmp_path):
|
||||
repository, member = _new_member_repository(tmp_path)
|
||||
member.email = ""
|
||||
repository.save_member(member)
|
||||
|
||||
with pytest.raises(RepositoryError, match="E-Mail-Adresse"):
|
||||
generate_and_send_welcome_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
delivery_mode="local",
|
||||
output_path=tmp_path / "Willkommen.eml",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
Reference in New Issue
Block a user