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:
Marcel Peterkau
2026-09-04 22:08:40 +02:00
co-authored by Claude Opus 5
parent 9e9bb7d668
commit 31c1646571
6 changed files with 81 additions and 7 deletions
+36 -3
View File
@@ -38,6 +38,15 @@ DEFAULT_RECIPIENT_STATUSES: tuple[str, ...] = (
# empty line the eye skips over.
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)
class GeneratedDataReviewMail:
@@ -106,9 +115,7 @@ def member_data_fields(member: Member) -> list[tuple[str, str]]:
),
]
)
if any(
value.strip() for value in (member.iban, member.mandate_reference, member.account_holder)
):
if has_bank_details(member):
signed = format_date_for_display(member.mandate_signed_at)
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]
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(
repository: MemberRepository, *, statuses: tuple[str, ...] | None = None
) -> list[Member]:
@@ -156,6 +187,8 @@ def data_review_mail_bytes(
"member.iban_masked": mask_iban(member.iban),
"data.sheet": "\n".join(f"{label}: {value}" for label, value in 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})