From 070684d9bc94ce60d4337c2378fb29b3e9a06ebf Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Wed, 22 Jul 2026 22:26:02 +0200 Subject: [PATCH 1/3] feat: extend inventory and administration workflows --- src/ccma/domain/dates.py | 6 + src/ccma/domain/models.py | 45 +++ src/ccma/rules/scripts/birthdate_check.py | 1 + src/ccma/rules/scripts/contribution_claims.py | 47 ++- src/ccma/services/documents.py | 1 + src/ccma/services/housekeeper.py | 4 +- src/ccma/storage/repository.py | 377 +++++++++++++++++- src/ccma/ui/asset_tab.py | 40 +- src/ccma/ui/claim_tab.py | 102 ++++- src/ccma/ui/dialogs.py | 158 +++++++- src/ccma/ui/member_tab.py | 138 ++++++- src/ccma/ui/payment_dialog.py | 263 ++++++++++++ src/ccma/ui/work_tabs.py | 20 +- tests/test_contributions.py | 114 ++++++ tests/test_dates.py | 9 + tests/test_repository.py | 61 ++- tests/test_rules.py | 25 ++ 17 files changed, 1342 insertions(+), 69 deletions(-) create mode 100644 src/ccma/ui/payment_dialog.py diff --git a/src/ccma/domain/dates.py b/src/ccma/domain/dates.py index 4592014..4c2ae40 100644 --- a/src/ccma/domain/dates.py +++ b/src/ccma/domain/dates.py @@ -117,18 +117,24 @@ def validate_member_dates( birth_date: str, accepted_at: str = "", membership_started_at: str = "", + membership_ended_at: str = "", today: date | None = None, ) -> None: reference = today or date.today() birth = validate_birth_date(birth_date, today=reference) accepted = _validate_not_future(accepted_at, "Aufnahmebeschluss", reference) started = _validate_not_future(membership_started_at, "Mitglied seit", reference) + ended = parse_iso_date(membership_ended_at, "Austrittsdatum") if birth and accepted and accepted < birth: raise DateValidationError("Aufnahmebeschluss darf nicht vor dem Geburtsdatum liegen.") if birth and started and started < birth: raise DateValidationError("Mitgliedschaft darf nicht vor dem Geburtsdatum beginnen.") if accepted and started and started < accepted: raise DateValidationError("Mitgliedschaft darf nicht vor dem Aufnahmebeschluss beginnen.") + if started and ended and ended < started: + raise DateValidationError("Austrittsdatum darf nicht vor dem Mitgliedschaftsbeginn liegen.") + if accepted and ended and ended < accepted: + raise DateValidationError("Austrittsdatum darf nicht vor dem Aufnahmebeschluss liegen.") def calculate_age(birth_date: date, on_date: date | None = None) -> int: diff --git a/src/ccma/domain/models.py b/src/ccma/domain/models.py index ea4b926..b512eb3 100644 --- a/src/ccma/domain/models.py +++ b/src/ccma/domain/models.py @@ -27,6 +27,19 @@ ASSET_STATUS_LABELS = { "retired": "AUSGEMUSTERT", } +ASSET_OWNER_TYPE_LABELS = { + "club": "VEREIN", + "member": "MITGLIED", + "external": "EXTERN", +} + +ASSET_CUSTODY_TYPE_LABELS = { + "club": "VEREIN", + "member": "MITGLIED", + "external": "EXTERN", + "unknown": "UNBEKANNT", +} + HOUSEKEEPER_MEMBER_FIELD_LABELS = { "nickname": "Nickname", "email": "E-Mail-Adresse", @@ -77,6 +90,7 @@ class Member: status: str = "application" accepted_at: str = "" membership_started_at: str = "" + membership_ended_at: str = "" payment_frequency: str = "annual" contribution_rule_id: str = "standard-2022" honorary: bool = False @@ -122,6 +136,7 @@ class Member: "status": self.status, "accepted_at": self.accepted_at, "started_at": self.membership_started_at, + "ended_at": self.membership_ended_at, "honorary": self.honorary, }, "contribution_profile": { @@ -165,6 +180,7 @@ class Member: status=str(membership.get("status", "application")), accepted_at=str(membership.get("accepted_at", "")), membership_started_at=str(membership.get("started_at", "")), + membership_ended_at=str(membership.get("ended_at", "")), honorary=bool(membership.get("honorary", False)), contribution_rule_id=str(contribution.get("rule_id", "standard-2022")), payment_frequency=str(contribution.get("payment_frequency", "annual")), @@ -183,6 +199,14 @@ class Asset: serial_number: str = "" status: str = "available" current_holder_member_id: str = "" + owner_type: str = "club" + owner_member_id: str = "" + owner_name: str = "" + custody_type: str = "club" + location: str = "" + handed_over_at: str = "" + condition: str = "" + estimated_value: str = "" deposit_amount_default: str = "0.00" notes: str = "" created_at: str = field(default_factory=_iso_now) @@ -199,6 +223,14 @@ class Asset: "serial_number": self.serial_number, "status": self.status, "current_holder_member_id": self.current_holder_member_id, + "owner_type": self.owner_type, + "owner_member_id": self.owner_member_id, + "owner_name": self.owner_name, + "custody_type": self.custody_type, + "location": self.location, + "handed_over_at": self.handed_over_at, + "condition": self.condition, + "estimated_value": self.estimated_value, "deposit_amount_default": self.deposit_amount_default, "notes": self.notes, "created_at": self.created_at, @@ -216,6 +248,19 @@ class Asset: serial_number=str(data.get("serial_number", "")), status=str(data.get("status", "available")), current_holder_member_id=str(data.get("current_holder_member_id", "")), + owner_type=str(data.get("owner_type", "club")), + owner_member_id=str(data.get("owner_member_id", "")), + owner_name=str(data.get("owner_name", "")), + custody_type=str( + data.get( + "custody_type", + "member" if data.get("current_holder_member_id") else "club", + ) + ), + location=str(data.get("location", "")), + handed_over_at=str(data.get("handed_over_at", "")), + condition=str(data.get("condition", "")), + estimated_value=str(data.get("estimated_value", "")), deposit_amount_default=str(data.get("deposit_amount_default", "0.00")), notes=str(data.get("notes", "")), created_at=str(data.get("created_at", _iso_now())), diff --git a/src/ccma/rules/scripts/birthdate_check.py b/src/ccma/rules/scripts/birthdate_check.py index 09674f4..ecb4da7 100644 --- a/src/ccma/rules/scripts/birthdate_check.py +++ b/src/ccma/rules/scripts/birthdate_check.py @@ -37,6 +37,7 @@ def evaluate(context: RuleContext): birth_date=member.birth_date, accepted_at=member.accepted_at, membership_started_at=member.membership_started_at, + membership_ended_at=member.membership_ended_at, today=context.today, ) except DateValidationError as exc: diff --git a/src/ccma/rules/scripts/contribution_claims.py b/src/ccma/rules/scripts/contribution_claims.py index cb4f312..9d6e5db 100644 --- a/src/ccma/rules/scripts/contribution_claims.py +++ b/src/ccma/rules/scripts/contribution_claims.py @@ -18,6 +18,7 @@ def evaluate(context: RuleContext): try: accepted_at = parse_iso_date(member.accepted_at, "Aufnahmebeschluss") started_at = parse_iso_date(member.membership_started_at, "Mitglied seit") or accepted_at + ended_at = parse_iso_date(member.membership_ended_at, "Austrittsdatum") except DateValidationError: return [] if not accepted_at or not started_at: @@ -29,20 +30,22 @@ def evaluate(context: RuleContext): admission_fee = Decimal(str(admission_rule.get("admission_fee", "0"))) if admission_fee > 0: due_days = int(admission_rule.get("first_payment_due_days_after_acceptance", 28)) - actions.append( - create_claim( - rule_id=RULE_ID, - member=member, - claim_key="admission-fee", - payload={ - "type": "admission_fee", - "title": "Aufnahmegebühr", - "amount": _money(admission_fee), - "due_date": (accepted_at + timedelta(days=due_days)).isoformat(), - "calculation": {"rule_id": admission_rule.get("rule_id", "")}, - }, + admission_due = accepted_at + timedelta(days=due_days) + if not ended_at or admission_due <= ended_at: + actions.append( + create_claim( + rule_id=RULE_ID, + member=member, + claim_key="admission-fee", + payload={ + "type": "admission_fee", + "title": "Aufnahmegebühr", + "amount": _money(admission_fee), + "due_date": admission_due.isoformat(), + "calculation": {"rule_id": admission_rule.get("rule_id", "")}, + }, + ) ) - ) year_from = ( started_at.year @@ -50,11 +53,17 @@ def evaluate(context: RuleContext): else context.today.year ) for year in range(year_from, context.today.year + 2): - actions.extend(_membership_claims(context, started_at, accepted_at, year)) + actions.extend(_membership_claims(context, started_at, accepted_at, ended_at, year)) return actions -def _membership_claims(context: RuleContext, started_at: date, accepted_at: date, year: int): +def _membership_claims( + context: RuleContext, + started_at: date, + accepted_at: date, + ended_at: date | None, + year: int, +): member = context.member period_start = max(started_at, date(year, 1, 1)) if period_start.year > year: @@ -96,7 +105,14 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date due_date = regular_due if entry_year and regular_due < accepted_at + timedelta(days=due_days_after_entry): due_date = accepted_at + timedelta(days=due_days_after_entry) + if ended_at and due_date > ended_at: + continue claim_key = f"membership-fee:{year}:{suffix}" + full_period_months = last_month - first_month + 1 + description = _title(year, suffix) + if months < full_period_months: + unit = "Monat" if months == 1 else "Monate" + description = f"{description} ({months} {unit})" actions.append( create_claim( rule_id=RULE_ID, @@ -105,6 +121,7 @@ def _membership_claims(context: RuleContext, started_at: date, accepted_at: date payload={ "type": "membership_fee", "title": _title(year, suffix), + "description": description, "amount": _money(amount), "due_date": due_date.isoformat(), "service_period": { diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index 31733e1..bb760c4 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -211,6 +211,7 @@ def _template_values( "member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status), "member.accepted_at": format_date_for_display(member.accepted_at), "member.started_at": format_date_for_display(member.membership_started_at), + "member.ended_at": format_date_for_display(member.membership_ended_at), "member.street": member.street, "member.address_addition": member.address_addition, "member.postal_code": member.postal_code, diff --git a/src/ccma/services/housekeeper.py b/src/ccma/services/housekeeper.py index d51b370..0577257 100644 --- a/src/ccma/services/housekeeper.py +++ b/src/ccma/services/housekeeper.py @@ -324,7 +324,9 @@ class Housekeeper: { "item_id": str(uuid4()), "type": "base", - "description": str(claim.get("title", claim_key)), + "description": str( + claim.get("description") or claim.get("title", claim_key) + ), "quantity": "1.00", "unit_price": str(claim.get("amount", "0.00")), "amount": str(claim.get("amount", "0.00")), diff --git a/src/ccma/storage/repository.py b/src/ccma/storage/repository.py index 5ae599a..ec78ce9 100644 --- a/src/ccma/storage/repository.py +++ b/src/ccma/storage/repository.py @@ -6,11 +6,13 @@ import shutil import unicodedata from collections.abc import Iterable 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 ( + allocated_total, claim_balance, claim_total, credit_allocated_total, @@ -21,6 +23,8 @@ from ccma.domain.contributions import ( ) from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates from ccma.domain.models import ( + ASSET_CUSTODY_TYPE_LABELS, + ASSET_OWNER_TYPE_LABELS, ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS, Asset, @@ -134,9 +138,7 @@ class MemberRepository: 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 + "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(): @@ -167,13 +169,13 @@ class MemberRepository: 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) + 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") @@ -199,8 +201,7 @@ class MemberRepository: 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) + 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") @@ -219,6 +220,17 @@ class MemberRepository: ) 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)) @@ -325,6 +337,7 @@ class MemberRepository: member.birth_date = normalize_date_input(member.birth_date, "Geburtsdatum") member.accepted_at = normalize_date_input(member.accepted_at, "Aufnahmebeschluss") member.membership_started_at = normalize_date_input(member.membership_started_at, "Mitglied seit") + member.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" @@ -333,7 +346,10 @@ class MemberRepository: 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) @@ -393,6 +409,14 @@ class MemberRepository: 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.") @@ -402,6 +426,7 @@ class MemberRepository: 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, "") asset_id = str(uuid4()) directory = self._asset_path(asset_id) directory.mkdir(parents=True, exist_ok=False) @@ -414,6 +439,14 @@ class MemberRepository: 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(), ) write_json_atomic(directory / "asset.json", asset.to_dict()) self.append_asset_event( @@ -423,6 +456,15 @@ class MemberRepository: 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: @@ -431,15 +473,21 @@ class MemberRepository: 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) + 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." @@ -450,6 +498,11 @@ class MemberRepository: 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": @@ -467,6 +520,25 @@ class MemberRepository: 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) @@ -476,6 +548,7 @@ class MemberRepository: 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") write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict()) @@ -503,6 +576,7 @@ class MemberRepository: 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") write_json_atomic(self._asset_path(asset.asset_id) / "asset.json", asset.to_dict()) @@ -528,6 +602,41 @@ class MemberRepository: 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, @@ -731,6 +840,88 @@ class MemberRepository: ) 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, @@ -760,6 +951,11 @@ class MemberRepository: 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, @@ -788,7 +984,7 @@ class MemberRepository: 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) + 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, @@ -802,6 +998,11 @@ class MemberRepository: 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, @@ -818,6 +1019,140 @@ class MemberRepository: ) return allocation + def update_payment( + self, + member_id: str, + payment_id: str, + *, + payment_date: str, + amount: str, + allocations: dict[str, str], + gnucash_transaction_id: str = "", + reference: str = "", + ) -> dict: + data = self.get_contributions(member_id) + payment = next( + (item for item in data.payments if str(item.get("payment_id", "")) == payment_id), + None, + ) + if payment is None: + raise RepositoryError("Zahlung nicht gefunden.") + try: + normalized_date = normalize_date_input(payment_date, "Zahlungsdatum") + selected_amount = decimal_value(amount) + except (DateValidationError, ValueError) as exc: + raise RepositoryError(str(exc)) from exc + if not normalized_date: + raise RepositoryError("Zahlungsdatum ist erforderlich.") + if selected_amount <= 0: + raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.") + + claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims} + old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id] + old_by_claim: dict[str, list[dict]] = {} + for allocation in old_allocations: + old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation) + + selected_allocations: dict[str, Decimal] = {} + for claim_id, raw_amount in allocations.items(): + if claim_id not in claims_by_id: + raise RepositoryError(f"Forderung nicht gefunden: {claim_id}") + try: + allocation_amount = decimal_value(raw_amount, "Zuordnung") + except ValueError as exc: + raise RepositoryError(str(exc)) from exc + if allocation_amount < 0: + raise RepositoryError("Zuordnungen dürfen nicht negativ sein.") + if allocation_amount == 0: + continue + claim = claims_by_id[claim_id] + currently_allocated = sum( + (decimal_value(item.get("amount", "0")) for item in old_by_claim.get(claim_id, [])), + Decimal("0"), + ) + available_claim_balance = max( + claim_balance(data, claim) + currently_allocated, + Decimal("0"), + ) + if allocation_amount > available_claim_balance: + raise RepositoryError( + f"{claim.get('title', 'Forderung')} hat nur " + f"{money_text(available_claim_balance)} EUR offen." + ) + selected_allocations[claim_id] = allocation_amount + + allocated_sum = sum(selected_allocations.values(), Decimal("0")) + if allocated_sum > selected_amount: + raise RepositoryError( + f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den " + f"Zahlungsbetrag ({money_text(selected_amount)} EUR)." + ) + + gnucash_id = gnucash_transaction_id.strip() + if gnucash_id: + self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id) + payment.update( + { + "date": normalized_date, + "amount": money_text(selected_amount), + "gnucash_transaction_id": gnucash_id, + "reference": reference.strip(), + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + ) + + retained_allocations = [ + item for item in data.allocations if str(item.get("payment_id", "")) != payment_id + ] + new_allocations = [] + for claim_id, allocation_amount in selected_allocations.items(): + prior = old_by_claim.get(claim_id, []) + new_allocations.append( + { + "allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())), + "payment_id": payment_id, + "claim_id": claim_id, + "amount": money_text(allocation_amount), + } + ) + data.allocations = retained_allocations + new_allocations + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="payment_changed", + summary=f"Zahlung geändert: {payment['amount']} EUR", + actor_type="user", + actor_name="Vorstand", + references={"payment_id": payment_id}, + data={ + "allocated_amount": money_text(allocated_sum), + "unallocated_amount": money_text(selected_amount - allocated_sum), + }, + ) + return payment + + def delete_payment(self, member_id: str, payment_id: str) -> None: + data = self.get_contributions(member_id) + payment = next( + (item for item in data.payments if str(item.get("payment_id", "")) == payment_id), + None, + ) + if payment is None: + raise RepositoryError("Zahlung nicht gefunden.") + data.payments = [item for item in data.payments if str(item.get("payment_id", "")) != payment_id] + data.allocations = [ + item for item in data.allocations if str(item.get("payment_id", "")) != payment_id + ] + self.save_contributions(member_id, data) + self.append_event( + member_id, + event_type="payment_deleted", + summary=f"Zahlung gelöscht: {payment.get('amount', '')} EUR", + actor_type="user", + actor_name="Vorstand", + references={"payment_id": payment_id}, + ) + def record_credit( self, member_id: str, @@ -1127,7 +1462,9 @@ class MemberRepository: references={"claim_id": claim_id}, ) - def _assert_gnucash_id_available(self, transaction_id: str) -> None: + def _assert_gnucash_id_available( + self, transaction_id: str, *, exclude_payment_id: str | None = None + ) -> None: selected = transaction_id.casefold() for member in self.list_members(): try: @@ -1135,7 +1472,9 @@ class MemberRepository: except RepositoryError: continue if any( - str(payment.get("gnucash_transaction_id", "")).casefold() == selected for payment in payments + str(payment.get("payment_id", "")) != exclude_payment_id + and str(payment.get("gnucash_transaction_id", "")).casefold() == selected + for payment in payments ): raise RepositoryError(f"GnuCash-ID bereits verwendet: {transaction_id}") @@ -1390,6 +1729,7 @@ class MemberRepository: "email": "E-Mail-Adresse", "phone": "Telefonnummer", "birth_date": "Geburtsdatum", + "membership_ended_at": "Austrittsdatum", "status": "Status", "payment_frequency": "Zahlungsweise", "contribution_rule_id": "Beitragsregel", @@ -1431,6 +1771,14 @@ class MemberRepository: "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", } @@ -1452,8 +1800,7 @@ def validate_iban(value: str) -> None: 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 + 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.") diff --git a/src/ccma/ui/asset_tab.py b/src/ccma/ui/asset_tab.py index 8b98abd..075fc9d 100644 --- a/src/ccma/ui/asset_tab.py +++ b/src/ccma/ui/asset_tab.py @@ -115,6 +115,10 @@ class AssetTab(ttk.Frame): ("Inventarnummer", "inventory_number"), ("Seriennummer", "serial_number"), ("Kaution (EUR)", "deposit_amount_default"), + ("Standort", "location"), + ("Übergabedatum", "handed_over_at"), + ("Zustand bei Übergabe", "condition"), + ("Wert / Kaufpreis", "estimated_value"), ] for row, (label, key) in enumerate(fields): self.variables[key] = tk.StringVar() @@ -151,15 +155,22 @@ class AssetTab(ttk.Frame): ) self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5) self.holder_label.bind("", lambda _event: self._open_holder_member(), add="+") + self.owner_var = tk.StringVar() + ttk.Label(data_tab, text="Eigentümer").grid( + row=holder_row + 2, column=0, sticky="w", pady=5, padx=(0, 12) + ) + self.owner_label = ttk.Label(data_tab, textvariable=self.owner_var, style="TimelineHeader.TLabel") + self.owner_label.grid(row=holder_row + 2, column=1, sticky="w", pady=5) + self.owner_label.bind("", lambda _event: self._open_owner_member(), add="+") ttk.Label(data_tab, text="Interne Notiz").grid( - row=holder_row + 2, + row=holder_row + 3, column=0, sticky="nw", pady=5, padx=(0, 12), ) self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word") - self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5) + self.notes_text.grid(row=holder_row + 3, column=1, sticky="ew", pady=5) data_tab.columnconfigure(1, weight=1) self.issue_button = ttk.Button( action_buttons, @@ -276,13 +287,19 @@ class AssetTab(ttk.Frame): self.variables["inventory_number"].set(self.asset.inventory_number) self.variables["serial_number"].set(self.asset.serial_number) self.variables["deposit_amount_default"].set(self.asset.deposit_amount_default) + self.variables["location"].set(self.asset.location) + self.variables["handed_over_at"].set(self.asset.handed_over_at) + self.variables["condition"].set(self.asset.condition) + self.variables["estimated_value"].set(self.asset.estimated_value) self.variables["status"].set(ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper())) if self.notes_text is not None: self.notes_text.delete("1.0", "end") self.notes_text.insert("1.0", self.asset.notes) self.holder_var.set(holder) + self.owner_var.set(self._owner_label()) issued = bool(self.asset.current_holder_member_id) self.holder_label.configure(cursor="hand2" if issued else "") + self.owner_label.configure(cursor="hand2" if self.asset.owner_type == "member" else "") self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled") self.return_button.configure(state="normal" if issued else "disabled") self.status_box.configure(state="disabled" if issued else "readonly") @@ -300,6 +317,21 @@ class AssetTab(ttk.Frame): return self.asset.current_holder_member_id return f"{member.member_number or member.member_id} · {member.display_name}" + def _owner_label(self) -> str: + if self.asset.owner_type == "club": + return "Verein" + if self.asset.owner_type == "external": + return self.asset.owner_name or "Extern" + try: + member = self.repository.get_member(self.asset.owner_member_id) + except RepositoryError: + return self.asset.owner_member_id or "—" + return f"{member.member_number or member.member_id} · {member.display_name}" + + def _open_owner_member(self) -> None: + if self.asset.owner_type == "member" and self.asset.owner_member_id: + self.on_open_member(self.asset.owner_member_id) + def _refresh_events(self) -> None: self.timeline.delete(*self.timeline.get_children()) try: @@ -352,6 +384,10 @@ class AssetTab(ttk.Frame): self.asset.inventory_number = self.variables["inventory_number"].get().strip() self.asset.serial_number = self.variables["serial_number"].get().strip() self.asset.notes = self.notes_text.get("1.0", "end-1c").strip() if self.notes_text else "" + self.asset.location = self.variables["location"].get().strip() + self.asset.handed_over_at = self.variables["handed_over_at"].get().strip() + self.asset.condition = self.variables["condition"].get().strip() + self.asset.estimated_value = self.variables["estimated_value"].get().strip() if not self.asset.current_holder_member_id: self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get().strip() self.asset.status = _asset_status_key(self.variables["status"].get()) diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index 1f84bb1..5720a04 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -88,8 +88,10 @@ class ClaimTab(ttk.Frame): footer.columnconfigure(0, weight=1) self.hold_button = ttk.Button(footer, text="Mahnsperre setzen", command=self._toggle_hold) self.hold_button.grid(row=0, column=0, sticky="w") + self.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim) + self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8)) self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim) - self.cancel_button.grid(row=0, column=1, sticky="e") + self.cancel_button.grid(row=0, column=2, sticky="e") def _build_ledger(self) -> None: ledger = ttk.Frame(self, padding=12) @@ -185,6 +187,7 @@ class ClaimTab(ttk.Frame): self.summary_vars["balance"].set(f"{money_text(balance)} EUR") self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper())) self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal") + self.edit_button.configure(state="disabled" if status == "cancelled" else "normal") self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen") self._render_ledger() @@ -478,6 +481,16 @@ class ClaimTab(ttk.Frame): return self._changed() + def _edit_claim(self) -> None: + ClaimEditDialog( + self, + self.repository, + self.member_id, + self.claim_id, + self.claim, + self._changed, + ) + def _changed(self) -> None: self.refresh() self.on_changed() @@ -502,6 +515,68 @@ class _Dialog(tk.Toplevel): ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left") +class ClaimEditDialog(_Dialog): + def __init__(self, master, repository, member_id, claim_id, claim, on_saved): + super().__init__(master, "Forderung bearbeiten", on_saved) + self.repository = repository + self.member_id = member_id + self.claim_id = claim_id + items = claim_items(claim) + base_item = next( + (item for item in items if str(item.get("type", "")) == "base"), + items[0] if items else {}, + ) + self.variables = { + "title": tk.StringVar(value=str(claim.get("title", ""))), + "due_date": tk.StringVar( + value=format_date_for_display(str(claim.get("due_date", ""))) + ), + "base_amount": tk.StringVar( + value=str(base_item.get("amount", claim.get("amount", ""))) + ), + "description": tk.StringVar(value=str(base_item.get("description", ""))), + } + fields = ( + ("Titel", "title"), + (f"Fällig am ({date_input_hint()})", "due_date"), + ("Grundbetrag", "base_amount"), + ("Beschreibung der Grundposition", "description"), + ) + for row, (label, key) in enumerate(fields): + ttk.Label(self.frame, text=label).grid( + row=row, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Entry(self.frame, textvariable=self.variables[key], width=52).grid( + row=row, column=1, sticky="ew", pady=5 + ) + ttk.Label( + self.frame, + text=( + "Der Grundbetrag ändert nur die Grundposition. Weitere Positionen, " + "zum Beispiel Mahngebühren, bleiben erhalten." + ), + style="Mono.TLabel", + wraplength=520, + ).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0)) + self._buttons(len(fields) + 1, self._save) + + def _save(self) -> None: + try: + self.repository.update_claim( + self.member_id, + self.claim_id, + title=self.variables["title"].get(), + due_date=self.variables["due_date"].get(), + base_amount=self.variables["base_amount"].get(), + description=self.variables["description"].get(), + ) + except RepositoryError as exc: + messagebox.showerror("Forderung konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved() + + class ItemDialog(_Dialog): def __init__(self, master, repository, member_id, claim_id, on_saved): super().__init__(master, "Forderungsposition hinzufügen", on_saved) @@ -634,7 +709,11 @@ class AllocatePaymentDialog(_Dialog): self.repository, self.member_id, self.claim_id = repository, member_id, claim_id data = repository.get_contributions(member_id) self.payment_by_label = {} - for payment in data.payments: + for payment in sorted( + data.payments, + key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))), + reverse=True, + ): payment_id = str(payment.get("payment_id", "")) available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id) if available <= 0: @@ -655,10 +734,23 @@ class AllocatePaymentDialog(_Dialog): width=60, ) combo.grid(row=0, column=1, pady=5) - ttk.Label(self.frame, text="Betrag").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12)) - ttk.Entry(self.frame, textvariable=self.amount_var).grid(row=1, column=1, sticky="ew", pady=5) + if not self.payment_by_label: + ttk.Label( + self.frame, + text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.", + style="Mono.TLabel", + ).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5)) + amount_row = 2 + else: + amount_row = 1 + ttk.Label(self.frame, text="Betrag").grid( + row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Entry(self.frame, textvariable=self.amount_var).grid( + row=amount_row, column=1, sticky="ew", pady=5 + ) combo.bind("<>", lambda _event: self._select(balance)) - self._buttons(2, self._save) + self._buttons(amount_row + 1, self._save) def _select(self, balance): _payment_id, available = self.payment_by_label[self.payment_var.get()] diff --git a/src/ccma/ui/dialogs.py b/src/ccma/ui/dialogs.py index 246ef7e..fa74a5a 100644 --- a/src/ccma/ui/dialogs.py +++ b/src/ccma/ui/dialogs.py @@ -124,13 +124,31 @@ class NewAssetDialog(tk.Toplevel): super().__init__(master) self.repository = repository self.on_created = on_created + self.members = repository.list_members() + self.member_labels = { + f"{member.member_number or member.member_id} · {member.display_name}": member.member_id + for member in self.members + } self.title("Neues Asset") self.transient(master.winfo_toplevel()) self.resizable(False, False) self.variables = { name: tk.StringVar() - for name in ("label", "category", "inventory_number", "serial_number", "deposit_amount_default") + for name in ( + "label", + "category", + "inventory_number", + "serial_number", + "deposit_amount_default", + "location", + "handed_over_at", + "condition", + "estimated_value", + "owner_name", + ) } + self.owner_type_var = tk.StringVar(value="VEREIN") + self.owner_member_var = tk.StringVar() self._build_ui() self.bind("", lambda _event: self.destroy()) self.bind("", lambda _event: self._create()) @@ -145,6 +163,10 @@ class NewAssetDialog(tk.Toplevel): ("Inventarnummer", "inventory_number"), ("Seriennummer", "serial_number"), ("Kaution (EUR)", "deposit_amount_default"), + ("Standort", "location"), + ("Übergabedatum", "handed_over_at"), + ("Zustand bei Übergabe", "condition"), + ("Wert / Kaufpreis", "estimated_value"), ] self.entries: dict[str, ttk.Entry] = {} for row, (label, key) in enumerate(fields): @@ -152,17 +174,42 @@ class NewAssetDialog(tk.Toplevel): entry = ttk.Entry(frame, textvariable=self.variables[key], width=38) entry.grid(row=row, column=1, sticky="ew", pady=5) self.entries[key] = entry + owner_row = len(fields) + ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Combobox( + frame, + textvariable=self.owner_type_var, + values=("VEREIN", "MITGLIED", "EXTERN"), + state="readonly", + width=35, + ).grid(row=owner_row, column=1, sticky="ew", pady=5) + ttk.Label(frame, text="Eigentümer-Mitglied").grid( + row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Combobox( + frame, + textvariable=self.owner_member_var, + values=list(self.member_labels), + state="readonly", + width=35, + ).grid(row=owner_row + 1, column=1, sticky="ew", pady=5) + ttk.Label(frame, text="Externer Eigentümer").grid( + row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid( + row=owner_row + 2, column=1, sticky="ew", pady=5 + ) ttk.Label(frame, text="Interne Notiz").grid( - row=len(fields), + row=owner_row + 3, column=0, sticky="nw", pady=5, padx=(0, 12), ) self.notes_text = tk.Text(frame, width=38, height=5, wrap="word") - self.notes_text.grid(row=len(fields), column=1, sticky="ew", pady=5) + self.notes_text.grid(row=owner_row + 3, column=1, sticky="ew", pady=5) buttons = ttk.Frame(frame) - buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0)) + buttons.grid(row=owner_row + 4, column=0, columnspan=2, sticky="e", pady=(16, 0)) ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) ttk.Button( buttons, @@ -176,9 +223,15 @@ class NewAssetDialog(tk.Toplevel): _activate_modal_window(self, self.entries.get("label")) def _create(self) -> None: + owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[self.owner_type_var.get()] try: asset = self.repository.create_asset( - **{key: variable.get() for key, variable in self.variables.items()}, + **{key: variable.get() for key, variable in self.variables.items() if key != "owner_name"}, + owner_type=owner_type, + owner_member_id=( + self.member_labels.get(self.owner_member_var.get(), "") if owner_type == "member" else "" + ), + owner_name=self.variables["owner_name"].get() if owner_type == "external" else "", notes=self.notes_text.get("1.0", "end-1c"), ) except RepositoryError as exc: @@ -201,6 +254,19 @@ class EditAssetDialog(tk.Toplevel): self.asset_id = asset_id self.on_saved = on_saved self.asset = repository.get_asset(asset_id) + self.members = repository.list_members() + self.member_labels = { + f"{member.member_number or member.member_id} · {member.display_name}": member.member_id + for member in self.members + } + owner_member_label = next( + ( + label + for label, member_id in self.member_labels.items() + if member_id == self.asset.owner_member_id + ), + "", + ) self.title("Asset bearbeiten") self.transient(master.winfo_toplevel()) self.resizable(False, False) @@ -211,7 +277,18 @@ class EditAssetDialog(tk.Toplevel): "serial_number": tk.StringVar(value=self.asset.serial_number), "deposit_amount_default": tk.StringVar(value=self.asset.deposit_amount_default), "status": tk.StringVar(value=self.asset.status), + "location": tk.StringVar(value=self.asset.location), + "handed_over_at": tk.StringVar(value=self.asset.handed_over_at), + "condition": tk.StringVar(value=self.asset.condition), + "estimated_value": tk.StringVar(value=self.asset.estimated_value), + "owner_name": tk.StringVar(value=self.asset.owner_name), } + self.owner_type_var = tk.StringVar( + value={"club": "VEREIN", "member": "MITGLIED", "external": "EXTERN"}.get( + self.asset.owner_type, "VEREIN" + ) + ) + self.owner_member_var = tk.StringVar(value=owner_member_label) self._build_ui() self.bind("", lambda _event: self.destroy()) self.bind("", lambda _event: self._save()) @@ -227,6 +304,10 @@ class EditAssetDialog(tk.Toplevel): ("Inventarnummer", "inventory_number"), ("Seriennummer", "serial_number"), ("Kaution (EUR)", "deposit_amount_default"), + ("Standort", "location"), + ("Übergabedatum", "handed_over_at"), + ("Zustand bei Übergabe", "condition"), + ("Wert / Kaufpreis", "estimated_value"), ] self.entries: dict[str, ttk.Entry] = {} for row, (label, key) in enumerate(fields): @@ -235,12 +316,40 @@ class EditAssetDialog(tk.Toplevel): entry = ttk.Entry(frame, textvariable=self.variables[key], width=38, state=state) entry.grid(row=row, column=1, sticky="ew", pady=5) self.entries[key] = entry - ttk.Label(frame, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12)) - status_values = [value for key, value in ( - ("available", "VERFUEGBAR"), - ("lost", "VERLOREN"), - ("retired", "AUSGEMUSTERT"), - )] + owner_row = len(fields) + ttk.Label(frame, text="Eigentümer").grid(row=owner_row, column=0, sticky="w", pady=5, padx=(0, 12)) + ttk.Combobox( + frame, + textvariable=self.owner_type_var, + values=("VEREIN", "MITGLIED", "EXTERN"), + state="readonly", + width=35, + ).grid(row=owner_row, column=1, sticky="ew", pady=5) + ttk.Label(frame, text="Eigentümer-Mitglied").grid( + row=owner_row + 1, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Combobox( + frame, + textvariable=self.owner_member_var, + values=list(self.member_labels), + state="readonly", + width=35, + ).grid(row=owner_row + 1, column=1, sticky="ew", pady=5) + ttk.Label(frame, text="Externer Eigentümer").grid( + row=owner_row + 2, column=0, sticky="w", pady=5, padx=(0, 12) + ) + ttk.Entry(frame, textvariable=self.variables["owner_name"], width=38).grid( + row=owner_row + 2, column=1, sticky="ew", pady=5 + ) + ttk.Label(frame, text="Status").grid(row=owner_row + 3, column=0, sticky="w", pady=5, padx=(0, 12)) + status_values = [ + value + for key, value in ( + ("available", "VERFUEGBAR"), + ("lost", "VERLOREN"), + ("retired", "AUSGEMUSTERT"), + ) + ] self.status_map = { "VERFUEGBAR": "available", "VERLOREN": "lost", @@ -260,8 +369,8 @@ class EditAssetDialog(tk.Toplevel): state="readonly" if not issued else "disabled", width=35, ) - self.status_box.grid(row=len(fields), column=1, sticky="ew", pady=5) - note_row = len(fields) + 1 + self.status_box.grid(row=owner_row + 3, column=1, sticky="ew", pady=5) + note_row = owner_row + 4 ttk.Label(frame, text="Interne Notiz").grid(row=note_row, column=0, sticky="nw", pady=5, padx=(0, 12)) self.notes_text = tk.Text(frame, width=38, height=5, wrap="word") self.notes_text.grid(row=note_row, column=1, sticky="ew", pady=5) @@ -290,6 +399,21 @@ class EditAssetDialog(tk.Toplevel): self.asset.inventory_number = self.variables["inventory_number"].get() self.asset.serial_number = self.variables["serial_number"].get() self.asset.notes = self.notes_text.get("1.0", "end-1c") + self.asset.location = self.variables["location"].get() + self.asset.handed_over_at = self.variables["handed_over_at"].get() + self.asset.condition = self.variables["condition"].get() + self.asset.estimated_value = self.variables["estimated_value"].get() + self.asset.owner_type = {"VEREIN": "club", "MITGLIED": "member", "EXTERN": "external"}[ + self.owner_type_var.get() + ] + self.asset.owner_member_id = ( + self.member_labels.get(self.owner_member_var.get(), "") + if self.asset.owner_type == "member" + else "" + ) + self.asset.owner_name = ( + self.variables["owner_name"].get() if self.asset.owner_type == "external" else "" + ) if not self.asset.current_holder_member_id: self.asset.deposit_amount_default = self.variables["deposit_amount_default"].get() self.asset.status = self.status_map.get(self.status_var.get(), self.asset.status) @@ -394,9 +518,7 @@ class IssueAssetDialog(tk.Toplevel): self.member_tree.delete(*self.member_tree.get_children()) query = self.search_var.get().strip().casefold() filtered = [ - member - for member in self.members - if not query or query in self._member_search_text(member) + member for member in self.members if not query or query in self._member_search_text(member) ] for member in filtered: self.member_tree.insert( @@ -599,9 +721,7 @@ class IntegrityWarningDialog(tk.Toplevel): text="\n".join(f"• {item}" for item in self.warnings), style="Warning.TLabel", justify="left", - ).grid( - row=1, column=0, sticky="w", pady=(12, 0) - ) + ).grid(row=1, column=0, sticky="w", pady=(12, 0)) buttons = ttk.Frame(frame) buttons.grid(row=2, column=0, sticky="e", pady=(18, 0)) ttk.Button(buttons, text="Nein", command=self.destroy).pack(side="left", padx=(0, 8)) diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index e1ad3f5..a7f5cc9 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -3,10 +3,17 @@ from __future__ import annotations import tkinter as tk from collections.abc import Callable from datetime import datetime +from decimal import Decimal from pathlib import Path from tkinter import messagebox, ttk -from ccma.domain.contributions import CLAIM_STATUS_LABELS, claim_status, claim_total, money_text +from ccma.domain.contributions import ( + CLAIM_STATUS_LABELS, + claim_status, + claim_total, + money_text, + payment_allocated_total, +) from ccma.domain.dates import age_label, date_input_hint, format_date_for_display from ccma.domain.models import ASSET_STATUS_LABELS, Event from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS @@ -16,6 +23,7 @@ from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.file_open import open_path from ccma.ui.labels import display_label, storage_key from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage +from ccma.ui.payment_dialog import PaymentEditDialog from ccma.ui.scrolling import ScrollableFrame CLAIM_TABLE_COLUMNS = ( @@ -145,9 +153,11 @@ class MemberTab(ttk.Frame): command=self._save, ).grid(row=0, column=0, sticky="e") contribution_tab = ttk.Frame(notebook, padding=16) + payments_tab = ttk.Frame(notebook, padding=16) assets_tab = ttk.Frame(notebook, padding=16) documents_tab = ttk.Frame(notebook, padding=16) notebook.add(contribution_tab, text="Forderungen") + notebook.add(payments_tab, text="Zahlungen") notebook.add(assets_tab, text="Assets") notebook.add(documents_tab, text="Dokumente") @@ -162,6 +172,7 @@ class MemberTab(ttk.Frame): (f"Geburtsdatum ({date_input_hint()})", "birth_date"), (f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"), (f"Mitglied seit ({date_input_hint()})", "membership_started_at"), + (f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"), ] for row, (label, key) in enumerate(fields): variable = tk.StringVar() @@ -256,6 +267,39 @@ class MemberTab(ttk.Frame): self.claims.bind("", lambda _event: self._open_selected_claim()) self.claims.bind("", lambda _event: self._open_selected_claim()) + payments_tab.columnconfigure(0, weight=1) + payments_tab.rowconfigure(1, weight=1) + self.payment_summary = tk.StringVar() + ttk.Label(payments_tab, textvariable=self.payment_summary, style="Mono.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 10) + ) + self.payments = ttk.Treeview( + payments_tab, + columns=("date", "amount", "allocated", "available", "reference"), + show="headings", + selectmode="browse", + ) + for key, title, width in ( + ("date", "Datum", 100), + ("amount", "Betrag", 90), + ("allocated", "Zugeordnet", 90), + ("available", "Frei", 90), + ("reference", "Referenz", 320), + ): + self.payments.heading(key, text=title) + self.payments.column(key, width=width, anchor="w") + self.payments.grid(row=1, column=0, sticky="nsew") + self.payments.bind("", lambda _event: self._edit_selected_payment()) + self.payments.bind("", lambda _event: self._edit_selected_payment()) + payment_actions = ttk.Frame(payments_tab) + payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0)) + ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack( + side="left", padx=(0, 8) + ) + ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack( + side="left" + ) + assets_tab.columnconfigure(0, weight=1) assets_tab.rowconfigure(1, weight=1) self.assets_summary = tk.StringVar() @@ -304,9 +348,7 @@ class MemberTab(ttk.Frame): style="Accent.TButton", command=self._create_document, ).pack(side="left", padx=(0, 8)) - ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack( - side="left" - ) + ttk.Button(document_buttons, text="Dateiordner öffnen", command=self._open_files).pack(side="left") self.documents = ttk.Treeview( documents_tab, columns=("name", "type", "modified", "size"), @@ -424,6 +466,7 @@ class MemberTab(ttk.Frame): "birth_date", "accepted_at", "membership_started_at", + "membership_ended_at", "mandate_signed_at", "mandate_revoked_at", } @@ -456,10 +499,12 @@ class MemberTab(ttk.Frame): def _refresh_contributions(self) -> None: self.claims.delete(*self.claims.get_children()) + self.payments.delete(*self.payments.get_children()) try: data = self.repository.get_contributions(self.member_id) except RepositoryError as exc: self.contribution_summary.set(f"FEHLER: {exc}") + self.payment_summary.set(f"FEHLER: {exc}") return claims = sorted( data.claims, @@ -481,7 +526,37 @@ class MemberTab(ttk.Frame): CLAIM_STATUS_LABELS.get(status, status.upper()), ), ) + for payment in sorted( + data.payments, + key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))), + reverse=True, + ): + payment_id = str(payment.get("payment_id", "")) + amount = payment.get("amount", "0") + allocated = payment_allocated_total(data, payment_id) + available = max(Decimal(str(amount)) - allocated, Decimal("0")) + self.payments.insert( + "", + "end", + iid=payment_id, + values=( + format_date_for_display(str(payment.get("date", ""))), + f"{money_text(amount)} EUR", + f"{money_text(allocated)} EUR", + f"{money_text(available)} EUR", + payment.get("reference", ""), + ), + ) self.contribution_summary.set(f"{len(data.claims)} Forderungen · {len(data.payments)} Zahlungen") + total_amount = sum((Decimal(str(item.get("amount", "0"))) for item in data.payments), Decimal("0")) + total_allocated = sum( + (payment_allocated_total(data, str(item.get("payment_id", ""))) for item in data.payments), + Decimal("0"), + ) + self.payment_summary.set( + f"{len(data.payments)} Zahlungen · Gesamt {money_text(total_amount)} EUR · " + f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR" + ) def _toggle_claim_sort(self, column: str) -> None: if self.claim_sort_column == column: @@ -507,6 +582,46 @@ class MemberTab(ttk.Frame): if selected and not selected[0].startswith("missing-id-"): self.on_open_claim(self.member_id, selected[0]) + def _edit_selected_payment(self) -> None: + selected = self.payments.selection() + if not selected: + messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self) + return + try: + PaymentEditDialog( + self, + self.repository, + self.member_id, + selected[0], + self._payment_changed, + ) + except RepositoryError as exc: + messagebox.showerror("Zahlung konnte nicht geöffnet werden", str(exc), parent=self) + + def _delete_selected_payment(self) -> None: + selected = self.payments.selection() + if not selected: + messagebox.showinfo("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self) + return + values = self.payments.item(selected[0], "values") + label = f"{values[0]} · {values[1]}" if values else selected[0] + if not messagebox.askyesno( + "Zahlung wirklich löschen?", + f"Die Zahlung {label} und alle ihre Zuordnungen werden endgültig gelöscht.", + parent=self, + ): + return + try: + self.repository.delete_payment(self.member_id, selected[0]) + except RepositoryError as exc: + messagebox.showerror("Zahlung konnte nicht gelöscht werden", str(exc), parent=self) + return + self._payment_changed() + + def _payment_changed(self) -> None: + self.refresh() + self.on_changed() + def _refresh_documents(self) -> None: self.documents.delete(*self.documents.get_children()) self.document_paths.clear() @@ -536,8 +651,11 @@ class MemberTab(ttk.Frame): def _refresh_assets(self) -> None: self.assets_tree.delete(*self.assets_tree.get_children()) - assets = self.repository.list_member_assets(self.member_id) - self.assets_summary.set(f"{len(assets)} ausgegebene Assets") + held_assets = self.repository.list_member_assets(self.member_id) + owned_assets = self.repository.list_member_owned_assets(self.member_id) + assets_by_id = {asset.asset_id: asset for asset in [*held_assets, *owned_assets]} + assets = list(assets_by_id.values()) + self.assets_summary.set(f"{len(owned_assets)} im Eigentum · {len(held_assets)} vom Verein erhalten") for asset in assets: self.assets_tree.insert( "", @@ -547,7 +665,13 @@ class MemberTab(ttk.Frame): asset.label, asset.category, asset.inventory_number, - ASSET_STATUS_LABELS.get(asset.status, asset.status), + ( + "EIGENTUM · DEM VEREIN ÜBERLASSEN" + if asset.owner_member_id == self.member_id and asset.custody_type == "club" + else "EIGENTUM" + if asset.owner_member_id == self.member_id + else ASSET_STATUS_LABELS.get(asset.status, asset.status) + ), ), ) diff --git a/src/ccma/ui/payment_dialog.py b/src/ccma/ui/payment_dialog.py new file mode 100644 index 0000000..0066089 --- /dev/null +++ b/src/ccma/ui/payment_dialog.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import tkinter as tk +from collections.abc import Callable +from decimal import Decimal, InvalidOperation +from tkinter import messagebox, ttk + +from ccma.domain.contributions import ( + claim_balance, + claim_status, + decimal_value, + money_text, +) +from ccma.domain.dates import date_input_hint, format_date_for_display +from ccma.storage.repository import MemberRepository, RepositoryError + + +class PaymentEditDialog(tk.Toplevel): + def __init__( + self, + master: tk.Misc, + repository: MemberRepository, + member_id: str, + payment_id: str, + on_saved: Callable[[], None], + ): + super().__init__(master) + self.repository = repository + self.member_id = member_id + self.payment_id = payment_id + self.on_saved = on_saved + self.data = repository.get_contributions(member_id) + self.payment = next( + ( + payment + for payment in self.data.payments + if str(payment.get("payment_id", "")) == payment_id + ), + None, + ) + if self.payment is None: + raise RepositoryError("Zahlung nicht gefunden.") + self.allocations = self._current_allocations() + self.claims_by_id = { + str(claim.get("claim_id", "")): claim + for claim in self.data.claims + if str(claim.get("claim_id", "")) + and ( + claim_status(self.data, claim) != "cancelled" + or str(claim.get("claim_id", "")) in self.allocations + ) + } + self.capacities = self._claim_capacities() + + self.title("Zahlung bearbeiten") + self.transient(master.winfo_toplevel()) + self.geometry("880x560") + self.minsize(720, 460) + self.protocol("WM_DELETE_WINDOW", self.destroy) + self.bind("", lambda _event: self.destroy()) + self._build_ui() + self._refresh_claims() + self.after_idle(self._activate) + + def _current_allocations(self) -> dict[str, str]: + totals: dict[str, Decimal] = {} + for allocation in self.data.allocations: + if str(allocation.get("payment_id", "")) != self.payment_id: + continue + claim_id = str(allocation.get("claim_id", "")) + totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value( + allocation.get("amount", "0") + ) + return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id} + + def _claim_capacities(self) -> dict[str, Decimal]: + capacities = {} + for claim_id, claim in self.claims_by_id.items(): + current = decimal_value(self.allocations.get(claim_id, "0")) + capacities[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0")) + return capacities + + def _build_ui(self) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + form = ttk.Frame(self, padding=16) + form.grid(row=0, column=0, sticky="ew") + form.columnconfigure(1, weight=1) + self.variables = { + "date": tk.StringVar( + value=format_date_for_display(str(self.payment.get("date", ""))) + ), + "amount": tk.StringVar(value=str(self.payment.get("amount", ""))), + "gnucash": tk.StringVar(value=str(self.payment.get("gnucash_transaction_id", ""))), + "reference": tk.StringVar(value=str(self.payment.get("reference", ""))), + } + fields = ( + (f"Zahlungsdatum ({date_input_hint()})", "date"), + ("Zahlungsbetrag", "amount"), + ("GnuCash-ID", "gnucash"), + ("Referenz", "reference"), + ) + for row, (label, key) in enumerate(fields): + ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4) + ttk.Entry(form, textvariable=self.variables[key], width=70).grid( + row=row, column=1, sticky="ew", pady=4 + ) + self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals()) + + allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12) + allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12)) + allocation_frame.columnconfigure(0, weight=1) + allocation_frame.rowconfigure(0, weight=1) + self.claims = ttk.Treeview( + allocation_frame, + columns=("title", "due", "available", "allocated"), + show="headings", + selectmode="browse", + ) + for key, title, width in ( + ("title", "Forderung", 300), + ("due", "Fällig", 110), + ("available", "Maximal zuordenbar", 150), + ("allocated", "Aktuell zugeordnet", 150), + ): + self.claims.heading(key, text=title) + self.claims.column(key, width=width, anchor="w") + self.claims.grid(row=0, column=0, sticky="nsew") + scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview) + scrollbar.grid(row=0, column=1, sticky="ns") + self.claims.configure(yscrollcommand=scrollbar.set) + self.claims.bind("<>", self._select_claim) + + allocation_actions = ttk.Frame(allocation_frame) + allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left") + self.allocation_var = tk.StringVar() + ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack( + side="left", padx=(8, 8) + ) + ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack( + side="left" + ) + ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack( + side="left", padx=(8, 0) + ) + self.total_var = tk.StringVar() + ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack( + side="right" + ) + + buttons = ttk.Frame(self, padding=(16, 0, 16, 16)) + buttons.grid(row=2, column=0, sticky="e") + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8)) + ttk.Button( + buttons, + text="Zahlung speichern", + style="Accent.TButton", + command=self._save, + ).pack(side="left") + + def _activate(self) -> None: + try: + self.deiconify() + self.lift() + self.focus_force() + self.grab_set() + except tk.TclError: + return + + def _refresh_claims(self) -> None: + selected = self.claims.selection() + self.claims.delete(*self.claims.get_children()) + ordered = sorted( + self.claims_by_id.items(), + key=lambda item: ( + str(item[1].get("due_date", "")), + str(item[1].get("title", "")).casefold(), + ), + ) + for claim_id, claim in ordered: + self.claims.insert( + "", + "end", + iid=claim_id, + values=( + claim.get("title", "Forderung"), + format_date_for_display(str(claim.get("due_date", ""))), + f"{money_text(self.capacities[claim_id])} EUR", + f"{self.allocations.get(claim_id, '0.00')} EUR", + ), + ) + if selected and self.claims.exists(selected[0]): + self.claims.selection_set(selected[0]) + self._refresh_totals() + + def _select_claim(self, _event=None) -> None: + selected = self.claims.selection() + if selected: + self.allocation_var.set(self.allocations.get(selected[0], "0.00")) + + def _set_allocation(self) -> None: + selected = self.claims.selection() + if not selected: + messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self) + return + try: + amount = decimal_value(self.allocation_var.get(), "Zuordnung") + except ValueError as exc: + messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self) + return + capacity = self.capacities[selected[0]] + if amount < 0 or amount > capacity: + messagebox.showerror( + "Ungültige Zuordnung", + f"Für diese Forderung können höchstens {money_text(capacity)} EUR zugeordnet werden.", + parent=self, + ) + return + if amount: + self.allocations[selected[0]] = money_text(amount) + else: + self.allocations.pop(selected[0], None) + self._refresh_claims() + + def _remove_allocation(self) -> None: + selected = self.claims.selection() + if not selected: + messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self) + return + self.allocations.pop(selected[0], None) + self.allocation_var.set("0.00") + self._refresh_claims() + + def _refresh_totals(self) -> None: + allocated = sum( + (decimal_value(value) for value in self.allocations.values()), Decimal("0") + ) + try: + payment_amount = decimal_value(self.variables["amount"].get()) + free = payment_amount - allocated + self.total_var.set( + f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR" + ) + except (ValueError, InvalidOperation): + self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig") + + def _save(self) -> None: + try: + self.repository.update_payment( + self.member_id, + self.payment_id, + payment_date=self.variables["date"].get(), + amount=self.variables["amount"].get(), + allocations=self.allocations, + gnucash_transaction_id=self.variables["gnucash"].get(), + reference=self.variables["reference"].get(), + ) + except RepositoryError as exc: + messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self) + return + self.destroy() + self.on_saved() diff --git a/src/ccma/ui/work_tabs.py b/src/ccma/ui/work_tabs.py index 26b2c1c..3029d1a 100644 --- a/src/ccma/ui/work_tabs.py +++ b/src/ccma/ui/work_tabs.py @@ -31,7 +31,9 @@ ASSET_TABLE_COLUMNS = ( ("category", "Kategorie", 140), ("inventory_number", "Inventarnummer", 140), ("status", "Status", 140), - ("holder", "Mitglied", 240), + ("owner", "Eigentümer", 240), + ("holder", "Gewahrsam", 240), + ("location", "Standort", 180), ) @@ -77,7 +79,7 @@ def _filter_label_frame(parent: tk.Misc) -> ttk.LabelFrame: return titled_frame(parent, "// FILTER") -def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str: +def _asset_table_value(asset: Asset, column: str, holder_label: str, owner_label: str = "") -> str: if column == "label": return asset.label if column == "category": @@ -88,6 +90,10 @@ def _asset_table_value(asset: Asset, column: str, holder_label: str) -> str: return ASSET_STATUS_LABELS.get(asset.status, asset.status) if column == "holder": return holder_label + if column == "owner": + return owner_label + if column == "location": + return asset.location return "" @@ -429,6 +435,7 @@ class AssetsTab(ttk.Frame): asset, self.sort_column, self.resolve_holder_label(asset.current_holder_member_id), + self._owner_label(asset), ).casefold(), reverse=self.sort_descending, ) @@ -447,11 +454,20 @@ class AssetsTab(ttk.Frame): asset.category, asset.inventory_number, ASSET_STATUS_LABELS.get(asset.status, asset.status), + self._owner_label(asset), self.resolve_holder_label(asset.current_holder_member_id), + asset.location, ), ) self._update_actions() + def _owner_label(self, asset: Asset) -> str: + if asset.owner_type == "club": + return "Verein" + if asset.owner_type == "member": + return self.resolve_holder_label(asset.owner_member_id) + return asset.owner_name or "Extern" + def _toggle_sort(self, column: str) -> None: if self.sort_column == column: self.sort_descending = not self.sort_descending diff --git a/tests/test_contributions.py b/tests/test_contributions.py index 992cba1..f274bd1 100644 --- a/tests/test_contributions.py +++ b/tests/test_contributions.py @@ -59,6 +59,49 @@ def test_legacy_claim_becomes_itemized_when_position_is_added(tmp_path) -> None: assert claim_balance(data, claim) == Decimal("90.00") +def test_claim_base_data_can_be_edited(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + + repository.update_claim( + member.member_id, + "claim-1", + title="Korrigierter Beitrag", + due_date="30.06.2026", + base_amount="75,00", + description="Beitrag für sechs Monate", + ) + + _data, claim = repository.get_claim(member.member_id, "claim-1") + assert claim["title"] == "Korrigierter Beitrag" + assert claim["due_date"] == "2026-06-30" + assert claim["amount"] == "75.00" + assert claim["items"][0]["description"] == "Beitrag für sechs Monate" + assert claim["items"][0]["amount"] == "75.00" + assert claim["calculation"]["manual_override"]["actor"] == "Vorstand" + assert repository.get_events(member.member_id)[-1].event_type == "claim_changed" + + +def test_claim_cannot_be_reduced_below_allocated_payment(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="80.00", + allocation_amount="80.00", + ) + + with pytest.raises(RepositoryError, match="bereits zugeordneten Betrag"): + repository.update_claim( + member.member_id, + "claim-1", + title="Zu klein", + due_date="2026-06-30", + base_amount="75.00", + description="", + ) + + def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None: repository, member = _repository_with_claim(tmp_path) data = repository.get_contributions(member.member_id) @@ -104,6 +147,77 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None: ) +def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path, amount="12.50") + + with pytest.raises(RepositoryError, match="nur noch 12.50 EUR offen"): + repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="22.50", + allocation_amount="22.50", + ) + + +def test_payment_can_be_edited_and_reallocated_atomically(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path, amount="12.50") + data = repository.get_contributions(member.member_id) + data.claims.append( + { + "claim_id": "admission-fee", + "claim_key": "admission-fee", + "title": "Aufnahmegebühr", + "amount": "10.00", + "due_date": "2026-12-31", + "status": "open", + } + ) + repository.save_contributions(member.member_id, data) + payment = repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="22.50", + allocation_amount="12.50", + reference="Vorher", + ) + + repository.update_payment( + member.member_id, + payment["payment_id"], + payment_date="22.06.2026", + amount="22.50", + allocations={"claim-1": "12.50", "admission-fee": "10.00"}, + reference="Korrigiert", + ) + + changed = repository.get_contributions(member.member_id) + assert changed.payments[0]["date"] == "2026-06-22" + assert changed.payments[0]["reference"] == "Korrigiert" + assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("22.50") + assert {item["claim_id"] for item in changed.allocations} == {"claim-1", "admission-fee"} + assert repository.get_events(member.member_id)[-1].event_type == "payment_changed" + + +def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None: + repository, member = _repository_with_claim(tmp_path) + payment = repository.record_payment( + member.member_id, + "claim-1", + payment_date="2026-06-21", + amount="10.00", + allocation_amount="10.00", + ) + + repository.delete_payment(member.member_id, payment["payment_id"]) + + data = repository.get_contributions(member.member_id) + assert data.payments == [] + assert data.allocations == [] + assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted" + + def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None: claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"} data = ContributionData( diff --git a/tests/test_dates.py b/tests/test_dates.py index f0ad752..d057824 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -58,6 +58,15 @@ def test_member_dates_must_be_chronological() -> None: today=date(2026, 6, 21), ) + with pytest.raises(DateValidationError, match="Austrittsdatum"): + validate_member_dates( + birth_date="2000-01-01", + accepted_at="2020-01-01", + membership_started_at="2020-02-01", + membership_ended_at="2020-01-31", + today=date(2026, 6, 21), + ) + def test_age_calculation_and_label() -> None: today = date(2026, 6, 21) diff --git a/tests/test_repository.py b/tests/test_repository.py index 0aa46ed..61cf11a 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -38,6 +38,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None: assert raw["person"]["first_name"] == "Ada" assert raw["person"]["nickname"] == "Enchantress" assert raw["schema_version"] == 1 + assert raw["membership"]["ended_at"] == "" assert raw["content_hash"] @@ -57,6 +58,22 @@ def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> Non assert [result.member_id for result in repository.search(query)] == [member.member_id] +def test_past_membership_end_sets_status_to_ended(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Former", last_name="Member") + member.status = "active" + member.accepted_at = "2020-01-01" + member.membership_started_at = "2020-01-01" + member.membership_ended_at = "2020-12-31" + + repository.save_member(member) + + loaded = repository.get_member(member.member_id) + assert loaded.membership_ended_at == "2020-12-31" + assert loaded.status == "ended" + + def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() @@ -145,7 +162,6 @@ def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tm first = repository.create_member(first_name="First", last_name="Member") second = repository.create_member(first_name="Second", last_name="Member") - assert first.member_number == "CCMA-0001" assert second.member_number == "CCMA-0002" assert repository.preview_member_number() == "CCMA-0003" @@ -286,6 +302,46 @@ def test_asset_can_be_assigned_and_returned_to_single_member(tmp_path) -> None: assert repository.list_member_assets(member.member_id) == [] +def test_member_owned_asset_is_kept_in_central_inventory(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + owner = repository.create_member(first_name="Ada", last_name="Lovelace") + + asset = repository.create_asset( + label="3D-Drucker", + owner_type="member", + owner_member_id=owner.member_id, + custody_type="club", + location="Clubraum / Werkstatt", + handed_over_at="2026-07-22", + condition="gebraucht, funktionsfähig", + estimated_value="450.00", + ) + + loaded = repository.get_asset(asset.asset_id) + assert loaded.owner_member_id == owner.member_id + assert loaded.custody_type == "club" + assert loaded.location == "Clubraum / Werkstatt" + assert [item.asset_id for item in repository.list_member_owned_assets(owner.member_id)] == [ + asset.asset_id + ] + assert repository.list_member_assets(owner.member_id) == [] + assert repository.list_assets()[0].asset_id == asset.asset_id + assert repository.get_events(owner.member_id)[-1].event_type == "member_asset_registered" + + +def test_member_ownership_requires_existing_member(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + + with pytest.raises(RepositoryError, match="Mitglied"): + repository.create_asset( + label="Privater Beamer", + owner_type="member", + owner_member_id="missing-member", + ) + + def test_asset_assignment_is_audited_on_asset_and_member(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() @@ -423,6 +479,5 @@ def test_housekeeper_reports_json_hash_mismatch(tmp_path) -> None: findings = Housekeeper(repository).run() assert any( - finding.code == "json_hash_mismatch" and finding.member_id == member.member_id - for finding in findings + finding.code == "json_hash_mismatch" and finding.member_id == member.member_id for finding in findings ) diff --git a/tests/test_rules.py b/tests/test_rules.py index b54c1c6..705995d 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -87,6 +87,11 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None: amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims} assert amounts["membership-fee:2026:first-half"] == "37.50" assert amounts["membership-fee:2026:second-half"] == "75.00" + descriptions = { + claim["claim_key"]: claim["items"][0]["description"] for claim in first_claims + } + assert descriptions["membership-fee:2026:first-half"].endswith("(3 Monate)") + assert descriptions["membership-fee:2026:second-half"] == "Mitgliedsbeitrag 2. Halbjahr 2026" assert state["run_counter"] == 2 assert state["last_completed_run"] == "2026-04-15:000002" @@ -121,6 +126,9 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat "membership-fee:2026:annual", } assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50" + assert claims_by_key["membership-fee:2024:annual"]["items"][0]["description"].endswith( + "(9 Monate)" + ) def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None: @@ -169,6 +177,23 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm } +def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -> None: + repository = MemberRepository(tmp_path) + repository.initialize() + member = repository.create_member(first_name="Former", last_name="Member", birth_date="1990-01-01") + member.status = "resigned_end_of_year" + member.accepted_at = "2024-01-01" + member.membership_started_at = "2024-01-01" + member.membership_ended_at = "2026-12-31" + repository.save_member(member) + + Housekeeper(repository).run(today=date(2026, 6, 21)) + + claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims} + assert "membership-fee:2026:annual" in claim_keys + assert "membership-fee:2027:annual" not in claim_keys + + def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None: repository = MemberRepository(tmp_path) repository.initialize() From dbae0ce29cd116a1ece6bec9eb9eaa1f5add0abb Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Wed, 22 Jul 2026 23:15:21 +0200 Subject: [PATCH 2/3] docs: add upcoming 0.2.0 changelog --- src/ccma/assets/CHANGELOG.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 48eae48..89101d4 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -1,6 +1,19 @@ [ { - "version": "1.0.0", + "version": "0.2.0", + "date": "upcoming", + "changes": [ + "Eigentum und aktueller Gewahrsam von Assets werden getrennt dokumentiert; neben Vereinseigentum kann nun auch Eigentum von Mitgliedern oder externen Personen zentral erfasst werden.", + "Mitgliedseigentum wird per UUID mit der Mitgliedsakte verknüpft und erscheint dort gemeinsam mit den vom Verein erhaltenen Assets in getrennt verständlichen Ansichten.", + "Standort, Übergabedatum, Zustand und Wert eines Assets können für Übergabe- und Versicherungsnachweise festgehalten werden; Eigentumsänderungen erscheinen zusätzlich in der Chronik.", + "Forderungen können nachträglich bearbeitet werden, ohne zusätzliche Forderungspositionen oder bereits verbuchte Zahlungen zu verlieren.", + "Zahlungen können bearbeitet, neu auf mehrere Forderungen verteilt oder einschließlich ihrer Zuordnungen gelöscht werden.", + "Das Austrittsdatum eines Mitglieds wird gespeichert und validiert; automatische Beitragsforderungen werden nicht mehr für Fälligkeiten nach dem Mitgliedschaftsende erzeugt.", + "Anteilig berechnete Mitgliedsbeiträge weisen den abgerechneten Zeitraum verständlicher in ihrer Beschreibung aus." + ] + }, + { + "version": "0.1.0", "date": "2026-06-27", "changes": [ "First Release der CCMA-Mitgliederverwaltung für lokale, dateibasierte Vereinsverwaltung.", From 7146615499160bb227e55a345949ff4208bd7e32 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Wed, 22 Jul 2026 23:48:39 +0200 Subject: [PATCH 3/3] test: derive expected version from VERSION file --- tests/test_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_version.py b/tests/test_version.py index cc5024d..bb2f4f7 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -5,4 +5,4 @@ from ccma import __version__ def test_ui_version_matches_version_file() -> None: expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip() - assert __version__ == expected == "0.0.1-dev1" + assert __version__ == expected