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
+16
View File
@@ -38,6 +38,7 @@ from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame
from ccma.ui.welcome_mail_dialog import WelcomeMailDialog
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
# and amount-like columns get the same fixed width, status columns get a third more
@@ -355,6 +356,13 @@ class MemberTab(ttk.Frame):
self.claims.grid(row=1, column=0, sticky="nsew")
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
claim_actions = ttk.Frame(contribution_tab)
claim_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(
claim_actions,
text="Willkommens- und Erstrechnungsmail",
command=self._send_welcome_mail,
).pack(side="left")
contribution_override_tab.columnconfigure(0, weight=1)
contribution_override_tab.rowconfigure(1, weight=1)
@@ -1177,6 +1185,14 @@ class MemberTab(ttk.Frame):
except OSError as exc:
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
def _send_welcome_mail(self) -> None:
WelcomeMailDialog(self, self.repository, self.member_id, self._welcome_mail_sent)
def _welcome_mail_sent(self) -> None:
self._refresh_documents()
self._refresh_events()
self.on_changed()
def _create_document(self) -> None:
DocumentTemplateDialog(
self,
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from decimal import Decimal
from email.parser import BytesParser
from email.policy import default as default_policy
from tkinter import filedialog, messagebox, ttk
from ccma.domain.contributions import claim_balance, money_text
from ccma.domain.dates import format_date_for_display
from ccma.services.welcome_mail import (
generate_and_send_welcome_mail,
open_claims,
welcome_mail_bytes,
)
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
class WelcomeMailDialog(tk.Toplevel):
"""Sends the welcome mail that doubles as the first invoice for a new member:
membership start, member number and every claim that is still open."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_sent: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.on_sent = on_sent
self.claims: list[dict] = []
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(value=str(organization.get("name") or "Verwaltung C3MA"))
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.summary_var = tk.StringVar()
self.title("Willkommens- und Erstrechnungsmail")
self.transient(master.winfo_toplevel())
self.resizable(True, True)
self._build_ui()
self._refresh_claims()
self.grab_set()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
ttk.Label(
self,
text=(
"Begrüßt das neue Mitglied und stellt die noch offenen Forderungen "
"(Aufnahmegebühr und erster Beitrag) als Erstrechnung zusammen. Text und "
"Betreff stammen aus der Vorlage unter Optionen → E-Mail-Vorlagen."
),
wraplength=640,
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
ttk.Label(
self,
text=(
"Standardmäßig sind alle offenen Forderungen ausgewählt einzelne mit "
"Strg+Klick abwählen."
),
style="Muted.TLabel",
wraplength=640,
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
table = ttk.Frame(self)
table.grid(row=2, column=0, sticky="nsew", padx=14)
table.columnconfigure(0, weight=1)
table.rowconfigure(0, weight=1)
self.table = ttk.Treeview(
table,
columns=("due", "amount", "title"),
show="headings",
selectmode="extended",
height=6,
)
for key, title, width in (
("due", "Fällig", 90),
("amount", "Offen", 90),
("title", "Forderung", 320),
):
self.table.heading(key, text=title)
self.table.column(key, width=width, anchor="w", stretch=key == "title")
self.table.grid(row=0, column=0, sticky="nsew")
scroll = ttk.Scrollbar(table, orient="vertical", command=self.table.yview)
scroll.grid(row=0, column=1, sticky="ns")
self.table.configure(yscrollcommand=scroll.set)
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=3, column=0, sticky="w", padx=14, pady=(6, 10)
)
fields = ttk.Frame(self)
fields.grid(row=4, column=0, sticky="ew", padx=14)
fields.columnconfigure(1, weight=1)
ttk.Label(fields, text="Absendername").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_name_var, width=52).grid(row=0, column=1, sticky="ew")
ttk.Label(fields, text="Absenderadresse").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_email_var).grid(row=1, column=1, sticky="ew")
ttk.Label(fields, text="Signatur").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
self.signature = tk.Text(fields, width=52, height=4, wrap="word")
self.signature.grid(row=2, column=1, sticky="ew", pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=5, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(buttons, text="Vorschau", command=self._preview).pack(side="left", padx=(8, 0))
ttk.Button(
buttons, text="E-Mail erzeugen", style="Accent.TButton", command=self._generate
).pack(side="left", padx=(8, 0))
def _refresh_claims(self) -> None:
try:
self.claims = open_claims(self.repository, self.member_id)
data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Forderungen konnten nicht gelesen werden", str(exc), parent=self)
return
self.table.delete(*self.table.get_children())
for index, claim in enumerate(self.claims):
self.table.insert(
"",
"end",
iid=str(index),
values=(
format_date_for_display(str(claim.get("due_date", ""))),
money_text(claim_balance(data, claim)),
str(claim.get("description", "") or claim.get("title", "")),
),
)
self.table.selection_set(self.table.get_children())
self._update_summary()
def _selected_claims(self) -> list[dict]:
return [self.claims[int(iid)] for iid in self.table.selection()]
def _update_summary(self) -> None:
data = self.repository.get_contributions(self.member_id)
selected = self._selected_claims()
total = sum((claim_balance(data, claim) for claim in selected), Decimal("0"))
self.summary_var.set(
f"{len(selected)} von {len(self.claims)} Forderungen ausgewählt "
f"Gesamtbetrag {money_text(total)} Euro"
)
def _mail_bytes(self) -> bytes:
member = self.repository.get_member(self.member_id)
data = self.repository.get_contributions(self.member_id)
claims = self._selected_claims()
return welcome_mail_bytes(
self.repository,
member=member,
claims=claims,
balances={
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
},
organization=self.repository.get_configuration().get("organization") or {},
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
def _preview(self) -> None:
try:
message = BytesParser(policy=default_policy).parsebytes(self._mail_bytes())
except (OSError, RepositoryError) as exc:
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
return
window = tk.Toplevel(self)
window.title("Vorschau der Willkommensmail")
window.transient(self)
window.columnconfigure(0, weight=1)
window.rowconfigure(1, weight=1)
ttk.Label(window, text=f"Betreff: {message['Subject']}", padding=12).grid(
row=0, column=0, sticky="w"
)
preview = tk.Text(window, wrap="word", width=88, height=28)
preview.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))
preview.insert("1.0", message.get_content())
preview.configure(state="disabled")
scroll = ttk.Scrollbar(window, orient="vertical", command=preview.yview)
scroll.grid(row=1, column=1, sticky="ns", pady=(0, 12), padx=(0, 12))
preview.configure(yscrollcommand=scroll.set)
ttk.Button(window, text="Schließen", command=window.destroy).grid(
row=2, column=0, columnspan=2, sticky="e", padx=12, pady=(0, 12)
)
def _generate(self) -> None:
selected = self._selected_claims()
if not selected and not messagebox.askyesno(
"Keine Forderung ausgewählt",
"Es ist keine Forderung ausgewählt. Soll die Mail ohne Rechnungspositionen "
"erzeugt werden?",
parent=self,
):
return
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_path = None
if delivery_mode == "local":
chosen = filedialog.asksaveasfilename(
parent=self,
title="Willkommensmail als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile="Willkommen.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not chosen:
return
output_path = chosen
try:
result = generate_and_send_welcome_mail(
self.repository,
self.member_id,
claim_ids=[str(claim.get("claim_id", "")) for claim in selected],
delivery_mode=delivery_mode,
output_path=output_path,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("E-Mail konnte nicht erzeugt werden", str(exc), parent=self)
return
outcome = {
"local": f"gespeichert:\n{result.export_path}",
"send": "direkt versandt.",
"drafts": "als Entwurf im Postfach abgelegt.",
}[delivery_mode]
messagebox.showinfo(
"Willkommensmail erzeugt",
f"Die E-Mail wurde {outcome}\n\nEine Kopie liegt in der Mitgliederakte.",
parent=self,
)
self.destroy()
self.on_sent()