mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-26 07:15:20 +02:00
Merge pull request 'Feature/claim delete payment create donations' (#13) from feature/claim-delete-payment-create-donations into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/13 Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
This commit is contained in:
@@ -17,7 +17,12 @@
|
|||||||
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
|
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
|
||||||
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
|
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
|
||||||
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert.",
|
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert.",
|
||||||
"Die Zahlweise kann pro Mitglied als monatlich, quartalsweise, halbjährlich oder jährlich festgelegt werden; Hausmeister und Lastschriftläufe erzeugen und berücksichtigen die dazu passenden Beitragsforderungen."
|
"Die Zahlweise kann pro Mitglied als monatlich, quartalsweise, halbjährlich oder jährlich festgelegt werden; Hausmeister und Lastschriftläufe erzeugen und berücksichtigen die dazu passenden Beitragsforderungen.",
|
||||||
|
"Forderungen können nun auch vollständig gelöscht werden, nicht nur storniert; zugeordnete Zahlungen werden dabei automatisch wieder gelöst und stehen erneut zur Zuordnung bereit.",
|
||||||
|
"Zahlungen können direkt im Zahlungen-Tab eines Mitglieds angelegt werden, ohne vorher eine Forderung öffnen zu müssen.",
|
||||||
|
"Ein neuer Spenden-Tab je Mitglied erfasst Beträge, die über den Mitgliedsbeitrag hinausgehen, und erlaubt die Zuordnung vorhandener oder neuer Zahlungen zu einer Spende.",
|
||||||
|
"Beim Anlegen oder Bearbeiten einer Zahlung lassen sich offene Forderungen und Spenden direkt im selben Fenster live auswählen und mit Beträgen zuordnen, inklusive der Möglichkeit, dort sofort eine neue Spende anzulegen.",
|
||||||
|
"Die Zuordnungsübersicht im Zahlungsfenster zeigt nur noch offene oder bereits zugeordnete Forderungen und Spenden; entfernte Zuordnungen lassen sich innerhalb desselben Fensters wieder herstellen."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,129 @@ class MemberRepository:
|
|||||||
)
|
)
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
def create_payment(
|
||||||
|
self,
|
||||||
|
member_id: str,
|
||||||
|
*,
|
||||||
|
payment_date: str,
|
||||||
|
amount: str,
|
||||||
|
claim_allocations: dict[str, str] | None = None,
|
||||||
|
donation_allocations: dict[str, str] | None = None,
|
||||||
|
gnucash_transaction_id: str = "",
|
||||||
|
reference: str = "",
|
||||||
|
method: str = "bank_transfer",
|
||||||
|
actor_name: str = "Vorstand",
|
||||||
|
) -> dict:
|
||||||
|
"""Record an incoming payment, optionally allocating parts of it immediately to
|
||||||
|
open claims and/or donations. Called with no allocations, this just logs a bank
|
||||||
|
transfer as soon as it arrives, to be assigned to claims or donations later."""
|
||||||
|
self.get_member(member_id)
|
||||||
|
try:
|
||||||
|
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
|
||||||
|
selected_amount = decimal_value(amount)
|
||||||
|
except (DateValidationError, ValueError) as exc:
|
||||||
|
raise RepositoryError(str(exc)) from exc
|
||||||
|
if not normalized_date:
|
||||||
|
raise RepositoryError("Zahlungsdatum ist erforderlich.")
|
||||||
|
if selected_amount <= 0:
|
||||||
|
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
||||||
|
gnucash_id = gnucash_transaction_id.strip()
|
||||||
|
if gnucash_id:
|
||||||
|
self._assert_gnucash_id_available(gnucash_id)
|
||||||
|
|
||||||
|
data = self.get_contributions(member_id)
|
||||||
|
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
|
||||||
|
donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}
|
||||||
|
|
||||||
|
selected_claim_allocations: dict[str, Decimal] = {}
|
||||||
|
for claim_id, raw_amount in (claim_allocations or {}).items():
|
||||||
|
claim = claims_by_id.get(claim_id)
|
||||||
|
if claim is None:
|
||||||
|
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
|
||||||
|
if str(claim.get("status", "")) == "cancelled":
|
||||||
|
raise RepositoryError("Eine stornierte Forderung kann nicht bezahlt werden.")
|
||||||
|
try:
|
||||||
|
selected = decimal_value(raw_amount, "Zuordnung")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RepositoryError(str(exc)) from exc
|
||||||
|
if selected <= 0:
|
||||||
|
continue
|
||||||
|
available = max(claim_balance(data, claim), Decimal("0"))
|
||||||
|
if selected > available:
|
||||||
|
raise RepositoryError(
|
||||||
|
f"{claim.get('title', 'Forderung')} hat nur {money_text(available)} EUR offen."
|
||||||
|
)
|
||||||
|
selected_claim_allocations[claim_id] = selected
|
||||||
|
|
||||||
|
selected_donation_allocations: dict[str, Decimal] = {}
|
||||||
|
for donation_id, raw_amount in (donation_allocations or {}).items():
|
||||||
|
donation = donations_by_id.get(donation_id)
|
||||||
|
if donation is None:
|
||||||
|
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
|
||||||
|
try:
|
||||||
|
selected = decimal_value(raw_amount, "Zuordnung")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RepositoryError(str(exc)) from exc
|
||||||
|
if selected <= 0:
|
||||||
|
continue
|
||||||
|
available = max(donation_balance(data, donation), Decimal("0"))
|
||||||
|
if selected > available:
|
||||||
|
raise RepositoryError(f"Die Spende hat nur noch {money_text(available)} EUR offen.")
|
||||||
|
selected_donation_allocations[donation_id] = selected
|
||||||
|
|
||||||
|
allocated_total_amount = sum(selected_claim_allocations.values(), Decimal("0")) + sum(
|
||||||
|
selected_donation_allocations.values(), Decimal("0")
|
||||||
|
)
|
||||||
|
if allocated_total_amount > selected_amount:
|
||||||
|
raise RepositoryError(
|
||||||
|
f"Die Zuordnungen ({money_text(allocated_total_amount)} EUR) übersteigen den "
|
||||||
|
f"Zahlungsbetrag ({money_text(selected_amount)} EUR)."
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = {
|
||||||
|
"payment_id": str(uuid4()),
|
||||||
|
"date": normalized_date,
|
||||||
|
"amount": money_text(selected_amount),
|
||||||
|
"method": method.strip() or "bank_transfer",
|
||||||
|
"gnucash_transaction_id": gnucash_id,
|
||||||
|
"reference": reference.strip(),
|
||||||
|
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
for claim_id, claim_amount in selected_claim_allocations.items():
|
||||||
|
data.allocations.append(
|
||||||
|
{
|
||||||
|
"allocation_id": str(uuid4()),
|
||||||
|
"payment_id": payment["payment_id"],
|
||||||
|
"claim_id": claim_id,
|
||||||
|
"amount": money_text(claim_amount),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for donation_id, donation_amount in selected_donation_allocations.items():
|
||||||
|
data.allocations.append(
|
||||||
|
{
|
||||||
|
"allocation_id": str(uuid4()),
|
||||||
|
"payment_id": payment["payment_id"],
|
||||||
|
"donation_id": donation_id,
|
||||||
|
"amount": money_text(donation_amount),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data.payments.append(payment)
|
||||||
|
self.save_contributions(member_id, data)
|
||||||
|
self.append_event(
|
||||||
|
member_id,
|
||||||
|
event_type="payment_recorded",
|
||||||
|
summary=f"Zahlung erfasst: {payment['amount']} EUR",
|
||||||
|
actor_type="user",
|
||||||
|
actor_name=actor_name,
|
||||||
|
references={"payment_id": str(payment["payment_id"])},
|
||||||
|
data={
|
||||||
|
"allocated_amount": money_text(allocated_total_amount),
|
||||||
|
"claim_ids": list(selected_claim_allocations),
|
||||||
|
"donation_ids": list(selected_donation_allocations),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return payment
|
||||||
|
|
||||||
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
|
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(
|
||||||
@@ -1143,9 +1267,13 @@ class MemberRepository:
|
|||||||
payment_date: str,
|
payment_date: str,
|
||||||
amount: str,
|
amount: str,
|
||||||
allocations: dict[str, str],
|
allocations: dict[str, str],
|
||||||
|
donation_allocations: dict[str, str] | None = None,
|
||||||
gnucash_transaction_id: str = "",
|
gnucash_transaction_id: str = "",
|
||||||
reference: str = "",
|
reference: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""`allocations` fully replaces the claim allocations of this payment, and
|
||||||
|
`donation_allocations` (if given) fully replaces its donation allocations. Omit
|
||||||
|
`donation_allocations` to leave any existing donation allocations untouched."""
|
||||||
data = self.get_contributions(member_id)
|
data = self.get_contributions(member_id)
|
||||||
payment = next(
|
payment = next(
|
||||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||||
@@ -1164,10 +1292,15 @@ class MemberRepository:
|
|||||||
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
||||||
|
|
||||||
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
|
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
|
||||||
|
donations_by_id = {str(item.get("donation_id", "")): item for item in data.donations}
|
||||||
old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id]
|
old_allocations = [item for item in data.allocations if str(item.get("payment_id", "")) == payment_id]
|
||||||
old_by_claim: dict[str, list[dict]] = {}
|
old_by_claim: dict[str, list[dict]] = {}
|
||||||
|
old_by_donation: dict[str, list[dict]] = {}
|
||||||
for allocation in old_allocations:
|
for allocation in old_allocations:
|
||||||
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
|
if str(allocation.get("donation_id", "")):
|
||||||
|
old_by_donation.setdefault(str(allocation.get("donation_id", "")), []).append(allocation)
|
||||||
|
else:
|
||||||
|
old_by_claim.setdefault(str(allocation.get("claim_id", "")), []).append(allocation)
|
||||||
|
|
||||||
selected_allocations: dict[str, Decimal] = {}
|
selected_allocations: dict[str, Decimal] = {}
|
||||||
for claim_id, raw_amount in allocations.items():
|
for claim_id, raw_amount in allocations.items():
|
||||||
@@ -1197,7 +1330,43 @@ class MemberRepository:
|
|||||||
)
|
)
|
||||||
selected_allocations[claim_id] = allocation_amount
|
selected_allocations[claim_id] = allocation_amount
|
||||||
|
|
||||||
allocated_sum = sum(selected_allocations.values(), Decimal("0"))
|
if donation_allocations is None:
|
||||||
|
selected_donation_allocations = {
|
||||||
|
donation_id: sum(
|
||||||
|
(decimal_value(item.get("amount", "0")) for item in items), Decimal("0")
|
||||||
|
)
|
||||||
|
for donation_id, items in old_by_donation.items()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
selected_donation_allocations = {}
|
||||||
|
for donation_id, raw_amount in donation_allocations.items():
|
||||||
|
if donation_id not in donations_by_id:
|
||||||
|
raise RepositoryError(f"Spende nicht gefunden: {donation_id}")
|
||||||
|
try:
|
||||||
|
donation_amount = decimal_value(raw_amount, "Zuordnung")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RepositoryError(str(exc)) from exc
|
||||||
|
if donation_amount < 0:
|
||||||
|
raise RepositoryError("Zuordnungen dürfen nicht negativ sein.")
|
||||||
|
if donation_amount == 0:
|
||||||
|
continue
|
||||||
|
donation = donations_by_id[donation_id]
|
||||||
|
currently_allocated = sum(
|
||||||
|
(decimal_value(item.get("amount", "0")) for item in old_by_donation.get(donation_id, [])),
|
||||||
|
Decimal("0"),
|
||||||
|
)
|
||||||
|
available_balance = max(
|
||||||
|
donation_balance(data, donation) + currently_allocated, Decimal("0")
|
||||||
|
)
|
||||||
|
if donation_amount > available_balance:
|
||||||
|
raise RepositoryError(
|
||||||
|
f"Die Spende hat nur noch {money_text(available_balance)} EUR offen."
|
||||||
|
)
|
||||||
|
selected_donation_allocations[donation_id] = donation_amount
|
||||||
|
|
||||||
|
allocated_sum = sum(selected_allocations.values(), Decimal("0")) + sum(
|
||||||
|
selected_donation_allocations.values(), Decimal("0")
|
||||||
|
)
|
||||||
if allocated_sum > selected_amount:
|
if allocated_sum > selected_amount:
|
||||||
raise RepositoryError(
|
raise RepositoryError(
|
||||||
f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den "
|
f"Die Zuordnungen ({money_text(allocated_sum)} EUR) übersteigen den "
|
||||||
@@ -1231,6 +1400,16 @@ class MemberRepository:
|
|||||||
"amount": money_text(allocation_amount),
|
"amount": money_text(allocation_amount),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
for donation_id, donation_amount in selected_donation_allocations.items():
|
||||||
|
prior = old_by_donation.get(donation_id, [])
|
||||||
|
new_allocations.append(
|
||||||
|
{
|
||||||
|
"allocation_id": (str(prior[0].get("allocation_id", "")) if prior else str(uuid4())),
|
||||||
|
"payment_id": payment_id,
|
||||||
|
"donation_id": donation_id,
|
||||||
|
"amount": money_text(donation_amount),
|
||||||
|
}
|
||||||
|
)
|
||||||
data.allocations = retained_allocations + new_allocations
|
data.allocations = retained_allocations + new_allocations
|
||||||
self.save_contributions(member_id, data)
|
self.save_contributions(member_id, data)
|
||||||
self.append_event(
|
self.append_event(
|
||||||
@@ -1355,6 +1534,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 +1971,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()
|
||||||
|
|||||||
+375
-134
@@ -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
|
||||||
|
|
||||||
@@ -9,10 +10,349 @@ from ccma.domain.contributions import (
|
|||||||
claim_balance,
|
claim_balance,
|
||||||
claim_status,
|
claim_status,
|
||||||
decimal_value,
|
decimal_value,
|
||||||
|
donation_balance,
|
||||||
money_text,
|
money_text,
|
||||||
)
|
)
|
||||||
from ccma.domain.dates import date_input_hint, format_date_for_display
|
from ccma.domain.dates import date_input_hint, format_date_for_display
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
from ccma.ui.donation_dialog import DonationEditDialog
|
||||||
|
|
||||||
|
|
||||||
|
class _AllocationTable:
|
||||||
|
"""Embeds a combined claims + donations allocation tree into a payment dialog.
|
||||||
|
|
||||||
|
Only claims/donations that are still open (or already linked to this payment) are
|
||||||
|
listed, so fully-settled ones don't clutter the picture. A row's visibility is
|
||||||
|
decided once, from a snapshot taken when the dialog opens (or when a new donation
|
||||||
|
is created inline) -- it never disappears again for the rest of the editing
|
||||||
|
session just because the user unassigned it. That way "Zuordnung lösen" followed
|
||||||
|
by "Zuordnung setzen" always works on the same row instead of the target vanishing.
|
||||||
|
|
||||||
|
Mutates the ``claim_allocations``/``donation_allocations`` dicts it is given in
|
||||||
|
place, so the owning dialog can read them back at save time. Nothing is written to
|
||||||
|
the repository until the dialog's own Save button does so.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: tk.Misc,
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
*,
|
||||||
|
claim_allocations: dict[str, str],
|
||||||
|
donation_allocations: dict[str, str],
|
||||||
|
on_change: Callable[[], None],
|
||||||
|
):
|
||||||
|
self.repository = repository
|
||||||
|
self.member_id = member_id
|
||||||
|
self.claim_allocations = claim_allocations
|
||||||
|
self.donation_allocations = donation_allocations
|
||||||
|
self.on_change = on_change
|
||||||
|
# Fixed snapshot of what this payment already covered when the dialog opened.
|
||||||
|
# Capacities are computed against this baseline rather than the live, editable
|
||||||
|
# dicts above -- otherwise "Maximal zuordenbar" would inflate every time the
|
||||||
|
# user changes an amount, since claim_balance() reflects only persisted state.
|
||||||
|
self._baseline_claim_allocations = dict(claim_allocations)
|
||||||
|
self._baseline_donation_allocations = dict(donation_allocations)
|
||||||
|
self._visible_claim_ids: set[str] = set(claim_allocations)
|
||||||
|
self._visible_donation_ids: set[str] = set(donation_allocations)
|
||||||
|
self._load_targets()
|
||||||
|
self._build(parent)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _load_targets(self) -> None:
|
||||||
|
self.data = self.repository.get_contributions(self.member_id)
|
||||||
|
self._visible_claim_ids |= {
|
||||||
|
str(claim.get("claim_id", ""))
|
||||||
|
for claim in self.data.claims
|
||||||
|
if str(claim.get("claim_id", ""))
|
||||||
|
and claim_status(self.data, claim) != "cancelled"
|
||||||
|
and claim_balance(self.data, claim) > 0
|
||||||
|
}
|
||||||
|
self._visible_donation_ids |= {
|
||||||
|
str(item.get("donation_id", ""))
|
||||||
|
for item in self.data.donations
|
||||||
|
if str(item.get("donation_id", "")) and donation_balance(self.data, item) > 0
|
||||||
|
}
|
||||||
|
self.claims_by_id = {
|
||||||
|
str(claim.get("claim_id", "")): claim
|
||||||
|
for claim in self.data.claims
|
||||||
|
if str(claim.get("claim_id", "")) in self._visible_claim_ids
|
||||||
|
}
|
||||||
|
self.donations_by_id = {
|
||||||
|
str(item.get("donation_id", "")): item
|
||||||
|
for item in self.data.donations
|
||||||
|
if str(item.get("donation_id", "")) in self._visible_donation_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
def _capacities(self) -> None:
|
||||||
|
self.claim_capacity: dict[str, Decimal] = {}
|
||||||
|
for claim_id, claim in self.claims_by_id.items():
|
||||||
|
baseline = decimal_value(self._baseline_claim_allocations.get(claim_id, "0"))
|
||||||
|
self.claim_capacity[claim_id] = max(claim_balance(self.data, claim) + baseline, Decimal("0"))
|
||||||
|
self.donation_capacity: dict[str, Decimal] = {}
|
||||||
|
for donation_id, donation in self.donations_by_id.items():
|
||||||
|
baseline = decimal_value(self._baseline_donation_allocations.get(donation_id, "0"))
|
||||||
|
self.donation_capacity[donation_id] = max(
|
||||||
|
donation_balance(self.data, donation) + baseline, Decimal("0")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build(self, parent: tk.Misc) -> None:
|
||||||
|
parent.columnconfigure(0, weight=1)
|
||||||
|
parent.rowconfigure(0, weight=1)
|
||||||
|
self.tree = ttk.Treeview(
|
||||||
|
parent,
|
||||||
|
columns=("kind", "title", "date", "capacity", "allocated"),
|
||||||
|
show="headings",
|
||||||
|
selectmode="browse",
|
||||||
|
)
|
||||||
|
for key, title, width in (
|
||||||
|
("kind", "Typ", 90),
|
||||||
|
("title", "Forderung / Spende", 260),
|
||||||
|
("date", "Fällig / Datum", 110),
|
||||||
|
("capacity", "Maximal zuordenbar", 140),
|
||||||
|
("allocated", "Aktuell zugeordnet", 140),
|
||||||
|
):
|
||||||
|
self.tree.heading(key, text=title)
|
||||||
|
self.tree.column(key, width=width, anchor="w")
|
||||||
|
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||||
|
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview)
|
||||||
|
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||||
|
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||||
|
self.tree.bind("<<TreeviewSelect>>", self._select)
|
||||||
|
|
||||||
|
actions = ttk.Frame(parent)
|
||||||
|
actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
||||||
|
ttk.Label(actions, text="Betrag für Auswahl").pack(side="left")
|
||||||
|
self.amount_var = tk.StringVar()
|
||||||
|
ttk.Entry(actions, textvariable=self.amount_var, width=14).pack(side="left", padx=(8, 8))
|
||||||
|
ttk.Button(actions, text="Zuordnung setzen", command=self._set).pack(side="left")
|
||||||
|
ttk.Button(actions, text="Zuordnung lösen", command=self._remove).pack(side="left", padx=(8, 0))
|
||||||
|
ttk.Separator(actions, orient="vertical").pack(side="left", fill="y", padx=10)
|
||||||
|
ttk.Button(actions, text="Neue Spende anlegen", command=self._create_donation).pack(side="left")
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
selected = self.tree.selection()
|
||||||
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
self._capacities()
|
||||||
|
for claim_id, claim in sorted(
|
||||||
|
self.claims_by_id.items(),
|
||||||
|
key=lambda item: (str(item[1].get("due_date", "")), str(item[1].get("title", "")).casefold()),
|
||||||
|
):
|
||||||
|
self.tree.insert(
|
||||||
|
"",
|
||||||
|
"end",
|
||||||
|
iid=f"claim:{claim_id}",
|
||||||
|
values=(
|
||||||
|
"Forderung",
|
||||||
|
claim.get("title", "Forderung"),
|
||||||
|
format_date_for_display(str(claim.get("due_date", ""))),
|
||||||
|
f"{money_text(self.claim_capacity[claim_id])} EUR",
|
||||||
|
f"{self.claim_allocations.get(claim_id, '0.00')} EUR",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for donation_id, donation in sorted(
|
||||||
|
self.donations_by_id.items(), key=lambda item: str(item[1].get("date", "")), reverse=True
|
||||||
|
):
|
||||||
|
label = donation.get("reference") or donation.get("purpose") or "Spende"
|
||||||
|
self.tree.insert(
|
||||||
|
"",
|
||||||
|
"end",
|
||||||
|
iid=f"donation:{donation_id}",
|
||||||
|
values=(
|
||||||
|
"Spende",
|
||||||
|
label,
|
||||||
|
format_date_for_display(str(donation.get("date", ""))),
|
||||||
|
f"{money_text(self.donation_capacity[donation_id])} EUR",
|
||||||
|
f"{self.donation_allocations.get(donation_id, '0.00')} EUR",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if selected and self.tree.exists(selected[0]):
|
||||||
|
self.tree.selection_set(selected[0])
|
||||||
|
self.on_change()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _target(iid: str) -> tuple[str, str]:
|
||||||
|
kind, target_id = iid.split(":", 1)
|
||||||
|
return kind, target_id
|
||||||
|
|
||||||
|
def _select(self, _event: tk.Event | None = None) -> None:
|
||||||
|
selected = self.tree.selection()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
kind, target_id = self._target(selected[0])
|
||||||
|
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
|
||||||
|
self.amount_var.set(allocations.get(target_id, "0.00"))
|
||||||
|
|
||||||
|
def _set(self) -> None:
|
||||||
|
selected = self.tree.selection()
|
||||||
|
if not selected:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
|
||||||
|
)
|
||||||
|
return
|
||||||
|
kind, target_id = self._target(selected[0])
|
||||||
|
try:
|
||||||
|
value = decimal_value(self.amount_var.get(), "Zuordnung")
|
||||||
|
except ValueError as exc:
|
||||||
|
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self.tree)
|
||||||
|
return
|
||||||
|
capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
|
||||||
|
if value < 0 or value > capacity:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Ungültige Zuordnung",
|
||||||
|
f"Es können höchstens {money_text(capacity)} EUR zugeordnet werden.",
|
||||||
|
parent=self.tree,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
|
||||||
|
if value:
|
||||||
|
allocations[target_id] = money_text(value)
|
||||||
|
else:
|
||||||
|
allocations.pop(target_id, None)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _remove(self) -> None:
|
||||||
|
selected = self.tree.selection()
|
||||||
|
if not selected:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
|
||||||
|
)
|
||||||
|
return
|
||||||
|
kind, target_id = self._target(selected[0])
|
||||||
|
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
|
||||||
|
allocations.pop(target_id, None)
|
||||||
|
self.amount_var.set("0.00")
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _create_donation(self) -> None:
|
||||||
|
DonationEditDialog(self.tree, self.repository, self.member_id, self._donation_created)
|
||||||
|
|
||||||
|
def _donation_created(self) -> None:
|
||||||
|
self._load_targets()
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def total_allocated(self) -> Decimal:
|
||||||
|
return sum(
|
||||||
|
(
|
||||||
|
decimal_value(value)
|
||||||
|
for value in (*self.claim_allocations.values(), *self.donation_allocations.values())
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentCreateDialog(tk.Toplevel):
|
||||||
|
"""Records a payment and lets the board allocate parts of it to open claims and/or
|
||||||
|
donations right away — including creating a new donation on the fly — instead of
|
||||||
|
having to save the bare payment first and assign it in a separate step."""
|
||||||
|
|
||||||
|
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.claim_allocations: dict[str, str] = {}
|
||||||
|
self.donation_allocations: dict[str, str] = {}
|
||||||
|
self.allocation_table: _AllocationTable | None = None
|
||||||
|
self.title("Zahlung anlegen")
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
|
self.geometry("880x560")
|
||||||
|
self.minsize(720, 460)
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||||
|
self.bind("<Escape>", lambda _event: self.destroy())
|
||||||
|
self._build_ui()
|
||||||
|
self.after_idle(self._activate)
|
||||||
|
|
||||||
|
def _activate(self) -> None:
|
||||||
|
try:
|
||||||
|
self.deiconify()
|
||||||
|
self.lift()
|
||||||
|
self.focus_force()
|
||||||
|
self.grab_set()
|
||||||
|
except tk.TclError:
|
||||||
|
return
|
||||||
|
|
||||||
|
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(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(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())
|
||||||
|
self.total_var = tk.StringVar()
|
||||||
|
|
||||||
|
allocation_frame = ttk.LabelFrame(self, text="Sofort zuordnen (optional)", padding=12)
|
||||||
|
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||||
|
self.allocation_table = _AllocationTable(
|
||||||
|
allocation_frame,
|
||||||
|
self.repository,
|
||||||
|
self.member_id,
|
||||||
|
claim_allocations=self.claim_allocations,
|
||||||
|
donation_allocations=self.donation_allocations,
|
||||||
|
on_change=self._refresh_totals,
|
||||||
|
)
|
||||||
|
|
||||||
|
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
|
||||||
|
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
||||||
|
buttons.grid(row=3, column=0, sticky="e")
|
||||||
|
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._refresh_totals()
|
||||||
|
|
||||||
|
def _refresh_totals(self) -> None:
|
||||||
|
allocated = self.allocation_table.total_allocated() if self.allocation_table else 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.create_payment(
|
||||||
|
self.member_id,
|
||||||
|
payment_date=self.variables["date"].get(),
|
||||||
|
amount=self.variables["amount"].get(),
|
||||||
|
claim_allocations=self.claim_allocations,
|
||||||
|
donation_allocations=self.donation_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()
|
||||||
|
|
||||||
|
|
||||||
class PaymentEditDialog(tk.Toplevel):
|
class PaymentEditDialog(tk.Toplevel):
|
||||||
@@ -40,17 +380,8 @@ class PaymentEditDialog(tk.Toplevel):
|
|||||||
)
|
)
|
||||||
if self.payment is None:
|
if self.payment is None:
|
||||||
raise RepositoryError("Zahlung nicht gefunden.")
|
raise RepositoryError("Zahlung nicht gefunden.")
|
||||||
self.allocations = self._current_allocations()
|
self.claim_allocations, self.donation_allocations = self._current_allocations()
|
||||||
self.claims_by_id = {
|
self.allocation_table: _AllocationTable | None = None
|
||||||
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.title("Zahlung bearbeiten")
|
||||||
self.transient(master.winfo_toplevel())
|
self.transient(master.winfo_toplevel())
|
||||||
@@ -59,26 +390,26 @@ class PaymentEditDialog(tk.Toplevel):
|
|||||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||||
self.bind("<Escape>", lambda _event: self.destroy())
|
self.bind("<Escape>", lambda _event: self.destroy())
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._refresh_claims()
|
|
||||||
self.after_idle(self._activate)
|
self.after_idle(self._activate)
|
||||||
|
|
||||||
def _current_allocations(self) -> dict[str, str]:
|
def _current_allocations(self) -> tuple[dict[str, str], dict[str, str]]:
|
||||||
totals: dict[str, Decimal] = {}
|
claim_totals: dict[str, Decimal] = {}
|
||||||
|
donation_totals: dict[str, Decimal] = {}
|
||||||
for allocation in self.data.allocations:
|
for allocation in self.data.allocations:
|
||||||
if str(allocation.get("payment_id", "")) != self.payment_id:
|
if str(allocation.get("payment_id", "")) != self.payment_id:
|
||||||
continue
|
continue
|
||||||
|
amount = decimal_value(allocation.get("amount", "0"))
|
||||||
|
donation_id = str(allocation.get("donation_id", ""))
|
||||||
|
if donation_id:
|
||||||
|
donation_totals[donation_id] = donation_totals.get(donation_id, Decimal("0")) + amount
|
||||||
|
continue
|
||||||
claim_id = str(allocation.get("claim_id", ""))
|
claim_id = str(allocation.get("claim_id", ""))
|
||||||
totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value(
|
if claim_id:
|
||||||
allocation.get("amount", "0")
|
claim_totals[claim_id] = claim_totals.get(claim_id, Decimal("0")) + amount
|
||||||
)
|
return (
|
||||||
return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id}
|
{claim_id: money_text(value) for claim_id, value in claim_totals.items()},
|
||||||
|
{donation_id: money_text(value) for donation_id, value in donation_totals.items()},
|
||||||
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:
|
def _build_ui(self) -> None:
|
||||||
self.columnconfigure(0, weight=1)
|
self.columnconfigure(0, weight=1)
|
||||||
@@ -106,51 +437,25 @@ class PaymentEditDialog(tk.Toplevel):
|
|||||||
row=row, column=1, sticky="ew", pady=4
|
row=row, column=1, sticky="ew", pady=4
|
||||||
)
|
)
|
||||||
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
|
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
|
||||||
|
|
||||||
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12)
|
|
||||||
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
|
||||||
allocation_frame.columnconfigure(0, weight=1)
|
|
||||||
allocation_frame.rowconfigure(0, weight=1)
|
|
||||||
self.claims = ttk.Treeview(
|
|
||||||
allocation_frame,
|
|
||||||
columns=("title", "due", "available", "allocated"),
|
|
||||||
show="headings",
|
|
||||||
selectmode="browse",
|
|
||||||
)
|
|
||||||
for key, title, width in (
|
|
||||||
("title", "Forderung", 300),
|
|
||||||
("due", "Fällig", 110),
|
|
||||||
("available", "Maximal zuordenbar", 150),
|
|
||||||
("allocated", "Aktuell zugeordnet", 150),
|
|
||||||
):
|
|
||||||
self.claims.heading(key, text=title)
|
|
||||||
self.claims.column(key, width=width, anchor="w")
|
|
||||||
self.claims.grid(row=0, column=0, sticky="nsew")
|
|
||||||
scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview)
|
|
||||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
|
||||||
self.claims.configure(yscrollcommand=scrollbar.set)
|
|
||||||
self.claims.bind("<<TreeviewSelect>>", self._select_claim)
|
|
||||||
|
|
||||||
allocation_actions = ttk.Frame(allocation_frame)
|
|
||||||
allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
|
||||||
ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left")
|
|
||||||
self.allocation_var = tk.StringVar()
|
|
||||||
ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack(
|
|
||||||
side="left", padx=(8, 8)
|
|
||||||
)
|
|
||||||
ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack(
|
|
||||||
side="left"
|
|
||||||
)
|
|
||||||
ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack(
|
|
||||||
side="left", padx=(8, 0)
|
|
||||||
)
|
|
||||||
self.total_var = tk.StringVar()
|
self.total_var = tk.StringVar()
|
||||||
ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack(
|
|
||||||
side="right"
|
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen und Spenden", padding=12)
|
||||||
|
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
|
||||||
|
self.allocation_table = _AllocationTable(
|
||||||
|
allocation_frame,
|
||||||
|
self.repository,
|
||||||
|
self.member_id,
|
||||||
|
claim_allocations=self.claim_allocations,
|
||||||
|
donation_allocations=self.donation_allocations,
|
||||||
|
on_change=self._refresh_totals,
|
||||||
|
)
|
||||||
|
|
||||||
|
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
|
||||||
|
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
|
||||||
)
|
)
|
||||||
|
|
||||||
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
||||||
buttons.grid(row=2, column=0, sticky="e")
|
buttons.grid(row=3, column=0, sticky="e")
|
||||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
|
||||||
ttk.Button(
|
ttk.Button(
|
||||||
buttons,
|
buttons,
|
||||||
@@ -158,6 +463,7 @@ class PaymentEditDialog(tk.Toplevel):
|
|||||||
style="Accent.TButton",
|
style="Accent.TButton",
|
||||||
command=self._save,
|
command=self._save,
|
||||||
).pack(side="left")
|
).pack(side="left")
|
||||||
|
self._refresh_totals()
|
||||||
|
|
||||||
def _activate(self) -> None:
|
def _activate(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -168,74 +474,8 @@ class PaymentEditDialog(tk.Toplevel):
|
|||||||
except tk.TclError:
|
except tk.TclError:
|
||||||
return
|
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:
|
def _refresh_totals(self) -> None:
|
||||||
allocated = sum(
|
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
|
||||||
(decimal_value(value) for value in self.allocations.values()), Decimal("0")
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
payment_amount = decimal_value(self.variables["amount"].get())
|
payment_amount = decimal_value(self.variables["amount"].get())
|
||||||
free = payment_amount - allocated
|
free = payment_amount - allocated
|
||||||
@@ -252,7 +492,8 @@ class PaymentEditDialog(tk.Toplevel):
|
|||||||
self.payment_id,
|
self.payment_id,
|
||||||
payment_date=self.variables["date"].get(),
|
payment_date=self.variables["date"].get(),
|
||||||
amount=self.variables["amount"].get(),
|
amount=self.variables["amount"].get(),
|
||||||
allocations=self.allocations,
|
allocations=self.claim_allocations,
|
||||||
|
donation_allocations=self.donation_allocations,
|
||||||
gnucash_transaction_id=self.variables["gnucash"].get(),
|
gnucash_transaction_id=self.variables["gnucash"].get(),
|
||||||
reference=self.variables["reference"].get(),
|
reference=self.variables["reference"].get(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,228 @@ 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_payment_can_be_created_with_immediate_claim_and_donation_allocation(tmp_path) -> None:
|
||||||
|
repository, member = _repository_with_claim(tmp_path, amount="30.00")
|
||||||
|
donation = repository.record_donation(
|
||||||
|
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Aufrundung"
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = repository.create_payment(
|
||||||
|
member.member_id,
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="50.00",
|
||||||
|
claim_allocations={"claim-1": "30.00"},
|
||||||
|
donation_allocations={donation["donation_id"]: "20.00"},
|
||||||
|
reference="Mitgliedsbeitrag plus Spende",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
||||||
|
assert claim_balance(data, claim) == Decimal("0.00")
|
||||||
|
assert donation_balance(data, donation) == Decimal("0.00")
|
||||||
|
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("50.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_creation_rejects_allocations_exceeding_amount(tmp_path) -> None:
|
||||||
|
repository, member = _repository_with_claim(tmp_path, amount="30.00")
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="übersteigen den"):
|
||||||
|
repository.create_payment(
|
||||||
|
member.member_id,
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="10.00",
|
||||||
|
claim_allocations={"claim-1": "30.00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_updating_payment_without_donation_allocations_preserves_existing_ones(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(first_name="Donation", last_name="Preserve")
|
||||||
|
donation = repository.record_donation(
|
||||||
|
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Sommerfest"
|
||||||
|
)
|
||||||
|
payment = repository.record_donation_payment(
|
||||||
|
member.member_id,
|
||||||
|
donation["donation_id"],
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="20.00",
|
||||||
|
allocation_amount="20.00",
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.update_payment(
|
||||||
|
member.member_id,
|
||||||
|
payment["payment_id"],
|
||||||
|
payment_date="22.06.2026",
|
||||||
|
amount="20.00",
|
||||||
|
allocations={},
|
||||||
|
reference="Korrigierte Referenz",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert donation_allocated_total(data, donation["donation_id"]) == Decimal("20.00")
|
||||||
|
assert data.payments[0]["reference"] == "Korrigierte Referenz"
|
||||||
|
|
||||||
|
|
||||||
|
def test_updating_payment_with_donation_allocations_replaces_them(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(first_name="Donation", last_name="Replace")
|
||||||
|
first_donation = repository.record_donation(
|
||||||
|
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Erste Spende"
|
||||||
|
)
|
||||||
|
second_donation = repository.record_donation(
|
||||||
|
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Zweite Spende"
|
||||||
|
)
|
||||||
|
payment = repository.record_donation_payment(
|
||||||
|
member.member_id,
|
||||||
|
first_donation["donation_id"],
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="20.00",
|
||||||
|
allocation_amount="20.00",
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.update_payment(
|
||||||
|
member.member_id,
|
||||||
|
payment["payment_id"],
|
||||||
|
payment_date="2026-06-21",
|
||||||
|
amount="20.00",
|
||||||
|
allocations={},
|
||||||
|
donation_allocations={second_donation["donation_id"]: "20.00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert donation_allocated_total(data, first_donation["donation_id"]) == Decimal("0.00")
|
||||||
|
assert donation_allocated_total(data, second_donation["donation_id"]) == Decimal("20.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