18 Commits
Author SHA1 Message Date
Marcel Peterkau 6d9c50b148 Merge pull request 'feature/member-number-allocation-strategy' (#10) from feature/member-number-allocation-strategy into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/10
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-30 17:13:53 +00:00
Marcel Peterkau 54cb413eff docs: add member numbering strategy to changelog 2026-07-30 19:08:11 +02:00
Marcel Peterkau cfd5de42f2 feat: configure member number allocation strategy 2026-07-30 19:07:18 +02:00
Git-CI 460dcac77c ci: bump dev version to 0.1.0-dev3 [skip ci] 2026-07-30 18:58:41 +02:00
Marcel Peterkau 2280a0c658 Merge pull request 'feature/sepa-direct-debit-export' (#6) from feature/sepa-direct-debit-export into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/6
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-30 16:57:03 +00:00
Marcel Peterkau f14011efdc docs: describe SEPA and reminder mail features 2026-07-30 18:51:57 +02:00
Marcel Peterkau 060a5aea01 docs: remove temporary legacy claims tool 2026-07-30 18:48:50 +02:00
Marcel Peterkau fc4eacb591 feat: add reminder email drafts 2026-07-30 01:17:33 +02:00
Marcel Peterkau 3d3b845d9f feat: add SEPA direct debit exports and notifications 2026-07-30 01:00:32 +02:00
Git-CI f4c8ae4f35 ci: bump dev version to 0.1.0-dev2 [skip ci] 2026-07-24 19:11:39 +02:00
Marcel Peterkau 975cb47d3e Merge pull request 'fix: improve member list defaults and highlighting' (#5) from fix/member-list-defaults-and-highlights into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/5
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-24 17:10:00 +00:00
Marcel Peterkau 9942e7aa22 fix: improve member list defaults and highlighting 2026-07-24 18:14:49 +02:00
Git-CI 68c24219ec ci: bump dev version to 0.1.0-dev1 [skip ci] 2026-07-22 23:51:07 +02:00
Marcel Peterkau 6a76c7b84b Merge pull request 'feat: extend inventory and administration workflows' (#4) from feature/inventory-and-admin-workflows into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/4
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-22 21:49:30 +00:00
Marcel Peterkau 7146615499 test: derive expected version from VERSION file 2026-07-22 23:48:39 +02:00
Marcel Peterkau dbae0ce29c docs: add upcoming 0.2.0 changelog 2026-07-22 23:15:21 +02:00
Marcel Peterkau 070684d9bc feat: extend inventory and administration workflows 2026-07-22 22:26:02 +02:00
Git-CI aeaaca1459 ci: bump dev version to 0.1.0-dev0 [skip ci] 2026-06-27 17:27:41 +02:00
29 changed files with 2947 additions and 104 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0 0.1.0-dev3
+20 -1
View File
@@ -1,6 +1,25 @@
[ [
{ {
"version": "1.0.0", "version": "0.2.0",
"date": "upcoming",
"changes": [
"Eigentum und aktueller Gewahrsam von Assets werden getrennt dokumentiert; neben Vereinseigentum kann nun auch Eigentum von Mitgliedern oder externen Personen zentral erfasst werden.",
"Mitgliedseigentum wird per UUID mit der Mitgliedsakte verknüpft und erscheint dort gemeinsam mit den vom Verein erhaltenen Assets in getrennt verständlichen Ansichten.",
"Standort, Übergabedatum, Zustand und Wert eines Assets können für Übergabe- und Versicherungsnachweise festgehalten werden; Eigentumsänderungen erscheinen zusätzlich in der Chronik.",
"Forderungen können nachträglich bearbeitet werden, ohne zusätzliche Forderungspositionen oder bereits verbuchte Zahlungen zu verlieren.",
"Zahlungen können bearbeitet, neu auf mehrere Forderungen verteilt oder einschließlich ihrer Zuordnungen gelöscht werden.",
"Das Austrittsdatum eines Mitglieds wird gespeichert und validiert; automatische Beitragsforderungen werden nicht mehr für Fälligkeiten nach dem Mitgliedschaftsende erzeugt.",
"Anteilig berechnete Mitgliedsbeiträge weisen den abgerechneten Zeitraum verständlicher in ihrer Beschreibung aus.",
"Die Mitgliederliste zeigt standardmäßig aktive Mitglieder, ordnet Nachname vor Vorname an und sortiert aufsteigend nach Nachnamen.",
"Mitglieder mit offenen Hausmeister-Vorgängen werden in der Liste je nach höchster Dringlichkeit blau, gelb oder rot hervorgehoben.",
"Offene Forderungen von Mitgliedern mit aktivem Lastschriftmandat können für einen frei wählbaren Fälligkeitszeitraum gesammelt und als bankfähige SEPA-XML-Datei oder als CSV-Übersicht exportiert werden; unvollständige Mandate werden dabei verständlich ausgewiesen.",
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert."
]
},
{
"version": "0.1.0",
"date": "2026-06-27", "date": "2026-06-27",
"changes": [ "changes": [
"First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.", "First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.",
+6
View File
@@ -117,18 +117,24 @@ def validate_member_dates(
birth_date: str, birth_date: str,
accepted_at: str = "", accepted_at: str = "",
membership_started_at: str = "", membership_started_at: str = "",
membership_ended_at: str = "",
today: date | None = None, today: date | None = None,
) -> None: ) -> None:
reference = today or date.today() reference = today or date.today()
birth = validate_birth_date(birth_date, today=reference) birth = validate_birth_date(birth_date, today=reference)
accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference) accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference)
started = _validate_not_future(membership_started_at, "Mitglied seit", reference) started = _validate_not_future(membership_started_at, "Mitglied seit", reference)
ended = parse_iso_date(membership_ended_at, "Austrittsdatum")
if birth and accepted and accepted < birth: if birth and accepted and accepted < birth:
raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.") raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.")
if birth and started and started < birth: if birth and started and started < birth:
raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.") raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.")
if accepted and started and started < accepted: if accepted and started and started < accepted:
raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.") raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.")
if started and ended and ended < started:
raise DateValidationError("Austrittsdatum darf nicht vor dem Mitgliedschaftsbeginn liegen.")
if accepted and ended and ended < accepted:
raise DateValidationError("Austrittsdatum darf nicht vor dem Aufnahmebeschluss liegen.")
def calculate_age(birth_date: date, on_date: date | None = None) -> int: def calculate_age(birth_date: date, on_date: date | None = None) -> int:
+45
View File
@@ -27,6 +27,19 @@ ASSET_STATUS_LABELS = {
"retired": "AUSGEMUSTERT", "retired": "AUSGEMUSTERT",
} }
ASSET_OWNER_TYPE_LABELS = {
"club": "VEREIN",
"member": "MITGLIED",
"external": "EXTERN",
}
ASSET_CUSTODY_TYPE_LABELS = {
"club": "VEREIN",
"member": "MITGLIED",
"external": "EXTERN",
"unknown": "UNBEKANNT",
}
HOUSEKEEPER_MEMBER_FIELD_LABELS = { HOUSEKEEPER_MEMBER_FIELD_LABELS = {
"nickname": "Nickname", "nickname": "Nickname",
"email": "E-Mail-Adresse", "email": "E-Mail-Adresse",
@@ -77,6 +90,7 @@ class Member:
status: str = "application" status: str = "application"
accepted_at: str = "" accepted_at: str = ""
membership_started_at: str = "" membership_started_at: str = ""
membership_ended_at: str = ""
payment_frequency: str = "annual" payment_frequency: str = "annual"
contribution_rule_id: str = "standard-2022" contribution_rule_id: str = "standard-2022"
honorary: bool = False honorary: bool = False
@@ -122,6 +136,7 @@ class Member:
"status": self.status, "status": self.status,
"accepted_at": self.accepted_at, "accepted_at": self.accepted_at,
"started_at": self.membership_started_at, "started_at": self.membership_started_at,
"ended_at": self.membership_ended_at,
"honorary": self.honorary, "honorary": self.honorary,
}, },
"contribution_profile": { "contribution_profile": {
@@ -165,6 +180,7 @@ class Member:
status=str(membership.get("status", "application")), status=str(membership.get("status", "application")),
accepted_at=str(membership.get("accepted_at", "")), accepted_at=str(membership.get("accepted_at", "")),
membership_started_at=str(membership.get("started_at", "")), membership_started_at=str(membership.get("started_at", "")),
membership_ended_at=str(membership.get("ended_at", "")),
honorary=bool(membership.get("honorary", False)), honorary=bool(membership.get("honorary", False)),
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")), contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
payment_frequency=str(contribution.get("payment_frequency", "annual")), payment_frequency=str(contribution.get("payment_frequency", "annual")),
@@ -183,6 +199,14 @@ class Asset:
serial_number: str = "" serial_number: str = ""
status: str = "available" status: str = "available"
current_holder_member_id: str = "" current_holder_member_id: str = ""
owner_type: str = "club"
owner_member_id: str = ""
owner_name: str = ""
custody_type: str = "club"
location: str = ""
handed_over_at: str = ""
condition: str = ""
estimated_value: str = ""
deposit_amount_default: str = "0.00" deposit_amount_default: str = "0.00"
notes: str = "" notes: str = ""
created_at: str = field(default_factory=_iso_now) created_at: str = field(default_factory=_iso_now)
@@ -199,6 +223,14 @@ class Asset:
"serial_number": self.serial_number, "serial_number": self.serial_number,
"status": self.status, "status": self.status,
"current_holder_member_id": self.current_holder_member_id, "current_holder_member_id": self.current_holder_member_id,
"owner_type": self.owner_type,
"owner_member_id": self.owner_member_id,
"owner_name": self.owner_name,
"custody_type": self.custody_type,
"location": self.location,
"handed_over_at": self.handed_over_at,
"condition": self.condition,
"estimated_value": self.estimated_value,
"deposit_amount_default": self.deposit_amount_default, "deposit_amount_default": self.deposit_amount_default,
"notes": self.notes, "notes": self.notes,
"created_at": self.created_at, "created_at": self.created_at,
@@ -216,6 +248,19 @@ class Asset:
serial_number=str(data.get("serial_number", "")), serial_number=str(data.get("serial_number", "")),
status=str(data.get("status", "available")), status=str(data.get("status", "available")),
current_holder_member_id=str(data.get("current_holder_member_id", "")), current_holder_member_id=str(data.get("current_holder_member_id", "")),
owner_type=str(data.get("owner_type", "club")),
owner_member_id=str(data.get("owner_member_id", "")),
owner_name=str(data.get("owner_name", "")),
custody_type=str(
data.get(
"custody_type",
"member" if data.get("current_holder_member_id") else "club",
)
),
location=str(data.get("location", "")),
handed_over_at=str(data.get("handed_over_at", "")),
condition=str(data.get("condition", "")),
estimated_value=str(data.get("estimated_value", "")),
deposit_amount_default=str(data.get("deposit_amount_default", "0.00")), deposit_amount_default=str(data.get("deposit_amount_default", "0.00")),
notes=str(data.get("notes", "")), notes=str(data.get("notes", "")),
created_at=str(data.get("created_at", _iso_now())), created_at=str(data.get("created_at", _iso_now())),
@@ -37,6 +37,7 @@ def evaluate(context: RuleContext):
birth_date=member.birth_date, birth_date=member.birth_date,
accepted_at=member.accepted_at, accepted_at=member.accepted_at,
membership_started_at=member.membership_started_at, membership_started_at=member.membership_started_at,
membership_ended_at=member.membership_ended_at,
today=context.today, today=context.today,
) )
except DateValidationError as exc: except DateValidationError as exc:
+32 -15
View File
@@ -18,6 +18,7 @@ def evaluate(context: RuleContext):
try: try:
accepted_at = parse_iso_date(member.accepted_at, "Aufnahmebeschluss") accepted_at = parse_iso_date(member.accepted_at, "Aufnahmebeschluss")
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") or accepted_at started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") or accepted_at
ended_at = parse_iso_date(member.membership_ended_at, "Austrittsdatum")
except DateValidationError: except DateValidationError:
return [] return []
if not accepted_at or not started_at: if not accepted_at or not started_at:
@@ -29,20 +30,22 @@ def evaluate(context: RuleContext):
admission_fee = Decimal(str(admission_rule.get("admission_fee", "0"))) admission_fee = Decimal(str(admission_rule.get("admission_fee", "0")))
if admission_fee > 0: if admission_fee > 0:
due_days = int(admission_rule.get("first_payment_due_days_after_acceptance", 28)) due_days = int(admission_rule.get("first_payment_due_days_after_acceptance", 28))
actions.append( admission_due = accepted_at + timedelta(days=due_days)
create_claim( if not ended_at or admission_due <= ended_at:
rule_id=RULE_ID, actions.append(
member=member, create_claim(
claim_key="admission-fee", rule_id=RULE_ID,
payload={ member=member,
"type": "admission_fee", claim_key="admission-fee",
"title": "Aufnahmegebühr", payload={
"amount": _money(admission_fee), "type": "admission_fee",
"due_date": (accepted_at + timedelta(days=due_days)).isoformat(), "title": "Aufnahmegebühr",
"calculation": {"rule_id": admission_rule.get("rule_id", "")}, "amount": _money(admission_fee),
}, "due_date": admission_due.isoformat(),
"calculation": {"rule_id": admission_rule.get("rule_id", "")},
},
)
) )
)
year_from = ( year_from = (
started_at.year started_at.year
@@ -50,11 +53,17 @@ def evaluate(context: RuleContext):
else context.today.year else context.today.year
) )
for year in range(year_from, context.today.year + 2): for year in range(year_from, context.today.year + 2):
actions.extend(_membership_claims(context, started_at, accepted_at, year)) actions.extend(_membership_claims(context, started_at, accepted_at, ended_at, year))
return actions return actions
def _membership_claims(context: RuleContext, started_at: date, accepted_at: date, year: int): def _membership_claims(
context: RuleContext,
started_at: date,
accepted_at: date,
ended_at: date | None,
year: int,
):
member = context.member member = context.member
period_start = max(started_at, date(year, 1, 1)) period_start = max(started_at, date(year, 1, 1))
if period_start.year > year: if period_start.year > year:
@@ -96,7 +105,14 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
due_date = regular_due due_date = regular_due
if entry_year and regular_due < accepted_at + timedelta(days=due_days_after_entry): if entry_year and regular_due < accepted_at + timedelta(days=due_days_after_entry):
due_date = accepted_at + timedelta(days=due_days_after_entry) due_date = accepted_at + timedelta(days=due_days_after_entry)
if ended_at and due_date > ended_at:
continue
claim_key = f"membership-fee:{year}:{suffix}" claim_key = f"membership-fee:{year}:{suffix}"
full_period_months = last_month - first_month + 1
description = _title(year, suffix)
if months < full_period_months:
unit = "Monat" if months == 1 else "Monate"
description = f"{description} ({months} {unit})"
actions.append( actions.append(
create_claim( create_claim(
rule_id=RULE_ID, rule_id=RULE_ID,
@@ -105,6 +121,7 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date
payload={ payload={
"type": "membership_fee", "type": "membership_fee",
"title": _title(year, suffix), "title": _title(year, suffix),
"description": description,
"amount": _money(amount), "amount": _money(amount),
"due_date": due_date.isoformat(), "due_date": due_date.isoformat(),
"service_period": { "service_period": {
+1
View File
@@ -211,6 +211,7 @@ def _template_values(
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), "member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
"member.accepted_at": format_date_for_display(member.accepted_at), "member.accepted_at": format_date_for_display(member.accepted_at),
"member.started_at": format_date_for_display(member.membership_started_at), "member.started_at": format_date_for_display(member.membership_started_at),
"member.ended_at": format_date_for_display(member.membership_ended_at),
"member.street": member.street, "member.street": member.street,
"member.address_addition": member.address_addition, "member.address_addition": member.address_addition,
"member.postal_code": member.postal_code, "member.postal_code": member.postal_code,
+3 -1
View File
@@ -324,7 +324,9 @@ class Housekeeper:
{ {
"item_id": str(uuid4()), "item_id": str(uuid4()),
"type": "base", "type": "base",
"description": str(claim.get("title", claim_key)), "description": str(
claim.get("description") or claim.get("title", claim_key)
),
"quantity": "1.00", "quantity": "1.00",
"unit_price": str(claim.get("amount", "0.00")), "unit_price": str(claim.get("amount", "0.00")),
"amount": str(claim.get("amount", "0.00")), "amount": str(claim.get("amount", "0.00")),
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
from datetime import datetime
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import format_datetime, make_msgid
from pathlib import Path
from ccma.domain.contributions import claim_balance
from ccma.domain.dates import format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
@dataclass(frozen=True, slots=True)
class GeneratedReminderMail:
export_path: Path
archive_path: Path
sha256: str
def _safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
return cleaned or "Mahnung"
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}"
def reminder_mail_bytes(
*,
recipient: str,
first_name: str,
reminder: dict,
claim: dict,
balance: str,
organization: dict,
sender_name: str,
sender_email: str,
signature: 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 die Mahnung 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"] = f"{reminder.get('name', 'Zahlungserinnerung')} {claim.get('title', '')}"
message["X-Mozilla-Draft-Info"] = (
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
)
iban = str(organization.get("iban", "")).strip()
bic = str(organization.get("bic", "")).strip()
payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "")
detail = str(reminder.get("detail", "")).strip()
message.set_content(
"\n".join(
(
f"Hallo {first_name.strip() or 'Mitglied'},",
"",
f"zu der Forderung „{claim.get('title', 'Offene Forderung')}"
"ist noch ein Betrag offen.",
"",
f"Offener Betrag: {balance} Euro",
f"Ursprünglich fällig: "
f"{format_date_for_display(str(claim.get('due_date', '')))}",
f"Neue Zahlungsfrist: "
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
f"Mahngebühr: {reminder.get('fee', '0.00')} Euro",
f"Hinweis: {detail}" if detail else "",
"",
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
payment_details,
"",
"Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, "
"betrachte sie bitte als gegenstandslos.",
"",
"Viele Grüße",
signature.strip(),
)
),
charset="utf-8",
)
return message.as_bytes()
def generate_and_send_reminder_mail(
repository: MemberRepository,
member_id: str,
claim_id: str,
reminder_id: str,
*,
output_path: Path | str,
sender_name: str,
sender_email: str,
signature: str,
) -> GeneratedReminderMail:
member = repository.get_member(member_id)
data, claim = repository.get_claim(member_id, claim_id)
reminder = repository._find_reminder(data, claim_id, reminder_id)
if str(reminder.get("status", "")) not in {"draft", "generated"}:
raise RepositoryError("Nur ein Mahnungsentwurf kann als E-Mail erzeugt werden.")
if not member.email.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 die Mahnung ist eine gültige Absenderadresse erforderlich.")
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
organization = repository.get_configuration().get("organization") or {}
content = reminder_mail_bytes(
recipient=member.email,
first_name=member.first_name,
reminder=sent_reminder,
claim=updated_claim,
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
archive_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"{datetime.now().date().isoformat()}-"
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
)
archive_path = _available_path(archive_dir, filename)
export_temp = target.with_name(f".{target.name}.tmp")
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
try:
export_temp.write_bytes(content)
archive_temp.write_bytes(content)
os.replace(export_temp, target)
os.replace(archive_temp, archive_path)
finally:
export_temp.unlink(missing_ok=True)
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="reminder_email_sent",
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
actor_type="user",
actor_name="Vorstand",
references={
"claim_id": claim_id,
"reminder_id": reminder_id,
"document": relative_path,
},
data={
"recipient": member.email,
"sha256": digest,
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
},
)
return GeneratedReminderMail(target, archive_path, digest)
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import csv
import io
import re
import unicodedata
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from uuid import uuid4
from ccma.domain.contributions import claim_balance
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
SEPA_CHARACTERS = frozenset(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
"':?,-(+.)/ ÄäÖöÜüß&*$%"
)
ET.register_namespace("", PAIN_NAMESPACE)
@dataclass(frozen=True, slots=True)
class DirectDebit:
member_id: str
member_number: str
account_holder: str
iban: str
bic: str
mandate_reference: str
mandate_signed_at: str
amount: Decimal
purpose: str
claim_ids: tuple[str, ...]
def _safe(value: Any, maximum: int) -> str:
replacements = str.maketrans({"": "'", "": "'", "´": "'", "`": "'", "": "-", "": "-"})
source = str(value).translate(replacements)
normalized: list[str] = []
for character in source:
if character in SEPA_CHARACTERS:
normalized.append(character)
continue
decomposed = unicodedata.normalize("NFKD", character)
replacement = "".join(
part for part in decomposed if not unicodedata.combining(part) and part in SEPA_CHARACTERS
)
normalized.append(replacement or " ")
return re.sub(r"\s+", " ", "".join(normalized)).strip()[:maximum]
def pending_direct_debits(
repository: MemberRepository, *, due_until: date, due_from: date | None = None
) -> tuple[list[DirectDebit], list[str]]:
if due_from and due_from > due_until:
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
debits: list[DirectDebit] = []
warnings: list[str] = []
for member in repository.list_members():
if not member.mandate_active:
continue
missing = [
label
for value, label in (
(member.iban, "IBAN"),
(member.mandate_reference, "Mandatsreferenz"),
(member.mandate_signed_at, "Mandatsdatum"),
)
if not str(value).strip()
]
if missing:
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
continue
try:
validate_iban(member.iban)
date.fromisoformat(member.mandate_signed_at)
except (RepositoryError, ValueError) as exc:
warnings.append(f"{member.member_number or member.display_name}: {exc}")
continue
data = repository.get_contributions(member.member_id)
selected = []
amount = Decimal("0")
for claim in data.claims:
if str(claim.get("status", "")) == "cancelled":
continue
try:
claim_due = date.fromisoformat(str(claim.get("due_date", "")))
except ValueError:
continue
balance = claim_balance(data, claim)
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
selected.append(claim)
amount += balance
if not selected:
continue
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
purpose = _safe(f"{member.member_number} " + ", ".join(dict.fromkeys(titles)), 140)
debits.append(
DirectDebit(
member_id=member.member_id,
member_number=member.member_number,
account_holder=member.account_holder.strip() or member.display_name,
iban=member.iban.replace(" ", "").upper(),
bic=member.bic.replace(" ", "").upper(),
mandate_reference=_safe(member.mandate_reference, 35),
mandate_signed_at=member.mandate_signed_at,
amount=amount.quantize(Decimal("0.01")),
purpose=purpose,
claim_ids=tuple(str(claim.get("claim_id", "")) for claim in selected),
)
)
return sorted(debits, key=lambda item: (item.member_number, item.account_holder)), warnings
def csv_text(debits: list[DirectDebit], *, collection_date: date) -> str:
target = io.StringIO(newline="")
writer = csv.writer(target, delimiter=";", lineterminator="\r\n")
writer.writerow(
["Einzugsdatum", "Mitgliedsnummer", "Kontoinhaber", "IBAN", "BIC", "Betrag",
"Waehrung", "Mandatsreferenz", "Mandatsdatum", "Verwendungszweck"]
)
for debit in debits:
writer.writerow(
[collection_date.isoformat(), debit.member_number, debit.account_holder, debit.iban,
debit.bic, f"{debit.amount:.2f}".replace(".", ","), "EUR",
debit.mandate_reference, debit.mandate_signed_at, debit.purpose]
)
return target.getvalue()
def write_csv(path: Path | str, debits: list[DirectDebit], *, collection_date: date) -> None:
Path(path).write_text(csv_text(debits, collection_date=collection_date), encoding="utf-8-sig")
def _el(parent: ET.Element, name: str, text: Any | None = None) -> ET.Element:
child = ET.SubElement(parent, f"{{{PAIN_NAMESPACE}}}{name}")
if text is not None:
child.text = str(text)
return child
def pain008_bytes(
debits: list[DirectDebit], *, collection_date: date, organization: dict[str, Any],
message_id: str | None = None, created_at: datetime | None = None,
) -> bytes:
if not debits:
raise RepositoryError("Es gibt keine Lastschriften für den Export.")
creditor_name = _safe(organization.get("name", ""), 70)
creditor_iban = str(organization.get("iban", "")).replace(" ", "").upper()
creditor_bic = str(organization.get("bic", "")).replace(" ", "").upper()
creditor_id = _safe(organization.get("creditor_id", ""), 35)
missing = [label for value, label in (
(creditor_name, "Vereinsname"), (creditor_iban, "Vereins-IBAN"),
(creditor_bic, "Vereins-BIC"), (creditor_id, "Gläubiger-ID"),
) if not value]
if missing:
raise RepositoryError("Für den SEPA-XML-Export fehlen in den Optionen: " + ", ".join(missing) + ".")
validate_iban(creditor_iban)
timestamp = created_at or datetime.now().astimezone()
msg_id = _safe(message_id or f"CCMA-{timestamp:%Y%m%d%H%M%S}-{uuid4().hex[:8]}", 35)
total = sum((item.amount for item in debits), Decimal("0"))
document = ET.Element(f"{{{PAIN_NAMESPACE}}}Document")
initiation = _el(document, "CstmrDrctDbtInitn")
header = _el(initiation, "GrpHdr")
for name, value in (("MsgId", msg_id), ("CreDtTm", timestamp.isoformat(timespec="seconds")),
("NbOfTxs", len(debits)), ("CtrlSum", f"{total:.2f}")):
_el(header, name, value)
_el(_el(header, "InitgPty"), "Nm", creditor_name)
payment = _el(initiation, "PmtInf")
for name, value in (("PmtInfId", _safe(f"{msg_id}-1", 35)), ("PmtMtd", "DD"),
("BtchBookg", "true"), ("NbOfTxs", len(debits)),
("CtrlSum", f"{total:.2f}")):
_el(payment, name, value)
payment_type = _el(payment, "PmtTpInf")
_el(_el(payment_type, "SvcLvl"), "Cd", "SEPA")
_el(_el(payment_type, "LclInstrm"), "Cd", "CORE")
_el(payment_type, "SeqTp", "RCUR")
_el(payment, "ReqdColltnDt", collection_date.isoformat())
_el(_el(payment, "Cdtr"), "Nm", creditor_name)
_el(_el(_el(payment, "CdtrAcct"), "Id"), "IBAN", creditor_iban)
_el(_el(_el(payment, "CdtrAgt"), "FinInstnId"), "BICFI", creditor_bic)
_el(payment, "ChrgBr", "SLEV")
for index, debit in enumerate(debits, start=1):
transaction = _el(payment, "DrctDbtTxInf")
_el(_el(transaction, "PmtId"), "EndToEndId", _safe(f"{debit.member_number}-{index}", 35))
amount = _el(transaction, "InstdAmt", f"{debit.amount:.2f}")
amount.set("Ccy", "EUR")
direct_debit = _el(transaction, "DrctDbtTx")
mandate = _el(direct_debit, "MndtRltdInf")
_el(mandate, "MndtId", debit.mandate_reference)
_el(mandate, "DtOfSgntr", debit.mandate_signed_at)
other = _el(_el(_el(_el(direct_debit, "CdtrSchmeId"), "Id"), "PrvtId"), "Othr")
_el(other, "Id", creditor_id)
_el(_el(other, "SchmeNm"), "Prtry", "SEPA")
financial = _el(_el(transaction, "DbtrAgt"), "FinInstnId")
if debit.bic:
_el(financial, "BICFI", debit.bic)
else:
_el(_el(financial, "Othr"), "Id", "NOTPROVIDED")
_el(_el(transaction, "Dbtr"), "Nm", _safe(debit.account_holder, 70))
_el(_el(_el(transaction, "DbtrAcct"), "Id"), "IBAN", debit.iban)
_el(_el(transaction, "RmtInf"), "Ustrd", debit.purpose)
ET.indent(document, space=" ")
return ET.tostring(document, encoding="utf-8", xml_declaration=True)
def write_pain008(
path: Path | str, debits: list[DirectDebit], *, collection_date: date,
organization: dict[str, Any],
) -> None:
Path(path).write_bytes(
pain008_bytes(debits, collection_date=collection_date, organization=organization)
)
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from datetime import date, datetime
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import format_datetime, make_msgid
from pathlib import Path
from ccma.services.sepa import DirectDebit
from ccma.storage.repository import MemberRepository, RepositoryError
@dataclass(frozen=True, slots=True)
class GeneratedDebitMail:
member_id: str
recipient: str
export_path: Path
archive_path: Path
def _safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
return cleaned or "Mitglied"
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}"
def debit_mail_bytes(
*,
recipient: str,
first_name: str,
debit: DirectDebit,
collection_date: date,
creditor_id: str,
sender_name: str,
sender_email: str,
signature: 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 die Info-Mails 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"] = f"SEPA-Lastschrift C3MA {collection_date.strftime('%d.%m.%Y')}"
message["X-Mozilla-Draft-Info"] = (
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
)
greeting = first_name.strip() or "Mitglied"
message.set_content(
"\n".join(
(
f"Hallo {greeting},",
"",
"hiermit informieren wir dich, dass wir den folgenden Betrag per "
"SEPA-Lastschrift einziehen werden:",
"",
f"Betrag: {debit.amount:.2f} Euro",
f"Einzugsdatum: {collection_date.strftime('%d.%m.%Y')}",
f"Verwendungszweck: {debit.purpose}",
f"Mandatsreferenz: {debit.mandate_reference}",
f"Gläubiger-Identifikationsnummer: {creditor_id}",
"",
"Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.",
"",
"Viele Grüße",
signature.strip(),
)
),
charset="utf-8",
)
return message.as_bytes()
def generate_debit_mails(
repository: MemberRepository,
debits: list[DirectDebit],
*,
collection_date: date,
output_directory: Path | str,
sender_name: str,
sender_email: str,
signature: str,
) -> tuple[list[GeneratedDebitMail], list[str]]:
output = Path(output_directory)
output.mkdir(parents=True, exist_ok=True)
organization = repository.get_configuration().get("organization") or {}
creditor_id = str(organization.get("creditor_id", "")).strip()
if not creditor_id:
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
generated: list[GeneratedDebitMail] = []
warnings: list[str] = []
for debit in debits:
member = repository.get_member(debit.member_id)
if not member.email.strip():
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
continue
content = debit_mail_bytes(
recipient=member.email,
first_name=member.first_name,
debit=debit,
collection_date=collection_date,
creditor_id=creditor_id,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
filename = (
f"SEPA-Info-{collection_date.isoformat()}-"
f"{_safe_filename(member.member_number or member.display_name)}.eml"
)
export_path = _available_path(output, filename)
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, filename)
export_path.write_bytes(content)
try:
archive_path.write_bytes(content)
except OSError:
export_path.unlink(missing_ok=True)
raise
relative_path = archive_path.relative_to(
repository.members_root / member.member_id / "files"
).as_posix()
digest = hashlib.sha256(content).hexdigest()
repository.append_event(
member.member_id,
event_type="sepa_notification_generated",
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
actor_type="user",
actor_name="Vorstand",
references={"document": relative_path},
data={
"sha256": digest,
"recipient": member.email,
"collection_date": collection_date.isoformat(),
"amount": f"{debit.amount:.2f}",
},
)
generated.append(
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
)
return generated, warnings
+559 -32
View File
@@ -2,15 +2,19 @@ from __future__ import annotations
import json import json
import os import os
import re
import shutil import shutil
import unicodedata import unicodedata
from collections.abc import Iterable from collections.abc import Iterable
from contextlib import contextmanager
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path from pathlib import Path
from string import Formatter from string import Formatter
from uuid import uuid4 from uuid import uuid4
from ccma.domain.contributions import ( from ccma.domain.contributions import (
allocated_total,
claim_balance, claim_balance,
claim_total, claim_total,
credit_allocated_total, credit_allocated_total,
@@ -21,6 +25,8 @@ from ccma.domain.contributions import (
) )
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
from ccma.domain.models import ( from ccma.domain.models import (
ASSET_CUSTODY_TYPE_LABELS,
ASSET_OWNER_TYPE_LABELS,
ASSET_STATUS_LABELS, ASSET_STATUS_LABELS,
MEMBERSHIP_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS,
Asset, Asset,
@@ -89,6 +95,7 @@ DEFAULT_CONFIGURATION = {
"member_number_policy": { "member_number_policy": {
"mode": "automatic", "mode": "automatic",
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN, "pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
"allocation_strategy": "next_free",
}, },
"member_number_sequences": {}, "member_number_sequences": {},
"reminder_policy": { "reminder_policy": {
@@ -134,9 +141,7 @@ class MemberRepository:
if builtin_templates.is_dir(): if builtin_templates.is_dir():
for source in builtin_templates.iterdir(): for source in builtin_templates.iterdir():
destination_name = ( destination_name = (
"Forderung mit Positionen.fodt" "Forderung mit Positionen.fodt" if source.name == "Forderung.fodt" else source.name
if source.name == "Forderung.fodt"
else source.name
) )
destination = templates_root / destination_name destination = templates_root / destination_name
if source.is_file() and not destination.exists(): if source.is_file() and not destination.exists():
@@ -158,6 +163,8 @@ class MemberRepository:
policy = config.get("member_number_policy") or {} policy = config.get("member_number_policy") or {}
if str(policy.get("mode", "automatic")) not in {"automatic", "manual"}: if str(policy.get("mode", "automatic")) not in {"automatic", "manual"}:
errors.append("repository.json: ungültiger Mitgliedsnummernmodus") errors.append("repository.json: ungültiger Mitgliedsnummernmodus")
if str(policy.get("allocation_strategy", "next_free")) not in {"next_free", "max_plus_one"}:
errors.append("repository.json: ungültige Vergabestrategie für Mitgliedsnummern")
validate_member_number_pattern(str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN))) validate_member_number_pattern(str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN)))
except (OSError, ValueError, TypeError, json.JSONDecodeError, RepositoryError) as exc: except (OSError, ValueError, TypeError, json.JSONDecodeError, RepositoryError) as exc:
errors.append(f"repository.json: {exc}") errors.append(f"repository.json: {exc}")
@@ -167,13 +174,13 @@ class MemberRepository:
try: try:
member, _contributions = self.preflight_member_record(member_dir.name) member, _contributions = self.preflight_member_record(member_dir.name)
errors.extend( errors.extend(
f"{member_dir.name}/{warning}" f"{member_dir.name}/{warning}" for warning in self.member_hash_warnings(member_dir.name)
for warning in self.member_hash_warnings(member_dir.name)
) )
validate_member_dates( validate_member_dates(
birth_date=member.birth_date, birth_date=member.birth_date,
accepted_at=member.accepted_at, accepted_at=member.accepted_at,
membership_started_at=member.membership_started_at, membership_started_at=member.membership_started_at,
membership_ended_at=member.membership_ended_at,
) )
if member.member_id != member_dir.name: if member.member_id != member_dir.name:
errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein") errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein")
@@ -199,8 +206,7 @@ class MemberRepository:
try: try:
asset = self.get_asset(asset_dir.name) asset = self.get_asset(asset_dir.name)
errors.extend( errors.extend(
f"{asset_dir.name}/{warning}" f"{asset_dir.name}/{warning}" for warning in self.asset_hash_warnings(asset_dir.name)
for warning in self.asset_hash_warnings(asset_dir.name)
) )
if asset.asset_id != asset_dir.name: if asset.asset_id != asset_dir.name:
errors.append(f"{asset_dir.name}/asset.json: asset_id stimmt nicht mit Ordner überein") errors.append(f"{asset_dir.name}/asset.json: asset_id stimmt nicht mit Ordner überein")
@@ -219,6 +225,17 @@ class MemberRepository:
) )
elif asset.status == "issued": elif asset.status == "issued":
errors.append(f"{asset_dir.name}/asset.json: issued benötigt current_holder_member_id") errors.append(f"{asset_dir.name}/asset.json: issued benötigt current_holder_member_id")
if asset.owner_type not in ASSET_OWNER_TYPE_LABELS:
errors.append(f"{asset_dir.name}/asset.json: ungültiger Eigentümertyp")
if asset.custody_type not in ASSET_CUSTODY_TYPE_LABELS:
errors.append(f"{asset_dir.name}/asset.json: ungültiger Gewahrsamstyp")
if asset.owner_type == "member":
if asset.owner_member_id:
self.get_member(asset.owner_member_id)
else:
errors.append(
f"{asset_dir.name}/asset.json: Mitgliedseigentum benötigt owner_member_id"
)
self.get_asset_events(asset.asset_id) self.get_asset_events(asset.asset_id)
except RepositoryError as exc: except RepositoryError as exc:
errors.append(str(exc)) errors.append(str(exc))
@@ -278,6 +295,28 @@ class MemberRepository:
phone: str = "", phone: str = "",
birth_date: str = "", birth_date: str = "",
member_number: str = "", member_number: str = "",
) -> Member:
with self._member_number_lock():
return self._create_member_locked(
first_name=first_name,
last_name=last_name,
nickname=nickname,
email=email,
phone=phone,
birth_date=birth_date,
member_number=member_number,
)
def _create_member_locked(
self,
*,
first_name: str,
last_name: str,
nickname: str,
email: str,
phone: str,
birth_date: str,
member_number: str,
) -> Member: ) -> Member:
if not first_name.strip() or not last_name.strip(): if not first_name.strip() or not last_name.strip():
raise RepositoryError("Vorname und Nachname sind erforderlich.") raise RepositoryError("Vorname und Nachname sind erforderlich.")
@@ -293,7 +332,9 @@ class MemberRepository:
elif policy["mode"] == "manual": elif policy["mode"] == "manual":
raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.") raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.")
else: else:
selected_number = self._allocate_member_number(policy["pattern"]) selected_number = self._allocate_member_number(
policy["pattern"], policy["allocation_strategy"]
)
member_id = str(uuid4()) member_id = str(uuid4())
directory = self._member_path(member_id) directory = self._member_path(member_id)
directory.mkdir(parents=True, exist_ok=False) directory.mkdir(parents=True, exist_ok=False)
@@ -325,6 +366,7 @@ class MemberRepository:
member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum") member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum")
member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss") member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss")
member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit") member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit")
member.membership_ended_at = normalize_date_input(member.membership_ended_at, "Austrittsdatum")
member.mandate_signed_at = normalize_date_input(member.mandate_signed_at, "Mandat erteilt am") member.mandate_signed_at = normalize_date_input(member.mandate_signed_at, "Mandat erteilt am")
member.mandate_revoked_at = normalize_date_input( member.mandate_revoked_at = normalize_date_input(
member.mandate_revoked_at, "Mandat widerrufen am" member.mandate_revoked_at, "Mandat widerrufen am"
@@ -333,7 +375,10 @@ class MemberRepository:
birth_date=member.birth_date, birth_date=member.birth_date,
accepted_at=member.accepted_at, accepted_at=member.accepted_at,
membership_started_at=member.membership_started_at, membership_started_at=member.membership_started_at,
membership_ended_at=member.membership_ended_at,
) )
if member.membership_ended_at and date.fromisoformat(member.membership_ended_at) <= date.today():
member.status = "ended"
except DateValidationError as exc: except DateValidationError as exc:
raise RepositoryError(str(exc)) from exc raise RepositoryError(str(exc)) from exc
member.iban = normalize_iban(member.iban) member.iban = normalize_iban(member.iban)
@@ -393,6 +438,14 @@ class MemberRepository:
serial_number: str = "", serial_number: str = "",
deposit_amount_default: str = "0", deposit_amount_default: str = "0",
notes: str = "", notes: str = "",
owner_type: str = "club",
owner_member_id: str = "",
owner_name: str = "",
custody_type: str = "club",
location: str = "",
handed_over_at: str = "",
condition: str = "",
estimated_value: str = "",
) -> Asset: ) -> Asset:
if not label.strip(): if not label.strip():
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.") raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
@@ -402,6 +455,7 @@ class MemberRepository:
raise RepositoryError(str(exc)) from exc raise RepositoryError(str(exc)) from exc
if deposit_amount < 0: if deposit_amount < 0:
raise RepositoryError("Die Kaution darf nicht negativ sein.") raise RepositoryError("Die Kaution darf nicht negativ sein.")
self._validate_asset_relationships(owner_type, owner_member_id, owner_name, custody_type, "")
asset_id = str(uuid4()) asset_id = str(uuid4())
directory = self._asset_path(asset_id) directory = self._asset_path(asset_id)
directory.mkdir(parents=True, exist_ok=False) directory.mkdir(parents=True, exist_ok=False)
@@ -414,6 +468,14 @@ class MemberRepository:
serial_number=serial_number.strip(), serial_number=serial_number.strip(),
deposit_amount_default=money_text(deposit_amount), deposit_amount_default=money_text(deposit_amount),
notes=notes.strip(), notes=notes.strip(),
owner_type=owner_type,
owner_member_id=owner_member_id,
owner_name=owner_name.strip(),
custody_type=custody_type,
location=location.strip(),
handed_over_at=handed_over_at.strip(),
condition=condition.strip(),
estimated_value=estimated_value.strip(),
) )
write_json_atomic(directory / "asset.json", asset.to_dict()) write_json_atomic(directory / "asset.json", asset.to_dict())
self.append_asset_event( self.append_asset_event(
@@ -423,6 +485,15 @@ class MemberRepository:
actor_type="user", actor_type="user",
actor_name="Vorstand", actor_name="Vorstand",
) )
if asset.owner_type == "member":
self.append_event(
asset.owner_member_id,
event_type="member_asset_registered",
summary=f"Privateigentum im Inventar erfasst: {asset.label}",
actor_type="user",
actor_name="Vorstand",
references={"asset_id": asset.asset_id},
)
return asset return asset
def save_asset(self, asset: Asset, *, actor_name: str = "Vorstand") -> None: def save_asset(self, asset: Asset, *, actor_name: str = "Vorstand") -> None:
@@ -431,15 +502,21 @@ class MemberRepository:
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.") raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
if asset.status not in ASSET_STATUS_LABELS: if asset.status not in ASSET_STATUS_LABELS:
raise RepositoryError("Ungültiger Asset-Status.") raise RepositoryError("Ungültiger Asset-Status.")
self._validate_asset_relationships(
asset.owner_type,
asset.owner_member_id,
asset.owner_name,
asset.custody_type,
asset.current_holder_member_id,
)
try: try:
deposit_amount = decimal_value(asset.deposit_amount_default or "0", "Kaution") deposit_amount = decimal_value(asset.deposit_amount_default or "0", "Kaution")
except ValueError as exc: except ValueError as exc:
raise RepositoryError(str(exc)) from exc raise RepositoryError(str(exc)) from exc
if deposit_amount < 0: if deposit_amount < 0:
raise RepositoryError("Die Kaution darf nicht negativ sein.") raise RepositoryError("Die Kaution darf nicht negativ sein.")
if ( if existing.current_holder_member_id and money_text(deposit_amount) != str(
existing.current_holder_member_id existing.deposit_amount_default
and money_text(deposit_amount) != str(existing.deposit_amount_default)
): ):
raise RepositoryError( raise RepositoryError(
"Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist." "Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist."
@@ -450,6 +527,11 @@ class MemberRepository:
asset.serial_number = asset.serial_number.strip() asset.serial_number = asset.serial_number.strip()
asset.deposit_amount_default = money_text(deposit_amount) asset.deposit_amount_default = money_text(deposit_amount)
asset.notes = asset.notes.strip() asset.notes = asset.notes.strip()
asset.owner_name = asset.owner_name.strip()
asset.location = asset.location.strip()
asset.handed_over_at = asset.handed_over_at.strip()
asset.condition = asset.condition.strip()
asset.estimated_value = asset.estimated_value.strip()
if asset.current_holder_member_id: if asset.current_holder_member_id:
self.get_member(asset.current_holder_member_id) self.get_member(asset.current_holder_member_id)
if asset.status != "issued": if asset.status != "issued":
@@ -467,6 +549,25 @@ class MemberRepository:
actor_type="user", actor_type="user",
actor_name=actor_name, actor_name=actor_name,
) )
if existing.owner_member_id != asset.owner_member_id:
if existing.owner_type == "member" and existing.owner_member_id:
self.append_event(
existing.owner_member_id,
event_type="member_asset_ownership_ended",
summary=f"Nicht mehr als Privateigentum geführt: {asset.label}",
actor_type="user",
actor_name=actor_name,
references={"asset_id": asset.asset_id},
)
if asset.owner_type == "member" and asset.owner_member_id:
self.append_event(
asset.owner_member_id,
event_type="member_asset_registered",
summary=f"Privateigentum im Inventar erfasst: {asset.label}",
actor_type="user",
actor_name=actor_name,
references={"asset_id": asset.asset_id},
)
def assign_asset(self, asset_id: str, member_id: str, *, actor_name: str = "Vorstand") -> Asset: def assign_asset(self, asset_id: str, member_id: str, *, actor_name: str = "Vorstand") -> Asset:
asset = self.get_asset(asset_id) asset = self.get_asset(asset_id)
@@ -476,6 +577,7 @@ class MemberRepository:
if asset.status in {"lost", "retired"}: if asset.status in {"lost", "retired"}:
raise RepositoryError("Verlorene oder ausgemusterte Assets können nicht ausgegeben werden.") raise RepositoryError("Verlorene oder ausgemusterte Assets können nicht ausgegeben werden.")
asset.current_holder_member_id = member.member_id asset.current_holder_member_id = member.member_id
asset.custody_type = "member"
asset.status = "issued" asset.status = "issued"
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict()) write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
@@ -503,6 +605,7 @@ class MemberRepository:
if not member_id: if not member_id:
raise RepositoryError("Das Asset ist aktuell keinem Mitglied zugeordnet.") raise RepositoryError("Das Asset ist aktuell keinem Mitglied zugeordnet.")
asset.current_holder_member_id = "" asset.current_holder_member_id = ""
asset.custody_type = "club"
asset.status = "available" asset.status = "available"
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds") asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict()) write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
@@ -528,6 +631,41 @@ class MemberRepository:
self.get_member(member_id) self.get_member(member_id)
return [asset for asset in self.list_assets() if asset.current_holder_member_id == member_id] return [asset for asset in self.list_assets() if asset.current_holder_member_id == member_id]
def list_member_owned_assets(self, member_id: str) -> list[Asset]:
self.get_member(member_id)
return [
asset
for asset in self.list_assets()
if asset.owner_type == "member" and asset.owner_member_id == member_id
]
def _validate_asset_relationships(
self,
owner_type: str,
owner_member_id: str,
owner_name: str,
custody_type: str,
holder_member_id: str,
) -> None:
if owner_type not in ASSET_OWNER_TYPE_LABELS:
raise RepositoryError("Ungültiger Eigentümertyp.")
if custody_type not in ASSET_CUSTODY_TYPE_LABELS:
raise RepositoryError("Ungültiger Gewahrsamstyp.")
if owner_type == "member":
if not owner_member_id:
raise RepositoryError("Bei Mitgliedseigentum muss ein Mitglied ausgewählt werden.")
self.get_member(owner_member_id)
elif owner_member_id:
raise RepositoryError("Eine Eigentümer-Mitglieds-ID ist nur bei Mitgliedseigentum erlaubt.")
if owner_type == "external" and not owner_name.strip():
raise RepositoryError("Bei externem Eigentum ist ein Eigentümername erforderlich.")
if custody_type == "member":
if not holder_member_id:
raise RepositoryError("Gewahrsam beim Mitglied benötigt eine Mitgliedszuordnung.")
self.get_member(holder_member_id)
elif holder_member_id:
raise RepositoryError("Eine Halter-Mitglieds-ID ist nur bei Gewahrsam eines Mitglieds erlaubt.")
def create_manual_claim( def create_manual_claim(
self, self,
member_id: str, member_id: str,
@@ -731,6 +869,88 @@ class MemberRepository:
) )
return item return item
def update_claim(
self,
member_id: str,
claim_id: str,
*,
title: str,
due_date: str,
base_amount: str,
description: str,
actor_name: str = "Vorstand",
) -> dict:
if not title.strip():
raise RepositoryError("Ein Forderungstitel ist erforderlich.")
try:
normalized_due_date = normalize_date_input(due_date, "Fälligkeitsdatum")
selected_base_amount = decimal_value(base_amount, "Grundbetrag")
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_due_date:
raise RepositoryError("Ein Fälligkeitsdatum ist erforderlich.")
if selected_base_amount == 0:
raise RepositoryError("Der Grundbetrag darf nicht null sein.")
data, claim = self.get_claim(member_id, claim_id)
if str(claim.get("status", "")) == "cancelled":
raise RepositoryError("Eine stornierte Forderung kann nicht bearbeitet werden.")
items = materialize_claim_items(claim)
base_item = next((item for item in items if str(item.get("type", "")) == "base"), None)
if base_item is None:
raise RepositoryError("Die Forderung hat keine bearbeitbare Grundposition.")
other_total = sum(
(decimal_value(item.get("amount", "0")) for item in items if item is not base_item),
Decimal("0"),
)
new_total = selected_base_amount + other_total
settled = allocated_total(data, claim_id)
if new_total >= 0 and settled > new_total:
raise RepositoryError(
f"Der neue Gesamtbetrag darf nicht unter dem bereits zugeordneten Betrag "
f"von {money_text(settled)} EUR liegen."
)
old_values = {
"title": str(claim.get("title", "")),
"due_date": str(claim.get("due_date", "")),
"base_amount": str(base_item.get("amount", "")),
"description": str(base_item.get("description", "")),
}
now = datetime.now().astimezone().isoformat(timespec="seconds")
claim["title"] = title.strip()
claim["due_date"] = normalized_due_date
claim["amount"] = money_text(new_total)
base_item["description"] = description.strip() or title.strip()
base_item["quantity"] = "1.00"
base_item["unit_price"] = money_text(selected_base_amount)
base_item["amount"] = money_text(selected_base_amount)
calculation = claim.get("calculation")
if not isinstance(calculation, dict):
calculation = {}
claim["calculation"] = calculation
calculation["manual_override"] = {"at": now, "actor": actor_name}
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="claim_changed",
summary=f"Forderung geändert: {claim['title']}",
actor_type="user",
actor_name=actor_name,
references={"claim_id": claim_id},
data={
"old": old_values,
"new": {
"title": claim["title"],
"due_date": claim["due_date"],
"base_amount": base_item["amount"],
"description": base_item["description"],
},
},
)
return claim
def record_payment( def record_payment(
self, self,
member_id: str, member_id: str,
@@ -760,6 +980,11 @@ class MemberRepository:
if gnucash_id: if gnucash_id:
self._assert_gnucash_id_available(gnucash_id) self._assert_gnucash_id_available(gnucash_id)
data, claim = self.get_claim(member_id, claim_id) data, claim = self.get_claim(member_id, claim_id)
available_claim_balance = max(claim_balance(data, claim), Decimal("0"))
if selected_allocation > available_claim_balance:
raise RepositoryError(
f"Die Forderung hat nur noch {money_text(available_claim_balance)} EUR offen."
)
payment = { payment = {
"payment_id": str(uuid4()), "payment_id": str(uuid4()),
"date": normalized_date, "date": normalized_date,
@@ -787,8 +1012,86 @@ class MemberRepository:
) )
return payment return payment
def record_combined_payment(
self,
member_id: str,
*,
payment_date: str,
allocations: dict[str, str],
reference: str = "",
method: str = "bank_transfer",
actor_name: str = "CCMA",
) -> dict:
"""Record one payment and atomically allocate it to multiple claims."""
try:
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
except DateValidationError as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Zahlungsdatum ist erforderlich.")
if not allocations:
raise RepositoryError("Mindestens eine Zuordnung ist erforderlich.")
data = self.get_contributions(member_id)
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
selected_allocations: dict[str, Decimal] = {}
for claim_id, raw_amount in allocations.items():
claim = claims_by_id.get(claim_id)
if claim is None:
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
if str(claim.get("status", "")) == "cancelled":
raise RepositoryError("Eine stornierte Forderung kann nicht bezahlt werden.")
try:
selected_amount = decimal_value(raw_amount, "Zuordnung")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
available = max(claim_balance(data, claim), Decimal("0"))
if selected_amount <= 0 or selected_amount > available:
raise RepositoryError(
f"{claim.get('title', 'Forderung')} hat nur "
f"{money_text(available)} EUR offen."
)
selected_allocations[claim_id] = selected_amount
total = sum(selected_allocations.values(), Decimal("0"))
now = datetime.now().astimezone().isoformat(timespec="seconds")
payment = {
"payment_id": str(uuid4()),
"date": normalized_date,
"amount": money_text(total),
"method": method.strip() or "bank_transfer",
"gnucash_transaction_id": "",
"reference": reference.strip(),
"created_at": now,
}
for claim_id, amount in selected_allocations.items():
data.allocations.append(
{
"allocation_id": str(uuid4()),
"payment_id": payment["payment_id"],
"claim_id": claim_id,
"amount": money_text(amount),
}
)
data.payments.append(payment)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_recorded",
summary=f"Zahlung eingegangen: {payment['amount']} EUR",
actor_type="system" if method == "dummy" else "user",
actor_name=actor_name,
references={"payment_id": str(payment["payment_id"])},
data={
"allocated_amount": payment["amount"],
"claim_ids": list(selected_allocations),
"method": payment["method"],
},
)
return payment
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict: def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
data, _claim = self.get_claim(member_id, claim_id) data, claim = self.get_claim(member_id, claim_id)
payment = next( payment = next(
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id), (item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
None, None,
@@ -802,6 +1105,11 @@ class MemberRepository:
raise RepositoryError(str(exc)) from exc raise RepositoryError(str(exc)) from exc
if selected_amount <= 0 or selected_amount > available: if selected_amount <= 0 or selected_amount > available:
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.") raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.")
available_claim_balance = max(claim_balance(data, claim), Decimal("0"))
if selected_amount > available_claim_balance:
raise RepositoryError(
f"Die Forderung hat nur noch {money_text(available_claim_balance)} EUR offen."
)
allocation = { allocation = {
"allocation_id": str(uuid4()), "allocation_id": str(uuid4()),
"payment_id": payment_id, "payment_id": payment_id,
@@ -818,6 +1126,140 @@ class MemberRepository:
) )
return allocation return allocation
def update_payment(
self,
member_id: str,
payment_id: str,
*,
payment_date: str,
amount: str,
allocations: dict[str, str],
gnucash_transaction_id: str = "",
reference: str = "",
) -> dict:
data = self.get_contributions(member_id)
payment = next(
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
None,
)
if payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
try:
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
selected_amount = decimal_value(amount)
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Zahlungsdatum ist erforderlich.")
if selected_amount <= 0:
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id]
old_by_claim: dict[str, list[dict]] = {}
for allocation in old_allocations:
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
selected_allocations: dict[str, Decimal] = {}
for claim_id, raw_amount in allocations.items():
if claim_id not in claims_by_id:
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
try:
allocation_amount = decimal_value(raw_amount, "Zuordnung")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if allocation_amount < 0:
raise RepositoryError("Zuordnungen dürfen nicht negativ sein.")
if allocation_amount == 0:
continue
claim = claims_by_id[claim_id]
currently_allocated = sum(
(decimal_value(item.get("amount", "0")) for item in old_by_claim.get(claim_id, [])),
Decimal("0"),
)
available_claim_balance = max(
claim_balance(data, claim) + currently_allocated,
Decimal("0"),
)
if allocation_amount > available_claim_balance:
raise RepositoryError(
f"{claim.get('title', 'Forderung')} hat nur "
f"{money_text(available_claim_balance)} EUR offen."
)
selected_allocations[claim_id] = allocation_amount
allocated_sum = sum(selected_allocations.values(), Decimal("0"))
if allocated_sum > selected_amount:
raise RepositoryError(
f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den "
f"Zahlungsbetrag ({money_text(selected_amount)} EUR)."
)
gnucash_id = gnucash_transaction_id.strip()
if gnucash_id:
self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id)
payment.update(
{
"date": normalized_date,
"amount": money_text(selected_amount),
"gnucash_transaction_id": gnucash_id,
"reference": reference.strip(),
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
)
retained_allocations = [
item for item in data.allocations if str(item.get("payment_id", "")) != payment_id
]
new_allocations = []
for claim_id, allocation_amount in selected_allocations.items():
prior = old_by_claim.get(claim_id, [])
new_allocations.append(
{
"allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())),
"payment_id": payment_id,
"claim_id": claim_id,
"amount": money_text(allocation_amount),
}
)
data.allocations = retained_allocations + new_allocations
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_changed",
summary=f"Zahlung geändert: {payment['amount']} EUR",
actor_type="user",
actor_name="Vorstand",
references={"payment_id": payment_id},
data={
"allocated_amount": money_text(allocated_sum),
"unallocated_amount": money_text(selected_amount - allocated_sum),
},
)
return payment
def delete_payment(self, member_id: str, payment_id: str) -> None:
data = self.get_contributions(member_id)
payment = next(
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
None,
)
if payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
data.payments = [item for item in data.payments if str(item.get("payment_id", "")) != payment_id]
data.allocations = [
item for item in data.allocations if str(item.get("payment_id", "")) != payment_id
]
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_deleted",
summary=f"Zahlung gelöscht: {payment.get('amount', '')} EUR",
actor_type="user",
actor_name="Vorstand",
references={"payment_id": payment_id},
)
def record_credit( def record_credit(
self, self,
member_id: str, member_id: str,
@@ -1127,7 +1569,9 @@ class MemberRepository:
references={"claim_id": claim_id}, references={"claim_id": claim_id},
) )
def _assert_gnucash_id_available(self, transaction_id: str) -> None: def _assert_gnucash_id_available(
self, transaction_id: str, *, exclude_payment_id: str | None = None
) -> None:
selected = transaction_id.casefold() selected = transaction_id.casefold()
for member in self.list_members(): for member in self.list_members():
try: try:
@@ -1135,7 +1579,9 @@ class MemberRepository:
except RepositoryError: except RepositoryError:
continue continue
if any( if any(
str(payment.get("gnucash_transaction_id", "")).casefold() == selected for payment in payments str(payment.get("payment_id", "")) != exclude_payment_id
and str(payment.get("gnucash_transaction_id", "")).casefold() == selected
for payment in payments
): ):
raise RepositoryError(f"GnuCash-ID bereits verwendet: {transaction_id}") raise RepositoryError(f"GnuCash-ID bereits verwendet: {transaction_id}")
@@ -1286,15 +1732,30 @@ class MemberRepository:
if mode not in {"automatic", "manual"}: if mode not in {"automatic", "manual"}:
mode = "automatic" mode = "automatic"
pattern = str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN)) pattern = str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN))
allocation_strategy = str(policy.get("allocation_strategy", "next_free"))
if allocation_strategy not in {"next_free", "max_plus_one"}:
allocation_strategy = "next_free"
validate_member_number_pattern(pattern) validate_member_number_pattern(pattern)
return {"mode": mode, "pattern": pattern} return {
"mode": mode,
"pattern": pattern,
"allocation_strategy": allocation_strategy,
}
def save_member_number_policy(self, *, mode: str, pattern: str) -> None: def save_member_number_policy(
self, *, mode: str, pattern: str, allocation_strategy: str = "next_free"
) -> None:
if mode not in {"automatic", "manual"}: if mode not in {"automatic", "manual"}:
raise RepositoryError("Ungültiger Mitgliedsnummernmodus.") raise RepositoryError("Ungültiger Mitgliedsnummernmodus.")
if allocation_strategy not in {"next_free", "max_plus_one"}:
raise RepositoryError("Ungültige Vergabestrategie für Mitgliedsnummern.")
validate_member_number_pattern(pattern) validate_member_number_pattern(pattern)
config = read_json(self.root / "repository.json") config = read_json(self.root / "repository.json")
config["member_number_policy"] = {"mode": mode, "pattern": pattern.strip()} config["member_number_policy"] = {
"mode": mode,
"pattern": pattern.strip(),
"allocation_strategy": allocation_strategy,
}
config.setdefault("member_number_sequences", {}) config.setdefault("member_number_sequences", {})
write_json_atomic(self.root / "repository.json", config) write_json_atomic(self.root / "repository.json", config)
@@ -1310,11 +1771,15 @@ class MemberRepository:
config["organization"] = organization config["organization"] = organization
write_json_atomic(self.root / "repository.json", config) write_json_atomic(self.root / "repository.json", config)
def preview_member_number(self, pattern: str | None = None) -> str: def preview_member_number(
selected_pattern = pattern or self.get_member_number_policy()["pattern"] self, pattern: str | None = None, allocation_strategy: str | None = None
) -> str:
policy = self.get_member_number_policy()
selected_pattern = pattern or policy["pattern"]
selected_strategy = allocation_strategy or policy["allocation_strategy"]
validate_member_number_pattern(selected_pattern) validate_member_number_pattern(selected_pattern)
config = read_json(self.root / "repository.json") config = read_json(self.root / "repository.json")
return self._next_available_member_number(config, selected_pattern)[0] return self._next_available_member_number(config, selected_pattern, selected_strategy)[0]
def _member_directories(self) -> Iterable[Path]: def _member_directories(self) -> Iterable[Path]:
if not self.members_root.exists(): if not self.members_root.exists():
@@ -1340,9 +1805,11 @@ class MemberRepository:
raise RepositoryError("Ungültige Asset-ID.") raise RepositoryError("Ungültige Asset-ID.")
return self.assets_root / asset_id return self.assets_root / asset_id
def _allocate_member_number(self, pattern: str) -> str: def _allocate_member_number(self, pattern: str, allocation_strategy: str) -> str:
config = read_json(self.root / "repository.json") config = read_json(self.root / "repository.json")
member_number, next_value = self._next_available_member_number(config, pattern) member_number, next_value = self._next_available_member_number(
config, pattern, allocation_strategy
)
sequences = config.get("member_number_sequences") sequences = config.get("member_number_sequences")
if not isinstance(sequences, dict): if not isinstance(sequences, dict):
sequences = {} sequences = {}
@@ -1351,15 +1818,20 @@ class MemberRepository:
write_json_atomic(self.root / "repository.json", config) write_json_atomic(self.root / "repository.json", config)
return member_number return member_number
def _next_available_member_number(self, config: dict, pattern: str) -> tuple[str, int]: def _next_available_member_number(
sequences = config.get("member_number_sequences") self, config: dict, pattern: str, allocation_strategy: str
if not isinstance(sequences, dict): ) -> tuple[str, int]:
sequences = {} if allocation_strategy not in {"next_free", "max_plus_one"}:
try: raise RepositoryError("Ungültige Vergabestrategie für Mitgliedsnummern.")
number = max(1, int(sequences.get(pattern, 1)))
except (TypeError, ValueError):
number = 1
existing = {member.member_number.casefold() for member in self.list_members() if member.member_number} existing = {member.member_number.casefold() for member in self.list_members() if member.member_number}
number = 1
if allocation_strategy == "max_plus_one":
used_values = (
value
for member_number in existing
if (value := _number_from_formatted_member_number(pattern, member_number)) is not None
)
number = max(used_values, default=0) + 1
for _attempt in range(1_000_000): for _attempt in range(1_000_000):
candidate = format_member_number(pattern, number) candidate = format_member_number(pattern, number)
number += 1 number += 1
@@ -1367,6 +1839,33 @@ class MemberRepository:
return candidate, number return candidate, number
raise RepositoryError("Keine freie Mitgliedsnummer im konfigurierten Nummernbereich gefunden.") raise RepositoryError("Keine freie Mitgliedsnummer im konfigurierten Nummernbereich gefunden.")
@contextmanager
def _member_number_lock(self):
lock_path = self.root / ".member-number.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
if os.name == "nt":
import msvcrt
handle.seek(0)
if not handle.read(1):
handle.write("\0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
if os.name == "nt":
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _assert_member_number_available( def _assert_member_number_available(
self, self,
member_number: str, member_number: str,
@@ -1390,6 +1889,7 @@ class MemberRepository:
"email": "E-Mail-Adresse", "email": "E-Mail-Adresse",
"phone": "Telefonnummer", "phone": "Telefonnummer",
"birth_date": "Geburtsdatum", "birth_date": "Geburtsdatum",
"membership_ended_at": "Austrittsdatum",
"status": "Status", "status": "Status",
"payment_frequency": "Zahlungsweise", "payment_frequency": "Zahlungsweise",
"contribution_rule_id": "Beitragsregel", "contribution_rule_id": "Beitragsregel",
@@ -1431,6 +1931,14 @@ class MemberRepository:
"serial_number": "Seriennummer", "serial_number": "Seriennummer",
"status": "Status", "status": "Status",
"current_holder_member_id": "Zuordnung", "current_holder_member_id": "Zuordnung",
"owner_type": "Eigentümertyp",
"owner_member_id": "Eigentümer",
"owner_name": "Externer Eigentümer",
"custody_type": "Gewahrsam",
"location": "Standort",
"handed_over_at": "Übergabedatum",
"condition": "Zustand",
"estimated_value": "Wert",
"deposit_amount_default": "Kaution", "deposit_amount_default": "Kaution",
"notes": "Notiz", "notes": "Notiz",
} }
@@ -1452,8 +1960,7 @@ def validate_iban(value: str) -> None:
raise RepositoryError("Die IBAN hat kein gültiges Format.") raise RepositoryError("Die IBAN hat kein gültiges Format.")
rearranged = value[4:] + value[:4] rearranged = value[4:] + value[:4]
numeric = "".join( numeric = "".join(
str(ord(character) - 55) if character.isalpha() else character str(ord(character) - 55) if character.isalpha() else character for character in rearranged
for character in rearranged
) )
if int(numeric) % 97 != 1: if int(numeric) % 97 != 1:
raise RepositoryError("Die IBAN-Prüfsumme ist ungültig.") raise RepositoryError("Die IBAN-Prüfsumme ist ungültig.")
@@ -1516,6 +2023,26 @@ def validate_member_number_pattern(pattern: str) -> None:
raise RepositoryError(f"Ungültiges Mitgliedsnummern-Pattern: {exc}") from exc raise RepositoryError(f"Ungültiges Mitgliedsnummern-Pattern: {exc}") from exc
def _number_from_formatted_member_number(pattern: str, member_number: str) -> int | None:
parts: list[str] = []
number_groups = 0
for literal, field_name, _format_spec, _conversion in Formatter().parse(pattern.strip()):
parts.append(re.escape(literal))
if field_name == "year":
parts.append(re.escape(str(date.today().year)))
elif field_name == "number":
parts.append(r"(\d+)")
number_groups += 1
match = re.fullmatch("".join(parts), member_number, flags=re.IGNORECASE)
if not match or not number_groups:
return None
values = [int(value) for value in match.groups()]
if any(value != values[0] for value in values[1:]):
return None
value = values[0]
return value if format_member_number(pattern, value).casefold() == member_number.casefold() else None
def format_member_number(pattern: str, number: int, *, year: int | None = None) -> str: def format_member_number(pattern: str, number: int, *, year: int | None = None) -> str:
try: try:
value = pattern.strip().format(number=number, year=year or date.today().year) value = pattern.strip().format(number=number, year=year or date.today().year)
+38 -2
View File
@@ -115,6 +115,10 @@ class AssetTab(ttk.Frame):
("Inventarnummer", "inventory_number"), ("Inventarnummer", "inventory_number"),
("Seriennummer", "serial_number"), ("Seriennummer", "serial_number"),
("Kaution (EUR)", "deposit_amount_default"), ("Kaution (EUR)", "deposit_amount_default"),
("Standort", "location"),
("Übergabedatum", "handed_over_at"),
("Zustand bei Übergabe", "condition"),
("Wert / Kaufpreis", "estimated_value"),
] ]
for row, (label, key) in enumerate(fields): for row, (label, key) in enumerate(fields):
self.variables[key] = tk.StringVar() self.variables[key] = tk.StringVar()
@@ -151,15 +155,22 @@ class AssetTab(ttk.Frame):
) )
self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5) self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5)
self.holder_label.bind("<Button-1>", lambda _event: self._open_holder_member(), add="+") self.holder_label.bind("<Button-1>", lambda _event: self._open_holder_member(), add="+")
self.owner_var = tk.StringVar()
ttk.Label(data_tab, text="Eigentümer").grid(
row=holder_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
)
self.owner_label = ttk.Label(data_tab, textvariable=self.owner_var, style="TimelineHeader.TLabel")
self.owner_label.grid(row=holder_row + 2, column=1, sticky="w", pady=5)
self.owner_label.bind("<Button-1>", lambda _event: self._open_owner_member(), add="+")
ttk.Label(data_tab, text="Interne Notiz").grid( ttk.Label(data_tab, text="Interne Notiz").grid(
row=holder_row + 2, row=holder_row + 3,
column=0, column=0,
sticky="nw", sticky="nw",
pady=5, pady=5,
padx=(0, 12), padx=(0, 12),
) )
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word") self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5) self.notes_text.grid(row=holder_row + 3, column=1, sticky="ew", pady=5)
data_tab.columnconfigure(1, weight=1) data_tab.columnconfigure(1, weight=1)
self.issue_button = ttk.Button( self.issue_button = ttk.Button(
action_buttons, action_buttons,
@@ -276,13 +287,19 @@ class AssetTab(ttk.Frame):
self.variables["inventory_number"].set(self.asset.inventory_number) self.variables["inventory_number"].set(self.asset.inventory_number)
self.variables["serial_number"].set(self.asset.serial_number) self.variables["serial_number"].set(self.asset.serial_number)
self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default) self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default)
self.variables["location"].set(self.asset.location)
self.variables["handed_over_at"].set(self.asset.handed_over_at)
self.variables["condition"].set(self.asset.condition)
self.variables["estimated_value"].set(self.asset.estimated_value)
self.variables["status"].set(ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper())) self.variables["status"].set(ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper()))
if self.notes_text is not None: if self.notes_text is not None:
self.notes_text.delete("1.0", "end") self.notes_text.delete("1.0", "end")
self.notes_text.insert("1.0", self.asset.notes) self.notes_text.insert("1.0", self.asset.notes)
self.holder_var.set(holder) self.holder_var.set(holder)
self.owner_var.set(self._owner_label())
issued = bool(self.asset.current_holder_member_id) issued = bool(self.asset.current_holder_member_id)
self.holder_label.configure(cursor="hand2" if issued else "") self.holder_label.configure(cursor="hand2" if issued else "")
self.owner_label.configure(cursor="hand2" if self.asset.owner_type == "member" else "")
self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled") self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled")
self.return_button.configure(state="normal" if issued else "disabled") self.return_button.configure(state="normal" if issued else "disabled")
self.status_box.configure(state="disabled" if issued else "readonly") self.status_box.configure(state="disabled" if issued else "readonly")
@@ -300,6 +317,21 @@ class AssetTab(ttk.Frame):
return self.asset.current_holder_member_id return self.asset.current_holder_member_id
return f"{member.member_number or member.member_id} · {member.display_name}" return f"{member.member_number or member.member_id} · {member.display_name}"
def _owner_label(self) -> str:
if self.asset.owner_type == "club":
return "Verein"
if self.asset.owner_type == "external":
return self.asset.owner_name or "Extern"
try:
member = self.repository.get_member(self.asset.owner_member_id)
except RepositoryError:
return self.asset.owner_member_id or ""
return f"{member.member_number or member.member_id} · {member.display_name}"
def _open_owner_member(self) -> None:
if self.asset.owner_type == "member" and self.asset.owner_member_id:
self.on_open_member(self.asset.owner_member_id)
def _refresh_events(self) -> None: def _refresh_events(self) -> None:
self.timeline.delete(*self.timeline.get_children()) self.timeline.delete(*self.timeline.get_children())
try: try:
@@ -352,6 +384,10 @@ class AssetTab(ttk.Frame):
self.asset.inventory_number = self.variables["inventory_number"].get().strip() self.asset.inventory_number = self.variables["inventory_number"].get().strip()
self.asset.serial_number = self.variables["serial_number"].get().strip() self.asset.serial_number = self.variables["serial_number"].get().strip()
self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else "" self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else ""
self.asset.location = self.variables["location"].get().strip()
self.asset.handed_over_at = self.variables["handed_over_at"].get().strip()
self.asset.condition = self.variables["condition"].get().strip()
self.asset.estimated_value = self.variables["estimated_value"].get().strip()
if not self.asset.current_holder_member_id: if not self.asset.current_holder_member_id:
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip() self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip()
self.asset.status = _asset_status_key(self.variables["status"].get()) self.asset.status = _asset_status_key(self.variables["status"].get())
+211 -6
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from tkinter import messagebox, ttk from tkinter import filedialog, messagebox, ttk
from ccma.domain.contributions import ( from ccma.domain.contributions import (
CLAIM_STATUS_LABELS, CLAIM_STATUS_LABELS,
@@ -21,6 +21,7 @@ from ccma.domain.contributions import (
payment_allocated_total, payment_allocated_total,
) )
from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.labels import ( from ccma.ui.labels import (
@@ -88,8 +89,10 @@ class ClaimTab(ttk.Frame):
footer.columnconfigure(0, weight=1) footer.columnconfigure(0, weight=1)
self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold) self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold)
self.hold_button.grid(row=0, column=0, sticky="w") self.hold_button.grid(row=0, column=0, sticky="w")
self.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim)
self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8))
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim) self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
self.cancel_button.grid(row=0, column=1, sticky="e") self.cancel_button.grid(row=0, column=2, sticky="e")
def _build_ledger(self) -> None: def _build_ledger(self) -> None:
ledger = ttk.Frame(self, padding=12) ledger = ttk.Frame(self, padding=12)
@@ -154,6 +157,13 @@ class ClaimTab(ttk.Frame):
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled" buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
) )
self.send_reminder_button.pack(side="right", padx=(8, 0)) self.send_reminder_button.pack(side="right", padx=(8, 0))
self.email_reminder_button = ttk.Button(
buttons,
text="E-Mail erzeugen & versandt",
command=self._email_reminder,
state="disabled",
)
self.email_reminder_button.pack(side="right", padx=(8, 0))
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack( ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
side="right", padx=(0, 8) side="right", padx=(0, 8)
@@ -185,6 +195,7 @@ class ClaimTab(ttk.Frame):
self.summary_vars["balance"].set(f"{money_text(balance)} EUR") self.summary_vars["balance"].set(f"{money_text(balance)} EUR")
self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper())) self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper()))
self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal") self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal")
self.edit_button.configure(state="disabled" if status == "cancelled" else "normal")
self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen") self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen")
self._render_ledger() self._render_ledger()
@@ -422,8 +433,22 @@ class ClaimTab(ttk.Frame):
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"}) editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
state = "normal" if editable else "disabled" state = "normal" if editable else "disabled"
self.send_reminder_button.configure(state=state) self.send_reminder_button.configure(state=state)
self.email_reminder_button.configure(state=state)
self.discard_reminder_button.configure(state=state) self.discard_reminder_button.configure(state=state)
def _email_reminder(self) -> None:
reminder = self._selected_reminder()
if reminder:
ReminderMailDialog(
self,
self.repository,
self.member_id,
self.claim_id,
str(reminder["reminder_id"]),
str(reminder.get("name", "Mahnung")),
self._changed,
)
def _send_reminder(self) -> None: def _send_reminder(self) -> None:
reminder = self._selected_reminder() reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno( if not reminder or not messagebox.askyesno(
@@ -478,6 +503,16 @@ class ClaimTab(ttk.Frame):
return return
self._changed() self._changed()
def _edit_claim(self) -> None:
ClaimEditDialog(
self,
self.repository,
self.member_id,
self.claim_id,
self.claim,
self._changed,
)
def _changed(self) -> None: def _changed(self) -> None:
self.refresh() self.refresh()
self.on_changed() self.on_changed()
@@ -502,6 +537,68 @@ class _Dialog(tk.Toplevel):
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left") ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
class ClaimEditDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, claim, on_saved):
super().__init__(master, "Forderung bearbeiten", on_saved)
self.repository = repository
self.member_id = member_id
self.claim_id = claim_id
items = claim_items(claim)
base_item = next(
(item for item in items if str(item.get("type", "")) == "base"),
items[0] if items else {},
)
self.variables = {
"title": tk.StringVar(value=str(claim.get("title", ""))),
"due_date": tk.StringVar(
value=format_date_for_display(str(claim.get("due_date", "")))
),
"base_amount": tk.StringVar(
value=str(base_item.get("amount", claim.get("amount", "")))
),
"description": tk.StringVar(value=str(base_item.get("description", ""))),
}
fields = (
("Titel", "title"),
(f"Fällig am ({date_input_hint()})", "due_date"),
("Grundbetrag", "base_amount"),
("Beschreibung der Grundposition", "description"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(
row=row, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.variables[key], width=52).grid(
row=row, column=1, sticky="ew", pady=5
)
ttk.Label(
self.frame,
text=(
"Der Grundbetrag ändert nur die Grundposition. Weitere Positionen, "
"zum Beispiel Mahngebühren, bleiben erhalten."
),
style="Mono.TLabel",
wraplength=520,
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0))
self._buttons(len(fields) + 1, self._save)
def _save(self) -> None:
try:
self.repository.update_claim(
self.member_id,
self.claim_id,
title=self.variables["title"].get(),
due_date=self.variables["due_date"].get(),
base_amount=self.variables["base_amount"].get(),
description=self.variables["description"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Forderung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class ItemDialog(_Dialog): class ItemDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved): def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Forderungsposition hinzufügen", on_saved) super().__init__(master, "Forderungsposition hinzufügen", on_saved)
@@ -634,7 +731,11 @@ class AllocatePaymentDialog(_Dialog):
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
data = repository.get_contributions(member_id) data = repository.get_contributions(member_id)
self.payment_by_label = {} self.payment_by_label = {}
for payment in data.payments: for payment in sorted(
data.payments,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
payment_id = str(payment.get("payment_id", "")) payment_id = str(payment.get("payment_id", ""))
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id) available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
if available <= 0: if available <= 0:
@@ -655,10 +756,23 @@ class AllocatePaymentDialog(_Dialog):
width=60, width=60,
) )
combo.grid(row=0, column=1, pady=5) combo.grid(row=0, column=1, pady=5)
ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) if not self.payment_by_label:
ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5) ttk.Label(
self.frame,
text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.",
style="Mono.TLabel",
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5))
amount_row = 2
else:
amount_row = 1
ttk.Label(self.frame, text="Betrag").grid(
row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.amount_var).grid(
row=amount_row, column=1, sticky="ew", pady=5
)
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance)) combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
self._buttons(2, self._save) self._buttons(amount_row + 1, self._save)
def _select(self, balance): def _select(self, balance):
_payment_id, available = self.payment_by_label[self.payment_var.get()] _payment_id, available = self.payment_by_label[self.payment_var.get()]
@@ -801,6 +915,97 @@ class ReminderDialog(_Dialog):
self.on_saved() self.on_saved()
class ReminderMailDialog(tk.Toplevel):
def __init__(
self,
master,
repository,
member_id,
claim_id,
reminder_id,
reminder_name,
on_saved,
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.claim_id = claim_id
self.reminder_id = reminder_id
self.reminder_name = reminder_name
self.on_saved = on_saved
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.title("Mahnung als E-Mail erzeugen")
self.transient(master)
self.resizable(True, False)
self.columnconfigure(1, weight=1)
ttk.Label(
self,
text=(
"Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung "
"sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam."
),
wraplength=620,
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
self.signature = tk.Text(self, width=55, height=5, wrap="word")
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="EML speichern und als versandt buchen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
selected = filedialog.asksaveasfilename(
parent=self,
title="Mahnung als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile=f"{self.reminder_name}.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not selected:
return
try:
result = generate_and_send_reminder_mail(
self.repository,
self.member_id,
self.claim_id,
self.reminder_id,
output_path=selected,
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("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
return
messagebox.showinfo(
"Mahnung erzeugt",
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
parent=self,
)
self.destroy()
self.on_saved()
class DunningHoldDialog(_Dialog): class DunningHoldDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved): def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnsperre setzen", on_saved) super().__init__(master, "Mahnsperre setzen", on_saved)
+139 -19
View File
@@ -124,13 +124,31 @@ class NewAssetDialog(tk.Toplevel):
super().__init__(master) super().__init__(master)
self.repository = repository self.repository = repository
self.on_created = on_created self.on_created = on_created
self.members = repository.list_members()
self.member_labels = {
f"{member.member_number or member.member_id} · {member.display_name}": member.member_id
for member in self.members
}
self.title("Neues Asset") self.title("Neues Asset")
self.transient(master.winfo_toplevel()) self.transient(master.winfo_toplevel())
self.resizable(False, False) self.resizable(False, False)
self.variables = { self.variables = {
name: tk.StringVar() name: tk.StringVar()
for name in ("label", "category", "inventory_number", "serial_number", "deposit_amount_default") for name in (
"label",
"category",
"inventory_number",
"serial_number",
"deposit_amount_default",
"location",
"handed_over_at",
"condition",
"estimated_value",
"owner_name",
)
} }
self.owner_type_var = tk.StringVar(value="VEREIN")
self.owner_member_var = tk.StringVar()
self._build_ui() self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy()) self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._create()) self.bind("<Return>", lambda _event: self._create())
@@ -145,6 +163,10 @@ class NewAssetDialog(tk.Toplevel):
("Inventarnummer", "inventory_number"), ("Inventarnummer", "inventory_number"),
("Seriennummer", "serial_number"), ("Seriennummer", "serial_number"),
("Kaution (EUR)", "deposit_amount_default"), ("Kaution (EUR)", "deposit_amount_default"),
("Standort", "location"),
("Übergabedatum", "handed_over_at"),
("Zustand bei Übergabe", "condition"),
("Wert / Kaufpreis", "estimated_value"),
] ]
self.entries: dict[str, ttk.Entry] = {} self.entries: dict[str, ttk.Entry] = {}
for row, (label, key) in enumerate(fields): for row, (label, key) in enumerate(fields):
@@ -152,17 +174,42 @@ class NewAssetDialog(tk.Toplevel):
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38) entry = ttk.Entry(frame, textvariable=self.variables[key], width=38)
entry.grid(row=row, column=1, sticky="ew", pady=5) entry.grid(row=row, column=1, sticky="ew", pady=5)
self.entries[key] = entry self.entries[key] = entry
owner_row = len(fields)
ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
frame,
textvariable=self.owner_type_var,
values=("VEREIN", "MITGLIED", "EXTERN"),
state="readonly",
width=35,
).grid(row=owner_row, column=1, sticky="ew", pady=5)
ttk.Label(frame, text="Eigentümer-Mitglied").grid(
row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Combobox(
frame,
textvariable=self.owner_member_var,
values=list(self.member_labels),
state="readonly",
width=35,
).grid(row=owner_row + 1, column=1, sticky="ew", pady=5)
ttk.Label(frame, text="Externer Eigentümer").grid(
row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid(
row=owner_row + 2, column=1, sticky="ew", pady=5
)
ttk.Label(frame, text="Interne Notiz").grid( ttk.Label(frame, text="Interne Notiz").grid(
row=len(fields), row=owner_row + 3,
column=0, column=0,
sticky="nw", sticky="nw",
pady=5, pady=5,
padx=(0, 12), padx=(0, 12),
) )
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word") self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
self.notes_text.grid(row=len(fields), column=1, sticky="ew", pady=5) self.notes_text.grid(row=owner_row + 3, column=1, sticky="ew", pady=5)
buttons = ttk.Frame(frame) buttons = ttk.Frame(frame)
buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0)) buttons.grid(row=owner_row + 4, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button( ttk.Button(
buttons, buttons,
@@ -176,9 +223,15 @@ class NewAssetDialog(tk.Toplevel):
_activate_modal_window(self, self.entries.get("label")) _activate_modal_window(self, self.entries.get("label"))
def _create(self) -> None: def _create(self) -> None:
owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[self.owner_type_var.get()]
try: try:
asset = self.repository.create_asset( asset = self.repository.create_asset(
**{key: variable.get() for key, variable in self.variables.items()}, **{key: variable.get() for key, variable in self.variables.items() if key != "owner_name"},
owner_type=owner_type,
owner_member_id=(
self.member_labels.get(self.owner_member_var.get(), "") if owner_type == "member" else ""
),
owner_name=self.variables["owner_name"].get() if owner_type == "external" else "",
notes=self.notes_text.get("1.0", "end-1c"), notes=self.notes_text.get("1.0", "end-1c"),
) )
except RepositoryError as exc: except RepositoryError as exc:
@@ -201,6 +254,19 @@ class EditAssetDialog(tk.Toplevel):
self.asset_id = asset_id self.asset_id = asset_id
self.on_saved = on_saved self.on_saved = on_saved
self.asset = repository.get_asset(asset_id) self.asset = repository.get_asset(asset_id)
self.members = repository.list_members()
self.member_labels = {
f"{member.member_number or member.member_id} · {member.display_name}": member.member_id
for member in self.members
}
owner_member_label = next(
(
label
for label, member_id in self.member_labels.items()
if member_id == self.asset.owner_member_id
),
"",
)
self.title("Asset bearbeiten") self.title("Asset bearbeiten")
self.transient(master.winfo_toplevel()) self.transient(master.winfo_toplevel())
self.resizable(False, False) self.resizable(False, False)
@@ -211,7 +277,18 @@ class EditAssetDialog(tk.Toplevel):
"serial_number": tk.StringVar(value=self.asset.serial_number), "serial_number": tk.StringVar(value=self.asset.serial_number),
"deposit_amount_default": tk.StringVar(value=self.asset.deposit_amount_default), "deposit_amount_default": tk.StringVar(value=self.asset.deposit_amount_default),
"status": tk.StringVar(value=self.asset.status), "status": tk.StringVar(value=self.asset.status),
"location": tk.StringVar(value=self.asset.location),
"handed_over_at": tk.StringVar(value=self.asset.handed_over_at),
"condition": tk.StringVar(value=self.asset.condition),
"estimated_value": tk.StringVar(value=self.asset.estimated_value),
"owner_name": tk.StringVar(value=self.asset.owner_name),
} }
self.owner_type_var = tk.StringVar(
value={"club": "VEREIN", "member": "MITGLIED", "external": "EXTERN"}.get(
self.asset.owner_type, "VEREIN"
)
)
self.owner_member_var = tk.StringVar(value=owner_member_label)
self._build_ui() self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy()) self.bind("<Escape>", lambda _event: self.destroy())
self.bind("<Return>", lambda _event: self._save()) self.bind("<Return>", lambda _event: self._save())
@@ -227,6 +304,10 @@ class EditAssetDialog(tk.Toplevel):
("Inventarnummer", "inventory_number"), ("Inventarnummer", "inventory_number"),
("Seriennummer", "serial_number"), ("Seriennummer", "serial_number"),
("Kaution (EUR)", "deposit_amount_default"), ("Kaution (EUR)", "deposit_amount_default"),
("Standort", "location"),
("Übergabedatum", "handed_over_at"),
("Zustand bei Übergabe", "condition"),
("Wert / Kaufpreis", "estimated_value"),
] ]
self.entries: dict[str, ttk.Entry] = {} self.entries: dict[str, ttk.Entry] = {}
for row, (label, key) in enumerate(fields): for row, (label, key) in enumerate(fields):
@@ -235,12 +316,40 @@ class EditAssetDialog(tk.Toplevel):
entry = ttk.Entry(frame, textvariable=self.variables[key], width=38, state=state) entry = ttk.Entry(frame, textvariable=self.variables[key], width=38, state=state)
entry.grid(row=row, column=1, sticky="ew", pady=5) entry.grid(row=row, column=1, sticky="ew", pady=5)
self.entries[key] = entry self.entries[key] = entry
ttk.Label(frame, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12)) owner_row = len(fields)
status_values = [value for key, value in ( ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12))
("available", "VERFUEGBAR"), ttk.Combobox(
("lost", "VERLOREN"), frame,
("retired", "AUSGEMUSTERT"), textvariable=self.owner_type_var,
)] values=("VEREIN", "MITGLIED", "EXTERN"),
state="readonly",
width=35,
).grid(row=owner_row, column=1, sticky="ew", pady=5)
ttk.Label(frame, text="Eigentümer-Mitglied").grid(
row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Combobox(
frame,
textvariable=self.owner_member_var,
values=list(self.member_labels),
state="readonly",
width=35,
).grid(row=owner_row + 1, column=1, sticky="ew", pady=5)
ttk.Label(frame, text="Externer Eigentümer").grid(
row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid(
row=owner_row + 2, column=1, sticky="ew", pady=5
)
ttk.Label(frame, text="Status").grid(row=owner_row + 3, column=0, sticky="w", pady=5, padx=(0, 12))
status_values = [
value
for key, value in (
("available", "VERFUEGBAR"),
("lost", "VERLOREN"),
("retired", "AUSGEMUSTERT"),
)
]
self.status_map = { self.status_map = {
"VERFUEGBAR": "available", "VERFUEGBAR": "available",
"VERLOREN": "lost", "VERLOREN": "lost",
@@ -260,8 +369,8 @@ class EditAssetDialog(tk.Toplevel):
state="readonly" if not issued else "disabled", state="readonly" if not issued else "disabled",
width=35, width=35,
) )
self.status_box.grid(row=len(fields), column=1, sticky="ew", pady=5) self.status_box.grid(row=owner_row + 3, column=1, sticky="ew", pady=5)
note_row = len(fields) + 1 note_row = owner_row + 4
ttk.Label(frame, text="Interne Notiz").grid(row=note_row, column=0, sticky="nw", pady=5, padx=(0, 12)) ttk.Label(frame, text="Interne Notiz").grid(row=note_row, column=0, sticky="nw", pady=5, padx=(0, 12))
self.notes_text = tk.Text(frame, width=38, height=5, wrap="word") self.notes_text = tk.Text(frame, width=38, height=5, wrap="word")
self.notes_text.grid(row=note_row, column=1, sticky="ew", pady=5) self.notes_text.grid(row=note_row, column=1, sticky="ew", pady=5)
@@ -290,6 +399,21 @@ class EditAssetDialog(tk.Toplevel):
self.asset.inventory_number = self.variables["inventory_number"].get() self.asset.inventory_number = self.variables["inventory_number"].get()
self.asset.serial_number = self.variables["serial_number"].get() self.asset.serial_number = self.variables["serial_number"].get()
self.asset.notes = self.notes_text.get("1.0", "end-1c") self.asset.notes = self.notes_text.get("1.0", "end-1c")
self.asset.location = self.variables["location"].get()
self.asset.handed_over_at = self.variables["handed_over_at"].get()
self.asset.condition = self.variables["condition"].get()
self.asset.estimated_value = self.variables["estimated_value"].get()
self.asset.owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[
self.owner_type_var.get()
]
self.asset.owner_member_id = (
self.member_labels.get(self.owner_member_var.get(), "")
if self.asset.owner_type == "member"
else ""
)
self.asset.owner_name = (
self.variables["owner_name"].get() if self.asset.owner_type == "external" else ""
)
if not self.asset.current_holder_member_id: if not self.asset.current_holder_member_id:
self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get() self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get()
self.asset.status = self.status_map.get(self.status_var.get(), self.asset.status) self.asset.status = self.status_map.get(self.status_var.get(), self.asset.status)
@@ -394,9 +518,7 @@ class IssueAssetDialog(tk.Toplevel):
self.member_tree.delete(*self.member_tree.get_children()) self.member_tree.delete(*self.member_tree.get_children())
query = self.search_var.get().strip().casefold() query = self.search_var.get().strip().casefold()
filtered = [ filtered = [
member member for member in self.members if not query or query in self._member_search_text(member)
for member in self.members
if not query or query in self._member_search_text(member)
] ]
for member in filtered: for member in filtered:
self.member_tree.insert( self.member_tree.insert(
@@ -599,9 +721,7 @@ class IntegrityWarningDialog(tk.Toplevel):
text="\n".join(f"{item}" for item in self.warnings), text="\n".join(f"{item}" for item in self.warnings),
style="Warning.TLabel", style="Warning.TLabel",
justify="left", justify="left",
).grid( ).grid(row=1, column=0, sticky="w", pady=(12, 0))
row=1, column=0, sticky="w", pady=(12, 0)
)
buttons = ttk.Frame(frame) buttons = ttk.Frame(frame)
buttons.grid(row=2, column=0, sticky="e", pady=(18, 0)) buttons.grid(row=2, column=0, sticky="e", pady=(18, 0))
ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8)) ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8))
+13 -1
View File
@@ -14,6 +14,7 @@ from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, N
from ccma.ui.icons import IconStore from ccma.ui.icons import IconStore
from ccma.ui.member_tab import MemberTab from ccma.ui.member_tab import MemberTab
from ccma.ui.options_dialog import OptionsDialog from ccma.ui.options_dialog import OptionsDialog
from ccma.ui.sepa_dialog import SepaExportDialog
from ccma.ui.theme import load_theme from ccma.ui.theme import load_theme
from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab
@@ -187,6 +188,13 @@ class MainWindow(ttk.Frame):
) )
assets_button.image = assets_icon assets_button.image = assets_icon
assets_button.pack(side="left", padx=(0, 6)) assets_button.pack(side="left", padx=(0, 6))
sepa_icon = self.icons.get("bank-transfer", 24) or self.icons.get("bank", 24)
sepa_button = ttk.Button(
actions, text="SEPA", image=sepa_icon, compound="top", width=14,
command=self.open_sepa_export,
)
sepa_button.image = sepa_icon
sepa_button.pack(side="left", padx=(0, 6))
new_icon = self.icons.get("account-plus", 24) new_icon = self.icons.get("account-plus", 24)
new_button = ttk.Button( new_button = ttk.Button(
actions, actions,
@@ -368,6 +376,7 @@ class MainWindow(ttk.Frame):
tab = MembersTab( tab = MembersTab(
self.notebook, self.notebook,
self.repository.list_members(), self.repository.list_members(),
self.findings,
self.open_member, self.open_member,
lambda: self.tabs.close(key), lambda: self.tabs.close(key),
) )
@@ -402,6 +411,9 @@ class MainWindow(ttk.Frame):
icon_name="key-variant", icon_name="key-variant",
) )
def open_sepa_export(self) -> None:
SepaExportDialog(self, self.repository)
def new_member(self) -> None: def new_member(self) -> None:
NewMemberDialog(self, self.repository, self._member_created) NewMemberDialog(self, self.repository, self._member_created)
@@ -502,7 +514,7 @@ class MainWindow(ttk.Frame):
self.dashboard.update_data(self.repository.member_count(), self.findings) self.dashboard.update_data(self.repository.member_count(), self.findings)
members_tab = self.tabs.tabs.get("members") members_tab = self.tabs.tabs.get("members")
if isinstance(members_tab, MembersTab) and members_tab.winfo_exists(): if isinstance(members_tab, MembersTab) and members_tab.winfo_exists():
members_tab.refresh(self.repository.list_members()) members_tab.refresh(self.repository.list_members(), self.findings)
assets_tab = self.tabs.tabs.get("assets") assets_tab = self.tabs.tabs.get("assets")
if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists(): if isinstance(assets_tab, AssetsTab) and assets_tab.winfo_exists():
assets_tab.refresh(self.repository.list_assets()) assets_tab.refresh(self.repository.list_assets())
+131 -7
View File
@@ -3,10 +3,17 @@ from __future__ import annotations
import tkinter as tk import tkinter as tk
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime from datetime import datetime
from decimal import Decimal
from pathlib import Path from pathlib import Path
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text from ccma.domain.contributions import (
CLAIM_STATUS_LABELS,
claim_status,
claim_total,
money_text,
payment_allocated_total,
)
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, Event from ccma.domain.models import ASSET_STATUS_LABELS, Event
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
@@ -16,6 +23,7 @@ from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.file_open import open_path from ccma.ui.file_open import open_path
from ccma.ui.labels import display_label, storage_key from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame from ccma.ui.scrolling import ScrollableFrame
CLAIM_TABLE_COLUMNS = ( CLAIM_TABLE_COLUMNS = (
@@ -145,9 +153,11 @@ class MemberTab(ttk.Frame):
command=self._save, command=self._save,
).grid(row=0, column=0, sticky="e") ).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16) contribution_tab = ttk.Frame(notebook, padding=16)
payments_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16) assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16) documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(contribution_tab, text="Forderungen") notebook.add(contribution_tab, text="Forderungen")
notebook.add(payments_tab, text="Zahlungen")
notebook.add(assets_tab, text="Assets") notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente") notebook.add(documents_tab, text="Dokumente")
@@ -162,6 +172,7 @@ class MemberTab(ttk.Frame):
(f"Geburtsdatum ({date_input_hint()})", "birth_date"), (f"Geburtsdatum ({date_input_hint()})", "birth_date"),
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"), (f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"), (f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
(f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"),
] ]
for row, (label, key) in enumerate(fields): for row, (label, key) in enumerate(fields):
variable = tk.StringVar() variable = tk.StringVar()
@@ -256,6 +267,39 @@ class MemberTab(ttk.Frame):
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim()) self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim()) self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
payments_tab.columnconfigure(0, weight=1)
payments_tab.rowconfigure(1, weight=1)
self.payment_summary = tk.StringVar()
ttk.Label(payments_tab, textvariable=self.payment_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.payments = ttk.Treeview(
payments_tab,
columns=("date", "amount", "allocated", "available", "reference"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("date", "Datum", 100),
("amount", "Betrag", 90),
("allocated", "Zugeordnet", 90),
("available", "Frei", 90),
("reference", "Referenz", 320),
):
self.payments.heading(key, text=title)
self.payments.column(key, width=width, anchor="w")
self.payments.grid(row=1, column=0, sticky="nsew")
self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
payment_actions = ttk.Frame(payments_tab)
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
side="left", padx=(0, 8)
)
ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack(
side="left"
)
assets_tab.columnconfigure(0, weight=1) assets_tab.columnconfigure(0, weight=1)
assets_tab.rowconfigure(1, weight=1) assets_tab.rowconfigure(1, weight=1)
self.assets_summary = tk.StringVar() self.assets_summary = tk.StringVar()
@@ -304,9 +348,7 @@ class MemberTab(ttk.Frame):
style="Accent.TButton", style="Accent.TButton",
command=self._create_document, command=self._create_document,
).pack(side="left", padx=(0, 8)) ).pack(side="left", padx=(0, 8))
ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack( ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(side="left")
side="left"
)
self.documents = ttk.Treeview( self.documents = ttk.Treeview(
documents_tab, documents_tab,
columns=("name", "type", "modified", "size"), columns=("name", "type", "modified", "size"),
@@ -424,6 +466,7 @@ class MemberTab(ttk.Frame):
"birth_date", "birth_date",
"accepted_at", "accepted_at",
"membership_started_at", "membership_started_at",
"membership_ended_at",
"mandate_signed_at", "mandate_signed_at",
"mandate_revoked_at", "mandate_revoked_at",
} }
@@ -456,10 +499,12 @@ class MemberTab(ttk.Frame):
def _refresh_contributions(self) -> None: def _refresh_contributions(self) -> None:
self.claims.delete(*self.claims.get_children()) self.claims.delete(*self.claims.get_children())
self.payments.delete(*self.payments.get_children())
try: try:
data = self.repository.get_contributions(self.member_id) data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc: except RepositoryError as exc:
self.contribution_summary.set(f"FEHLER: {exc}") self.contribution_summary.set(f"FEHLER: {exc}")
self.payment_summary.set(f"FEHLER: {exc}")
return return
claims = sorted( claims = sorted(
data.claims, data.claims,
@@ -481,7 +526,37 @@ class MemberTab(ttk.Frame):
CLAIM_STATUS_LABELS.get(status, status.upper()), CLAIM_STATUS_LABELS.get(status, status.upper()),
), ),
) )
for payment in sorted(
data.payments,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
payment_id = str(payment.get("payment_id", ""))
amount = payment.get("amount", "0")
allocated = payment_allocated_total(data, payment_id)
available = max(Decimal(str(amount)) - allocated, Decimal("0"))
self.payments.insert(
"",
"end",
iid=payment_id,
values=(
format_date_for_display(str(payment.get("date", ""))),
f"{money_text(amount)} EUR",
f"{money_text(allocated)} EUR",
f"{money_text(available)} EUR",
payment.get("reference", ""),
),
)
self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen")
total_amount = sum((Decimal(str(item.get("amount", "0"))) for item in data.payments), Decimal("0"))
total_allocated = sum(
(payment_allocated_total(data, str(item.get("payment_id", ""))) for item in data.payments),
Decimal("0"),
)
self.payment_summary.set(
f"{len(data.payments)} Zahlungen · Gesamt {money_text(total_amount)} EUR · "
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
)
def _toggle_claim_sort(self, column: str) -> None: def _toggle_claim_sort(self, column: str) -> None:
if self.claim_sort_column == column: if self.claim_sort_column == column:
@@ -507,6 +582,46 @@ class MemberTab(ttk.Frame):
if selected and not selected[0].startswith("missing-id-"): if selected and not selected[0].startswith("missing-id-"):
self.on_open_claim(self.member_id, selected[0]) self.on_open_claim(self.member_id, selected[0])
def _edit_selected_payment(self) -> None:
selected = self.payments.selection()
if not selected:
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
return
try:
PaymentEditDialog(
self,
self.repository,
self.member_id,
selected[0],
self._payment_changed,
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht geöffnet werden", str(exc), parent=self)
def _delete_selected_payment(self) -> None:
selected = self.payments.selection()
if not selected:
messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
return
values = self.payments.item(selected[0], "values")
label = f"{values[0]} · {values[1]}" if values else selected[0]
if not messagebox.askyesno(
"Zahlung wirklich löschen?",
f"Die Zahlung {label} und alle ihre Zuordnungen werden endgültig gelöscht.",
parent=self,
):
return
try:
self.repository.delete_payment(self.member_id, selected[0])
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gelöscht werden", str(exc), parent=self)
return
self._payment_changed()
def _payment_changed(self) -> None:
self.refresh()
self.on_changed()
def _refresh_documents(self) -> None: def _refresh_documents(self) -> None:
self.documents.delete(*self.documents.get_children()) self.documents.delete(*self.documents.get_children())
self.document_paths.clear() self.document_paths.clear()
@@ -536,8 +651,11 @@ class MemberTab(ttk.Frame):
def _refresh_assets(self) -> None: def _refresh_assets(self) -> None:
self.assets_tree.delete(*self.assets_tree.get_children()) self.assets_tree.delete(*self.assets_tree.get_children())
assets = self.repository.list_member_assets(self.member_id) held_assets = self.repository.list_member_assets(self.member_id)
self.assets_summary.set(f"{len(assets)} ausgegebene Assets") owned_assets = self.repository.list_member_owned_assets(self.member_id)
assets_by_id = {asset.asset_id: asset for asset in [*held_assets, *owned_assets]}
assets = list(assets_by_id.values())
self.assets_summary.set(f"{len(owned_assets)} im Eigentum · {len(held_assets)} vom Verein erhalten")
for asset in assets: for asset in assets:
self.assets_tree.insert( self.assets_tree.insert(
"", "",
@@ -547,7 +665,13 @@ class MemberTab(ttk.Frame):
asset.label, asset.label,
asset.category, asset.category,
asset.inventory_number, asset.inventory_number,
ASSET_STATUS_LABELS.get(asset.status, asset.status), (
"EIGENTUM · DEM VEREIN ÜBERLASSEN"
if asset.owner_member_id == self.member_id and asset.custody_type == "club"
else "EIGENTUM"
if asset.owner_member_id == self.member_id
else ASSET_STATUS_LABELS.get(asset.status, asset.status)
),
), ),
) )
+33 -7
View File
@@ -47,6 +47,7 @@ class OptionsDialog(tk.Toplevel):
number_policy = repository.get_member_number_policy() number_policy = repository.get_member_number_policy()
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual") self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"]) self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
self.number_strategy_var = tk.StringVar(value=number_policy["allocation_strategy"])
self.number_preview_var = tk.StringVar() self.number_preview_var = tk.StringVar()
organization = repository.get_configuration().get("organization") or {} organization = repository.get_configuration().get("organization") or {}
if not isinstance(organization, dict): if not isinstance(organization, dict):
@@ -280,19 +281,38 @@ class OptionsDialog(tk.Toplevel):
text="Platzhalter: {number}, {number:04d} und optional {year}", text="Platzhalter: {number}, {number:04d} und optional {year}",
style="Muted.TLabel", style="Muted.TLabel",
).grid(row=2, column=1, sticky="w", pady=(0, 12)) ).grid(row=2, column=1, sticky="w", pady=(0, 12))
ttk.Label(parent, text="Nächste Nummer").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=6) ttk.Label(parent, text="Vergabestrategie").grid(
row=3, column=0, sticky="nw", padx=(0, 12), pady=6
)
strategy_frame = ttk.Frame(parent)
strategy_frame.grid(row=3, column=1, sticky="w", pady=6)
ttk.Radiobutton(
strategy_frame,
text="Lücken füllen (nächste freie Nummer)",
variable=self.number_strategy_var,
value="next_free",
command=self._update_number_preview,
).pack(anchor="w")
ttk.Radiobutton(
strategy_frame,
text="Immer Max++ (höchste Nummer + 1)",
variable=self.number_strategy_var,
value="max_plus_one",
command=self._update_number_preview,
).pack(anchor="w", pady=(4, 0))
ttk.Label(parent, text="Nächste Nummer").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid( ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid(
row=3, column=1, sticky="w", pady=6 row=4, column=1, sticky="w", pady=6
) )
ttk.Label( ttk.Label(
parent, parent,
text=( text=(
"Der Zähler wird pro Pattern im Mitglieder-Store geführt. Bereits vergebene Nummern " "Die Strategie wird im Mitglieder-Store gespeichert. Die Vergabe ist gesperrt, "
"werden niemals doppelt erzeugt." "solange ein neues Mitglied angelegt wird, damit keine Nummer doppelt entsteht."
), ),
style="Muted.TLabel", style="Muted.TLabel",
wraplength=620, wraplength=620,
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(14, 0)) ).grid(row=5, column=0, columnspan=2, sticky="w", pady=(14, 0))
self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview()) self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview())
self._update_member_number_controls() self._update_member_number_controls()
@@ -309,7 +329,9 @@ class OptionsDialog(tk.Toplevel):
return return
pattern = self.number_pattern_var.get() pattern = self.number_pattern_var.get()
try: try:
preview = self.repository.preview_member_number(pattern) preview = self.repository.preview_member_number(
pattern, self.number_strategy_var.get()
)
except RepositoryError as exc: except RepositoryError as exc:
preview = f"UNGÜLTIG: {exc}" preview = f"UNGÜLTIG: {exc}"
self.number_preview_var.set(preview) self.number_preview_var.set(preview)
@@ -409,7 +431,11 @@ class OptionsDialog(tk.Toplevel):
) )
try: try:
self.config_obj.save() self.config_obj.save()
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern) self.repository.save_member_number_policy(
mode=number_mode,
pattern=number_pattern,
allocation_strategy=self.number_strategy_var.get(),
)
self.repository.save_organization( self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()} {key: variable.get() for key, variable in self.organization_vars.items()}
) )
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from decimal import Decimal, InvalidOperation
from tkinter import messagebox, ttk
from ccma.domain.contributions import (
claim_balance,
claim_status,
decimal_value,
money_text,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
class PaymentEditDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
payment_id: str,
on_saved: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.payment_id = payment_id
self.on_saved = on_saved
self.data = repository.get_contributions(member_id)
self.payment = next(
(
payment
for payment in self.data.payments
if str(payment.get("payment_id", "")) == payment_id
),
None,
)
if self.payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
self.allocations = self._current_allocations()
self.claims_by_id = {
str(claim.get("claim_id", "")): claim
for claim in self.data.claims
if str(claim.get("claim_id", ""))
and (
claim_status(self.data, claim) != "cancelled"
or str(claim.get("claim_id", "")) in self.allocations
)
}
self.capacities = self._claim_capacities()
self.title("Zahlung bearbeiten")
self.transient(master.winfo_toplevel())
self.geometry("880x560")
self.minsize(720, 460)
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
self._build_ui()
self._refresh_claims()
self.after_idle(self._activate)
def _current_allocations(self) -> dict[str, str]:
totals: dict[str, Decimal] = {}
for allocation in self.data.allocations:
if str(allocation.get("payment_id", "")) != self.payment_id:
continue
claim_id = str(allocation.get("claim_id", ""))
totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value(
allocation.get("amount", "0")
)
return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id}
def _claim_capacities(self) -> dict[str, Decimal]:
capacities = {}
for claim_id, claim in self.claims_by_id.items():
current = decimal_value(self.allocations.get(claim_id, "0"))
capacities[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0"))
return capacities
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
form = ttk.Frame(self, padding=16)
form.grid(row=0, column=0, sticky="ew")
form.columnconfigure(1, weight=1)
self.variables = {
"date": tk.StringVar(
value=format_date_for_display(str(self.payment.get("date", "")))
),
"amount": tk.StringVar(value=str(self.payment.get("amount", ""))),
"gnucash": tk.StringVar(value=str(self.payment.get("gnucash_transaction_id", ""))),
"reference": tk.StringVar(value=str(self.payment.get("reference", ""))),
}
fields = (
(f"Zahlungsdatum ({date_input_hint()})", "date"),
("Zahlungsbetrag", "amount"),
("GnuCash-ID", "gnucash"),
("Referenz", "reference"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
row=row, column=1, sticky="ew", pady=4
)
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12)
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
allocation_frame.columnconfigure(0, weight=1)
allocation_frame.rowconfigure(0, weight=1)
self.claims = ttk.Treeview(
allocation_frame,
columns=("title", "due", "available", "allocated"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("title", "Forderung", 300),
("due", "Fällig", 110),
("available", "Maximal zuordenbar", 150),
("allocated", "Aktuell zugeordnet", 150),
):
self.claims.heading(key, text=title)
self.claims.column(key, width=width, anchor="w")
self.claims.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.claims.configure(yscrollcommand=scrollbar.set)
self.claims.bind("<<TreeviewSelect>>", self._select_claim)
allocation_actions = ttk.Frame(allocation_frame)
allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left")
self.allocation_var = tk.StringVar()
ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack(
side="left", padx=(8, 8)
)
ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack(
side="left"
)
ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack(
side="left", padx=(8, 0)
)
self.total_var = tk.StringVar()
ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack(
side="right"
)
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
buttons.grid(row=2, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons,
text="Zahlung speichern",
style="Accent.TButton",
command=self._save,
).pack(side="left")
def _activate(self) -> None:
try:
self.deiconify()
self.lift()
self.focus_force()
self.grab_set()
except tk.TclError:
return
def _refresh_claims(self) -> None:
selected = self.claims.selection()
self.claims.delete(*self.claims.get_children())
ordered = sorted(
self.claims_by_id.items(),
key=lambda item: (
str(item[1].get("due_date", "")),
str(item[1].get("title", "")).casefold(),
),
)
for claim_id, claim in ordered:
self.claims.insert(
"",
"end",
iid=claim_id,
values=(
claim.get("title", "Forderung"),
format_date_for_display(str(claim.get("due_date", ""))),
f"{money_text(self.capacities[claim_id])} EUR",
f"{self.allocations.get(claim_id, '0.00')} EUR",
),
)
if selected and self.claims.exists(selected[0]):
self.claims.selection_set(selected[0])
self._refresh_totals()
def _select_claim(self, _event=None) -> None:
selected = self.claims.selection()
if selected:
self.allocation_var.set(self.allocations.get(selected[0], "0.00"))
def _set_allocation(self) -> None:
selected = self.claims.selection()
if not selected:
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
return
try:
amount = decimal_value(self.allocation_var.get(), "Zuordnung")
except ValueError as exc:
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self)
return
capacity = self.capacities[selected[0]]
if amount < 0 or amount > capacity:
messagebox.showerror(
"Ungültige Zuordnung",
f"Für diese Forderung können höchstens {money_text(capacity)} EUR zugeordnet werden.",
parent=self,
)
return
if amount:
self.allocations[selected[0]] = money_text(amount)
else:
self.allocations.pop(selected[0], None)
self._refresh_claims()
def _remove_allocation(self) -> None:
selected = self.claims.selection()
if not selected:
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
return
self.allocations.pop(selected[0], None)
self.allocation_var.set("0.00")
self._refresh_claims()
def _refresh_totals(self) -> None:
allocated = sum(
(decimal_value(value) for value in self.allocations.values()), Decimal("0")
)
try:
payment_amount = decimal_value(self.variables["amount"].get())
free = payment_amount - allocated
self.total_var.set(
f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR"
)
except (ValueError, InvalidOperation):
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")
def _save(self) -> None:
try:
self.repository.update_payment(
self.member_id,
self.payment_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
allocations=self.allocations,
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
+266
View File
@@ -0,0 +1,266 @@
from __future__ import annotations
import tkinter as tk
from datetime import date, timedelta
from decimal import Decimal
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.domain.dates import DateValidationError, normalize_date_input
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
from ccma.services.sepa_mail import generate_debit_mails
from ccma.storage.repository import MemberRepository, RepositoryError
def _next_weekday(selected: date) -> date:
while selected.weekday() >= 5:
selected += timedelta(days=1)
return selected
class SepaExportDialog(tk.Toplevel):
def __init__(self, master: tk.Misc, repository: MemberRepository):
super().__init__(master)
self.repository = repository
self.debits = []
self.warnings: list[str] = []
self.title("SEPA-Lastschriften")
self.transient(master.winfo_toplevel())
self.geometry("1000x560")
self.minsize(760, 420)
self.from_var = tk.StringVar(value=date(date.today().year, 1, 1).isoformat())
self.due_var = tk.StringVar(value=date.today().isoformat())
self.collection_var = tk.StringVar(
value=_next_weekday(date.today() + timedelta(days=2)).isoformat()
)
self.summary_var = tk.StringVar()
self.warning_var = tk.StringVar()
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
filters = ttk.Frame(self, padding=12)
filters.grid(row=0, column=0, sticky="ew")
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
ttk.Entry(filters, textvariable=self.from_var, width=14).grid(
row=0, column=1, padx=(8, 20)
)
ttk.Label(filters, text="Fällig bis").grid(row=0, column=2)
ttk.Entry(filters, textvariable=self.due_var, width=14).grid(
row=0, column=3, padx=(8, 20)
)
ttk.Label(filters, text="Einzugsdatum").grid(row=0, column=4)
ttk.Entry(filters, textvariable=self.collection_var, width=14).grid(
row=0, column=5, padx=(8, 20)
)
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6)
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", padx=12, pady=(0, 8)
)
self.table = ttk.Treeview(
self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings"
)
for key, label, width, anchor in (
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
("iban", "IBAN", 190, "w"), ("mandate", "Mandat", 130, "w"),
("purpose", "Verwendungszweck", 280, "w"), ("amount", "Betrag", 90, "e"),
):
self.table.heading(key, text=label)
self.table.column(key, width=width, anchor=anchor)
self.table.grid(row=2, column=0, sticky="nsew", padx=12)
footer = ttk.Frame(self, padding=12)
footer.grid(row=3, column=0, sticky="ew")
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w")
ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
row=0, column=1, padx=(8, 0)
)
ttk.Button(footer, text="Info-Mails erzeugen", command=self._open_mail_export).grid(
row=0, column=2, padx=(8, 0)
)
ttk.Button(
footer, text="SEPA-XML speichern", style="Accent.TButton", command=self._save_xml
).grid(row=0, column=3, padx=(8, 0))
ttk.Button(footer, text="Schließen", command=self.destroy).grid(
row=0, column=4, padx=(8, 0)
)
def _dates(self) -> tuple[date, date, date]:
try:
due_from = date.fromisoformat(
normalize_date_input(self.from_var.get(), "Fällig ab")
)
due = date.fromisoformat(normalize_date_input(self.due_var.get(), "Fällig bis"))
collection = date.fromisoformat(
normalize_date_input(self.collection_var.get(), "Einzugsdatum")
)
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if due_from > due:
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
if collection.weekday() >= 5:
raise RepositoryError(
"Das Einzugsdatum muss ein Bankarbeitstag von Montag bis Freitag sein."
)
return due_from, due, collection
def refresh(self) -> None:
try:
due_from, due, _collection = self._dates()
self.debits, self.warnings = pending_direct_debits(
self.repository, due_from=due_from, due_until=due
)
except RepositoryError as exc:
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
return
self.table.delete(*self.table.get_children())
for debit in self.debits:
self.table.insert(
"", "end",
values=(debit.member_number, debit.account_holder, debit.iban,
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
)
total = sum((item.amount for item in self.debits), Decimal("0"))
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
self.warning_var.set(
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
)
def _prepare(self) -> tuple[date, date, date] | None:
self.refresh()
if not self.debits:
messagebox.showinfo(
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
)
return None
if self.warnings and not messagebox.askyesno(
"Unvollständige Mandate",
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
parent=self,
):
return None
return self._dates()
def _target(self, extension: str, title: str, collection: date) -> Path | None:
selected = filedialog.asksaveasfilename(
parent=self, title=title, defaultextension=extension,
initialfile=f"SEPA-Lastschriften-{collection.isoformat()}{extension}",
filetypes=[(title, f"*{extension}"), ("Alle Dateien", "*.*")],
)
return Path(selected) if selected else None
def _save_csv(self) -> None:
dates = self._prepare()
if not dates:
return
target = self._target(".csv", "CSV-Datei", dates[2])
if target:
write_csv(target, self.debits, collection_date=dates[2])
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
def _save_xml(self) -> None:
dates = self._prepare()
if not dates:
return
target = self._target(".xml", "SEPA-XML", dates[2])
if not target:
return
try:
write_pain008(
target, self.debits, collection_date=dates[2],
organization=self.repository.get_configuration().get("organization") or {},
)
except RepositoryError as exc:
messagebox.showerror("SEPA-XML konnte nicht erstellt werden", str(exc), parent=self)
return
messagebox.showinfo("Export gespeichert", f"SEPA-XML gespeichert:\n{target}", parent=self)
def _open_mail_export(self) -> None:
dates = self._prepare()
if dates:
SepaMailExportDialog(self, self.repository, self.debits, dates[2])
class SepaMailExportDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
debits: list,
collection_date: date,
):
super().__init__(master)
self.repository = repository
self.debits = debits
self.collection_date = collection_date
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.title("SEPA-Info-Mails erzeugen")
self.transient(master)
self.resizable(True, False)
self.columnconfigure(1, weight=1)
ttk.Label(
self,
text=(
f"Für {len(debits)} Lastschrift(en) werden Thunderbird-kompatible "
".eml-Entwürfe erzeugt."
),
wraplength=620,
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
self.signature = tk.Text(self, width=55, height=5, wrap="word")
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="Ordner wählen und Entwürfe erzeugen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
selected = filedialog.askdirectory(
parent=self,
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
mustexist=True,
)
if not selected:
return
try:
generated, warnings = generate_debit_mails(
self.repository,
self.debits,
collection_date=self.collection_date,
output_directory=selected,
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("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
return
detail = (
f"{len(generated)} Entwurf/Entwürfe wurden in\n{selected}\nerzeugt und in den "
"Mitgliederakten archiviert."
)
if warnings:
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
self.destroy()
+49 -8
View File
@@ -16,8 +16,8 @@ from ccma.ui.sections import titled_frame
MEMBER_TABLE_COLUMNS = ( MEMBER_TABLE_COLUMNS = (
("number", "Nummer", 110), ("number", "Nummer", 110),
("first_name", "Vorname", 160),
("last_name", "Nachname", 180), ("last_name", "Nachname", 180),
("first_name", "Vorname", 160),
("nickname", "Nickname", 160), ("nickname", "Nickname", 160),
("email", "E-Mail-Adresse", 270), ("email", "E-Mail-Adresse", 270),
("birth", "Geburtsdatum", 120), ("birth", "Geburtsdatum", 120),
@@ -25,13 +25,21 @@ MEMBER_TABLE_COLUMNS = (
) )
STATUS_FILTER_ALL = "Alle" STATUS_FILTER_ALL = "Alle"
MEMBER_FINDING_COLORS = {
"info": "#d9efff",
"warning": "#fff3b0",
"error": "#ffd0d0",
}
MEMBER_FINDING_PRIORITY = {"info": 1, "warning": 2, "error": 3}
ASSET_FILTER_ALL = "Alle" ASSET_FILTER_ALL = "Alle"
ASSET_TABLE_COLUMNS = ( ASSET_TABLE_COLUMNS = (
("label", "Bezeichnung", 260), ("label", "Bezeichnung", 260),
("category", "Kategorie", 140), ("category", "Kategorie", 140),
("inventory_number", "Inventarnummer", 140), ("inventory_number", "Inventarnummer", 140),
("status", "Status", 140), ("status", "Status", 140),
("holder", "Mitglied", 240), ("owner", "Eigentümer", 240),
("holder", "Gewahrsam", 240),
("location", "Standort", 180),
) )
@@ -73,11 +81,22 @@ def _selected_status_filter(label: str) -> str:
return storage_key(MEMBERSHIP_STATUS_LABELS, label) return storage_key(MEMBERSHIP_STATUS_LABELS, label)
def _member_finding_severities(findings: list[HousekeeperFinding]) -> dict[str, str]:
severities: dict[str, str] = {}
for finding in findings:
if not finding.member_id or finding.severity not in MEMBER_FINDING_PRIORITY:
continue
current = severities.get(finding.member_id)
if current is None or MEMBER_FINDING_PRIORITY[finding.severity] > MEMBER_FINDING_PRIORITY[current]:
severities[finding.member_id] = finding.severity
return severities
def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame: def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame:
return titled_frame(parent, "// FILTER") return titled_frame(parent, "// FILTER")
def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str: def _asset_table_value(asset: Asset, column: str, holder_label: str, owner_label: str = "") -> str:
if column == "label": if column == "label":
return asset.label return asset.label
if column == "category": if column == "category":
@@ -88,6 +107,10 @@ def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str:
return ASSET_STATUS_LABELS.get(asset.status, asset.status) return ASSET_STATUS_LABELS.get(asset.status, asset.status)
if column == "holder": if column == "holder":
return holder_label return holder_label
if column == "owner":
return owner_label
if column == "location":
return asset.location
return "" return ""
@@ -219,11 +242,13 @@ class MembersTab(ttk.Frame):
self, self,
master: tk.Misc, master: tk.Misc,
members: list[Member], members: list[Member],
findings: list[HousekeeperFinding],
on_open: Callable[[str], None], on_open: Callable[[str], None],
on_close: Callable[[], None], on_close: Callable[[], None],
): ):
super().__init__(master, padding=12) super().__init__(master, padding=12)
self.members = members self.members = members
self.findings = findings
self.on_open = on_open self.on_open = on_open
self.on_close = on_close self.on_close = on_close
self._build_ui() self._build_ui()
@@ -244,12 +269,12 @@ class MembersTab(ttk.Frame):
filter_row.grid(row=0, column=0, sticky="w") filter_row.grid(row=0, column=0, sticky="w")
self.tree = ttk.Treeview( self.tree = ttk.Treeview(
self, self,
columns=("number", "first_name", "last_name", "nickname", "email", "birth", "status"), columns=tuple(column for column, _title, _width in MEMBER_TABLE_COLUMNS),
show="headings", show="headings",
) )
self.sort_column = "last_name" self.sort_column = "last_name"
self.sort_descending = False self.sort_descending = False
self.status_filter_var = tk.StringVar(value=STATUS_FILTER_ALL) self.status_filter_var = tk.StringVar(value=MEMBERSHIP_STATUS_LABELS["active"])
ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8)) ttk.Label(filter_row, text="Status").grid(row=0, column=0, sticky="w", padx=(0, 8))
self.status_filter = ttk.Combobox( self.status_filter = ttk.Combobox(
filter_row, filter_row,
@@ -263,13 +288,16 @@ class MembersTab(ttk.Frame):
for key, title, width in MEMBER_TABLE_COLUMNS: for key, title, width in MEMBER_TABLE_COLUMNS:
self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column)) self.tree.heading(key, text=title, command=lambda column=key: self._toggle_sort(column))
self.tree.column(key, width=width, anchor="w") self.tree.column(key, width=width, anchor="w")
for severity, color in MEMBER_FINDING_COLORS.items():
self.tree.tag_configure(f"finding-{severity}", background=color, foreground="#202020")
self.tree.grid(row=2, column=0, sticky="nsew") self.tree.grid(row=2, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected()) self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<Return>", lambda _event: self._open_selected()) self.tree.bind("<Return>", lambda _event: self._open_selected())
self.refresh(self.members) self.refresh(self.members, self.findings)
def refresh(self, members: list[Member]) -> None: def refresh(self, members: list[Member], findings: list[HousekeeperFinding]) -> None:
self.members = members self.members = members
self.findings = findings
self._render_members() self._render_members()
def _render_members(self) -> None: def _render_members(self) -> None:
@@ -277,20 +305,23 @@ class MembersTab(ttk.Frame):
status_filter = _selected_status_filter(self.status_filter_var.get()) status_filter = _selected_status_filter(self.status_filter_var.get())
filtered_members = _filter_members(self.members, status_filter) filtered_members = _filter_members(self.members, status_filter)
sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending) sorted_members = _sort_members(filtered_members, self.sort_column, self.sort_descending)
finding_severities = _member_finding_severities(self.findings)
if len(filtered_members) == len(self.members): if len(filtered_members) == len(self.members):
self.count_var.set(f"{len(filtered_members)} Mitglieder") self.count_var.set(f"{len(filtered_members)} Mitglieder")
else: else:
self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder") self.count_var.set(f"{len(filtered_members)} / {len(self.members)} Mitglieder")
self._update_tree_headings() self._update_tree_headings()
for member in sorted_members: for member in sorted_members:
severity = finding_severities.get(member.member_id)
self.tree.insert( self.tree.insert(
"", "",
"end", "end",
iid=member.member_id, iid=member.member_id,
tags=(f"finding-{severity}",) if severity else (),
values=( values=(
member.member_number, member.member_number,
member.first_name,
member.last_name, member.last_name,
member.first_name,
member.nickname, member.nickname,
member.email, member.email,
format_date_for_display(member.birth_date), format_date_for_display(member.birth_date),
@@ -429,6 +460,7 @@ class AssetsTab(ttk.Frame):
asset, asset,
self.sort_column, self.sort_column,
self.resolve_holder_label(asset.current_holder_member_id), self.resolve_holder_label(asset.current_holder_member_id),
self._owner_label(asset),
).casefold(), ).casefold(),
reverse=self.sort_descending, reverse=self.sort_descending,
) )
@@ -447,11 +479,20 @@ class AssetsTab(ttk.Frame):
asset.category, asset.category,
asset.inventory_number, asset.inventory_number,
ASSET_STATUS_LABELS.get(asset.status, asset.status), ASSET_STATUS_LABELS.get(asset.status, asset.status),
self._owner_label(asset),
self.resolve_holder_label(asset.current_holder_member_id), self.resolve_holder_label(asset.current_holder_member_id),
asset.location,
), ),
) )
self._update_actions() self._update_actions()
def _owner_label(self, asset: Asset) -> str:
if asset.owner_type == "club":
return "Verein"
if asset.owner_type == "member":
return self.resolve_holder_label(asset.owner_member_id)
return asset.owner_name or "Extern"
def _toggle_sort(self, column: str) -> None: def _toggle_sort(self, column: str) -> None:
if self.sort_column == column: if self.sort_column == column:
self.sort_descending = not self.sort_descending self.sort_descending = not self.sort_descending
+144
View File
@@ -59,6 +59,49 @@ def test_legacy_claim_becomes_itemized_when_position_is_added(tmp_path) -> None:
assert claim_balance(data, claim) == Decimal("90.00") assert claim_balance(data, claim) == Decimal("90.00")
def test_claim_base_data_can_be_edited(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.update_claim(
member.member_id,
"claim-1",
title="Korrigierter Beitrag",
due_date="30.06.2026",
base_amount="75,00",
description="Beitrag für sechs Monate",
)
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim["title"] == "Korrigierter Beitrag"
assert claim["due_date"] == "2026-06-30"
assert claim["amount"] == "75.00"
assert claim["items"][0]["description"] == "Beitrag für sechs Monate"
assert claim["items"][0]["amount"] == "75.00"
assert claim["calculation"]["manual_override"]["actor"] == "Vorstand"
assert repository.get_events(member.member_id)[-1].event_type == "claim_changed"
def test_claim_cannot_be_reduced_below_allocated_payment(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="80.00",
allocation_amount="80.00",
)
with pytest.raises(RepositoryError, match="bereits zugeordneten Betrag"):
repository.update_claim(
member.member_id,
"claim-1",
title="Zu klein",
due_date="2026-06-30",
base_amount="75.00",
description="",
)
def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None: def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path) repository, member = _repository_with_claim(tmp_path)
data = repository.get_contributions(member.member_id) data = repository.get_contributions(member.member_id)
@@ -104,6 +147,107 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
) )
def test_combined_dummy_payment_settles_multiple_claims(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "claim-2",
"title": "Zweite Forderung",
"amount": "50.00",
"due_date": "2019-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
payment = repository.record_combined_payment(
member.member_id,
payment_date="2019-12-31",
allocations={"claim-1": "100.00", "claim-2": "50.00"},
method="dummy",
reference="Altbestand",
)
changed = repository.get_contributions(member.member_id)
assert payment["amount"] == "150.00"
assert payment["method"] == "dummy"
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("150.00")
assert all(claim_balance(changed, claim) == 0 for claim in changed.claims)
assert repository.get_events(member.member_id)[-1].data["method"] == "dummy"
def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="12.50")
with pytest.raises(RepositoryError, match="nur noch 12.50 EUR offen"):
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="22.50",
allocation_amount="22.50",
)
def test_payment_can_be_edited_and_reallocated_atomically(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="12.50")
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "admission-fee",
"claim_key": "admission-fee",
"title": "Aufnahmegebühr",
"amount": "10.00",
"due_date": "2026-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="22.50",
allocation_amount="12.50",
reference="Vorher",
)
repository.update_payment(
member.member_id,
payment["payment_id"],
payment_date="22.06.2026",
amount="22.50",
allocations={"claim-1": "12.50", "admission-fee": "10.00"},
reference="Korrigiert",
)
changed = repository.get_contributions(member.member_id)
assert changed.payments[0]["date"] == "2026-06-22"
assert changed.payments[0]["reference"] == "Korrigiert"
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("22.50")
assert {item["claim_id"] for item in changed.allocations} == {"claim-1", "admission-fee"}
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
)
repository.delete_payment(member.member_id, payment["payment_id"])
data = repository.get_contributions(member.member_id)
assert data.payments == []
assert data.allocations == []
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None: def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"} claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData( data = ContributionData(
+9
View File
@@ -58,6 +58,15 @@ def test_member_dates_must_be_chronological() -> None:
today=date(2026, 6, 21), today=date(2026, 6, 21),
) )
with pytest.raises(DateValidationError, match="Austrittsdatum"):
validate_member_dates(
birth_date="2000-01-01",
accepted_at="2020-01-01",
membership_started_at="2020-02-01",
membership_ended_at="2020-01-31",
today=date(2026, 6, 21),
)
def test_age_calculation_and_label() -> None: def test_age_calculation_and_label() -> None:
today = date(2026, 6, 21) today = date(2026, 6, 21)
+83
View File
@@ -0,0 +1,83 @@
from email.parser import BytesParser
from email.policy import default
from test_reminders import _overdue_claim_repository
from ccma.services.reminder_mail import generate_and_send_reminder_mail
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
detail="Bitte Mitgliedsnummer angeben.",
)
export_path = tmp_path / "Zahlungserinnerung.eml"
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
output_path=export_path,
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert message["To"] == "reminder@example.org"
assert "Zahlungserinnerung" in message["Subject"]
assert "100.00 Euro" in message.get_content()
assert "DE98670505050038907751" in message.get_content()
data = repository.get_contributions(member.member_id)
assert data.reminders[0]["status"] == "sent"
assert data.reminders[0]["payment_deadline"]
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
assert generated.archive_path.parent.name == "Mahnungen"
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Erste Mahnung",
payment_deadline_days=14,
fee="5.00",
)
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert "Offener Betrag: 105.00 Euro" in message.get_content()
assert "Mahngebühr: 5.00 Euro" in message.get_content()
+96 -3
View File
@@ -1,4 +1,5 @@
import json import json
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal from decimal import Decimal
import pytest import pytest
@@ -38,6 +39,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
assert raw["person"]["first_name"] == "Ada" assert raw["person"]["first_name"] == "Ada"
assert raw["person"]["nickname"] == "Enchantress" assert raw["person"]["nickname"] == "Enchantress"
assert raw["schema_version"] == 1 assert raw["schema_version"] == 1
assert raw["membership"]["ended_at"] == ""
assert raw["content_hash"] assert raw["content_hash"]
@@ -57,6 +59,22 @@ def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> Non
assert [result.member_id for result in repository.search(query)] == [member.member_id] assert [result.member_id for result in repository.search(query)] == [member.member_id]
def test_past_membership_end_sets_status_to_ended(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Former", last_name="Member")
member.status = "active"
member.accepted_at = "2020-01-01"
member.membership_started_at = "2020-01-01"
member.membership_ended_at = "2020-12-31"
repository.save_member(member)
loaded = repository.get_member(member.member_id)
assert loaded.membership_ended_at == "2020-12-31"
assert loaded.status == "ended"
def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None: def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()
@@ -145,7 +163,6 @@ def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tm
first = repository.create_member(first_name="First", last_name="Member") first = repository.create_member(first_name="First", last_name="Member")
second = repository.create_member(first_name="Second", last_name="Member") second = repository.create_member(first_name="Second", last_name="Member")
assert first.member_number == "CCMA-0001" assert first.member_number == "CCMA-0001"
assert second.member_number == "CCMA-0002" assert second.member_number == "CCMA-0002"
assert repository.preview_member_number() == "CCMA-0003" assert repository.preview_member_number() == "CCMA-0003"
@@ -168,6 +185,43 @@ def test_custom_pattern_and_manual_mode(tmp_path) -> None:
repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7") repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7")
def test_member_number_strategy_fills_gaps_or_uses_max_plus_one(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.create_member(first_name="One", last_name="Member", member_number="MA-001")
repository.create_member(first_name="Three", last_name="Member", member_number="MA-003")
repository.save_member_number_policy(
mode="automatic",
pattern="MA-{number:03d}",
allocation_strategy="next_free",
)
assert repository.preview_member_number() == "MA-002"
assert repository.get_member_number_policy()["allocation_strategy"] == "next_free"
repository.save_member_number_policy(
mode="automatic",
pattern="MA-{number:03d}",
allocation_strategy="max_plus_one",
)
assert repository.preview_member_number() == "MA-004"
created = repository.create_member(first_name="Four", last_name="Member")
assert created.member_number == "MA-004"
def test_concurrent_automatic_member_numbers_are_unique(tmp_path) -> None:
MemberRepository(tmp_path).initialize()
def create(index: int) -> str:
repository = MemberRepository(tmp_path)
return repository.create_member(first_name=f"Member {index}", last_name="Concurrent").member_number
with ThreadPoolExecutor(max_workers=8) as executor:
numbers = list(executor.map(create, range(16)))
assert len(numbers) == len(set(numbers)) == 16
@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"]) @pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"])
def test_invalid_member_number_patterns_are_rejected(pattern) -> None: def test_invalid_member_number_patterns_are_rejected(pattern) -> None:
with pytest.raises(RepositoryError): with pytest.raises(RepositoryError):
@@ -286,6 +340,46 @@ def test_asset_can_be_assigned_and_returned_to_single_member(tmp_path) -> None:
assert repository.list_member_assets(member.member_id) == [] assert repository.list_member_assets(member.member_id) == []
def test_member_owned_asset_is_kept_in_central_inventory(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
owner = repository.create_member(first_name="Ada", last_name="Lovelace")
asset = repository.create_asset(
label="3D-Drucker",
owner_type="member",
owner_member_id=owner.member_id,
custody_type="club",
location="Clubraum / Werkstatt",
handed_over_at="2026-07-22",
condition="gebraucht, funktionsfähig",
estimated_value="450.00",
)
loaded = repository.get_asset(asset.asset_id)
assert loaded.owner_member_id == owner.member_id
assert loaded.custody_type == "club"
assert loaded.location == "Clubraum / Werkstatt"
assert [item.asset_id for item in repository.list_member_owned_assets(owner.member_id)] == [
asset.asset_id
]
assert repository.list_member_assets(owner.member_id) == []
assert repository.list_assets()[0].asset_id == asset.asset_id
assert repository.get_events(owner.member_id)[-1].event_type == "member_asset_registered"
def test_member_ownership_requires_existing_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Mitglied"):
repository.create_asset(
label="Privater Beamer",
owner_type="member",
owner_member_id="missing-member",
)
def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None: def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()
@@ -423,6 +517,5 @@ def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None:
findings = Housekeeper(repository).run() findings = Housekeeper(repository).run()
assert any( assert any(
finding.code == "json_hash_mismatch" and finding.member_id == member.member_id finding.code == "json_hash_mismatch" and finding.member_id == member.member_id for finding in findings
for finding in findings
) )
+25
View File
@@ -87,6 +87,11 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims} amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims}
assert amounts["membership-fee:2026:first-half"] == "37.50" assert amounts["membership-fee:2026:first-half"] == "37.50"
assert amounts["membership-fee:2026:second-half"] == "75.00" assert amounts["membership-fee:2026:second-half"] == "75.00"
descriptions = {
claim["claim_key"]: claim["items"][0]["description"] for claim in first_claims
}
assert descriptions["membership-fee:2026:first-half"].endswith("(3 Monate)")
assert descriptions["membership-fee:2026:second-half"] == "Mitgliedsbeitrag 2. Halbjahr 2026"
assert state["run_counter"] == 2 assert state["run_counter"] == 2
assert state["last_completed_run"] == "2026-04-15:000002" assert state["last_completed_run"] == "2026-04-15:000002"
@@ -121,6 +126,9 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
"membership-fee:2026:annual", "membership-fee:2026:annual",
} }
assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50" assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50"
assert claims_by_key["membership-fee:2024:annual"]["items"][0]["description"].endswith(
"(9 Monate)"
)
def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None: def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None:
@@ -169,6 +177,23 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
} }
def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Former", last_name="Member", birth_date="1990-01-01")
member.status = "resigned_end_of_year"
member.accepted_at = "2024-01-01"
member.membership_started_at = "2024-01-01"
member.membership_ended_at = "2026-12-31"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert "membership-fee:2026:annual" in claim_keys
assert "membership-fee:2027:annual" not in claim_keys
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
repository = MemberRepository(tmp_path) repository = MemberRepository(tmp_path)
repository.initialize() repository.initialize()
+207
View File
@@ -0,0 +1,207 @@
import xml.etree.ElementTree as ET
from datetime import UTC, date, datetime
from decimal import Decimal
from email.parser import BytesParser
from email.policy import default
from ccma.domain.models import ContributionData
from ccma.services.sepa import PAIN_NAMESPACE, _safe, csv_text, pain008_bytes, pending_direct_debits
from ccma.services.sepa_mail import debit_mail_bytes, generate_debit_mails
from ccma.storage.repository import MemberRepository
from ccma.ui.sepa_dialog import _next_weekday
def _repository(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace", member_number="C3-42")
member.status = "active"
member.email = "ada@example.org"
member.account_holder = "Ada Lovelace"
member.iban = "DE89370400440532013000"
member.bic = "COBADEFFXXX"
member.mandate_reference = "MANDAT-42"
member.mandate_signed_at = "2025-01-10"
member.mandate_active = True
repository.save_member(member)
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": "due",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
},
{
"claim_id": "future",
"title": "Mitgliedsbeitrag 2027",
"amount": "150.00",
"due_date": "2027-01-31",
"status": "open",
},
],
payments=[{"payment_id": "payment", "amount": "25.00"}],
allocations=[{"payment_id": "payment", "claim_id": "due", "amount": "25.00"}],
),
)
return repository, member
def test_pending_debits_select_due_open_balance(tmp_path):
repository, member = _repository(tmp_path)
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert warnings == []
assert len(debits) == 1
assert debits[0].member_id == member.member_id
assert debits[0].amount == Decimal("125.00")
assert debits[0].claim_ids == ("due",)
def test_pending_debits_exclude_claims_before_lower_date(tmp_path):
repository, _member = _repository(tmp_path)
debits, warnings = pending_direct_debits(
repository,
due_from=date(2026, 2, 1),
due_until=date(2026, 12, 31),
)
assert warnings == []
assert debits == []
def test_next_weekday_moves_weekend_to_monday():
assert _next_weekday(date(2026, 8, 1)) == date(2026, 8, 3)
assert _next_weekday(date(2026, 8, 3)) == date(2026, 8, 3)
def test_sepa_text_normalizes_unsupported_characters():
assert _safe("Sébastien OConnor Müller", 70) == "Sebastien O'Connor - Müller"
def test_csv_is_semicolon_separated_and_uses_decimal_comma(tmp_path):
repository, _member = _repository(tmp_path)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
text = csv_text(debits, collection_date=date(2026, 8, 3))
assert "Einzugsdatum;Mitgliedsnummer" in text
assert "125,00;EUR;MANDAT-42" in text
def test_pain008_contains_control_sum_mandate_and_creditor(tmp_path):
repository, _member = _repository(tmp_path)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
organization = {
"name": "Chaos Computer Club Mannheim e.V.",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
content = pain008_bytes(
debits,
collection_date=date(2026, 8, 3),
organization=organization,
message_id="CCMA-TEST",
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
)
root = ET.fromstring(content)
ns = {"p": PAIN_NAMESPACE}
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "125.00"
assert root.findtext(".//p:MndtId", namespaces=ns) == "MANDAT-42"
assert (
root.findtext(".//p:DrctDbtTx/p:CdtrSchmeId//p:Othr/p:Id", namespaces=ns)
== "DE98ZZZ09999999999"
)
assert root.find("p:CstmrDrctDbtInitn/p:PmtInf/p:CdtrSchmeId", ns) is None
assert root.findtext(".//p:ReqdColltnDt", namespaces=ns) == "2026-08-03"
def test_pain008_contains_one_logical_batch_with_multiple_transactions(tmp_path):
repository, _member = _repository(tmp_path)
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
organization = {
"name": "Chaos Computer Club Mannheim e.V.",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
root = ET.fromstring(
pain008_bytes(
[debit, debit],
collection_date=date(2026, 8, 3),
organization=organization,
message_id="CCMA-BATCH-TEST",
)
)
ns = {"p": PAIN_NAMESPACE}
assert len(root.findall("p:CstmrDrctDbtInitn", ns)) == 1
assert len(root.findall(".//p:PmtInf", ns)) == 1
assert len(root.findall(".//p:DrctDbtTxInf", ns)) == 2
assert root.findtext(".//p:GrpHdr/p:NbOfTxs", namespaces=ns) == "2"
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "250.00"
def test_debit_mail_is_thunderbird_draft(tmp_path):
repository, member = _repository(tmp_path)
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
content = debit_mail_bytes(
recipient=member.email,
first_name=member.first_name,
debit=debit,
collection_date=date(2026, 8, 3),
creditor_id="DE98ZZZ09999999999",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
)
message = BytesParser(policy=default).parsebytes(content)
assert message["To"] == "ada@example.org"
assert str(message["X-Mozilla-Draft-Info"]).strip().startswith("internal/draft")
assert "125.00 Euro" in message.get_content()
assert "MANDAT-42" in message.get_content()
def test_generated_mail_is_exported_archived_and_logged(tmp_path):
repository, member = _repository(tmp_path)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
generated, warnings = generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
output_directory=tmp_path / "mail-export",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert warnings == []
assert generated[0].export_path.is_file()
assert generated[0].archive_path.is_file()
assert generated[0].archive_path.read_bytes() == generated[0].export_path.read_bytes()
event = repository.get_events(member.member_id)[-1]
assert event.event_type == "sepa_notification_generated"
assert event.references["document"].startswith("documents/SEPA/")
+14
View File
@@ -131,6 +131,20 @@ def test_member_table_sort_uses_display_values() -> None:
assert [member.member_id for member in _sort_members(members, "status", False)] == ["2", "1", "3"] assert [member.member_id for member in _sort_members(members, "status", False)] == ["2", "1", "3"]
def test_member_finding_severity_uses_highest_level_per_member() -> None:
from ccma.domain.models import HousekeeperFinding
from ccma.ui.work_tabs import _member_finding_severities
findings = [
HousekeeperFinding("info", "info", "Info", "", member_id="1"),
HousekeeperFinding("warning", "warning", "Warning", "", member_id="1"),
HousekeeperFinding("error", "error", "Error", "", member_id="2"),
HousekeeperFinding("info", "global", "Global", ""),
]
assert _member_finding_severities(findings) == {"1": "warning", "2": "error"}
def test_asset_table_filter_and_sort_use_status_and_holder_label() -> None: def test_asset_table_filter_and_sort_use_status_and_holder_label() -> None:
from ccma.domain.models import Asset from ccma.domain.models import Asset
from ccma.ui.work_tabs import _asset_table_value, _filter_assets, _selected_asset_filter from ccma.ui.work_tabs import _asset_table_value, _filter_assets, _selected_asset_filter
+1 -1
View File
@@ -5,4 +5,4 @@ from ccma import __version__
def test_ui_version_matches_version_file() -> None: def test_ui_version_matches_version_file() -> None:
expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip() expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip()
assert __version__ == expected == "0.0.1-dev1" assert __version__ == expected