mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
1527 lines
64 KiB
Python
1527 lines
64 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import unicodedata
|
|
from collections.abc import Iterable
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from string import Formatter
|
|
from uuid import uuid4
|
|
|
|
from ccma.domain.contributions import (
|
|
claim_balance,
|
|
claim_total,
|
|
credit_allocated_total,
|
|
decimal_value,
|
|
materialize_claim_items,
|
|
money_text,
|
|
payment_allocated_total,
|
|
)
|
|
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
|
|
from ccma.domain.models import (
|
|
ASSET_STATUS_LABELS,
|
|
MEMBERSHIP_STATUS_LABELS,
|
|
Asset,
|
|
ContributionData,
|
|
Event,
|
|
Member,
|
|
)
|
|
from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic
|
|
|
|
|
|
class RepositoryError(RuntimeError):
|
|
pass
|
|
|
|
|
|
DEFAULT_MEMBER_NUMBER_PATTERN = "CCMA-{number:04d}"
|
|
|
|
DEFAULT_CONTRIBUTION_RULES = [
|
|
{
|
|
"rule_id": "standard-before-2022",
|
|
"name": "Regulärer Beitrag bis 2021",
|
|
"valid_from": "1900-01-01",
|
|
"valid_until": "2021-12-31",
|
|
"annual_amount": "120.00",
|
|
"admission_fee": "10.00",
|
|
"annual_due": "01-31",
|
|
"semiannual_due": ["01-31", "07-31"],
|
|
"entry_proration": {"mode": "monthly", "started_month": "included"},
|
|
"first_payment_due_days_after_acceptance": 28,
|
|
"issue_days_before_due": 30,
|
|
"reminder_fee": "5.00",
|
|
"failed_debit_fee": "5.00",
|
|
},
|
|
{
|
|
"rule_id": "standard-2022",
|
|
"name": "Regulärer Beitrag ab 2022",
|
|
"valid_from": "2022-01-01",
|
|
"valid_until": None,
|
|
"annual_amount": "150.00",
|
|
"admission_fee": "10.00",
|
|
"annual_due": "01-31",
|
|
"semiannual_due": ["01-31", "07-31"],
|
|
"entry_proration": {"mode": "monthly", "started_month": "included"},
|
|
"first_payment_due_days_after_acceptance": 28,
|
|
"issue_days_before_due": 30,
|
|
"reminder_fee": "5.00",
|
|
"failed_debit_fee": "5.00",
|
|
},
|
|
]
|
|
|
|
|
|
DEFAULT_CONFIGURATION = {
|
|
"schema_version": 1,
|
|
"organization": {
|
|
"name": "Chaos Computer Club Mannheim e.V.",
|
|
"street": "",
|
|
"postal_code": "",
|
|
"city": "Mannheim",
|
|
"country": "Deutschland",
|
|
"email": "",
|
|
"phone": "",
|
|
"website": "",
|
|
"iban": "",
|
|
"bic": "",
|
|
"creditor_id": "",
|
|
},
|
|
"member_number_policy": {
|
|
"mode": "automatic",
|
|
"pattern": DEFAULT_MEMBER_NUMBER_PATTERN,
|
|
},
|
|
"member_number_sequences": {},
|
|
"reminder_policy": {
|
|
"grace_days_after_due": 7,
|
|
"levels": [
|
|
{
|
|
"level": 1,
|
|
"name": "Zahlungserinnerung",
|
|
"fee": "0.00",
|
|
"payment_deadline_days": 14,
|
|
},
|
|
{
|
|
"level": 2,
|
|
"name": "Erste Mahnung",
|
|
"fee": "5.00",
|
|
"payment_deadline_days": 14,
|
|
},
|
|
{
|
|
"level": 3,
|
|
"name": "Zweite Mahnung",
|
|
"fee": "5.00",
|
|
"payment_deadline_days": 14,
|
|
},
|
|
],
|
|
},
|
|
"contribution_rules": DEFAULT_CONTRIBUTION_RULES,
|
|
}
|
|
|
|
|
|
class MemberRepository:
|
|
def __init__(self, root: Path | str):
|
|
self.root = Path(root).expanduser().resolve()
|
|
self.members_root = self.root / "members"
|
|
self.assets_root = self.root / "assets"
|
|
|
|
def initialize(self) -> None:
|
|
self.members_root.mkdir(parents=True, exist_ok=True)
|
|
self.assets_root.mkdir(parents=True, exist_ok=True)
|
|
(self.root / "rules").mkdir(parents=True, exist_ok=True)
|
|
templates_root = self.root / "templates"
|
|
templates_root.mkdir(parents=True, exist_ok=True)
|
|
builtin_templates = Path(__file__).resolve().parent.parent / "assets" / "templates"
|
|
if builtin_templates.is_dir():
|
|
for source in builtin_templates.iterdir():
|
|
destination_name = (
|
|
"Forderung mit Positionen.fodt"
|
|
if source.name == "Forderung.fodt"
|
|
else source.name
|
|
)
|
|
destination = templates_root / destination_name
|
|
if source.is_file() and not destination.exists():
|
|
shutil.copyfile(source, destination)
|
|
config_path = self.root / "repository.json"
|
|
if not config_path.exists():
|
|
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
|
|
|
def validate(self) -> list[str]:
|
|
errors: list[str] = []
|
|
try:
|
|
config = read_json(self.root / "repository.json")
|
|
if not json_content_hash_matches(config):
|
|
errors.append(
|
|
"repository.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert."
|
|
)
|
|
if int(config.get("schema_version", 0)) != 1:
|
|
errors.append("repository.json: nicht unterstützte schema_version")
|
|
policy = config.get("member_number_policy") or {}
|
|
if str(policy.get("mode", "automatic")) not in {"automatic", "manual"}:
|
|
errors.append("repository.json: ungültiger Mitgliedsnummernmodus")
|
|
validate_member_number_pattern(str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN)))
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError, RepositoryError) as exc:
|
|
errors.append(f"repository.json: {exc}")
|
|
|
|
seen_numbers: dict[str, str] = {}
|
|
for member_dir in self._member_directories():
|
|
try:
|
|
member, _contributions = self.preflight_member_record(member_dir.name)
|
|
errors.extend(
|
|
f"{member_dir.name}/{warning}"
|
|
for warning in self.member_hash_warnings(member_dir.name)
|
|
)
|
|
validate_member_dates(
|
|
birth_date=member.birth_date,
|
|
accepted_at=member.accepted_at,
|
|
membership_started_at=member.membership_started_at,
|
|
)
|
|
if member.member_id != member_dir.name:
|
|
errors.append(f"{member_dir.name}/member.json: member_id stimmt nicht mit Ordner überein")
|
|
normalized_number = member.member_number.casefold().strip()
|
|
if normalized_number and normalized_number in seen_numbers:
|
|
errors.append(
|
|
f"{member_dir.name}/member.json: Mitgliedsnummer {member.member_number} ist doppelt"
|
|
)
|
|
elif normalized_number:
|
|
seen_numbers[normalized_number] = member.member_id
|
|
except RepositoryError as exc:
|
|
errors.append(str(exc))
|
|
except (
|
|
OSError,
|
|
ValueError,
|
|
TypeError,
|
|
KeyError,
|
|
json.JSONDecodeError,
|
|
DateValidationError,
|
|
) as exc:
|
|
errors.append(f"{member_dir.name}/member.json: {exc}")
|
|
for asset_dir in self._asset_directories():
|
|
try:
|
|
asset = self.get_asset(asset_dir.name)
|
|
errors.extend(
|
|
f"{asset_dir.name}/{warning}"
|
|
for warning in self.asset_hash_warnings(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")
|
|
if asset.schema_version != 1:
|
|
errors.append(
|
|
f"{asset_dir.name}/asset.json: "
|
|
f"nicht unterstützte schema_version {asset.schema_version}"
|
|
)
|
|
if asset.status not in ASSET_STATUS_LABELS:
|
|
errors.append(f"{asset_dir.name}/asset.json: ungültiger Asset-Status")
|
|
if asset.current_holder_member_id:
|
|
self.get_member(asset.current_holder_member_id)
|
|
if asset.status != "issued":
|
|
errors.append(
|
|
f"{asset_dir.name}/asset.json: zugeordnetes Asset muss Status issued haben"
|
|
)
|
|
elif asset.status == "issued":
|
|
errors.append(f"{asset_dir.name}/asset.json: issued benötigt current_holder_member_id")
|
|
self.get_asset_events(asset.asset_id)
|
|
except RepositoryError as exc:
|
|
errors.append(str(exc))
|
|
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
errors.append(f"{asset_dir.name}/asset.json: {exc}")
|
|
return errors
|
|
|
|
def list_members(self) -> list[Member]:
|
|
members: list[Member] = []
|
|
for member_id in self.list_member_ids():
|
|
try:
|
|
members.append(self.get_member(member_id))
|
|
except RepositoryError:
|
|
continue
|
|
return sorted(members, key=lambda item: (item.last_name.casefold(), item.first_name.casefold()))
|
|
|
|
def list_member_ids(self) -> list[str]:
|
|
return sorted(directory.name for directory in self._member_directories())
|
|
|
|
def get_member(self, member_id: str) -> Member:
|
|
path = self._member_path(member_id) / "member.json"
|
|
if not path.is_file():
|
|
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
|
|
try:
|
|
raw = read_json(path)
|
|
if not isinstance(raw, dict):
|
|
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
|
|
for section_name in ("person", "address", "banking", "membership", "contribution_profile"):
|
|
if section_name in raw and not isinstance(raw[section_name], dict):
|
|
raise TypeError(f"{section_name} muss ein JSON-Objekt sein")
|
|
return Member.from_dict(raw)
|
|
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
raise RepositoryError(f"{member_id}/member.json konnte nicht gelesen werden: {exc}") from exc
|
|
|
|
def preflight_member_record(self, member_id: str) -> tuple[Member, ContributionData]:
|
|
member = self.get_member(member_id)
|
|
if member.member_id != member_id:
|
|
raise RepositoryError(f"{member_id}/member.json: member_id stimmt nicht mit Ordner überein")
|
|
if member.schema_version != 1:
|
|
raise RepositoryError(
|
|
f"{member_id}/member.json: nicht unterstützte schema_version {member.schema_version}"
|
|
)
|
|
contributions = self.get_contributions(member_id)
|
|
try:
|
|
self.get_events(member_id)
|
|
except (OSError, UnicodeError) as exc:
|
|
raise RepositoryError(f"{member_id}/events.jsonl konnte nicht gelesen werden: {exc}") from exc
|
|
return member, contributions
|
|
|
|
def create_member(
|
|
self,
|
|
*,
|
|
first_name: str,
|
|
last_name: str,
|
|
nickname: str = "",
|
|
email: str = "",
|
|
phone: str = "",
|
|
birth_date: str = "",
|
|
member_number: str = "",
|
|
) -> Member:
|
|
if not first_name.strip() or not last_name.strip():
|
|
raise RepositoryError("Vorname und Nachname sind erforderlich.")
|
|
try:
|
|
birth_date = normalize_date_input(birth_date, "Geburtsdatum")
|
|
validate_member_dates(birth_date=birth_date)
|
|
except DateValidationError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
selected_number = member_number.strip()
|
|
policy = self.get_member_number_policy()
|
|
if selected_number:
|
|
self._assert_member_number_available(selected_number)
|
|
elif policy["mode"] == "manual":
|
|
raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.")
|
|
else:
|
|
selected_number = self._allocate_member_number(policy["pattern"])
|
|
member_id = str(uuid4())
|
|
directory = self._member_path(member_id)
|
|
directory.mkdir(parents=True, exist_ok=False)
|
|
(directory / "files").mkdir()
|
|
member = Member(
|
|
member_id=member_id,
|
|
member_number=selected_number,
|
|
first_name=first_name.strip(),
|
|
last_name=last_name.strip(),
|
|
nickname=nickname.strip(),
|
|
email=email.strip(),
|
|
phone=phone.strip(),
|
|
birth_date=birth_date,
|
|
)
|
|
write_json_atomic(directory / "member.json", member.to_dict())
|
|
write_json_atomic(directory / "contributions.json", ContributionData().to_dict())
|
|
self.append_event(
|
|
member_id,
|
|
event_type="member_created",
|
|
summary="Mitgliederakte angelegt",
|
|
actor_type="user",
|
|
actor_name="Vorstand",
|
|
)
|
|
return member
|
|
|
|
def save_member(self, member: Member, *, actor_name: str = "Vorstand") -> None:
|
|
existing = self.get_member(member.member_id)
|
|
try:
|
|
member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum")
|
|
member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss")
|
|
member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit")
|
|
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, "Mandat widerrufen am"
|
|
)
|
|
validate_member_dates(
|
|
birth_date=member.birth_date,
|
|
accepted_at=member.accepted_at,
|
|
membership_started_at=member.membership_started_at,
|
|
)
|
|
except DateValidationError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
member.iban = normalize_iban(member.iban)
|
|
member.bic = member.bic.replace(" ", "").upper()
|
|
validate_iban(member.iban)
|
|
validate_bic(member.bic)
|
|
if member.mandate_active and not (
|
|
member.iban and member.mandate_reference.strip() and member.mandate_signed_at
|
|
):
|
|
raise RepositoryError(
|
|
"Ein aktives Lastschriftmandat benötigt IBAN, Mandatsreferenz und Erteilungsdatum."
|
|
)
|
|
if member.member_number != existing.member_number:
|
|
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
|
|
changes = self._summarize_changes(existing, member)
|
|
member.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
write_json_atomic(self._member_path(member.member_id) / "member.json", member.to_dict())
|
|
if changes:
|
|
self.append_event(
|
|
member.member_id,
|
|
event_type="member_data_changed",
|
|
summary=f"Mitgliedsdaten geändert: {', '.join(changes)}",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
)
|
|
|
|
def list_assets(self) -> list[Asset]:
|
|
assets: list[Asset] = []
|
|
for asset_id in self.list_asset_ids():
|
|
try:
|
|
assets.append(self.get_asset(asset_id))
|
|
except RepositoryError:
|
|
continue
|
|
return sorted(assets, key=lambda item: (item.label.casefold(), item.inventory_number.casefold()))
|
|
|
|
def list_asset_ids(self) -> list[str]:
|
|
return sorted(directory.name for directory in self._asset_directories())
|
|
|
|
def get_asset(self, asset_id: str) -> Asset:
|
|
path = self._asset_path(asset_id) / "asset.json"
|
|
if not path.is_file():
|
|
raise RepositoryError(f"Asset nicht gefunden: {asset_id}")
|
|
try:
|
|
raw = read_json(path)
|
|
if not isinstance(raw, dict):
|
|
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
|
|
return Asset.from_dict(raw)
|
|
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
raise RepositoryError(f"{asset_id}/asset.json konnte nicht gelesen werden: {exc}") from exc
|
|
|
|
def create_asset(
|
|
self,
|
|
*,
|
|
label: str,
|
|
category: str = "",
|
|
inventory_number: str = "",
|
|
serial_number: str = "",
|
|
deposit_amount_default: str = "0",
|
|
notes: str = "",
|
|
) -> Asset:
|
|
if not label.strip():
|
|
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
|
|
try:
|
|
deposit_amount = decimal_value(deposit_amount_default or "0", "Kaution")
|
|
except ValueError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if deposit_amount < 0:
|
|
raise RepositoryError("Die Kaution darf nicht negativ sein.")
|
|
asset_id = str(uuid4())
|
|
directory = self._asset_path(asset_id)
|
|
directory.mkdir(parents=True, exist_ok=False)
|
|
(directory / "files").mkdir()
|
|
asset = Asset(
|
|
asset_id=asset_id,
|
|
label=label.strip(),
|
|
category=category.strip(),
|
|
inventory_number=inventory_number.strip(),
|
|
serial_number=serial_number.strip(),
|
|
deposit_amount_default=money_text(deposit_amount),
|
|
notes=notes.strip(),
|
|
)
|
|
write_json_atomic(directory / "asset.json", asset.to_dict())
|
|
self.append_asset_event(
|
|
asset.asset_id,
|
|
event_type="asset_created",
|
|
summary="Asset angelegt",
|
|
actor_type="user",
|
|
actor_name="Vorstand",
|
|
)
|
|
return asset
|
|
|
|
def save_asset(self, asset: Asset, *, actor_name: str = "Vorstand") -> None:
|
|
existing = self.get_asset(asset.asset_id)
|
|
if not asset.label.strip():
|
|
raise RepositoryError("Eine Bezeichnung für das Asset ist erforderlich.")
|
|
if asset.status not in ASSET_STATUS_LABELS:
|
|
raise RepositoryError("Ungültiger Asset-Status.")
|
|
try:
|
|
deposit_amount = decimal_value(asset.deposit_amount_default or "0", "Kaution")
|
|
except ValueError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if deposit_amount < 0:
|
|
raise RepositoryError("Die Kaution darf nicht negativ sein.")
|
|
if (
|
|
existing.current_holder_member_id
|
|
and money_text(deposit_amount) != str(existing.deposit_amount_default)
|
|
):
|
|
raise RepositoryError(
|
|
"Die Kaution kann nur geändert werden, wenn das Asset nicht ausgegeben ist."
|
|
)
|
|
asset.label = asset.label.strip()
|
|
asset.category = asset.category.strip()
|
|
asset.inventory_number = asset.inventory_number.strip()
|
|
asset.serial_number = asset.serial_number.strip()
|
|
asset.deposit_amount_default = money_text(deposit_amount)
|
|
asset.notes = asset.notes.strip()
|
|
if asset.current_holder_member_id:
|
|
self.get_member(asset.current_holder_member_id)
|
|
if asset.status != "issued":
|
|
raise RepositoryError("Ein zugeordnetes Asset muss den Status issued haben.")
|
|
elif asset.status == "issued":
|
|
raise RepositoryError("Status issued benötigt ein zugeordnetes Mitglied.")
|
|
changes = self._summarize_asset_changes(existing, asset)
|
|
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
|
if changes:
|
|
self.append_asset_event(
|
|
asset.asset_id,
|
|
event_type="asset_data_changed",
|
|
summary=f"Assetdaten geändert: {', '.join(changes)}",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
)
|
|
|
|
def assign_asset(self, asset_id: str, member_id: str, *, actor_name: str = "Vorstand") -> Asset:
|
|
asset = self.get_asset(asset_id)
|
|
member = self.get_member(member_id)
|
|
if asset.current_holder_member_id:
|
|
raise RepositoryError("Das Asset ist bereits einem Mitglied zugeordnet.")
|
|
if asset.status in {"lost", "retired"}:
|
|
raise RepositoryError("Verlorene oder ausgemusterte Assets können nicht ausgegeben werden.")
|
|
asset.current_holder_member_id = member.member_id
|
|
asset.status = "issued"
|
|
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
|
self.append_asset_event(
|
|
asset.asset_id,
|
|
event_type="asset_issued",
|
|
summary=f"Asset ausgegeben an {member.member_number or member.member_id}",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
references={"member_id": member.member_id},
|
|
)
|
|
self.append_event(
|
|
member.member_id,
|
|
event_type="asset_assigned",
|
|
summary=f"Asset ausgegeben: {asset.label}",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
references={"asset_id": asset.asset_id},
|
|
)
|
|
return asset
|
|
|
|
def return_asset(self, asset_id: str, *, actor_name: str = "Vorstand") -> Asset:
|
|
asset = self.get_asset(asset_id)
|
|
member_id = asset.current_holder_member_id
|
|
if not member_id:
|
|
raise RepositoryError("Das Asset ist aktuell keinem Mitglied zugeordnet.")
|
|
asset.current_holder_member_id = ""
|
|
asset.status = "available"
|
|
asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict())
|
|
self.append_asset_event(
|
|
asset.asset_id,
|
|
event_type="asset_returned",
|
|
summary="Asset zurückgenommen",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
references={"member_id": member_id},
|
|
)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="asset_returned",
|
|
summary=f"Asset zurückgegeben: {asset.label}",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
references={"asset_id": asset.asset_id},
|
|
)
|
|
return asset
|
|
|
|
def list_member_assets(self, member_id: str) -> list[Asset]:
|
|
self.get_member(member_id)
|
|
return [asset for asset in self.list_assets() if asset.current_holder_member_id == member_id]
|
|
|
|
def create_manual_claim(
|
|
self,
|
|
member_id: str,
|
|
*,
|
|
title: str,
|
|
amount: str,
|
|
due_date: str,
|
|
description: str = "",
|
|
claim_type: str = "asset_charge",
|
|
references: dict[str, str] | None = None,
|
|
actor_name: str = "Vorstand",
|
|
) -> dict:
|
|
self.get_member(member_id)
|
|
if not title.strip():
|
|
raise RepositoryError("Ein Forderungstitel ist erforderlich.")
|
|
try:
|
|
normalized_due_date = normalize_date_input(due_date, "Fälligkeitsdatum")
|
|
amount_value = decimal_value(amount)
|
|
except (DateValidationError, ValueError) as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if not normalized_due_date:
|
|
raise RepositoryError("Ein Fälligkeitsdatum ist erforderlich.")
|
|
if amount_value == 0:
|
|
raise RepositoryError("Der Betrag darf nicht null sein.")
|
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
claim_id = str(uuid4())
|
|
item_type = "credit" if amount_value < 0 else "base"
|
|
claim = {
|
|
"claim_id": claim_id,
|
|
"claim_key": f"manual:{claim_type}:{claim_id}",
|
|
"title": title.strip(),
|
|
"amount": money_text(amount_value),
|
|
"due_date": normalized_due_date,
|
|
"status": "open",
|
|
"created_at": now,
|
|
"items": [
|
|
{
|
|
"item_id": str(uuid4()),
|
|
"type": item_type,
|
|
"description": description.strip() or title.strip(),
|
|
"quantity": "1.00",
|
|
"unit_price": money_text(amount_value),
|
|
"amount": money_text(amount_value),
|
|
"created_at": now,
|
|
}
|
|
],
|
|
"origin": {
|
|
"type": "manual",
|
|
"subtype": claim_type.strip() or "asset_charge",
|
|
"actor": actor_name,
|
|
"asset_id": (references or {}).get("asset_id", ""),
|
|
},
|
|
}
|
|
data = self.get_contributions(member_id)
|
|
data.claims.append(claim)
|
|
self.save_contributions(member_id, data)
|
|
event = self.append_event(
|
|
member_id,
|
|
event_type="claim_created",
|
|
summary=f"Forderung angelegt: {claim['title']}",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
references={"claim_id": claim_id, **(references or {})},
|
|
data={"amount": claim["amount"], "due_date": claim["due_date"], "claim_type": claim_type},
|
|
)
|
|
if references and references.get("asset_id"):
|
|
self.append_asset_event(
|
|
references["asset_id"],
|
|
event_type="asset_claim_created",
|
|
summary=f"Forderung für Asset angelegt: {claim['title']}",
|
|
actor_type="user",
|
|
actor_name=actor_name,
|
|
references={"member_id": member_id, "claim_id": claim_id},
|
|
data={"amount": claim["amount"], "due_date": claim["due_date"], "claim_type": claim_type},
|
|
)
|
|
return {"claim": claim, "event": event}
|
|
|
|
def append_asset_event(
|
|
self,
|
|
asset_id: str,
|
|
*,
|
|
event_type: str,
|
|
summary: str,
|
|
actor_type: str = "system",
|
|
actor_name: str = "CCMA",
|
|
references: dict[str, str] | None = None,
|
|
data: dict[str, object] | None = None,
|
|
) -> Event:
|
|
directory = self._asset_path(asset_id)
|
|
if not (directory / "asset.json").is_file():
|
|
raise RepositoryError(f"Asset nicht gefunden: {asset_id}")
|
|
event = Event(
|
|
event_id=str(uuid4()),
|
|
timestamp=datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
event_type=event_type,
|
|
summary=summary.strip(),
|
|
actor_type=actor_type,
|
|
actor_name=actor_name,
|
|
references=references or {},
|
|
data=data or {},
|
|
)
|
|
path = directory / "events.jsonl"
|
|
line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
handle.write(line)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
return event
|
|
|
|
def get_asset_events(self, asset_id: str) -> list[Event]:
|
|
path = self._asset_path(asset_id) / "events.jsonl"
|
|
if not path.exists():
|
|
return []
|
|
events: list[Event] = []
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
for line_number, line in enumerate(handle, start=1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
raw = json.loads(line)
|
|
if not isinstance(raw, dict):
|
|
raise TypeError("Event muss ein JSON-Objekt sein")
|
|
events.append(Event.from_dict(raw))
|
|
except (AttributeError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc
|
|
return events
|
|
|
|
def get_contributions(self, member_id: str) -> ContributionData:
|
|
path = self._member_path(member_id) / "contributions.json"
|
|
if not path.exists():
|
|
return ContributionData()
|
|
try:
|
|
raw = read_json(path)
|
|
if not isinstance(raw, dict):
|
|
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
|
|
for field_name in ("claims", "payments", "credits", "allocations", "reminders"):
|
|
if field_name in raw and not isinstance(raw[field_name], list):
|
|
raise TypeError(f"{field_name} muss eine JSON-Liste sein")
|
|
if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]):
|
|
raise TypeError(f"Alle Einträge in {field_name} müssen JSON-Objekte sein")
|
|
return ContributionData.from_dict(raw)
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
raise RepositoryError(
|
|
f"{member_id}/contributions.json konnte nicht gelesen werden: {exc}"
|
|
) from exc
|
|
|
|
def save_contributions(self, member_id: str, data: ContributionData) -> None:
|
|
self.get_member(member_id)
|
|
write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict())
|
|
|
|
def get_claim(self, member_id: str, claim_id: str) -> tuple[ContributionData, dict]:
|
|
data = self.get_contributions(member_id)
|
|
claim = next(
|
|
(item for item in data.claims if str(item.get("claim_id", "")) == claim_id),
|
|
None,
|
|
)
|
|
if claim is None:
|
|
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
|
|
return data, claim
|
|
|
|
def add_claim_item(
|
|
self,
|
|
member_id: str,
|
|
claim_id: str,
|
|
*,
|
|
description: str,
|
|
quantity: str,
|
|
unit_price: str,
|
|
item_type: str = "correction",
|
|
) -> dict:
|
|
if not description.strip():
|
|
raise RepositoryError("Eine Beschreibung ist erforderlich.")
|
|
try:
|
|
selected_quantity = decimal_value(quantity, "Menge")
|
|
selected_unit_price = decimal_value(unit_price, "Einzelpreis")
|
|
except ValueError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if selected_quantity == 0:
|
|
raise RepositoryError("Die Menge 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 verändert werden.")
|
|
amount = selected_quantity * selected_unit_price
|
|
item = {
|
|
"item_id": str(uuid4()),
|
|
"type": item_type.strip() or "correction",
|
|
"description": description.strip(),
|
|
"quantity": money_text(selected_quantity),
|
|
"unit_price": money_text(selected_unit_price),
|
|
"amount": money_text(amount),
|
|
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
}
|
|
materialize_claim_items(claim).append(item)
|
|
claim["amount"] = money_text(claim_total(claim))
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="claim_item_added",
|
|
summary=f"Forderungsposition ergänzt: {item['description']} ({item['amount']} EUR)",
|
|
references={"claim_id": claim_id, "item_id": str(item["item_id"])},
|
|
)
|
|
return item
|
|
|
|
def record_payment(
|
|
self,
|
|
member_id: str,
|
|
claim_id: str,
|
|
*,
|
|
payment_date: str,
|
|
amount: str,
|
|
allocation_amount: str,
|
|
gnucash_transaction_id: str = "",
|
|
reference: str = "",
|
|
) -> dict:
|
|
try:
|
|
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
|
|
selected_amount = decimal_value(amount)
|
|
selected_allocation = decimal_value(allocation_amount, "Zuordnung")
|
|
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.")
|
|
if selected_allocation <= 0 or selected_allocation > selected_amount:
|
|
raise RepositoryError(
|
|
"Die Zuordnung muss größer als null und höchstens so hoch wie die Zahlung sein."
|
|
)
|
|
gnucash_id = gnucash_transaction_id.strip()
|
|
if gnucash_id:
|
|
self._assert_gnucash_id_available(gnucash_id)
|
|
data, claim = self.get_claim(member_id, claim_id)
|
|
payment = {
|
|
"payment_id": str(uuid4()),
|
|
"date": normalized_date,
|
|
"amount": money_text(selected_amount),
|
|
"method": "bank_transfer",
|
|
"gnucash_transaction_id": gnucash_id,
|
|
"reference": reference.strip(),
|
|
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
}
|
|
allocation = {
|
|
"allocation_id": str(uuid4()),
|
|
"payment_id": payment["payment_id"],
|
|
"claim_id": claim_id,
|
|
"amount": money_text(selected_allocation),
|
|
}
|
|
data.payments.append(payment)
|
|
data.allocations.append(allocation)
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="payment_recorded",
|
|
summary=f"Zahlung eingegangen: {payment['amount']} EUR",
|
|
references={"claim_id": claim_id, "payment_id": str(payment["payment_id"])},
|
|
data={"allocation_amount": allocation["amount"]},
|
|
)
|
|
return payment
|
|
|
|
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)
|
|
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:
|
|
selected_amount = decimal_value(amount, "Zuordnung")
|
|
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
|
|
except ValueError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if selected_amount <= 0 or selected_amount > available:
|
|
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.")
|
|
allocation = {
|
|
"allocation_id": str(uuid4()),
|
|
"payment_id": payment_id,
|
|
"claim_id": claim_id,
|
|
"amount": money_text(selected_amount),
|
|
}
|
|
data.allocations.append(allocation)
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="payment_allocated",
|
|
summary=f"Zahlung zugeordnet: {allocation['amount']} EUR",
|
|
references={"claim_id": claim_id, "payment_id": payment_id},
|
|
)
|
|
return allocation
|
|
|
|
def record_credit(
|
|
self,
|
|
member_id: str,
|
|
claim_id: str,
|
|
*,
|
|
credit_date: str,
|
|
amount: str,
|
|
allocation_amount: str,
|
|
reference: str = "",
|
|
) -> dict:
|
|
try:
|
|
normalized_date = normalize_date_input(credit_date, "Gutschriftsdatum")
|
|
selected_amount = decimal_value(amount, "Gutschrift")
|
|
selected_allocation = decimal_value(allocation_amount, "Zuordnung")
|
|
except (DateValidationError, ValueError) as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if not normalized_date:
|
|
raise RepositoryError("Gutschriftsdatum ist erforderlich.")
|
|
if selected_amount <= 0:
|
|
raise RepositoryError("Der Gutschriftsbetrag muss größer als null sein.")
|
|
if selected_allocation <= 0 or selected_allocation > selected_amount:
|
|
raise RepositoryError(
|
|
"Die Zuordnung muss größer als null und höchstens so hoch wie die Gutschrift sein."
|
|
)
|
|
data, claim = self.get_claim(member_id, claim_id)
|
|
if claim_total(claim) >= 0:
|
|
raise RepositoryError("Gutschriften können nur negativen Forderungen zugeordnet werden.")
|
|
credit = {
|
|
"credit_id": str(uuid4()),
|
|
"date": normalized_date,
|
|
"amount": money_text(selected_amount),
|
|
"method": "payout",
|
|
"reference": reference.strip(),
|
|
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
}
|
|
allocation = {
|
|
"allocation_id": str(uuid4()),
|
|
"credit_id": credit["credit_id"],
|
|
"claim_id": claim_id,
|
|
"amount": money_text(selected_allocation),
|
|
}
|
|
data.credits.append(credit)
|
|
data.allocations.append(allocation)
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="credit_recorded",
|
|
summary=f"Gutschrift / Auszahlung erfasst: {credit['amount']} EUR",
|
|
references={"claim_id": claim_id, "credit_id": str(credit["credit_id"])},
|
|
data={"allocation_amount": allocation["amount"]},
|
|
)
|
|
return credit
|
|
|
|
def allocate_credit(self, member_id: str, claim_id: str, *, credit_id: str, amount: str) -> dict:
|
|
data, claim = self.get_claim(member_id, claim_id)
|
|
if claim_total(claim) >= 0:
|
|
raise RepositoryError("Gutschriften können nur negativen Forderungen zugeordnet werden.")
|
|
credit = next(
|
|
(item for item in data.credits if str(item.get("credit_id", "")) == credit_id),
|
|
None,
|
|
)
|
|
if credit is None:
|
|
raise RepositoryError("Gutschrift nicht gefunden.")
|
|
try:
|
|
selected_amount = decimal_value(amount, "Zuordnung")
|
|
available = decimal_value(credit.get("amount", "0")) - credit_allocated_total(data, credit_id)
|
|
except ValueError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if selected_amount <= 0 or selected_amount > available:
|
|
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Gutschrift verfügbar.")
|
|
allocation = {
|
|
"allocation_id": str(uuid4()),
|
|
"credit_id": credit_id,
|
|
"claim_id": claim_id,
|
|
"amount": money_text(selected_amount),
|
|
}
|
|
data.allocations.append(allocation)
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="credit_allocated",
|
|
summary=f"Gutschrift zugeordnet: {allocation['amount']} EUR",
|
|
references={"claim_id": claim_id, "credit_id": credit_id},
|
|
)
|
|
return allocation
|
|
|
|
def create_reminder_draft(
|
|
self,
|
|
member_id: str,
|
|
claim_id: str,
|
|
*,
|
|
level: int,
|
|
name: str,
|
|
payment_deadline_days: int,
|
|
detail: str = "",
|
|
fee: str = "0",
|
|
channel: str = "email",
|
|
) -> dict:
|
|
if level < 1:
|
|
raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
|
|
try:
|
|
selected_fee = decimal_value(fee, "Mahngebühr")
|
|
except ValueError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
if selected_fee < 0:
|
|
raise RepositoryError("Die Mahngebühr darf nicht negativ sein.")
|
|
if payment_deadline_days < 1 or payment_deadline_days > 365:
|
|
raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
|
|
data, claim = self.get_claim(member_id, claim_id)
|
|
if str(claim.get("status", "")) == "cancelled":
|
|
raise RepositoryError("Eine stornierte Forderung kann nicht gemahnt werden.")
|
|
if _dunning_hold_is_active(claim):
|
|
raise RepositoryError("Für diese Forderung ist eine Mahnsperre aktiv.")
|
|
if claim_balance(data, claim) <= 0:
|
|
raise RepositoryError("Die Forderung hat keinen offenen Betrag.")
|
|
if any(
|
|
str(item.get("claim_id", "")) == claim_id
|
|
and int(item.get("level", 0)) == level
|
|
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
|
|
for item in data.reminders
|
|
):
|
|
raise RepositoryError(f"Mahnstufe {level} existiert bereits.")
|
|
if level > 1 and not any(
|
|
str(item.get("claim_id", "")) == claim_id
|
|
and int(item.get("level", 0)) == level - 1
|
|
and str(item.get("status", "")) == "sent"
|
|
for item in data.reminders
|
|
):
|
|
raise RepositoryError(f"Mahnstufe {level - 1} wurde noch nicht versandt.")
|
|
created_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
reminder = {
|
|
"reminder_id": str(uuid4()),
|
|
"reminder_key": f"{claim_id}:level-{level}",
|
|
"claim_id": claim_id,
|
|
"level": level,
|
|
"name": name.strip() or f"Mahnung Stufe {level}",
|
|
"status": "draft",
|
|
"detail": detail.strip(),
|
|
"channel": channel.strip() or "email",
|
|
"created_at": created_at,
|
|
"generated_at": None,
|
|
"sent_at": None,
|
|
"payment_deadline_days": payment_deadline_days,
|
|
"payment_deadline": None,
|
|
"balance_snapshot": money_text(claim_balance(data, claim)),
|
|
"fee": money_text(selected_fee),
|
|
"fee_item_id": None,
|
|
"document": {"path": "", "sha256": ""},
|
|
}
|
|
data.reminders.append(reminder)
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="reminder_draft_created",
|
|
summary=f"Mahnungsentwurf erstellt: {reminder['name']}",
|
|
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
|
data={"fee": reminder["fee"], "balance_snapshot": reminder["balance_snapshot"]},
|
|
)
|
|
return reminder
|
|
|
|
def mark_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict:
|
|
data, claim = self.get_claim(member_id, claim_id)
|
|
reminder = self._find_reminder(data, claim_id, reminder_id)
|
|
if str(reminder.get("status", "")) not in {"draft", "generated"}:
|
|
raise RepositoryError("Nur ein Entwurf kann als versandt markiert werden.")
|
|
if claim_balance(data, claim) <= 0:
|
|
raise RepositoryError("Die Forderung hat keinen offenen Betrag mehr.")
|
|
now = datetime.now().astimezone()
|
|
selected_fee = decimal_value(reminder.get("fee", "0"), "Mahngebühr")
|
|
if selected_fee > 0 and not reminder.get("fee_item_id"):
|
|
item = {
|
|
"item_id": str(uuid4()),
|
|
"type": "fee",
|
|
"description": f"Mahngebühr Stufe {reminder.get('level', '')}",
|
|
"quantity": "1.00",
|
|
"unit_price": money_text(selected_fee),
|
|
"amount": money_text(selected_fee),
|
|
"created_at": now.isoformat(timespec="seconds"),
|
|
}
|
|
materialize_claim_items(claim).append(item)
|
|
claim["amount"] = money_text(claim_total(claim))
|
|
reminder["fee_item_id"] = item["item_id"]
|
|
reminder["status"] = "sent"
|
|
reminder["sent_at"] = now.isoformat(timespec="seconds")
|
|
reminder["payment_deadline"] = (
|
|
now.date() + timedelta(days=int(reminder.get("payment_deadline_days", 14)))
|
|
).isoformat()
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="reminder_sent",
|
|
summary=f"Mahnung als versandt markiert: {reminder.get('name', '')}",
|
|
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
|
|
data={"fee": money_text(selected_fee), "payment_deadline": reminder["payment_deadline"]},
|
|
)
|
|
return reminder
|
|
|
|
def cancel_reminder(self, member_id: str, claim_id: str, reminder_id: str) -> None:
|
|
data, _claim = self.get_claim(member_id, claim_id)
|
|
reminder = self._find_reminder(data, claim_id, reminder_id)
|
|
if str(reminder.get("status", "")) == "sent":
|
|
raise RepositoryError("Eine bereits versandte Mahnung kann nicht verworfen werden.")
|
|
reminder["status"] = "cancelled"
|
|
reminder["cancelled_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="reminder_cancelled",
|
|
summary=f"Mahnungsentwurf verworfen: {reminder.get('name', '')}",
|
|
references={"claim_id": claim_id, "reminder_id": reminder_id},
|
|
)
|
|
|
|
def register_reminder_document(
|
|
self,
|
|
member_id: str,
|
|
claim_id: str,
|
|
reminder_id: str,
|
|
*,
|
|
relative_path: str,
|
|
sha256: str,
|
|
template: str,
|
|
) -> None:
|
|
document_path = Path(relative_path)
|
|
if document_path.is_absolute() or ".." in document_path.parts:
|
|
raise RepositoryError("Der Dokumentpfad muss innerhalb des Mitglieder-Dateiordners liegen.")
|
|
data, _claim = self.get_claim(member_id, claim_id)
|
|
reminder = self._find_reminder(data, claim_id, reminder_id)
|
|
reminder["document"] = {
|
|
"path": relative_path,
|
|
"sha256": sha256,
|
|
"template": template,
|
|
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
}
|
|
if str(reminder.get("status", "draft")) == "draft":
|
|
reminder["status"] = "generated"
|
|
reminder["generated_at"] = reminder["document"]["generated_at"]
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="reminder_document_generated",
|
|
summary=f"Mahndokument erzeugt: {Path(relative_path).name}",
|
|
actor_type="user",
|
|
actor_name="Vorstand",
|
|
references={
|
|
"claim_id": claim_id,
|
|
"reminder_id": reminder_id,
|
|
"document": relative_path,
|
|
},
|
|
data={"template": template, "sha256": sha256},
|
|
)
|
|
|
|
def set_dunning_hold(
|
|
self,
|
|
member_id: str,
|
|
claim_id: str,
|
|
*,
|
|
active: bool,
|
|
reason: str = "",
|
|
until: str = "",
|
|
) -> None:
|
|
data, claim = self.get_claim(member_id, claim_id)
|
|
try:
|
|
normalized_until = normalize_date_input(until, "Mahnsperre bis")
|
|
except DateValidationError as exc:
|
|
raise RepositoryError(str(exc)) from exc
|
|
claim["dunning_hold"] = {
|
|
"active": active,
|
|
"reason": reason.strip(),
|
|
"until": normalized_until or None,
|
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
}
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="dunning_hold_changed",
|
|
summary="Mahnsperre gesetzt" if active else "Mahnsperre aufgehoben",
|
|
references={"claim_id": claim_id},
|
|
data={"reason": reason.strip(), "until": normalized_until},
|
|
)
|
|
|
|
@staticmethod
|
|
def _find_reminder(data: ContributionData, claim_id: str, reminder_id: str) -> dict:
|
|
reminder = next(
|
|
(
|
|
item
|
|
for item in data.reminders
|
|
if str(item.get("claim_id", "")) == claim_id
|
|
and str(item.get("reminder_id", "")) == reminder_id
|
|
),
|
|
None,
|
|
)
|
|
if reminder is None:
|
|
raise RepositoryError("Mahnung nicht gefunden.")
|
|
return reminder
|
|
|
|
def cancel_claim(self, member_id: str, claim_id: str) -> None:
|
|
data, claim = self.get_claim(member_id, claim_id)
|
|
if claim_balance(data, claim) != claim_total(claim):
|
|
raise RepositoryError("Eine Forderung mit Zahlungszuordnungen kann nicht storniert werden.")
|
|
claim["status"] = "cancelled"
|
|
claim["cancelled_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
self.save_contributions(member_id, data)
|
|
self.append_event(
|
|
member_id,
|
|
event_type="claim_cancelled",
|
|
summary=f"Forderung storniert: {claim.get('title', claim_id)}",
|
|
references={"claim_id": claim_id},
|
|
)
|
|
|
|
def _assert_gnucash_id_available(self, transaction_id: str) -> None:
|
|
selected = transaction_id.casefold()
|
|
for member in self.list_members():
|
|
try:
|
|
payments = self.get_contributions(member.member_id).payments
|
|
except RepositoryError:
|
|
continue
|
|
if any(
|
|
str(payment.get("gnucash_transaction_id", "")).casefold() == selected for payment in payments
|
|
):
|
|
raise RepositoryError(f"GnuCash-ID bereits verwendet: {transaction_id}")
|
|
|
|
def append_event(
|
|
self,
|
|
member_id: str,
|
|
*,
|
|
event_type: str,
|
|
summary: str,
|
|
actor_type: str = "system",
|
|
actor_name: str = "CCMA",
|
|
references: dict[str, str] | None = None,
|
|
data: dict[str, object] | None = None,
|
|
) -> Event:
|
|
directory = self._member_path(member_id)
|
|
if not (directory / "member.json").is_file():
|
|
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
|
|
event = Event(
|
|
event_id=str(uuid4()),
|
|
timestamp=datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
event_type=event_type,
|
|
summary=summary.strip(),
|
|
actor_type=actor_type,
|
|
actor_name=actor_name,
|
|
references=references or {},
|
|
data=data or {},
|
|
)
|
|
path = directory / "events.jsonl"
|
|
line = json.dumps(event.to_dict(), ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
with path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
handle.write(line)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
return event
|
|
|
|
def get_events(self, member_id: str) -> list[Event]:
|
|
path = self._member_path(member_id) / "events.jsonl"
|
|
if not path.exists():
|
|
return []
|
|
events: list[Event] = []
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
for line_number, line in enumerate(handle, start=1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
raw = json.loads(line)
|
|
if not isinstance(raw, dict):
|
|
raise TypeError("Event muss ein JSON-Objekt sein")
|
|
events.append(Event.from_dict(raw))
|
|
except (AttributeError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
|
raise RepositoryError(f"Ungültiges Event in Zeile {line_number}: {exc}") from exc
|
|
return events
|
|
|
|
def search(self, query: str) -> list[Member]:
|
|
tokens = [_normalize(token) for token in query.split() if token.strip()]
|
|
if not tokens:
|
|
return self.list_members()
|
|
scored: list[tuple[int, Member]] = []
|
|
for member in self.list_members():
|
|
fields = [
|
|
member.member_number,
|
|
member.first_name,
|
|
member.last_name,
|
|
member.nickname,
|
|
member.display_name,
|
|
member.email,
|
|
member.phone,
|
|
member.street,
|
|
member.postal_code,
|
|
member.city,
|
|
member.birth_date,
|
|
_german_date(member.birth_date),
|
|
]
|
|
normalized = [_normalize(value) for value in fields if value]
|
|
if not all(any(token in value for value in normalized) for token in tokens):
|
|
continue
|
|
exact = sum(token == value for token in tokens for value in normalized)
|
|
prefix = sum(value.startswith(token) for token in tokens for value in normalized)
|
|
scored.append((exact * 100 + prefix * 10, member))
|
|
scored.sort(key=lambda item: (-item[0], item[1].last_name.casefold(), item[1].first_name.casefold()))
|
|
return [member for _, member in scored]
|
|
|
|
def member_count(self) -> int:
|
|
return sum(1 for _ in self._member_directories())
|
|
|
|
def asset_count(self) -> int:
|
|
return sum(1 for _ in self._asset_directories())
|
|
|
|
def get_configuration(self) -> dict:
|
|
try:
|
|
configuration = read_json(self.root / "repository.json")
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
raise RepositoryError(f"repository.json konnte nicht gelesen werden: {exc}") from exc
|
|
if not isinstance(configuration, dict):
|
|
raise RepositoryError("repository.json enthält keine gültige Konfiguration.")
|
|
return configuration
|
|
|
|
def member_hash_warnings(self, member_id: str) -> list[str]:
|
|
warnings: list[str] = []
|
|
try:
|
|
member_raw = read_json(self._member_path(member_id) / "member.json")
|
|
if not json_content_hash_matches(member_raw):
|
|
warnings.append(
|
|
"member.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert."
|
|
)
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
pass
|
|
try:
|
|
contributions_raw = read_json(self._member_path(member_id) / "contributions.json")
|
|
if not json_content_hash_matches(contributions_raw):
|
|
warnings.append(
|
|
"contributions.json: Hash fehlt oder stimmt nicht; "
|
|
"Datei wurde vermutlich extern geändert."
|
|
)
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
pass
|
|
return warnings
|
|
|
|
def asset_hash_warnings(self, asset_id: str) -> list[str]:
|
|
warnings: list[str] = []
|
|
try:
|
|
asset_raw = read_json(self._asset_path(asset_id) / "asset.json")
|
|
if not json_content_hash_matches(asset_raw):
|
|
warnings.append(
|
|
"asset.json: Hash fehlt oder stimmt nicht; Datei wurde vermutlich extern geändert."
|
|
)
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
pass
|
|
return warnings
|
|
|
|
def refresh_member_record_hashes(self, member_id: str) -> None:
|
|
member = self.get_member(member_id)
|
|
contributions = self.get_contributions(member_id)
|
|
write_json_atomic(self._member_path(member_id) / "member.json", member.to_dict())
|
|
write_json_atomic(self._member_path(member_id) / "contributions.json", contributions.to_dict())
|
|
|
|
def refresh_asset_record_hashes(self, asset_id: str) -> None:
|
|
asset = self.get_asset(asset_id)
|
|
write_json_atomic(self._asset_path(asset_id) / "asset.json", asset.to_dict())
|
|
|
|
def get_member_number_policy(self) -> dict[str, str]:
|
|
try:
|
|
config = read_json(self.root / "repository.json")
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
raise RepositoryError(f"Mitgliedsnummernregel konnte nicht gelesen werden: {exc}") from exc
|
|
policy = config.get("member_number_policy") or {}
|
|
mode = str(policy.get("mode", "automatic"))
|
|
if mode not in {"automatic", "manual"}:
|
|
mode = "automatic"
|
|
pattern = str(policy.get("pattern", DEFAULT_MEMBER_NUMBER_PATTERN))
|
|
validate_member_number_pattern(pattern)
|
|
return {"mode": mode, "pattern": pattern}
|
|
|
|
def save_member_number_policy(self, *, mode: str, pattern: str) -> None:
|
|
if mode not in {"automatic", "manual"}:
|
|
raise RepositoryError("Ungültiger Mitgliedsnummernmodus.")
|
|
validate_member_number_pattern(pattern)
|
|
config = read_json(self.root / "repository.json")
|
|
config["member_number_policy"] = {"mode": mode, "pattern": pattern.strip()}
|
|
config.setdefault("member_number_sequences", {})
|
|
write_json_atomic(self.root / "repository.json", config)
|
|
|
|
def save_organization(self, values: dict[str, str]) -> None:
|
|
organization = {key: str(value).strip() for key, value in values.items()}
|
|
organization["iban"] = normalize_iban(organization.get("iban", ""))
|
|
organization["bic"] = organization.get("bic", "").replace(" ", "").upper()
|
|
validate_iban(organization["iban"])
|
|
validate_bic(organization["bic"])
|
|
if not organization.get("name"):
|
|
raise RepositoryError("Der Vereinsname ist erforderlich.")
|
|
config = self.get_configuration()
|
|
config["organization"] = organization
|
|
write_json_atomic(self.root / "repository.json", config)
|
|
|
|
def preview_member_number(self, pattern: str | None = None) -> str:
|
|
selected_pattern = pattern or self.get_member_number_policy()["pattern"]
|
|
validate_member_number_pattern(selected_pattern)
|
|
config = read_json(self.root / "repository.json")
|
|
return self._next_available_member_number(config, selected_pattern)[0]
|
|
|
|
def _member_directories(self) -> Iterable[Path]:
|
|
if not self.members_root.exists():
|
|
return []
|
|
return (
|
|
path for path in self.members_root.iterdir() if path.is_dir() and not path.name.startswith(".")
|
|
)
|
|
|
|
def _asset_directories(self) -> Iterable[Path]:
|
|
if not self.assets_root.exists():
|
|
return []
|
|
return (
|
|
path for path in self.assets_root.iterdir() if path.is_dir() and not path.name.startswith(".")
|
|
)
|
|
|
|
def _member_path(self, member_id: str) -> Path:
|
|
if not member_id or Path(member_id).name != member_id or member_id in {".", ".."}:
|
|
raise RepositoryError("Ungültige Mitglieds-ID.")
|
|
return self.members_root / member_id
|
|
|
|
def _asset_path(self, asset_id: str) -> Path:
|
|
if not asset_id or Path(asset_id).name != asset_id or asset_id in {".", ".."}:
|
|
raise RepositoryError("Ungültige Asset-ID.")
|
|
return self.assets_root / asset_id
|
|
|
|
def _allocate_member_number(self, pattern: str) -> str:
|
|
config = read_json(self.root / "repository.json")
|
|
member_number, next_value = self._next_available_member_number(config, pattern)
|
|
sequences = config.get("member_number_sequences")
|
|
if not isinstance(sequences, dict):
|
|
sequences = {}
|
|
config["member_number_sequences"] = sequences
|
|
sequences[pattern] = next_value
|
|
write_json_atomic(self.root / "repository.json", config)
|
|
return member_number
|
|
|
|
def _next_available_member_number(self, config: dict, pattern: str) -> tuple[str, int]:
|
|
sequences = config.get("member_number_sequences")
|
|
if not isinstance(sequences, dict):
|
|
sequences = {}
|
|
try:
|
|
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}
|
|
for _attempt in range(1_000_000):
|
|
candidate = format_member_number(pattern, number)
|
|
number += 1
|
|
if candidate.casefold() not in existing:
|
|
return candidate, number
|
|
raise RepositoryError("Keine freie Mitgliedsnummer im konfigurierten Nummernbereich gefunden.")
|
|
|
|
def _assert_member_number_available(
|
|
self,
|
|
member_number: str,
|
|
*,
|
|
exclude_member_id: str | None = None,
|
|
) -> None:
|
|
normalized = member_number.casefold().strip()
|
|
if not normalized:
|
|
raise RepositoryError("Eine Mitgliedsnummer ist erforderlich.")
|
|
for member in self.list_members():
|
|
if member.member_id != exclude_member_id and member.member_number.casefold() == normalized:
|
|
raise RepositoryError(f"Die Mitgliedsnummer {member_number} ist bereits vergeben.")
|
|
|
|
@staticmethod
|
|
def _summarize_changes(before: Member, after: Member) -> list[str]:
|
|
labels = {
|
|
"member_number": "Mitgliedsnummer",
|
|
"first_name": "Vorname",
|
|
"last_name": "Nachname",
|
|
"nickname": "Nickname",
|
|
"email": "E-Mail-Adresse",
|
|
"phone": "Telefonnummer",
|
|
"birth_date": "Geburtsdatum",
|
|
"status": "Status",
|
|
"payment_frequency": "Zahlungsweise",
|
|
"contribution_rule_id": "Beitragsregel",
|
|
"honorary": "Ehrenmitgliedschaft",
|
|
"notes": "interne Notiz",
|
|
"street": "Anschrift",
|
|
"address_addition": "Adresszusatz",
|
|
"postal_code": "Postleitzahl",
|
|
"city": "Ort",
|
|
"country": "Land",
|
|
"account_holder": "Kontoinhaber",
|
|
"iban": "IBAN",
|
|
"bic": "BIC",
|
|
"mandate_reference": "SEPA-Mandatsreferenz",
|
|
"mandate_signed_at": "Mandat erteilt am",
|
|
"mandate_active": "Lastschriftmandat aktiv",
|
|
"mandate_revoked_at": "Mandat widerrufen am",
|
|
}
|
|
changes: list[str] = []
|
|
for field, label in labels.items():
|
|
old_value = getattr(before, field)
|
|
new_value = getattr(after, field)
|
|
if old_value == new_value:
|
|
continue
|
|
if field == "status":
|
|
old_label = MEMBERSHIP_STATUS_LABELS.get(str(old_value), str(old_value))
|
|
new_label = MEMBERSHIP_STATUS_LABELS.get(str(new_value), str(new_value))
|
|
changes.append(f"Status von {old_label} zu {new_label}")
|
|
else:
|
|
changes.append(label)
|
|
return changes
|
|
|
|
@staticmethod
|
|
def _summarize_asset_changes(before: Asset, after: Asset) -> list[str]:
|
|
labels = {
|
|
"label": "Bezeichnung",
|
|
"category": "Kategorie",
|
|
"inventory_number": "Inventarnummer",
|
|
"serial_number": "Seriennummer",
|
|
"status": "Status",
|
|
"current_holder_member_id": "Zuordnung",
|
|
"deposit_amount_default": "Kaution",
|
|
"notes": "Notiz",
|
|
}
|
|
changes: list[str] = []
|
|
for field, label in labels.items():
|
|
if getattr(before, field) != getattr(after, field):
|
|
changes.append(label)
|
|
return changes
|
|
|
|
|
|
def normalize_iban(value: str) -> str:
|
|
return "".join(value.split()).upper()
|
|
|
|
|
|
def validate_iban(value: str) -> None:
|
|
if not value:
|
|
return
|
|
if not 15 <= len(value) <= 34 or not value[:2].isalpha() or not value[2:].isalnum():
|
|
raise RepositoryError("Die IBAN hat kein gültiges Format.")
|
|
rearranged = value[4:] + value[:4]
|
|
numeric = "".join(
|
|
str(ord(character) - 55) if character.isalpha() else character
|
|
for character in rearranged
|
|
)
|
|
if int(numeric) % 97 != 1:
|
|
raise RepositoryError("Die IBAN-Prüfsumme ist ungültig.")
|
|
|
|
|
|
def validate_bic(value: str) -> None:
|
|
if value and (len(value) not in {8, 11} or not value.isalnum()):
|
|
raise RepositoryError("Der BIC muss 8 oder 11 Zeichen enthalten.")
|
|
|
|
|
|
def _normalize(value: str) -> str:
|
|
normalized = unicodedata.normalize("NFKD", value.casefold().strip())
|
|
return "".join(character for character in normalized if not unicodedata.combining(character))
|
|
|
|
|
|
def _german_date(value: str) -> str:
|
|
try:
|
|
return date.fromisoformat(value).strftime("%d.%m.%Y")
|
|
except ValueError:
|
|
return ""
|
|
|
|
|
|
def _dunning_hold_is_active(claim: dict) -> bool:
|
|
hold = claim.get("dunning_hold") or {}
|
|
if not hold.get("active"):
|
|
return False
|
|
if not hold.get("until"):
|
|
return True
|
|
try:
|
|
return date.fromisoformat(str(hold["until"])) >= date.today()
|
|
except ValueError:
|
|
return True
|
|
|
|
|
|
def validate_member_number_pattern(pattern: str) -> None:
|
|
selected = pattern.strip()
|
|
if not selected:
|
|
raise RepositoryError("Das Mitgliedsnummern-Pattern darf nicht leer sein.")
|
|
has_number = False
|
|
try:
|
|
for _literal, field_name, format_spec, conversion in Formatter().parse(selected):
|
|
if field_name is None:
|
|
continue
|
|
if field_name not in {"number", "year"}:
|
|
raise RepositoryError(f"Unbekannter Platzhalter: {{{field_name}}}")
|
|
if conversion:
|
|
raise RepositoryError("Konvertierungen wie !r sind im Pattern nicht erlaubt.")
|
|
if "{" in format_spec or "}" in format_spec:
|
|
raise RepositoryError("Verschachtelte Formatierungen sind nicht erlaubt.")
|
|
has_number = has_number or field_name == "number"
|
|
if not has_number:
|
|
raise RepositoryError("Das Pattern muss den Platzhalter {number} enthalten.")
|
|
first = format_member_number(selected, 1)
|
|
second = format_member_number(selected, 2)
|
|
if first == second:
|
|
raise RepositoryError("Das Pattern erzeugt keine eindeutigen Mitgliedsnummern.")
|
|
except RepositoryError:
|
|
raise
|
|
except (KeyError, ValueError) as exc:
|
|
raise RepositoryError(f"Ungültiges Mitgliedsnummern-Pattern: {exc}") from exc
|
|
|
|
|
|
def format_member_number(pattern: str, number: int, *, year: int | None = None) -> str:
|
|
try:
|
|
value = pattern.strip().format(number=number, year=year or date.today().year)
|
|
except (KeyError, ValueError) as exc:
|
|
raise RepositoryError(f"Mitgliedsnummer konnte nicht formatiert werden: {exc}") from exc
|
|
if not value or len(value) > 80 or any(character in value for character in "\r\n\t"):
|
|
raise RepositoryError("Das Pattern erzeugt eine ungültige Mitgliedsnummer.")
|
|
return value
|