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:
Marcel Peterkau
2026-08-28 20:49:08 +02:00
co-authored by Claude Opus 5
parent d6cd58a788
commit aa0c16347b
5 changed files with 722 additions and 1 deletions
+242
View File
@@ -0,0 +1,242 @@
from __future__ import annotations
import hashlib
import os
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from ccma.domain.contributions import claim_balance, claim_status, claim_total, money_text
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import Member
from ccma.services.mail_composition import (
compose_mail,
payment_instructions,
render_template,
template_values,
)
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
imap_session,
send_via_smtp,
smtp_session,
)
from ccma.storage.repository import MemberRepository, RepositoryError
OPEN_CLAIM_STATUSES = {"open", "partially_paid", "overdue"}
@dataclass(frozen=True, slots=True)
class GeneratedWelcomeMail:
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
export_path: Path | None
archive_path: Path
sha256: str
def open_claims(repository: MemberRepository, member_id: str) -> list[dict]:
"""The claims a welcome mail can bill: everything still owed, which for a fresh
member is the admission fee plus the first contribution claim(s)."""
data = repository.get_contributions(member_id)
selected = [
claim for claim in data.claims if claim_status(data, claim) in OPEN_CLAIM_STATUSES
]
return sorted(selected, key=lambda claim: (str(claim.get("due_date", "")), str(claim.get("title", ""))))
def membership_start(member: Member, *, today: date | None = None) -> date:
"""The date the membership (and with it the first contribution) starts. A member
accepted this month usually starts on the 1st of the next one, and that date
cannot be stored yet -- "Mitglied seit" rejects future dates."""
try:
return date.fromisoformat(str(member.membership_started_at))
except ValueError:
pass
try:
reference = date.fromisoformat(str(member.accepted_at))
except ValueError:
reference = today or date.today()
return date(reference.year + reference.month // 12, reference.month % 12 + 1, 1)
def welcome_mail_bytes(
repository: MemberRepository,
*,
member: Member,
claims: list[dict],
balances: dict[str, Decimal],
organization: dict,
sender_name: str,
sender_email: str,
signature: str,
created_at: datetime | None = None,
) -> bytes:
values = template_values(member, organization, signature=signature, created_at=created_at)
entries = [
{
"claim.title": str(claim.get("title", "")),
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
"claim.due_date": format_date_for_display(str(claim.get("due_date", ""))),
"claim.amount": money_text(claim_total(claim)),
"claim.balance": money_text(balances[str(claim.get("claim_id", ""))]),
}
for claim in claims
]
total = sum((balances[str(claim.get("claim_id", ""))] for claim in claims), Decimal("0"))
due_dates = sorted(
str(claim.get("due_date", "")) for claim in claims if str(claim.get("due_date", ""))
)
first_due_date = format_date_for_display(due_dates[0]) if due_dates else ""
reference = " ".join(
part
for part in (
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
"Mitgliedsbeitrag",
)
if part
)
values.update(
{
"membership.start_date": format_date_for_display(
membership_start(member).isoformat()
),
"claims.list": "\n".join(
f"{entry['claim.description']} (fällig {entry['claim.due_date']}): "
f"{entry['claim.balance']} Euro"
for entry in entries
),
"claims.total": money_text(total),
"claims.count": str(len(entries)),
"claims.first_due_date": first_due_date,
"payment.reference": reference,
"payment.instructions": payment_instructions(
member, organization, due_date=first_due_date, reference=reference
),
}
)
rendered = render_template(repository, "welcome", values, {"claims": entries})
return compose_mail(
recipient=member.email,
subject=rendered.subject,
body=rendered.body,
sender_name=sender_name,
sender_email=sender_email,
created_at=created_at,
)
def generate_and_send_welcome_mail(
repository: MemberRepository,
member_id: str,
*,
claim_ids: list[str] | None = None,
delivery_mode: str,
output_path: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
) -> GeneratedWelcomeMail:
if delivery_mode not in {"local", "send", "drafts"}:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
member = repository.get_member(member_id)
if not member.email.strip():
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
data = repository.get_contributions(member_id)
selected_ids = None if claim_ids is None else {str(value) for value in claim_ids}
claims = [
claim
for claim in open_claims(repository, member_id)
if selected_ids is None or str(claim.get("claim_id", "")) in selected_ids
]
if selected_ids is not None and len(claims) != len(selected_ids):
raise RepositoryError("Mindestens eine ausgewählte Forderung ist nicht mehr offen.")
balances = {
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
}
target: Path | None = None
if delivery_mode == "local":
if not output_path:
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
organization = repository.get_configuration().get("organization") or {}
content = welcome_mail_bytes(
repository,
member=member,
claims=claims,
balances=balances,
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
export_path: Path | None = None
try:
archive_temp.write_bytes(content)
if delivery_mode == "local":
export_temp = target.with_name(f".{target.name}.tmp")
try:
export_temp.write_bytes(content)
os.replace(export_temp, target)
finally:
export_temp.unlink(missing_ok=True)
export_path = target
elif delivery_mode == "send":
settings = repository.get_email_settings()
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
send_via_smtp(smtp_client, content)
if imap_client is not None:
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
append_message(
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
settings = repository.get_email_settings()
with imap_session(settings) as client:
ensure_imap_folder(client, settings["imap_drafts_folder"])
append_message(
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
)
os.replace(archive_temp, archive_path)
finally:
archive_temp.unlink(missing_ok=True)
relative_path = archive_path.relative_to(
repository.members_root / member_id / "files"
).as_posix()
digest = hashlib.sha256(content).hexdigest()
repository.append_event(
member_id,
event_type="welcome_email_sent",
summary="Willkommens- und Erstrechnungsmail erzeugt",
actor_type="user",
actor_name="Vorstand",
references={"document": relative_path},
data={
"recipient": member.email,
"sha256": digest,
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
"delivery_mode": delivery_mode,
},
)
return GeneratedWelcomeMail(export_path, archive_path, digest)
def _available_path(directory: Path, filename: str) -> Path:
candidate = directory / filename
if not candidate.exists():
return candidate
stem, suffix = Path(filename).stem, Path(filename).suffix
index = 2
while (directory / f"{stem}-{index}{suffix}").exists():
index += 1
return directory / f"{stem}-{index}{suffix}"