mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Tell members which reference to put on a transfer
A data-review mail that lists the payment frequency but not how to pay leaves the
board matching anonymous transfers by hand. The mail now closes with the
reference to quote -- member number and full name, joined only where both exist
so a missing half cannot leave a dangling dash. Both are offered because either
identifies the payment on its own: a member who has forgotten their number falls
back on the name, and two members sharing a name are told apart by the number.
The note about the shortened IBAN moved out of the fixed template text into
{{data.iban_hint}}, filled only for members whose bank details are actually
listed -- it used to explain a masked IBAN to members who pay by transfer and see
no IBAN at all. It sits directly under the record as a parenthesised footnote,
which also keeps the dropped line from leaving a blank one behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9e9bb7d668
commit
31c1646571
@@ -219,11 +219,15 @@ Each recipient gets one mail listing their own record -- number, name, nickname,
|
|||||||
birth date, contact data, address, status, member since, payment frequency, and the
|
birth date, contact data, address, status, member since, payment frequency, and the
|
||||||
bank details only for members who have any. A field with no value is printed as
|
bank details only for members who have any. A field with no value is printed as
|
||||||
`(nicht hinterlegt)` so the gap is visible, and the IBAN is masked down to its
|
`(nicht hinterlegt)` so the gap is visible, and the IBAN is masked down to its
|
||||||
country code and last four digits, which is enough to recognise the account.
|
country code and last four digits, which is enough to recognise the account. The
|
||||||
|
mail closes with the reference to put on a transfer -- member number and full name,
|
||||||
|
either of which identifies the payment on its own.
|
||||||
|
|
||||||
The text comes from the `Datenüberprüfung` template. Besides the usual member and
|
The text comes from the `Datenüberprüfung` template. Besides the usual member and
|
||||||
organization placeholders it offers `{{data.sheet}}` (the whole record, one field per
|
organization placeholders it offers `{{data.sheet}}` (the whole record, one field per
|
||||||
line), `{{data.count}}`, `{{member.iban_masked}}`, and the repeat block
|
line), `{{data.count}}`, `{{member.iban_masked}}`, `{{payment.reference}}` (the
|
||||||
|
suggested transfer reference), `{{data.iban_hint}}` (the note about the shortened
|
||||||
|
IBAN, filled only for members whose bank details are listed) and the repeat block
|
||||||
`{{#data}}{{field.label}}: {{field.value}}{{/data}}` for a custom layout.
|
`{{#data}}{{field.label}}: {{field.value}}{{/data}}` for a custom layout.
|
||||||
|
|
||||||
Delivery follows the configured e-mail mode (local `.eml` files, direct send, or
|
Delivery follows the configured e-mail mode (local `.eml` files, direct send, or
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
|
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
|
||||||
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
|
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
|
||||||
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart.",
|
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart.",
|
||||||
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt."
|
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt. Die Mail nennt außerdem die Zahlweise und den Verwendungszweck, den das Mitglied bei Überweisungen angeben soll (Mitgliedsnummer und voller Name)."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ Liebes Mitglied,
|
|||||||
folgende Daten haben wir über dich gespeichert. Wir bitten dich, diese einmal zu überprüfen und uns Änderungen ggf. mitzuteilen.
|
folgende Daten haben wir über dich gespeichert. Wir bitten dich, diese einmal zu überprüfen und uns Änderungen ggf. mitzuteilen.
|
||||||
|
|
||||||
{{data.sheet}}
|
{{data.sheet}}
|
||||||
|
{{data.iban_hint}}
|
||||||
|
|
||||||
Deine IBAN zeigen wir hier absichtlich nur verkürzt an – die letzten vier Stellen genügen zum Abgleich.
|
Bitte gib bei Überweisungen an uns immer deine Mitgliedsnummer und/oder deinen vollen Namen (Vorname und Nachname) als Verwendungszweck an – dann können wir deine Zahlung sofort zuordnen:
|
||||||
|
|
||||||
|
{{payment.reference}}
|
||||||
|
|
||||||
Stimmt etwas nicht oder fehlt eine Angabe? Dann antworte einfach auf diese Mail oder schreib uns an {{organization.email}}, dann pflegen wir die Änderung ein. Wenn alles passt, musst du nichts weiter tun.
|
Stimmt etwas nicht oder fehlt eine Angabe? Dann antworte einfach auf diese Mail oder schreib uns an {{organization.email}}, dann pflegen wir die Änderung ein. Wenn alles passt, musst du nichts weiter tun.
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,14 @@ MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
|
|||||||
+ (
|
+ (
|
||||||
("data.sheet", "Alle gespeicherten Daten als Liste, eine Zeile je Feld"),
|
("data.sheet", "Alle gespeicherten Daten als Liste, eine Zeile je Feld"),
|
||||||
("data.count", "Anzahl der aufgeführten Felder"),
|
("data.count", "Anzahl der aufgeführten Felder"),
|
||||||
|
(
|
||||||
|
"data.iban_hint",
|
||||||
|
"Hinweis zur verkürzten IBAN, nur gefüllt wenn Bankdaten aufgeführt werden",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"payment.reference",
|
||||||
|
"Vorgeschlagener Verwendungszweck aus Mitgliedsnummer und vollem Namen",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
blocks=(
|
blocks=(
|
||||||
MailTemplateBlock(
|
MailTemplateBlock(
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ DEFAULT_RECIPIENT_STATUSES: tuple[str, ...] = (
|
|||||||
# empty line the eye skips over.
|
# empty line the eye skips over.
|
||||||
NOT_STORED = "(nicht hinterlegt)"
|
NOT_STORED = "(nicht hinterlegt)"
|
||||||
|
|
||||||
|
# Only makes sense next to a masked IBAN, so it is filled per member rather than
|
||||||
|
# written into the template as a fixed line. It sits directly under the record and
|
||||||
|
# reads as a footnote to it, which is also why it carries its own parentheses: a
|
||||||
|
# dropped line must not leave a blank one behind.
|
||||||
|
IBAN_HINT = (
|
||||||
|
"(Die IBAN zeigen wir absichtlich nur verkürzt an – die letzten vier Stellen "
|
||||||
|
"genügen zum Abgleich.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class GeneratedDataReviewMail:
|
class GeneratedDataReviewMail:
|
||||||
@@ -106,9 +115,7 @@ def member_data_fields(member: Member) -> list[tuple[str, str]]:
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if any(
|
if has_bank_details(member):
|
||||||
value.strip() for value in (member.iban, member.mandate_reference, member.account_holder)
|
|
||||||
):
|
|
||||||
signed = format_date_for_display(member.mandate_signed_at)
|
signed = format_date_for_display(member.mandate_signed_at)
|
||||||
fields.extend(
|
fields.extend(
|
||||||
[
|
[
|
||||||
@@ -125,6 +132,30 @@ def member_data_fields(member: Member) -> list[tuple[str, str]]:
|
|||||||
return [(label, value.strip() or NOT_STORED) for label, value in fields]
|
return [(label, value.strip() or NOT_STORED) for label, value in fields]
|
||||||
|
|
||||||
|
|
||||||
|
def has_bank_details(member: Member) -> bool:
|
||||||
|
"""Whether the club stores anything about this member's account at all. Members
|
||||||
|
who pay by transfer have nothing here, and asking them to check a mandate they
|
||||||
|
never gave is noise."""
|
||||||
|
return any(
|
||||||
|
value.strip() for value in (member.iban, member.mandate_reference, member.account_holder)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def payment_reference(member: Member) -> str:
|
||||||
|
"""What the member should write on a transfer. Both halves are offered because
|
||||||
|
either one identifies the payment on its own: a member who has forgotten their
|
||||||
|
number can fall back on the name, and a name shared by two members is told apart
|
||||||
|
by the number."""
|
||||||
|
return " – ".join(
|
||||||
|
part
|
||||||
|
for part in (
|
||||||
|
member.member_number.strip(),
|
||||||
|
" ".join(value for value in (member.first_name, member.last_name) if value).strip(),
|
||||||
|
)
|
||||||
|
if part
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def data_review_recipients(
|
def data_review_recipients(
|
||||||
repository: MemberRepository, *, statuses: tuple[str, ...] | None = None
|
repository: MemberRepository, *, statuses: tuple[str, ...] | None = None
|
||||||
) -> list[Member]:
|
) -> list[Member]:
|
||||||
@@ -156,6 +187,8 @@ def data_review_mail_bytes(
|
|||||||
"member.iban_masked": mask_iban(member.iban),
|
"member.iban_masked": mask_iban(member.iban),
|
||||||
"data.sheet": "\n".join(f"{label}: {value}" for label, value in fields),
|
"data.sheet": "\n".join(f"{label}: {value}" for label, value in fields),
|
||||||
"data.count": str(len(fields)),
|
"data.count": str(len(fields)),
|
||||||
|
"data.iban_hint": IBAN_HINT if has_bank_details(member) else "",
|
||||||
|
"payment.reference": payment_reference(member),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
rendered = render_template(repository, "data_review", values, {"data": entries})
|
rendered = render_template(repository, "data_review", values, {"data": entries})
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ from email.policy import default
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from ccma.domain.models import Member
|
||||||
from ccma.services.member_data_mail import (
|
from ccma.services.member_data_mail import (
|
||||||
data_review_recipients,
|
data_review_recipients,
|
||||||
generate_data_review_mails,
|
generate_data_review_mails,
|
||||||
mask_iban,
|
mask_iban,
|
||||||
member_data_fields,
|
member_data_fields,
|
||||||
|
payment_reference,
|
||||||
)
|
)
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
@@ -75,6 +77,13 @@ def test_the_mail_lists_the_stored_record_of_that_member(tmp_path):
|
|||||||
assert "Telefon: 0621 123456" in content
|
assert "Telefon: 0621 123456" in content
|
||||||
assert "PLZ und Ort: 68159 Mannheim" in content
|
assert "PLZ und Ort: 68159 Mannheim" in content
|
||||||
assert "Mitglied seit: 01.01.2026" in content
|
assert "Mitglied seit: 01.01.2026" in content
|
||||||
|
assert "Zahlweise: Halbjährlich" in content
|
||||||
|
# Both halves identify a transfer on their own, so both are offered.
|
||||||
|
assert "deine Mitgliedsnummer und/oder deinen vollen Namen" in content
|
||||||
|
assert f"{member.member_number} – Ada Lovelace" in content
|
||||||
|
# Nothing bank-related is stored, so the note about the shortened IBAN is left
|
||||||
|
# out instead of pointing at a line that is not there.
|
||||||
|
assert "verkürzt" not in content
|
||||||
assert content.rstrip().endswith("Der Vorstand")
|
assert content.rstrip().endswith("Der Vorstand")
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +132,23 @@ def test_bank_details_are_listed_with_a_masked_iban(tmp_path):
|
|||||||
assert "DE98670505050038907751" not in content
|
assert "DE98670505050038907751" not in content
|
||||||
assert "Mandatsreferenz: MANDAT-42" in content
|
assert "Mandatsreferenz: MANDAT-42" in content
|
||||||
assert "Lastschriftmandat: aktiv, erteilt am 01.02.2026" in content
|
assert "Lastschriftmandat: aktiv, erteilt am 01.02.2026" in content
|
||||||
|
assert "(Die IBAN zeigen wir absichtlich nur verkürzt an" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_transfer_reference_joins_number_and_name():
|
||||||
|
member = Member(
|
||||||
|
member_id="1", member_number="CCMA-0007", first_name="Ada", last_name="Lovelace"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payment_reference(member) == "CCMA-0007 – Ada Lovelace"
|
||||||
|
# The store insists on a member number, but a record read from elsewhere may not
|
||||||
|
# have one -- the name then carries the reference without a dangling dash.
|
||||||
|
assert (
|
||||||
|
payment_reference(
|
||||||
|
Member(member_id="1", member_number="", first_name="Ada", last_name="Lovelace")
|
||||||
|
)
|
||||||
|
== "Ada Lovelace"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mask_iban_keeps_country_and_last_four_digits():
|
def test_mask_iban_keeps_country_and_last_four_digits():
|
||||||
|
|||||||
Reference in New Issue
Block a user