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,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}"
|
||||
Reference in New Issue
Block a user