mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
Allow deleting claims, creating standalone payments, and add a donations tab
Claims could previously only be cancelled (stornieren), which blocks once a payment is allocated. Add a hard delete that releases any linked payments/credits back to being unallocated instead of destroying them. Payments could only be created from within a claim, forcing immediate allocation. Add a bare payment creation flow in the Zahlungen tab so incoming transfers can be logged first and allocated later. Add a per-member Spenden tab (donations, backed by a new donations list on ContributionData) so amounts paid beyond the membership fee can be tracked and existing free payments allocated to them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a61ea3cb57
commit
42fb4c4224
@@ -19,6 +19,7 @@ from ccma.domain.contributions import (
|
||||
claim_total,
|
||||
credit_allocated_total,
|
||||
decimal_value,
|
||||
donation_balance,
|
||||
materialize_claim_items,
|
||||
money_text,
|
||||
payment_allocated_total,
|
||||
@@ -1099,6 +1100,55 @@ class MemberRepository:
|
||||
)
|
||||
return payment
|
||||
|
||||
def create_payment(
|
||||
self,
|
||||
member_id: str,
|
||||
*,
|
||||
payment_date: str,
|
||||
amount: str,
|
||||
gnucash_transaction_id: str = "",
|
||||
reference: str = "",
|
||||
method: str = "bank_transfer",
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
"""Record an incoming payment without allocating it yet. Useful for logging
|
||||
a bank transfer as soon as it arrives, to be assigned to claims or donations later."""
|
||||
self.get_member(member_id)
|
||||
try:
|
||||
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
|
||||
selected_amount = decimal_value(amount)
|
||||
except (DateValidationError, ValueError) as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
if not normalized_date:
|
||||
raise RepositoryError("Zahlungsdatum ist erforderlich.")
|
||||
if selected_amount <= 0:
|
||||
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
|
||||
gnucash_id = gnucash_transaction_id.strip()
|
||||
if gnucash_id:
|
||||
self._assert_gnucash_id_available(gnucash_id)
|
||||
payment = {
|
||||
"payment_id": str(uuid4()),
|
||||
"date": normalized_date,
|
||||
"amount": money_text(selected_amount),
|
||||
"method": method.strip() or "bank_transfer",
|
||||
"gnucash_transaction_id": gnucash_id,
|
||||
"reference": reference.strip(),
|
||||
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
}
|
||||
data = self.get_contributions(member_id)
|
||||
data.payments.append(payment)
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_recorded",
|
||||
summary=f"Zahlung erfasst: {payment['amount']} EUR",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"payment_id": str(payment["payment_id"])},
|
||||
data={"allocation_amount": "0.00"},
|
||||
)
|
||||
return payment
|
||||
|
||||
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
payment = next(
|
||||
@@ -1355,6 +1405,220 @@ class MemberRepository:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
member_id: str,
|
||||
@@ -1578,6 +1842,37 @@ class MemberRepository:
|
||||
references={"claim_id": claim_id},
|
||||
)
|
||||
|
||||
def delete_claim(self, member_id: str, claim_id: str, *, actor_name: str = "Vorstand") -> None:
|
||||
"""Permanently remove a claim. Any payments/credits allocated to it are released
|
||||
(kept intact, just unlinked) rather than deleted, so they can be reallocated."""
|
||||
data, claim = self.get_claim(member_id, claim_id)
|
||||
released_allocations = [
|
||||
allocation for allocation in data.allocations if str(allocation.get("claim_id", "")) == claim_id
|
||||
]
|
||||
released_total = sum(
|
||||
(decimal_value(item.get("amount", "0")) for item in released_allocations), Decimal("0")
|
||||
)
|
||||
data.claims = [item for item in data.claims if str(item.get("claim_id", "")) != claim_id]
|
||||
data.allocations = [
|
||||
item for item in data.allocations if str(item.get("claim_id", "")) != claim_id
|
||||
]
|
||||
data.reminders = [
|
||||
item for item in data.reminders if str(item.get("claim_id", "")) != claim_id
|
||||
]
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="claim_deleted",
|
||||
summary=f"Forderung gelöscht: {claim.get('title', claim_id)}",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"claim_id": claim_id},
|
||||
data={
|
||||
"amount": money_text(claim_total(claim)),
|
||||
"released_allocations": money_text(released_total),
|
||||
},
|
||||
)
|
||||
|
||||
def _assert_gnucash_id_available(
|
||||
self, transaction_id: str, *, exclude_payment_id: str | None = None
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user