mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
Allow deleting claims, creating standalone payments, and add a donations tab
Claims could previously only be cancelled (stornieren), which blocks once a payment is allocated. Add a hard delete that releases any linked payments/credits back to being unallocated instead of destroying them. Payments could only be created from within a claim, forcing immediate allocation. Add a bare payment creation flow in the Zahlungen tab so incoming transfers can be logged first and allocated later. Add a per-member Spenden tab (donations, backed by a new donations list on ContributionData) so amounts paid beyond the membership fee can be tracked and existing free payments allocated to them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a61ea3cb57
commit
42fb4c4224
@@ -18,6 +18,13 @@ CLAIM_STATUS_LABELS = {
|
|||||||
"cancelled": "STORNIERT",
|
"cancelled": "STORNIERT",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DONATION_STATUS_LABELS = {
|
||||||
|
"open": "OFFEN",
|
||||||
|
"partially_allocated": "TEILWEISE ZUGEORDNET",
|
||||||
|
"allocated": "ZUGEORDNET",
|
||||||
|
"overallocated": "ÜBERZUGEORDNET",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
|
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
|
||||||
text = str(value).strip().replace(",", ".")
|
text = str(value).strip().replace(",", ".")
|
||||||
@@ -109,6 +116,38 @@ def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal:
|
|||||||
return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT)
|
return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT)
|
||||||
|
|
||||||
|
|
||||||
|
def donation_allocated_total(data: ContributionData, donation_id: str) -> Decimal:
|
||||||
|
return sum(
|
||||||
|
(
|
||||||
|
decimal_value(allocation.get("amount", "0"))
|
||||||
|
for allocation in data.allocations
|
||||||
|
if str(allocation.get("donation_id", "")) == donation_id
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def donation_amount(donation: dict[str, Any]) -> Decimal:
|
||||||
|
return decimal_value(donation.get("amount", "0"))
|
||||||
|
|
||||||
|
|
||||||
|
def donation_balance(data: ContributionData, donation: dict[str, Any]) -> Decimal:
|
||||||
|
donation_id = str(donation.get("donation_id", ""))
|
||||||
|
return (donation_amount(donation) - donation_allocated_total(data, donation_id)).quantize(CENT)
|
||||||
|
|
||||||
|
|
||||||
|
def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
|
||||||
|
balance = donation_balance(data, donation)
|
||||||
|
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
|
||||||
|
if balance < 0:
|
||||||
|
return "overallocated"
|
||||||
|
if balance == 0:
|
||||||
|
return "allocated"
|
||||||
|
if allocated > 0:
|
||||||
|
return "partially_allocated"
|
||||||
|
return "open"
|
||||||
|
|
||||||
|
|
||||||
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
|
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
|
||||||
if str(claim.get("status", "")) == "cancelled":
|
if str(claim.get("status", "")) == "cancelled":
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ class ContributionData:
|
|||||||
credits: list[dict[str, Any]] = field(default_factory=list)
|
credits: list[dict[str, Any]] = field(default_factory=list)
|
||||||
allocations: list[dict[str, Any]] = field(default_factory=list)
|
allocations: list[dict[str, Any]] = field(default_factory=list)
|
||||||
reminders: list[dict[str, Any]] = field(default_factory=list)
|
reminders: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
donations: list[dict[str, Any]] = field(default_factory=list)
|
||||||
schema_version: int = 1
|
schema_version: int = 1
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
@@ -333,6 +334,7 @@ class ContributionData:
|
|||||||
"credits": self.credits,
|
"credits": self.credits,
|
||||||
"allocations": self.allocations,
|
"allocations": self.allocations,
|
||||||
"reminders": self.reminders,
|
"reminders": self.reminders,
|
||||||
|
"donations": self.donations,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -344,6 +346,7 @@ class ContributionData:
|
|||||||
credits=list(data.get("credits") or []),
|
credits=list(data.get("credits") or []),
|
||||||
allocations=list(data.get("allocations") or []),
|
allocations=list(data.get("allocations") or []),
|
||||||
reminders=list(data.get("reminders") or []),
|
reminders=list(data.get("reminders") or []),
|
||||||
|
donations=list(data.get("donations") or []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ccma.domain.contributions import (
|
|||||||
claim_total,
|
claim_total,
|
||||||
credit_allocated_total,
|
credit_allocated_total,
|
||||||
decimal_value,
|
decimal_value,
|
||||||
|
donation_balance,
|
||||||
materialize_claim_items,
|
materialize_claim_items,
|
||||||
money_text,
|
money_text,
|
||||||
payment_allocated_total,
|
payment_allocated_total,
|
||||||
@@ -1099,6 +1100,55 @@ class MemberRepository:
|
|||||||
)
|
)
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
def create_payment(
|
||||||
|
self,
|
||||||
|
member_id: str,
|
||||||
|
*,
|
||||||
|
payment_date: str,
|
||||||
|
amount: str,
|
||||||
|
gnucash_transaction_id: str = "",
|
||||||
|
reference: str = "",
|
||||||
|
method: str = "bank_transfer",
|
||||||
|
actor_name: str = "Vorstand",
|
||||||
|
) -> dict:
|
||||||
|
"""Record an incoming payment without allocating it yet. Useful for logging
|
||||||
|
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)
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
data = self.get_contributions(member_id)
|
||||||
|
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={"allocation_amount": "0.00"},
|
||||||
|
)
|
||||||
|
return payment
|
||||||
|
|
||||||
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
|
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
|
||||||
data, claim = self.get_claim(member_id, claim_id)
|
data, claim = self.get_claim(member_id, claim_id)
|
||||||
payment = next(
|
payment = next(
|
||||||
@@ -1355,6 +1405,220 @@ class MemberRepository:
|
|||||||
)
|
)
|
||||||
return allocation
|
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 create_reminder_draft(
|
def create_reminder_draft(
|
||||||
self,
|
self,
|
||||||
member_id: str,
|
member_id: str,
|
||||||
@@ -1578,6 +1842,37 @@ class MemberRepository:
|
|||||||
references={"claim_id": 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(
|
def _assert_gnucash_id_available(
|
||||||
self, transaction_id: str, *, exclude_payment_id: str | None = None
|
self, transaction_id: str, *, exclude_payment_id: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ class ClaimTab(ttk.Frame):
|
|||||||
self.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim)
|
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.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8))
|
||||||
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
|
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
|
||||||
self.cancel_button.grid(row=0, column=2, sticky="e")
|
self.cancel_button.grid(row=0, column=2, sticky="e", padx=(0, 8))
|
||||||
|
self.delete_button = ttk.Button(footer, text="Forderung löschen", command=self._delete_claim)
|
||||||
|
self.delete_button.grid(row=0, column=3, sticky="e")
|
||||||
|
|
||||||
def _build_ledger(self) -> None:
|
def _build_ledger(self) -> None:
|
||||||
ledger = ttk.Frame(self, padding=12)
|
ledger = ttk.Frame(self, padding=12)
|
||||||
@@ -503,6 +505,24 @@ class ClaimTab(ttk.Frame):
|
|||||||
return
|
return
|
||||||
self._changed()
|
self._changed()
|
||||||
|
|
||||||
|
def _delete_claim(self) -> None:
|
||||||
|
allocated = allocated_total(self.data, self.claim_id)
|
||||||
|
detail = "Diese Forderung wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
|
||||||
|
if allocated:
|
||||||
|
detail += (
|
||||||
|
f"\n\nZugeordnete Zahlungen/Gutschriften in Höhe von {money_text(allocated)} EUR "
|
||||||
|
"werden dabei gelöst und stehen danach wieder frei zur Verfügung."
|
||||||
|
)
|
||||||
|
if not messagebox.askyesno("Forderung löschen", detail, parent=self):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.repository.delete_claim(self.member_id, self.claim_id)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
self.on_changed()
|
||||||
|
self.on_close()
|
||||||
|
|
||||||
def _edit_claim(self) -> None:
|
def _edit_claim(self) -> None:
|
||||||
ClaimEditDialog(
|
ClaimEditDialog(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
|
from ccma.domain.contributions import (
|
||||||
|
decimal_value,
|
||||||
|
money_text,
|
||||||
|
payment_allocated_total,
|
||||||
|
)
|
||||||
|
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
|
||||||
|
class _Dialog(tk.Toplevel):
|
||||||
|
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
|
||||||
|
super().__init__(master)
|
||||||
|
self.on_saved = on_saved
|
||||||
|
self.title(title)
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.frame = ttk.Frame(self, padding=18)
|
||||||
|
self.frame.pack(fill="both", expand=True)
|
||||||
|
self.bind("<Escape>", lambda _event: self.destroy())
|
||||||
|
self.after_idle(self.grab_set)
|
||||||
|
|
||||||
|
def _buttons(self, row: int, command: Callable[[], None]) -> None:
|
||||||
|
buttons = ttk.Frame(self.frame)
|
||||||
|
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||||
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||||
|
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
|
||||||
|
|
||||||
|
|
||||||
|
class DonationEditDialog(_Dialog):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Misc,
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
on_saved: Callable[[], None],
|
||||||
|
donation: dict | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
master, "Spende bearbeiten" if donation else "Spende anlegen", on_saved
|
||||||
|
)
|
||||||
|
self.repository = repository
|
||||||
|
self.member_id = member_id
|
||||||
|
self.donation_id = str(donation.get("donation_id")) if donation else None
|
||||||
|
initial_date = str(donation.get("date", "")) if donation else date.today().isoformat()
|
||||||
|
self.variables = {
|
||||||
|
"date": tk.StringVar(value=format_date_for_display(initial_date)),
|
||||||
|
"amount": tk.StringVar(value=str(donation.get("amount", "")) if donation else ""),
|
||||||
|
"reference": tk.StringVar(value=str(donation.get("reference", "")) if donation else ""),
|
||||||
|
"purpose": tk.StringVar(value=str(donation.get("purpose", "")) if donation else ""),
|
||||||
|
}
|
||||||
|
fields = (
|
||||||
|
(f"Spendendatum ({date_input_hint()})", "date"),
|
||||||
|
("Betrag", "amount"),
|
||||||
|
("Referenz", "reference"),
|
||||||
|
("Verwendungszweck", "purpose"),
|
||||||
|
)
|
||||||
|
self.frame.columnconfigure(1, weight=1)
|
||||||
|
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=42).grid(
|
||||||
|
row=row, column=1, sticky="ew", pady=5
|
||||||
|
)
|
||||||
|
self._buttons(len(fields), self._save)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.donation_id:
|
||||||
|
self.repository.update_donation(
|
||||||
|
self.member_id,
|
||||||
|
self.donation_id,
|
||||||
|
donation_date=self.variables["date"].get(),
|
||||||
|
amount=self.variables["amount"].get(),
|
||||||
|
reference=self.variables["reference"].get(),
|
||||||
|
purpose=self.variables["purpose"].get(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.repository.record_donation(
|
||||||
|
self.member_id,
|
||||||
|
donation_date=self.variables["date"].get(),
|
||||||
|
amount=self.variables["amount"].get(),
|
||||||
|
reference=self.variables["reference"].get(),
|
||||||
|
purpose=self.variables["purpose"].get(),
|
||||||
|
)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("Spende konnte nicht gespeichert werden", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
self.destroy()
|
||||||
|
self.on_saved()
|
||||||
|
|
||||||
|
|
||||||
|
class DonationPaymentDialog(_Dialog):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Misc,
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
donation_id: str,
|
||||||
|
balance: Decimal,
|
||||||
|
on_saved: Callable[[], None],
|
||||||
|
):
|
||||||
|
super().__init__(master, "Zahlung für Spende erfassen", on_saved)
|
||||||
|
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
|
||||||
|
initial = money_text(max(balance, Decimal("0")))
|
||||||
|
self.variables = {
|
||||||
|
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
|
||||||
|
"amount": tk.StringVar(value=initial),
|
||||||
|
"allocation": tk.StringVar(value=initial),
|
||||||
|
"gnucash": tk.StringVar(),
|
||||||
|
"reference": tk.StringVar(),
|
||||||
|
}
|
||||||
|
fields = (
|
||||||
|
(f"Zahlungsdatum ({date_input_hint()})", "date"),
|
||||||
|
("Zahlungsbetrag", "amount"),
|
||||||
|
("Dieser Spende zuordnen", "allocation"),
|
||||||
|
("GnuCash-ID (optional)", "gnucash"),
|
||||||
|
("Referenz", "reference"),
|
||||||
|
)
|
||||||
|
self.frame.columnconfigure(1, weight=1)
|
||||||
|
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=38).grid(row=row, column=1, pady=5)
|
||||||
|
self._buttons(len(fields), self._save)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
try:
|
||||||
|
self.repository.record_donation_payment(
|
||||||
|
self.member_id,
|
||||||
|
self.donation_id,
|
||||||
|
payment_date=self.variables["date"].get(),
|
||||||
|
amount=self.variables["amount"].get(),
|
||||||
|
allocation_amount=self.variables["allocation"].get(),
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
class AllocateDonationPaymentDialog(_Dialog):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Misc,
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
donation_id: str,
|
||||||
|
balance: Decimal,
|
||||||
|
on_saved: Callable[[], None],
|
||||||
|
):
|
||||||
|
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
|
||||||
|
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
|
||||||
|
data = repository.get_contributions(member_id)
|
||||||
|
self.payment_by_label = {}
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
label = (
|
||||||
|
f"{payment.get('date', '')} · {money_text(available)} EUR frei · "
|
||||||
|
f"{payment.get('reference', '')}"
|
||||||
|
)
|
||||||
|
self.payment_by_label[label] = (payment_id, available)
|
||||||
|
self.payment_var = tk.StringVar()
|
||||||
|
self.amount_var = tk.StringVar(value=money_text(max(balance, Decimal("0"))))
|
||||||
|
self.frame.columnconfigure(1, weight=1)
|
||||||
|
ttk.Label(self.frame, text="Zahlung").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||||
|
combo = ttk.Combobox(
|
||||||
|
self.frame,
|
||||||
|
textvariable=self.payment_var,
|
||||||
|
values=list(self.payment_by_label),
|
||||||
|
state="readonly",
|
||||||
|
width=60,
|
||||||
|
)
|
||||||
|
combo.grid(row=0, column=1, 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("<<ComboboxSelected>>", lambda _event: self._select(balance))
|
||||||
|
self._buttons(amount_row + 1, self._save)
|
||||||
|
|
||||||
|
def _select(self, balance: Decimal) -> None:
|
||||||
|
_payment_id, available = self.payment_by_label[self.payment_var.get()]
|
||||||
|
self.amount_var.set(money_text(min(available, max(balance, Decimal("0")))))
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
selected = self.payment_by_label.get(self.payment_var.get())
|
||||||
|
if not selected:
|
||||||
|
messagebox.showerror("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.repository.allocate_payment_to_donation(
|
||||||
|
self.member_id, self.donation_id, payment_id=selected[0], amount=self.amount_var.get()
|
||||||
|
)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
self.destroy()
|
||||||
|
self.on_saved()
|
||||||
+177
-1
@@ -9,8 +9,12 @@ from tkinter import messagebox, ttk
|
|||||||
|
|
||||||
from ccma.domain.contributions import (
|
from ccma.domain.contributions import (
|
||||||
CLAIM_STATUS_LABELS,
|
CLAIM_STATUS_LABELS,
|
||||||
|
DONATION_STATUS_LABELS,
|
||||||
claim_status,
|
claim_status,
|
||||||
claim_total,
|
claim_total,
|
||||||
|
donation_allocated_total,
|
||||||
|
donation_balance,
|
||||||
|
donation_status,
|
||||||
money_text,
|
money_text,
|
||||||
payment_allocated_total,
|
payment_allocated_total,
|
||||||
)
|
)
|
||||||
@@ -20,10 +24,15 @@ from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
|||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
from ccma.ui.dialogs import IntegrityWarningDialog
|
from ccma.ui.dialogs import IntegrityWarningDialog
|
||||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||||
|
from ccma.ui.donation_dialog import (
|
||||||
|
AllocateDonationPaymentDialog,
|
||||||
|
DonationEditDialog,
|
||||||
|
DonationPaymentDialog,
|
||||||
|
)
|
||||||
from ccma.ui.file_open import open_path
|
from ccma.ui.file_open import open_path
|
||||||
from ccma.ui.labels import display_label, storage_key
|
from ccma.ui.labels import display_label, storage_key
|
||||||
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
||||||
from ccma.ui.payment_dialog import PaymentEditDialog
|
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
||||||
from ccma.ui.scrolling import ScrollableFrame
|
from ccma.ui.scrolling import ScrollableFrame
|
||||||
|
|
||||||
CLAIM_TABLE_COLUMNS = (
|
CLAIM_TABLE_COLUMNS = (
|
||||||
@@ -154,10 +163,12 @@ class MemberTab(ttk.Frame):
|
|||||||
).grid(row=0, column=0, sticky="e")
|
).grid(row=0, column=0, sticky="e")
|
||||||
contribution_tab = ttk.Frame(notebook, padding=16)
|
contribution_tab = ttk.Frame(notebook, padding=16)
|
||||||
payments_tab = ttk.Frame(notebook, padding=16)
|
payments_tab = ttk.Frame(notebook, padding=16)
|
||||||
|
donations_tab = ttk.Frame(notebook, padding=16)
|
||||||
assets_tab = ttk.Frame(notebook, padding=16)
|
assets_tab = ttk.Frame(notebook, padding=16)
|
||||||
documents_tab = ttk.Frame(notebook, padding=16)
|
documents_tab = ttk.Frame(notebook, padding=16)
|
||||||
notebook.add(contribution_tab, text="Forderungen")
|
notebook.add(contribution_tab, text="Forderungen")
|
||||||
notebook.add(payments_tab, text="Zahlungen")
|
notebook.add(payments_tab, text="Zahlungen")
|
||||||
|
notebook.add(donations_tab, text="Spenden")
|
||||||
notebook.add(assets_tab, text="Assets")
|
notebook.add(assets_tab, text="Assets")
|
||||||
notebook.add(documents_tab, text="Dokumente")
|
notebook.add(documents_tab, text="Dokumente")
|
||||||
|
|
||||||
@@ -305,6 +316,9 @@ class MemberTab(ttk.Frame):
|
|||||||
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
|
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
|
||||||
payment_actions = ttk.Frame(payments_tab)
|
payment_actions = ttk.Frame(payments_tab)
|
||||||
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||||
|
ttk.Button(payment_actions, text="Zahlung anlegen", command=self._create_payment).pack(
|
||||||
|
side="left", padx=(0, 8)
|
||||||
|
)
|
||||||
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
|
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
|
||||||
side="left", padx=(0, 8)
|
side="left", padx=(0, 8)
|
||||||
)
|
)
|
||||||
@@ -312,6 +326,50 @@ class MemberTab(ttk.Frame):
|
|||||||
side="left"
|
side="left"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
donations_tab.columnconfigure(0, weight=1)
|
||||||
|
donations_tab.rowconfigure(1, weight=1)
|
||||||
|
self.donation_summary = tk.StringVar()
|
||||||
|
ttk.Label(donations_tab, textvariable=self.donation_summary, style="Mono.TLabel").grid(
|
||||||
|
row=0, column=0, sticky="w", pady=(0, 10)
|
||||||
|
)
|
||||||
|
self.donations = ttk.Treeview(
|
||||||
|
donations_tab,
|
||||||
|
columns=("date", "amount", "allocated", "balance", "status", "reference"),
|
||||||
|
show="headings",
|
||||||
|
selectmode="browse",
|
||||||
|
)
|
||||||
|
for key, title, width in (
|
||||||
|
("date", "Datum", 100),
|
||||||
|
("amount", "Betrag", 90),
|
||||||
|
("allocated", "Zugeordnet", 90),
|
||||||
|
("balance", "Offen", 90),
|
||||||
|
("status", "Status", 150),
|
||||||
|
("reference", "Referenz / Zweck", 260),
|
||||||
|
):
|
||||||
|
self.donations.heading(key, text=title)
|
||||||
|
self.donations.column(key, width=width, anchor="w")
|
||||||
|
self.donations.grid(row=1, column=0, sticky="nsew")
|
||||||
|
self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
|
||||||
|
self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
|
||||||
|
donation_actions = ttk.Frame(donations_tab)
|
||||||
|
donation_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||||
|
ttk.Button(donation_actions, text="Spende anlegen", command=self._create_donation).pack(
|
||||||
|
side="left", padx=(0, 8)
|
||||||
|
)
|
||||||
|
ttk.Button(donation_actions, text="Spende bearbeiten", command=self._edit_selected_donation).pack(
|
||||||
|
side="left", padx=(0, 8)
|
||||||
|
)
|
||||||
|
ttk.Button(donation_actions, text="Spende löschen", command=self._delete_selected_donation).pack(
|
||||||
|
side="left", padx=(0, 8)
|
||||||
|
)
|
||||||
|
ttk.Separator(donation_actions, orient="vertical").pack(side="left", fill="y", padx=(0, 8))
|
||||||
|
ttk.Button(
|
||||||
|
donation_actions, text="Vorhandene Zahlung zuordnen", command=self._allocate_donation_payment
|
||||||
|
).pack(side="left", padx=(0, 8))
|
||||||
|
ttk.Button(donation_actions, text="Zahlung erfassen", command=self._record_donation_payment).pack(
|
||||||
|
side="left"
|
||||||
|
)
|
||||||
|
|
||||||
assets_tab.columnconfigure(0, weight=1)
|
assets_tab.columnconfigure(0, weight=1)
|
||||||
assets_tab.rowconfigure(1, weight=1)
|
assets_tab.rowconfigure(1, weight=1)
|
||||||
self.assets_summary = tk.StringVar()
|
self.assets_summary = tk.StringVar()
|
||||||
@@ -499,6 +557,7 @@ class MemberTab(ttk.Frame):
|
|||||||
self._clear_dirty()
|
self._clear_dirty()
|
||||||
self._refresh_events()
|
self._refresh_events()
|
||||||
self._refresh_contributions()
|
self._refresh_contributions()
|
||||||
|
self._refresh_donations()
|
||||||
self._refresh_assets()
|
self._refresh_assets()
|
||||||
self._refresh_documents()
|
self._refresh_documents()
|
||||||
|
|
||||||
@@ -573,6 +632,44 @@ class MemberTab(ttk.Frame):
|
|||||||
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
|
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _refresh_donations(self) -> None:
|
||||||
|
self.donations.delete(*self.donations.get_children())
|
||||||
|
try:
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
self.donation_summary.set(f"FEHLER: {exc}")
|
||||||
|
return
|
||||||
|
for donation in sorted(
|
||||||
|
data.donations,
|
||||||
|
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
|
||||||
|
reverse=True,
|
||||||
|
):
|
||||||
|
donation_id = str(donation.get("donation_id", ""))
|
||||||
|
amount = donation.get("amount", "0")
|
||||||
|
allocated = donation_allocated_total(data, donation_id)
|
||||||
|
balance = donation_balance(data, donation)
|
||||||
|
status = donation_status(data, donation)
|
||||||
|
reference = " · ".join(
|
||||||
|
part for part in (donation.get("reference", ""), donation.get("purpose", "")) if part
|
||||||
|
)
|
||||||
|
self.donations.insert(
|
||||||
|
"",
|
||||||
|
"end",
|
||||||
|
iid=donation_id,
|
||||||
|
values=(
|
||||||
|
format_date_for_display(str(donation.get("date", ""))),
|
||||||
|
f"{money_text(amount)} EUR",
|
||||||
|
f"{money_text(allocated)} EUR",
|
||||||
|
f"{money_text(balance)} EUR",
|
||||||
|
DONATION_STATUS_LABELS.get(status, status.upper()),
|
||||||
|
reference,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
total_amount = sum(
|
||||||
|
(Decimal(str(item.get("amount", "0"))) for item in data.donations), Decimal("0")
|
||||||
|
)
|
||||||
|
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
|
||||||
|
|
||||||
def _toggle_claim_sort(self, column: str) -> None:
|
def _toggle_claim_sort(self, column: str) -> None:
|
||||||
if self.claim_sort_column == column:
|
if self.claim_sort_column == column:
|
||||||
self.claim_sort_descending = not self.claim_sort_descending
|
self.claim_sort_descending = not self.claim_sort_descending
|
||||||
@@ -633,10 +730,89 @@ class MemberTab(ttk.Frame):
|
|||||||
return
|
return
|
||||||
self._payment_changed()
|
self._payment_changed()
|
||||||
|
|
||||||
|
def _create_payment(self) -> None:
|
||||||
|
PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed)
|
||||||
|
|
||||||
def _payment_changed(self) -> None:
|
def _payment_changed(self) -> None:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
self.on_changed()
|
self.on_changed()
|
||||||
|
|
||||||
|
def _selected_donation(self) -> dict | None:
|
||||||
|
selected = self.donations.selection()
|
||||||
|
if not selected:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
_data, donation = self.repository.get_donation(self.member_id, selected[0])
|
||||||
|
except RepositoryError:
|
||||||
|
return None
|
||||||
|
return donation
|
||||||
|
|
||||||
|
def _create_donation(self) -> None:
|
||||||
|
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed)
|
||||||
|
|
||||||
|
def _edit_selected_donation(self) -> None:
|
||||||
|
donation = self._selected_donation()
|
||||||
|
if not donation:
|
||||||
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||||
|
return
|
||||||
|
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed, donation)
|
||||||
|
|
||||||
|
def _delete_selected_donation(self) -> None:
|
||||||
|
donation = self._selected_donation()
|
||||||
|
if not donation:
|
||||||
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||||
|
return
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
|
||||||
|
detail = "Diese Spende wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
|
||||||
|
if allocated:
|
||||||
|
detail += (
|
||||||
|
f"\n\nZugeordnete Zahlungen in Höhe von {money_text(allocated)} EUR werden dabei "
|
||||||
|
"gelöst und stehen danach wieder frei zur Verfügung."
|
||||||
|
)
|
||||||
|
if not messagebox.askyesno("Spende löschen", detail, parent=self):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.repository.delete_donation(self.member_id, str(donation.get("donation_id", "")))
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
self._donation_changed()
|
||||||
|
|
||||||
|
def _record_donation_payment(self) -> None:
|
||||||
|
donation = self._selected_donation()
|
||||||
|
if not donation:
|
||||||
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||||
|
return
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
DonationPaymentDialog(
|
||||||
|
self,
|
||||||
|
self.repository,
|
||||||
|
self.member_id,
|
||||||
|
str(donation.get("donation_id", "")),
|
||||||
|
donation_balance(data, donation),
|
||||||
|
self._donation_changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _allocate_donation_payment(self) -> None:
|
||||||
|
donation = self._selected_donation()
|
||||||
|
if not donation:
|
||||||
|
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
|
||||||
|
return
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
AllocateDonationPaymentDialog(
|
||||||
|
self,
|
||||||
|
self.repository,
|
||||||
|
self.member_id,
|
||||||
|
str(donation.get("donation_id", "")),
|
||||||
|
donation_balance(data, donation),
|
||||||
|
self._donation_changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _donation_changed(self) -> None:
|
||||||
|
self.refresh()
|
||||||
|
self.on_changed()
|
||||||
|
|
||||||
def _refresh_documents(self) -> None:
|
def _refresh_documents(self) -> None:
|
||||||
self.documents.delete(*self.documents.get_children())
|
self.documents.delete(*self.documents.get_children())
|
||||||
self.document_paths.clear()
|
self.document_paths.clear()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from tkinter import messagebox, ttk
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
@@ -15,6 +16,77 @@ from ccma.domain.dates import date_input_hint, format_date_for_display
|
|||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentCreateDialog(tk.Toplevel):
|
||||||
|
"""Records a payment without requiring it to be tied to a claim right away.
|
||||||
|
Useful for logging an incoming bank transfer as soon as it arrives; it can be
|
||||||
|
allocated to claims or donations afterwards."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Misc,
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
on_saved: Callable[[], None],
|
||||||
|
):
|
||||||
|
super().__init__(master)
|
||||||
|
self.repository = repository
|
||||||
|
self.member_id = member_id
|
||||||
|
self.on_saved = on_saved
|
||||||
|
self.title("Zahlung anlegen")
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.bind("<Escape>", lambda _event: self.destroy())
|
||||||
|
self.frame = ttk.Frame(self, padding=18)
|
||||||
|
self.frame.pack(fill="both", expand=True)
|
||||||
|
self.frame.columnconfigure(1, weight=1)
|
||||||
|
self.variables = {
|
||||||
|
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
|
||||||
|
"amount": tk.StringVar(),
|
||||||
|
"gnucash": tk.StringVar(),
|
||||||
|
"reference": tk.StringVar(),
|
||||||
|
}
|
||||||
|
fields = (
|
||||||
|
(f"Zahlungsdatum ({date_input_hint()})", "date"),
|
||||||
|
("Zahlungsbetrag", "amount"),
|
||||||
|
("GnuCash-ID (optional)", "gnucash"),
|
||||||
|
("Referenz", "reference"),
|
||||||
|
)
|
||||||
|
for row, (label, key) in enumerate(fields):
|
||||||
|
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
|
||||||
|
ttk.Entry(self.frame, textvariable=self.variables[key], width=42).grid(
|
||||||
|
row=row, column=1, sticky="ew", pady=5
|
||||||
|
)
|
||||||
|
ttk.Label(
|
||||||
|
self.frame,
|
||||||
|
text=(
|
||||||
|
"Die Zahlung wird zunächst ohne Zuordnung gespeichert. Sie kann anschließend "
|
||||||
|
"einer Forderung oder Spende zugeordnet werden."
|
||||||
|
),
|
||||||
|
style="Mono.TLabel",
|
||||||
|
wraplength=380,
|
||||||
|
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||||
|
buttons = ttk.Frame(self.frame)
|
||||||
|
buttons.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="e", pady=(16, 0))
|
||||||
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||||
|
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=self._save).pack(side="left")
|
||||||
|
self.after_idle(self.grab_set)
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
try:
|
||||||
|
self.repository.create_payment(
|
||||||
|
self.member_id,
|
||||||
|
payment_date=self.variables["date"].get(),
|
||||||
|
amount=self.variables["amount"].get(),
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
class PaymentEditDialog(tk.Toplevel):
|
class PaymentEditDialog(tk.Toplevel):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from ccma.domain.contributions import (
|
|||||||
claim_settled_total,
|
claim_settled_total,
|
||||||
claim_status,
|
claim_status,
|
||||||
claim_total,
|
claim_total,
|
||||||
|
donation_allocated_total,
|
||||||
|
donation_balance,
|
||||||
|
donation_status,
|
||||||
payment_allocated_total,
|
payment_allocated_total,
|
||||||
)
|
)
|
||||||
from ccma.domain.models import ContributionData
|
from ccma.domain.models import ContributionData
|
||||||
@@ -311,6 +314,133 @@ def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None:
|
|||||||
repository.cancel_claim(member.member_id, "claim-1")
|
repository.cancel_claim(member.member_id, "claim-1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_can_be_deleted_and_releases_allocated_payment(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="60.00",
|
||||||
|
allocation_amount="60.00",
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.delete_claim(member.member_id, "claim-1")
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert data.claims == []
|
||||||
|
assert data.allocations == []
|
||||||
|
assert data.payments[0]["payment_id"] == payment["payment_id"]
|
||||||
|
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
|
||||||
|
assert repository.get_events(member.member_id)[-1].event_type == "claim_deleted"
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="nicht gefunden"):
|
||||||
|
repository.get_claim(member.member_id, "claim-1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_payment_can_be_deleted_even_though_it_cannot_be_cancelled(tmp_path) -> None:
|
||||||
|
repository, member = _repository_with_claim(tmp_path)
|
||||||
|
repository.record_payment(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="10.00",
|
||||||
|
allocation_amount="10.00",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="Zahlungszuordnungen"):
|
||||||
|
repository.cancel_claim(member.member_id, "claim-1")
|
||||||
|
|
||||||
|
repository.delete_claim(member.member_id, "claim-1")
|
||||||
|
assert repository.get_contributions(member.member_id).claims == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_payment_can_be_created_without_allocation(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(first_name="Payment", last_name="Test")
|
||||||
|
|
||||||
|
payment = repository.create_payment(
|
||||||
|
member.member_id,
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="42.00",
|
||||||
|
reference="Überweisung ohne Zuordnung",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert data.payments == [payment]
|
||||||
|
assert data.allocations == []
|
||||||
|
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
|
||||||
|
assert repository.get_events(member.member_id)[-1].event_type == "payment_recorded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_donation_can_be_recorded_paid_and_deleted_releases_payment(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(first_name="Donation", last_name="Test")
|
||||||
|
|
||||||
|
donation = repository.record_donation(
|
||||||
|
member.member_id,
|
||||||
|
donation_date="2026-06-21",
|
||||||
|
amount="30.00",
|
||||||
|
reference="Sommerfest",
|
||||||
|
purpose="Freiwillige Zusatzspende",
|
||||||
|
)
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert donation_status(data, donation) == "open"
|
||||||
|
assert donation_balance(data, donation) == Decimal("30.00")
|
||||||
|
|
||||||
|
payment = repository.record_donation_payment(
|
||||||
|
member.member_id,
|
||||||
|
donation["donation_id"],
|
||||||
|
payment_date="2026-06-22",
|
||||||
|
amount="30.00",
|
||||||
|
allocation_amount="30.00",
|
||||||
|
)
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert donation_allocated_total(data, donation["donation_id"]) == Decimal("30.00")
|
||||||
|
assert donation_status(data, donation) == "allocated"
|
||||||
|
|
||||||
|
repository.delete_donation(member.member_id, donation["donation_id"])
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert data.donations == []
|
||||||
|
assert data.allocations == []
|
||||||
|
assert data.payments[0]["payment_id"] == payment["payment_id"]
|
||||||
|
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
|
||||||
|
assert repository.get_events(member.member_id)[-1].event_type == "donation_deleted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_free_payment_can_be_allocated_to_a_donation(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(first_name="Donation", last_name="Allocate")
|
||||||
|
|
||||||
|
payment = repository.create_payment(
|
||||||
|
member.member_id,
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="100.00",
|
||||||
|
reference="Mitgliedsbeitrag plus Spende",
|
||||||
|
)
|
||||||
|
donation = repository.record_donation(
|
||||||
|
member.member_id,
|
||||||
|
donation_date="2026-06-21",
|
||||||
|
amount="20.00",
|
||||||
|
reference="Aufrundung",
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.allocate_payment_to_donation(
|
||||||
|
member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="20.00"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert donation_balance(data, donation) == Decimal("0.00")
|
||||||
|
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("20.00")
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="nur noch 0.00 EUR"):
|
||||||
|
repository.allocate_payment_to_donation(
|
||||||
|
member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="1.00"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None:
|
def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None:
|
||||||
repository, first_member = _repository_with_claim(tmp_path / "store")
|
repository, first_member = _repository_with_claim(tmp_path / "store")
|
||||||
repository.record_payment(
|
repository.record_payment(
|
||||||
|
|||||||
Reference in New Issue
Block a user