mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +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>
111 lines
4.2 KiB
Python
111 lines
4.2 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from email.message import EmailMessage
|
||
from email.policy import SMTP
|
||
from email.utils import format_datetime, make_msgid
|
||
|
||
from ccma.domain.mail_templates import (
|
||
MailTemplate,
|
||
MailTemplateError,
|
||
render_mail_template,
|
||
template_spec,
|
||
validate_block_markers,
|
||
)
|
||
from ccma.domain.models import Member
|
||
from ccma.domain.placeholders import base_placeholder_values
|
||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||
|
||
|
||
def template_values(
|
||
member: Member,
|
||
organization: dict | None,
|
||
*,
|
||
signature: str,
|
||
created_at: datetime | None = None,
|
||
) -> dict[str, str]:
|
||
values = base_placeholder_values(member, organization, created_at=created_at)
|
||
# A mail always opens with a salutation, so an empty first name would read as
|
||
# "Hallo ," -- every other placeholder may legitimately render empty.
|
||
values["member.first_name"] = member.first_name.strip() or "Mitglied"
|
||
values["signature"] = signature.strip()
|
||
return values
|
||
|
||
|
||
def payment_instructions(
|
||
member: Member,
|
||
organization: dict | None,
|
||
*,
|
||
due_date: str,
|
||
reference: str,
|
||
expect_transfer: bool = False,
|
||
) -> str:
|
||
"""Ready-made payment paragraph: members with an active mandate are told the
|
||
money is collected, everyone else gets the club's bank details. `expect_transfer`
|
||
overrides that for a claim the mandate no longer covers -- a dunned claim is
|
||
expected as a transfer even though the member still has a mandate."""
|
||
organization = organization if isinstance(organization, dict) else {}
|
||
if member.mandate_active and not expect_transfer:
|
||
mandate = member.mandate_reference.strip()
|
||
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
|
||
return (
|
||
f"Du hast uns ein SEPA-Lastschriftmandat erteilt{mandate_hint} – wir ziehen den "
|
||
"Betrag fristgerecht von deinem Konto ein. Du musst also nichts weiter tun; "
|
||
"vor dem Einzug informieren wir dich rechtzeitig per E-Mail."
|
||
)
|
||
deadline = f" bis zum {due_date}" if due_date else ""
|
||
lines = [f"Bitte überweise den Betrag{deadline} auf unser Vereinskonto:", ""]
|
||
lines.append(f"IBAN: {str(organization.get('iban', '')).strip()}")
|
||
bic = str(organization.get("bic", "")).strip()
|
||
if bic:
|
||
lines.append(f"BIC: {bic}")
|
||
if reference.strip():
|
||
lines.append(f"Verwendungszweck: {reference.strip()}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def render_template(
|
||
repository: MemberRepository,
|
||
key: str,
|
||
values: dict[str, str],
|
||
repeats: dict[str, list[dict[str, str]]] | None = None,
|
||
) -> MailTemplate:
|
||
template = repository.get_mail_template(key)
|
||
try:
|
||
# The templates are plain files in the store and may have been edited outside
|
||
# CCMA, so the structural check runs again here: refusing to send beats
|
||
# sending a mail with "{{#claims}}" in its text.
|
||
validate_block_markers(key, template.subject, template.body)
|
||
return render_mail_template(template, values, repeats)
|
||
except MailTemplateError as exc:
|
||
raise RepositoryError(
|
||
f"Die E-Mail-Vorlage „{template_spec(key).label}“ ist fehlerhaft: {exc}"
|
||
) from exc
|
||
|
||
|
||
def compose_mail(
|
||
*,
|
||
recipient: str,
|
||
subject: str,
|
||
body: str,
|
||
sender_name: str,
|
||
sender_email: str,
|
||
created_at: datetime | None = None,
|
||
) -> bytes:
|
||
if not recipient.strip():
|
||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||
if not sender_email.strip() or "@" not in sender_email:
|
||
raise RepositoryError("Für den Versand ist eine gültige Absenderadresse erforderlich.")
|
||
timestamp = created_at or datetime.now().astimezone()
|
||
message = EmailMessage(policy=SMTP)
|
||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
||
message["Date"] = format_datetime(timestamp)
|
||
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
|
||
message["To"] = recipient.strip()
|
||
message["Subject"] = subject.strip()
|
||
message["X-Mozilla-Draft-Info"] = (
|
||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||
)
|
||
message.set_content(body.strip() + "\n", charset="utf-8")
|
||
return message.as_bytes()
|