from __future__ import annotations

import json
import os
import re
import shutil
import unicodedata
from collections.abc import Iterable
from contextlib import contextmanager
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from string import Formatter
from uuid import uuid4

from ccma.domain.contributions import (
    CONTRIBUTION_OVERRIDE_KIND_LABELS,
    allocated_total,
    claim_balance,
    claim_items,
    claim_settled_total,
    claim_total,
    contribution_override_ranges_overlap,
    credit_allocated_total,
    decimal_value,
    donation_balance,
    materialize_claim_items,
    money_text,
    payment_allocated_total,
    reminder_items_total,
)
from ccma.domain.dates import (
    DateValidationError,
    normalize_date_input,
    parse_month_input,
    validate_member_dates,
)
from ccma.domain.mail_templates import (
    MAIL_TEMPLATES,
    MailTemplate,
    MailTemplateError,
    default_mail_template,
    parse_mail_template,
    serialize_mail_template,
    template_spec,
    validate_mail_template,
)
from ccma.domain.models import (
    ASSET_CUSTODY_TYPE_LABELS,
    ASSET_OWNER_TYPE_LABELS,
    ASSET_STATUS_LABELS,
    MEMBERSHIP_STATUS_LABELS,
    PAYMENT_FREQUENCY_LABELS,
    Asset,
    ContributionData,
    Event,
    Member,
)
from ccma.storage.atomic import (
    json_content_hash_matches,
    read_json,
    write_json_atomic,
    write_text_atomic,
)


class RepositoryError(RuntimeError):
    pass


class ReadOnlyStoreError(RepositoryError):
    """Raised instead of a bare PermissionError when the store is mounted read-only:
    the encrypted volume holding the member data can be mounted without write access,
    and CCMA stays usable for reading in that case."""


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,
    },
    {
        "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,
    },
]


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,
        "allocation_strategy": "next_free",
    },
    "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,
            },
        ],
        # Reusable description/amount presets offered as rows when recording a
        # reminder or a failed direct debit; boards can add/edit/remove freely.
        "standard_fee_items": [
            {"description": "Mahngebühr", "default_amount": "5.00"},
            {"description": "Rücklastschriftgebühr", "default_amount": "5.00"},
            {"description": "Bankgebühr", "default_amount": "0.00"},
            {"description": "Porto", "default_amount": "0.00"},
        ],
    },
    "contribution_rules": DEFAULT_CONTRIBUTION_RULES,
    # "local" reproduces the historical behaviour (always ask for a save location,
    # never touch the network) so repositories that never configured this stay on
    # exactly what they had before -- it is a first-class choice, not just a fallback.
    "email_settings": {
        "delivery_mode": "local",
        "smtp_host": "",
        "smtp_port": 587,
        "smtp_security": "starttls",
        "smtp_username": "",
        "smtp_password": "",
        "imap_host": "",
        "imap_port": 993,
        "imap_security": "ssl",
        "imap_username": "",
        "imap_password": "",
        "imap_drafts_folder": "INBOX.Entwürfe",
        # Sent-copy applies only to "send" (a direct SMTP send has no server-side
        # "Sent" copy on its own, unlike drafts which are inherently server-side).
        "imap_sent_enabled": False,
        "imap_sent_folder": "INBOX.Sent",
    },
}


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"
        self._read_only: bool | None = None

    @property
    def read_only(self) -> bool:
        """Whether the store can be written to at all. Probed once and cached: the
        answer only changes when the volume is remounted, which needs a restart (or
        an explicit refresh_read_only()) anyway."""
        if self._read_only is None:
            self._read_only = self.detect_read_only()
        return self._read_only

    def refresh_read_only(self) -> bool:
        self._read_only = self.detect_read_only()
        return self._read_only

    def detect_read_only(self) -> bool:
        # Probe by actually writing: permissions, mount options and filesystem state
        # all matter, and only an attempt covers them together. A store directory that
        # does not exist yet counts as writable when its nearest existing parent is --
        # that is an ordinary first start, not a read-only volume.
        target = self.root
        while not target.exists() and target != target.parent:
            target = target.parent
        probe = target / f".ccma-write-probe-{uuid4().hex}"
        try:
            probe.touch()
            probe.unlink()
        except OSError:
            return True
        return False

    def assert_writable(self) -> None:
        if self.read_only:
            raise ReadOnlyStoreError(
                "Der Mitglieder-Store ist schreibgeschützt eingebunden. Änderungen sind "
                "erst wieder möglich, wenn der Store mit Schreibrechten eingebunden ist."
            )

    def _write_json(self, path: Path, data) -> None:
        self.assert_writable()
        write_json_atomic(path, data)

    def initialize(self) -> None:
        # A read-only store cannot be set up -- but it also does not need to be, as
        # long as it was initialized while it was still writable. Opening one that was
        # never initialized is the only case that has to fail here.
        if self.read_only:
            if not (self.root / "repository.json").is_file():
                raise ReadOnlyStoreError(
                    f"Der Mitglieder-Store {self.root} ist schreibgeschützt eingebunden und "
                    "enthält keine repository.json. Ein neuer Store kann nur mit "
                    "Schreibrechten angelegt werden."
                )
            return
        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)
        mail_templates_root = self.mail_templates_root
        mail_templates_root.mkdir(parents=True, exist_ok=True)
        for spec in MAIL_TEMPLATES:
            destination = mail_templates_root / spec.filename
            if not destination.exists():
                try:
                    template = default_mail_template(spec.key)
                    write_text_atomic(
                        destination, serialize_mail_template(template.subject, template.body)
                    )
                except (MailTemplateError, OSError):
                    continue
        config_path = self.root / "repository.json"
        if not config_path.exists():
            self._write_json(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")
            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)))
        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,
                    membership_ended_at=member.membership_ended_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")
                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)
            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 = "",
        application_date: 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,
                application_date=application_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,
        application_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")
            application_date = normalize_date_input(application_date, "Antragsdatum")
            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"], policy["allocation_strategy"]
            )
        self.assert_writable()
        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,
            application_date=application_date,
        )
        self._write_json(directory / "member.json", member.to_dict())
        self._write_json(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.application_date = normalize_date_input(member.application_date, "Antragsdatum")
            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_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_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,
                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:
            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.payment_frequency not in PAYMENT_FREQUENCY_LABELS:
            raise RepositoryError("Ungültige Zahlweise.")
        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")
        self._write_json(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 = "",
        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:
        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.")
        self._validate_asset_relationships(owner_type, owner_member_id, owner_name, custody_type, "")
        self.assert_writable()
        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(),
            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(),
        )
        self._write_json(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",
        )
        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

    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.")
        self._validate_asset_relationships(
            asset.owner_type,
            asset.owner_member_id,
            asset.owner_name,
            asset.custody_type,
            asset.current_holder_member_id,
        )
        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()
        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:
            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")
        self._write_json(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,
            )
        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:
        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.custody_type = "member"
        asset.status = "issued"
        asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
        self._write_json(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.custody_type = "club"
        asset.status = "available"
        asset.updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
        self._write_json(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 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(
        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 {},
        )
        self.assert_writable()
        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",
                "donations",
                "contribution_overrides",
            ):
                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)
        self._write_json(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 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(
        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)
        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_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 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 create_payment(
        self,
        member_id: str,
        *,
        payment_date: str,
        amount: str,
        claim_allocations: dict[str, str] | None = None,
        donation_allocations: dict[str, str] | None = None,
        gnucash_transaction_id: str = "",
        reference: str = "",
        method: str = "bank_transfer",
        actor_name: str = "Vorstand",
    ) -> dict:
        """Record an incoming payment, optionally allocating parts of it immediately to
        open claims and/or donations. Called with no allocations, this just logs a bank
        transfer as soon as it arrives, to be assigned to claims or donations later."""
        self.get_member(member_id)
        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.")
        gnucash_id = gnucash_transaction_id.strip()
        if gnucash_id:
            self._assert_gnucash_id_available(gnucash_id)

        data = self.get_contributions(member_id)
        claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
        donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}

        selected_claim_allocations: dict[str, Decimal] = {}
        for claim_id, raw_amount in (claim_allocations or {}).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 = decimal_value(raw_amount, "Zuordnung")
            except ValueError as exc:
                raise RepositoryError(str(exc)) from exc
            if selected <= 0:
                continue
            available = max(claim_balance(data, claim), Decimal("0"))
            if selected > available:
                raise RepositoryError(
                    f"{claim.get('title', 'Forderung')} hat nur {money_text(available)} EUR offen."
                )
            selected_claim_allocations[claim_id] = selected

        selected_donation_allocations: dict[str, Decimal] = {}
        for donation_id, raw_amount in (donation_allocations or {}).items():
            donation = donations_by_id.get(donation_id)
            if donation is None:
                raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
            try:
                selected = decimal_value(raw_amount, "Zuordnung")
            except ValueError as exc:
                raise RepositoryError(str(exc)) from exc
            if selected <= 0:
                continue
            available = max(donation_balance(data, donation), Decimal("0"))
            if selected > available:
                raise RepositoryError(f"Die Spende hat nur noch {money_text(available)} EUR offen.")
            selected_donation_allocations[donation_id] = selected

        allocated_total_amount = sum(selected_claim_allocations.values(), Decimal("0")) + sum(
            selected_donation_allocations.values(), Decimal("0")
        )
        if allocated_total_amount > selected_amount:
            raise RepositoryError(
                f"Die Zuordnungen ({money_text(allocated_total_amount)} EUR) übersteigen den "
                f"Zahlungsbetrag ({money_text(selected_amount)} EUR)."
            )

        payment = {
            "payment_id": str(uuid4()),
            "date": normalized_date,
            "amount": money_text(selected_amount),
            "method": method.strip() or "bank_transfer",
            "gnucash_transaction_id": gnucash_id,
            "reference": reference.strip(),
            "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
        }
        for claim_id, claim_amount in selected_claim_allocations.items():
            data.allocations.append(
                {
                    "allocation_id": str(uuid4()),
                    "payment_id": payment["payment_id"],
                    "claim_id": claim_id,
                    "amount": money_text(claim_amount),
                }
            )
        for donation_id, donation_amount in selected_donation_allocations.items():
            data.allocations.append(
                {
                    "allocation_id": str(uuid4()),
                    "payment_id": payment["payment_id"],
                    "donation_id": donation_id,
                    "amount": money_text(donation_amount),
                }
            )
        data.payments.append(payment)
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="payment_recorded",
            summary=f"Zahlung erfasst: {payment['amount']} EUR",
            actor_type="user",
            actor_name=actor_name,
            references={"payment_id": str(payment["payment_id"])},
            data={
                "allocated_amount": money_text(allocated_total_amount),
                "claim_ids": list(selected_claim_allocations),
                "donation_ids": list(selected_donation_allocations),
            },
        )
        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.")
        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_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 update_payment(
        self,
        member_id: str,
        payment_id: str,
        *,
        payment_date: str,
        amount: str,
        allocations: dict[str, str],
        donation_allocations: dict[str, str] | None = None,
        gnucash_transaction_id: str = "",
        reference: str = "",
    ) -> dict:
        """`allocations` fully replaces the claim allocations of this payment, and
        `donation_allocations` (if given) fully replaces its donation allocations. Omit
        `donation_allocations` to leave any existing donation allocations untouched."""
        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}
        donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}
        old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id]
        old_by_claim: dict[str, list[dict]] = {}
        old_by_donation: dict[str, list[dict]] = {}
        for allocation in old_allocations:
            if str(allocation.get("donation_id", "")):
                old_by_donation.setdefault(str(allocation.get("donation_id", "")), []).append(allocation)
            else:
                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

        if donation_allocations is None:
            selected_donation_allocations = {
                donation_id: sum(
                    (decimal_value(item.get("amount", "0")) for item in items), Decimal("0")
                )
                for donation_id, items in old_by_donation.items()
            }
        else:
            selected_donation_allocations = {}
            for donation_id, raw_amount in donation_allocations.items():
                if donation_id not in donations_by_id:
                    raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
                try:
                    donation_amount = decimal_value(raw_amount, "Zuordnung")
                except ValueError as exc:
                    raise RepositoryError(str(exc)) from exc
                if donation_amount < 0:
                    raise RepositoryError("Zuordnungen dürfen nicht negativ sein.")
                if donation_amount == 0:
                    continue
                donation = donations_by_id[donation_id]
                currently_allocated = sum(
                    (decimal_value(item.get("amount", "0")) for item in old_by_donation.get(donation_id, [])),
                    Decimal("0"),
                )
                available_balance = max(
                    donation_balance(data, donation) + currently_allocated, Decimal("0")
                )
                if donation_amount > available_balance:
                    raise RepositoryError(
                        f"Die Spende hat nur noch {money_text(available_balance)} EUR offen."
                    )
                selected_donation_allocations[donation_id] = donation_amount

        allocated_sum = sum(selected_allocations.values(), Decimal("0")) + sum(
            selected_donation_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),
                }
            )
        for donation_id, donation_amount in selected_donation_allocations.items():
            prior = old_by_donation.get(donation_id, [])
            new_allocations.append(
                {
                    "allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())),
                    "payment_id": payment_id,
                    "donation_id": donation_id,
                    "amount": money_text(donation_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 update_payment_reference(
        self,
        member_id: str,
        payment_id: str,
        *,
        reference: str,
        gnucash_transaction_id: str = "",
        actor_name: str = "Vorstand",
    ) -> dict:
        """Relabel an existing payment without touching its date, amount, or
        allocations -- used e.g. when a GnuCash import recognizes a booking as
        matching an already-recorded payment and the board wants to adopt the
        (better) description from the statement instead of re-importing it."""
        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.")
        gnucash_id = gnucash_transaction_id.strip()
        if gnucash_id and gnucash_id != str(payment.get("gnucash_transaction_id", "")):
            self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id)
        payment["reference"] = reference.strip()
        if gnucash_id:
            payment["gnucash_transaction_id"] = gnucash_id
        payment["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="payment_changed",
            summary=f"Zahlung geändert: Referenz aktualisiert ({payment['reference']})",
            actor_type="user",
            actor_name=actor_name,
            references={"payment_id": payment_id},
        )
        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(
        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 record_donation(
        self,
        member_id: str,
        *,
        donation_date: str,
        amount: str,
        reference: str = "",
        purpose: str = "",
        actor_name: str = "Vorstand",
    ) -> dict:
        self.get_member(member_id)
        try:
            normalized_date = normalize_date_input(donation_date, "Spendendatum")
            selected_amount = decimal_value(amount, "Spendenbetrag")
        except (DateValidationError, ValueError) as exc:
            raise RepositoryError(str(exc)) from exc
        if not normalized_date:
            raise RepositoryError("Ein Spendendatum ist erforderlich.")
        if selected_amount <= 0:
            raise RepositoryError("Der Spendenbetrag muss größer als null sein.")
        donation = {
            "donation_id": str(uuid4()),
            "date": normalized_date,
            "amount": money_text(selected_amount),
            "reference": reference.strip(),
            "purpose": purpose.strip(),
            "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
        }
        data = self.get_contributions(member_id)
        data.donations.append(donation)
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="donation_recorded",
            summary=f"Spende erfasst: {donation['amount']} EUR",
            actor_type="user",
            actor_name=actor_name,
            references={"donation_id": donation["donation_id"]},
        )
        return donation

    def get_donation(self, member_id: str, donation_id: str) -> tuple[ContributionData, dict]:
        data = self.get_contributions(member_id)
        donation = next(
            (item for item in data.donations if str(item.get("donation_id", "")) == donation_id),
            None,
        )
        if donation is None:
            raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
        return data, donation

    def update_donation(
        self,
        member_id: str,
        donation_id: str,
        *,
        donation_date: str,
        amount: str,
        reference: str = "",
        purpose: str = "",
        actor_name: str = "Vorstand",
    ) -> dict:
        try:
            normalized_date = normalize_date_input(donation_date, "Spendendatum")
            selected_amount = decimal_value(amount, "Spendenbetrag")
        except (DateValidationError, ValueError) as exc:
            raise RepositoryError(str(exc)) from exc
        if not normalized_date:
            raise RepositoryError("Ein Spendendatum ist erforderlich.")
        if selected_amount <= 0:
            raise RepositoryError("Der Spendenbetrag muss größer als null sein.")
        data, donation = self.get_donation(member_id, donation_id)
        allocated = sum(
            (
                decimal_value(item.get("amount", "0"))
                for item in data.allocations
                if str(item.get("donation_id", "")) == donation_id
            ),
            Decimal("0"),
        )
        if selected_amount < allocated:
            raise RepositoryError(
                f"Der Betrag darf nicht unter den bereits zugeordneten "
                f"{money_text(allocated)} EUR liegen."
            )
        donation["date"] = normalized_date
        donation["amount"] = money_text(selected_amount)
        donation["reference"] = reference.strip()
        donation["purpose"] = purpose.strip()
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="donation_changed",
            summary=f"Spende geändert: {donation['amount']} EUR",
            actor_type="user",
            actor_name=actor_name,
            references={"donation_id": donation_id},
        )
        return donation

    def delete_donation(self, member_id: str, donation_id: str, *, actor_name: str = "Vorstand") -> None:
        """Permanently remove a donation. Payments allocated to it are released, not deleted."""
        data, donation = self.get_donation(member_id, donation_id)
        data.donations = [
            item for item in data.donations if str(item.get("donation_id", "")) != donation_id
        ]
        data.allocations = [
            item for item in data.allocations if str(item.get("donation_id", "")) != donation_id
        ]
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="donation_deleted",
            summary=f"Spende gelöscht: {donation.get('amount', '')} EUR",
            actor_type="user",
            actor_name=actor_name,
            references={"donation_id": donation_id},
        )

    def record_donation_payment(
        self,
        member_id: str,
        donation_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, donation = self.get_donation(member_id, donation_id)
        available_balance = max(donation_balance(data, donation), Decimal("0"))
        if selected_allocation > available_balance:
            raise RepositoryError(f"Die Spende hat nur noch {money_text(available_balance)} EUR offen.")
        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"],
            "donation_id": donation_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 für Spende eingegangen: {payment['amount']} EUR",
            references={"donation_id": donation_id, "payment_id": str(payment["payment_id"])},
            data={"allocation_amount": allocation["amount"]},
        )
        return payment

    def allocate_payment_to_donation(
        self, member_id: str, donation_id: str, *, payment_id: str, amount: str
    ) -> dict:
        data, donation = self.get_donation(member_id, donation_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.")
        available_balance = max(donation_balance(data, donation), Decimal("0"))
        if selected_amount > available_balance:
            raise RepositoryError(f"Die Spende hat nur noch {money_text(available_balance)} EUR offen.")
        allocation = {
            "allocation_id": str(uuid4()),
            "payment_id": payment_id,
            "donation_id": donation_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 Spende zugeordnet: {allocation['amount']} EUR",
            references={"donation_id": donation_id, "payment_id": payment_id},
        )
        return allocation

    def get_contribution_override(self, member_id: str, override_id: str) -> tuple[ContributionData, dict]:
        data = self.get_contributions(member_id)
        override = next(
            (
                item
                for item in data.contribution_overrides
                if str(item.get("override_id", "")) == override_id
            ),
            None,
        )
        if override is None:
            raise RepositoryError(f"Abweichender Beitrag nicht gefunden: {override_id}")
        return data, override

    def record_contribution_override(
        self,
        member_id: str,
        *,
        valid_from: str,
        valid_until: str = "",
        kind: str,
        value: str,
        reason: str,
        actor_name: str = "Vorstand",
    ) -> dict:
        self.get_member(member_id)
        normalized = self._normalize_contribution_override_input(
            valid_from=valid_from, valid_until=valid_until, kind=kind, value=value, reason=reason
        )
        normalized_from, normalized_until, normalized_kind, normalized_value, normalized_reason = normalized
        data = self.get_contributions(member_id)
        self._assert_contribution_override_range_free(data, normalized_from, normalized_until)
        override = {
            "override_id": str(uuid4()),
            "valid_from": normalized_from,
            "valid_until": normalized_until,
            "kind": normalized_kind,
            "value": normalized_value,
            "reason": normalized_reason,
            "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
            "created_by": actor_name,
        }
        data.contribution_overrides.append(override)
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="contribution_override_recorded",
            summary=f"Abweichender Mitgliedsbeitrag erfasst: {_contribution_override_summary(override)}",
            actor_type="user",
            actor_name=actor_name,
            references={"override_id": override["override_id"]},
        )
        return override

    def update_contribution_override(
        self,
        member_id: str,
        override_id: str,
        *,
        valid_from: str,
        valid_until: str = "",
        kind: str,
        value: str,
        reason: str,
        actor_name: str = "Vorstand",
    ) -> dict:
        normalized = self._normalize_contribution_override_input(
            valid_from=valid_from, valid_until=valid_until, kind=kind, value=value, reason=reason
        )
        normalized_from, normalized_until, normalized_kind, normalized_value, normalized_reason = normalized
        data, override = self.get_contribution_override(member_id, override_id)
        self._assert_contribution_override_range_free(
            data, normalized_from, normalized_until, exclude_override_id=override_id
        )
        override.update(
            {
                "valid_from": normalized_from,
                "valid_until": normalized_until,
                "kind": normalized_kind,
                "value": normalized_value,
                "reason": normalized_reason,
                "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
            }
        )
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="contribution_override_changed",
            summary=f"Abweichender Mitgliedsbeitrag geändert: {_contribution_override_summary(override)}",
            actor_type="user",
            actor_name=actor_name,
            references={"override_id": override_id},
        )
        return override

    def delete_contribution_override(
        self, member_id: str, override_id: str, *, actor_name: str = "Vorstand"
    ) -> None:
        data, override = self.get_contribution_override(member_id, override_id)
        data.contribution_overrides = [
            item
            for item in data.contribution_overrides
            if str(item.get("override_id", "")) != override_id
        ]
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="contribution_override_deleted",
            summary=f"Abweichender Mitgliedsbeitrag gelöscht: {_contribution_override_summary(override)}",
            actor_type="user",
            actor_name=actor_name,
            references={"override_id": override_id},
        )

    @staticmethod
    def _normalize_contribution_override_input(
        *, valid_from: str, valid_until: str, kind: str, value: str, reason: str
    ) -> tuple[str, str, str, str, str]:
        try:
            normalized_from = parse_month_input(valid_from, "Ab", allow_empty=False)
            normalized_until = parse_month_input(valid_until, "Bis")
        except DateValidationError as exc:
            raise RepositoryError(str(exc)) from exc
        if normalized_until and normalized_until < normalized_from:
            raise RepositoryError("Der Zeitraum 'Bis' darf nicht vor dem Zeitraum 'Ab' liegen.")
        normalized_kind = kind.strip().lower()
        if normalized_kind not in CONTRIBUTION_OVERRIDE_KIND_LABELS:
            raise RepositoryError("Ungültige Art des abweichenden Beitrags.")
        try:
            parsed_value = decimal_value(value, "Wert")
        except ValueError as exc:
            raise RepositoryError(str(exc)) from exc
        if normalized_kind == "percent":
            if parsed_value <= 0 or parsed_value > 100:
                raise RepositoryError("Die Ermäßigung muss zwischen 0 und 100 Prozent liegen.")
        elif parsed_value < 0:
            raise RepositoryError("Der Jahresbetrag darf nicht negativ sein.")
        normalized_reason = reason.strip()
        if not normalized_reason:
            raise RepositoryError("Eine Begründung ist erforderlich.")
        return normalized_from, normalized_until, normalized_kind, money_text(parsed_value), normalized_reason

    @staticmethod
    def _assert_contribution_override_range_free(
        data: ContributionData,
        valid_from: str,
        valid_until: str,
        *,
        exclude_override_id: str | None = None,
    ) -> None:
        for existing in data.contribution_overrides:
            if exclude_override_id and str(existing.get("override_id", "")) == exclude_override_id:
                continue
            if contribution_override_ranges_overlap(
                valid_from,
                valid_until,
                str(existing.get("valid_from", "")),
                str(existing.get("valid_until", "") or ""),
            ):
                existing_until = str(existing.get("valid_until", "") or "") or "unbefristet"
                raise RepositoryError(
                    "Der Zeitraum überschneidet sich mit einem bereits vorhandenen abweichenden "
                    f"Beitrag ({existing.get('valid_from', '')} bis {existing_until})."
                )

    def create_reminder_draft(
        self,
        member_id: str,
        claim_id: str,
        *,
        level: int,
        name: str,
        payment_deadline_days: int,
        detail: str = "",
        items: list[dict[str, str]] | None = None,
        channel: str = "email",
    ) -> dict:
        if level < 1:
            raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
        try:
            normalized_items = _normalize_reminder_items(items)
        except ValueError as exc:
            raise RepositoryError(str(exc)) from exc
        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)),
            "items": normalized_items,
            "fee_item_ids": [],
            "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": money_text(reminder_items_total(reminder)),
                "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()
        if reminder.get("items") and not reminder.get("fee_item_ids"):
            fee_item_ids = []
            for entry in reminder["items"]:
                amount = decimal_value(entry.get("amount", "0"))
                if amount <= 0:
                    continue
                description = str(entry.get("description")) or f"Mahngebühr Stufe {reminder.get('level', '')}"
                item = {
                    "item_id": str(uuid4()),
                    "type": "fee",
                    "description": description,
                    "quantity": "1.00",
                    "unit_price": money_text(amount),
                    "amount": money_text(amount),
                    "created_at": now.isoformat(timespec="seconds"),
                }
                materialize_claim_items(claim).append(item)
                fee_item_ids.append(item["item_id"])
            if fee_item_ids:
                claim["amount"] = money_text(claim_total(claim))
                reminder["fee_item_ids"] = fee_item_ids
        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(reminder_items_total(reminder)),
                "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 edit_reminder_draft(
        self,
        member_id: str,
        claim_id: str,
        reminder_id: str,
        *,
        name: str,
        payment_deadline_days: int,
        detail: str = "",
        items: list[dict[str, str]] | None = None,
        channel: str = "email",
    ) -> dict:
        try:
            normalized_items = _normalize_reminder_items(items)
        except ValueError as exc:
            raise RepositoryError(str(exc)) from exc
        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)
        reminder = self._find_reminder(data, claim_id, reminder_id)
        if str(reminder.get("status", "draft")) not in {"draft", "generated"}:
            raise RepositoryError("Nur ein Entwurf kann bearbeitet werden.")
        reminder["name"] = name.strip() or reminder["name"]
        reminder["detail"] = detail.strip()
        reminder["channel"] = channel.strip() or "email"
        reminder["payment_deadline_days"] = payment_deadline_days
        reminder["items"] = normalized_items
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="reminder_draft_edited",
            summary=f"Mahnungsentwurf bearbeitet: {reminder['name']}",
            references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
            data={"fee": money_text(reminder_items_total(reminder))},
        )
        return reminder

    def revert_reminder_sent(self, member_id: str, claim_id: str, reminder_id: str) -> dict:
        """Undoes mark_reminder_sent: removes the fee items it booked onto the claim
        and puts the reminder back into "draft" so its text/items can be edited and it
        can be sent again -- e.g. after generating a Mahnung email and noticing the
        wording needs a fix. Refuses if a later Mahnstufe already exists (would break
        the level ordering) or if payments already booked against the claim would no
        longer fit once the fee is removed."""
        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("Nur eine bereits versandte Mahnung kann zurückgesetzt werden.")
        if any(
            str(item.get("claim_id", "")) == claim_id
            and int(item.get("level", 0)) > int(reminder.get("level", 0))
            and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
            for item in data.reminders
        ):
            raise RepositoryError(
                "Es existiert bereits eine höhere Mahnstufe; diese Mahnung kann nicht "
                "zurückgesetzt werden."
            )
        fee_item_ids = set(reminder.get("fee_item_ids") or [])
        if fee_item_ids:
            removed_total = sum(
                (
                    decimal_value(item.get("amount", "0"))
                    for item in claim_items(claim)
                    if str(item.get("item_id", "")) in fee_item_ids
                ),
                Decimal("0"),
            )
            new_total = claim_total(claim) - removed_total
            if claim_settled_total(data, claim) > new_total:
                raise RepositoryError(
                    "Für diese Forderung wurden bereits Zahlungen verbucht, die die "
                    "Mahngebühr abdecken; der Versand kann nicht zurückgesetzt werden."
                )
            claim["items"] = [
                item for item in claim_items(claim) if str(item.get("item_id", "")) not in fee_item_ids
            ]
            claim["amount"] = money_text(claim_total(claim))
        reminder["status"] = "draft"
        reminder["sent_at"] = None
        reminder["payment_deadline"] = None
        reminder["fee_item_ids"] = []
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="reminder_sent_reverted",
            summary=f"Versand zurückgesetzt: {reminder.get('name', '')}",
            references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
        )
        return reminder

    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 delete_claim(self, member_id: str, claim_id: str, *, actor_name: str = "Vorstand") -> None:
        """Permanently remove a claim. Any payments/credits allocated to it are released
        (kept intact, just unlinked) rather than deleted, so they can be reallocated."""
        data, claim = self.get_claim(member_id, claim_id)
        released_allocations = [
            allocation for allocation in data.allocations if str(allocation.get("claim_id", "")) == claim_id
        ]
        released_total = sum(
            (decimal_value(item.get("amount", "0")) for item in released_allocations), Decimal("0")
        )
        data.claims = [item for item in data.claims if str(item.get("claim_id", "")) != claim_id]
        data.allocations = [
            item for item in data.allocations if str(item.get("claim_id", "")) != claim_id
        ]
        data.reminders = [
            item for item in data.reminders if str(item.get("claim_id", "")) != claim_id
        ]
        self.save_contributions(member_id, data)
        self.append_event(
            member_id,
            event_type="claim_deleted",
            summary=f"Forderung gelöscht: {claim.get('title', claim_id)}",
            actor_type="user",
            actor_name=actor_name,
            references={"claim_id": claim_id},
            data={
                "amount": money_text(claim_total(claim)),
                "released_allocations": money_text(released_total),
            },
        )

    def _assert_gnucash_id_available(
        self, transaction_id: str, *, exclude_payment_id: str | None = None
    ) -> 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("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}")

    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 {},
        )
        self.assert_writable()
        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)
        self._write_json(self._member_path(member_id) / "member.json", member.to_dict())
        self._write_json(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)
        self._write_json(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))
        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)
        return {
            "mode": mode,
            "pattern": pattern,
            "allocation_strategy": allocation_strategy,
        }

    def save_member_number_policy(
        self, *, mode: str, pattern: str, allocation_strategy: str = "next_free"
    ) -> None:
        if mode not in {"automatic", "manual"}:
            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)
        config = read_json(self.root / "repository.json")
        config["member_number_policy"] = {
            "mode": mode,
            "pattern": pattern.strip(),
            "allocation_strategy": allocation_strategy,
        }
        config.setdefault("member_number_sequences", {})
        self._write_json(self.root / "repository.json", config)

    def get_reminder_policy(self) -> dict:
        config = self.get_configuration()
        policy = config.get("reminder_policy") or {}
        levels = sorted(
            (
                {
                    "level": int(entry.get("level", 0)),
                    "name": str(entry.get("name", "")),
                    "fee": money_text(entry.get("fee", "0")),
                    "payment_deadline_days": int(entry.get("payment_deadline_days", 14)),
                }
                for entry in policy.get("levels") or []
            ),
            key=lambda entry: entry["level"],
        )
        # Repositories created before this setting existed have no "standard_fee_items"
        # key at all -- fall back to the built-in defaults rather than an empty list, but
        # once the board has actually saved (and possibly emptied) the list, respect that.
        if "standard_fee_items" in policy:
            standard_items_raw = policy.get("standard_fee_items") or []
        else:
            standard_items_raw = DEFAULT_CONFIGURATION["reminder_policy"]["standard_fee_items"]
        standard_fee_items = [
            {
                "description": str(entry.get("description", "")),
                "default_amount": money_text(entry.get("default_amount", "0")),
            }
            for entry in standard_items_raw
        ]
        return {
            "grace_days_after_due": int(policy.get("grace_days_after_due", 7)),
            "levels": levels,
            "standard_fee_items": standard_fee_items,
        }

    def save_reminder_policy(
        self,
        *,
        grace_days_after_due: int,
        levels: list[dict[str, str]],
        standard_fee_items: list[dict[str, str]],
    ) -> None:
        if grace_days_after_due < 0 or grace_days_after_due > 365:
            raise RepositoryError("Die Kulanzfrist muss zwischen 0 und 365 Tagen liegen.")
        normalized_levels = []
        for index, entry in enumerate(levels, start=1):
            name = str(entry.get("name", "")).strip()
            if not name:
                raise RepositoryError(f"Mahnstufe {index} benötigt einen Namen.")
            try:
                fee = decimal_value(entry.get("fee", "0"), "Gebühr")
            except ValueError as exc:
                raise RepositoryError(str(exc)) from exc
            if fee < 0:
                raise RepositoryError("Die Gebühr darf nicht negativ sein.")
            try:
                deadline_days = int(entry.get("payment_deadline_days", 14))
            except (TypeError, ValueError) as exc:
                raise RepositoryError("Die Zahlungsfrist muss eine ganze Zahl sein.") from exc
            if deadline_days < 1 or deadline_days > 365:
                raise RepositoryError("Die Zahlungsfrist muss zwischen 1 und 365 Tagen liegen.")
            # Levels are always renumbered 1..N by row order -- picking arbitrary level
            # numbers would break the sequencing checks in create_reminder_draft.
            normalized_levels.append(
                {"level": index, "name": name, "fee": money_text(fee), "payment_deadline_days": deadline_days}
            )
        if not normalized_levels:
            raise RepositoryError("Mindestens eine Mahnstufe ist erforderlich.")
        normalized_items = []
        for entry in standard_fee_items:
            description = str(entry.get("description", "")).strip()
            if not description:
                raise RepositoryError("Jede Standardposition benötigt eine Beschreibung.")
            try:
                amount = decimal_value(entry.get("default_amount", "0"), "Standardbetrag")
            except ValueError as exc:
                raise RepositoryError(str(exc)) from exc
            if amount < 0:
                raise RepositoryError("Der Standardbetrag darf nicht negativ sein.")
            normalized_items.append({"description": description, "default_amount": money_text(amount)})
        config = self.get_configuration()
        config["reminder_policy"] = {
            "grace_days_after_due": grace_days_after_due,
            "levels": normalized_levels,
            "standard_fee_items": normalized_items,
        }
        self._write_json(self.root / "repository.json", config)

    def get_email_settings(self) -> dict:
        config = self.get_configuration()
        settings = config.get("email_settings") or {}
        defaults = DEFAULT_CONFIGURATION["email_settings"]
        delivery_mode = str(settings.get("delivery_mode", defaults["delivery_mode"]))
        if delivery_mode not in {"local", "send", "drafts", "ask"}:
            delivery_mode = defaults["delivery_mode"]
        smtp_security = str(settings.get("smtp_security", defaults["smtp_security"]))
        if smtp_security not in {"starttls", "ssl", "none"}:
            smtp_security = defaults["smtp_security"]
        imap_security = str(settings.get("imap_security", defaults["imap_security"]))
        if imap_security not in {"starttls", "ssl", "none"}:
            imap_security = defaults["imap_security"]
        try:
            smtp_port = int(settings.get("smtp_port", defaults["smtp_port"]))
        except (TypeError, ValueError):
            smtp_port = defaults["smtp_port"]
        try:
            imap_port = int(settings.get("imap_port", defaults["imap_port"]))
        except (TypeError, ValueError):
            imap_port = defaults["imap_port"]
        return {
            "delivery_mode": delivery_mode,
            "smtp_host": str(settings.get("smtp_host", "")),
            "smtp_port": smtp_port,
            "smtp_security": smtp_security,
            "smtp_username": str(settings.get("smtp_username", "")),
            "smtp_password": str(settings.get("smtp_password", "")),
            "imap_host": str(settings.get("imap_host", "")),
            "imap_port": imap_port,
            "imap_security": imap_security,
            "imap_username": str(settings.get("imap_username", "")),
            "imap_password": str(settings.get("imap_password", "")),
            "imap_drafts_folder": str(
                settings.get("imap_drafts_folder", defaults["imap_drafts_folder"])
            ),
            "imap_sent_enabled": bool(settings.get("imap_sent_enabled", defaults["imap_sent_enabled"])),
            "imap_sent_folder": str(settings.get("imap_sent_folder", defaults["imap_sent_folder"])),
        }

    def save_email_settings(
        self,
        *,
        delivery_mode: str,
        smtp_host: str,
        smtp_port: int,
        smtp_security: str,
        smtp_username: str,
        smtp_password: str,
        imap_host: str,
        imap_port: int,
        imap_security: str,
        imap_username: str,
        imap_password: str,
        imap_drafts_folder: str,
        imap_sent_enabled: bool = False,
        imap_sent_folder: str = "",
    ) -> None:
        if delivery_mode not in {"local", "send", "drafts", "ask"}:
            raise RepositoryError("Ungültiger Versandmodus.")
        if smtp_security not in {"starttls", "ssl", "none"}:
            raise RepositoryError("Ungültige SMTP-Verschlüsselung.")
        if imap_security not in {"starttls", "ssl", "none"}:
            raise RepositoryError("Ungültige IMAP-Verschlüsselung.")
        if delivery_mode in {"send", "ask"} and not smtp_host.strip():
            raise RepositoryError("Für den direkten Versand ist ein SMTP-Server erforderlich.")
        if delivery_mode in {"drafts", "ask"} and not imap_host.strip():
            raise RepositoryError("Für die Ablage als Entwurf ist ein IMAP-Server erforderlich.")
        if imap_sent_enabled and not imap_host.strip():
            raise RepositoryError(
                "Für die Ablage im Gesendet-Ordner ist ein IMAP-Server erforderlich."
            )
        for label, port in (("Der SMTP-Port", smtp_port), ("Der IMAP-Port", imap_port)):
            if port < 1 or port > 65535:
                raise RepositoryError(f"{label} muss zwischen 1 und 65535 liegen.")
        config = self.get_configuration()
        config["email_settings"] = {
            "delivery_mode": delivery_mode,
            "smtp_host": smtp_host.strip(),
            "smtp_port": int(smtp_port),
            "smtp_security": smtp_security,
            "smtp_username": smtp_username.strip(),
            "smtp_password": smtp_password,
            "imap_host": imap_host.strip(),
            "imap_port": int(imap_port),
            "imap_security": imap_security,
            "imap_username": imap_username.strip(),
            "imap_password": imap_password,
            "imap_drafts_folder": imap_drafts_folder.strip() or "INBOX.Entwürfe",
            "imap_sent_enabled": bool(imap_sent_enabled),
            "imap_sent_folder": imap_sent_folder.strip() or "INBOX.Sent",
        }
        self._write_json(self.root / "repository.json", config)

    @property
    def mail_templates_root(self) -> Path:
        return self.root / "templates" / "mail"

    def get_mail_template(self, key: str) -> MailTemplate:
        """Reads the store's copy of a mail template. Stores created before mail
        templates existed (or with a deleted file) fall back to the shipped default
        and get the file written back, so the board always has an editable copy -- on a
        read-only store the fallback is simply used without storing it."""
        spec = template_spec(key)
        path = self.mail_templates_root / spec.filename
        try:
            return parse_mail_template(path.read_text(encoding="utf-8"))
        except OSError:
            template = default_mail_template(key)
            try:
                path.parent.mkdir(parents=True, exist_ok=True)
                write_text_atomic(path, serialize_mail_template(template.subject, template.body))
            except OSError:
                pass
            return template

    def save_mail_template(self, key: str, *, subject: str, body: str) -> None:
        self.assert_writable()
        spec = template_spec(key)
        try:
            validate_mail_template(key, subject, body)
        except MailTemplateError as exc:
            raise RepositoryError(str(exc)) from exc
        path = self.mail_templates_root / spec.filename
        path.parent.mkdir(parents=True, exist_ok=True)
        write_text_atomic(path, serialize_mail_template(subject, body))

    def reset_mail_template(self, key: str) -> MailTemplate:
        template = default_mail_template(key)
        self.save_mail_template(key, subject=template.subject, body=template.body)
        return template

    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
        self._write_json(self.root / "repository.json", config)

    def preview_member_number(
        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)
        config = read_json(self.root / "repository.json")
        return self._next_available_member_number(config, selected_pattern, selected_strategy)[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, allocation_strategy: str) -> str:
        config = read_json(self.root / "repository.json")
        member_number, next_value = self._next_available_member_number(
            config, pattern, allocation_strategy
        )
        sequences = config.get("member_number_sequences")
        if not isinstance(sequences, dict):
            sequences = {}
            config["member_number_sequences"] = sequences
        sequences[pattern] = next_value
        self._write_json(self.root / "repository.json", config)
        return member_number

    def _next_available_member_number(
        self, config: dict, pattern: str, allocation_strategy: str
    ) -> tuple[str, int]:
        if allocation_strategy not in {"next_free", "max_plus_one"}:
            raise RepositoryError("Ungültige Vergabestrategie für Mitgliedsnummern.")
        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):
            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.")

    @contextmanager
    def _member_number_lock(self):
        self.assert_writable()
        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(
        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",
            "application_date": "Antragsdatum",
            "membership_ended_at": "Austrittsdatum",
            "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",
            "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",
            "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 _contribution_override_summary(override: dict) -> str:
    value = str(override.get("value", ""))
    value_text = f"{value}%" if str(override.get("kind", "")) == "percent" else f"{value} EUR"
    valid_from = str(override.get("valid_from", ""))
    valid_until = str(override.get("valid_until", "") or "")
    period = f"{valid_from} bis {valid_until}" if valid_until else f"ab {valid_from}"
    return f"{value_text} ({period})"


def _normalize_reminder_items(items: list[dict[str, str]] | None) -> list[dict[str, str]]:
    normalized = []
    for entry in items or []:
        description = str(entry.get("description", "")).strip()
        if not description:
            raise ValueError("Jede Position benötigt eine Beschreibung.")
        amount = decimal_value(entry.get("amount", "0"), "Betrag")
        if amount < 0:
            raise ValueError("Der Betrag darf nicht negativ sein.")
        normalized.append({"description": description, "amount": money_text(amount)})
    return normalized


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 _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:
    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
