feat: add itemized claims and payments

This commit is contained in:
Marcel Peterkau
2026-06-21 18:20:55 +02:00
parent c717d6806b
commit 80d4d5ef90
12 changed files with 1049 additions and 6 deletions
+227 -1
View File
@@ -9,6 +9,14 @@ from pathlib import Path
from string import Formatter
from uuid import uuid4
from ccma.domain.contributions import (
claim_balance,
claim_total,
decimal_value,
materialize_claim_items,
money_text,
payment_allocated_total,
)
from ccma.domain.dates import DateValidationError, normalize_date_input, validate_member_dates
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, ContributionData, Event, Member
from ccma.storage.atomic import read_json, write_json_atomic
@@ -229,7 +237,7 @@ class MemberRepository:
raw = read_json(path)
if not isinstance(raw, dict):
raise TypeError("Wurzelelement muss ein JSON-Objekt sein")
for field_name in ("claims", "payments", "allocations"):
for field_name in ("claims", "payments", "allocations", "reminders"):
if field_name in raw and not isinstance(raw[field_name], list):
raise TypeError(f"{field_name} muss eine JSON-Liste sein")
if field_name in raw and any(not isinstance(item, dict) for item in raw[field_name]):
@@ -244,6 +252,224 @@ class MemberRepository:
self.get_member(member_id)
write_json_atomic(self._member_path(member_id) / "contributions.json", data.to_dict())
def get_claim(self, member_id: str, claim_id: str) -> tuple[ContributionData, dict]:
data = self.get_contributions(member_id)
claim = next(
(item for item in data.claims if str(item.get("claim_id", "")) == claim_id),
None,
)
if claim is None:
raise RepositoryError(f"Forderung nicht gefunden: {claim_id}")
return data, claim
def add_claim_item(
self,
member_id: str,
claim_id: str,
*,
description: str,
quantity: str,
unit_price: str,
item_type: str = "correction",
) -> dict:
if not description.strip():
raise RepositoryError("Eine Beschreibung ist erforderlich.")
try:
selected_quantity = decimal_value(quantity, "Menge")
selected_unit_price = decimal_value(unit_price, "Einzelpreis")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected_quantity == 0:
raise RepositoryError("Die Menge darf nicht null sein.")
data, claim = self.get_claim(member_id, claim_id)
if str(claim.get("status", "")) == "cancelled":
raise RepositoryError("Eine stornierte Forderung kann nicht verändert werden.")
amount = selected_quantity * selected_unit_price
item = {
"item_id": str(uuid4()),
"type": item_type.strip() or "correction",
"description": description.strip(),
"quantity": money_text(selected_quantity),
"unit_price": money_text(selected_unit_price),
"amount": money_text(amount),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
materialize_claim_items(claim).append(item)
claim["amount"] = money_text(claim_total(claim))
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="claim_item_added",
summary=f"Forderungsposition ergänzt: {item['description']} ({item['amount']} EUR)",
references={"claim_id": claim_id, "item_id": str(item["item_id"])},
)
return item
def record_payment(
self,
member_id: str,
claim_id: str,
*,
payment_date: str,
amount: str,
allocation_amount: str,
gnucash_transaction_id: str = "",
reference: str = "",
) -> dict:
try:
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
selected_amount = decimal_value(amount)
selected_allocation = decimal_value(allocation_amount, "Zuordnung")
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Zahlungsdatum ist erforderlich.")
if selected_amount <= 0:
raise RepositoryError("Der Zahlungsbetrag muss größer als null sein.")
if selected_allocation <= 0 or selected_allocation > selected_amount:
raise RepositoryError(
"Die Zuordnung muss größer als null und höchstens so hoch wie die Zahlung sein."
)
gnucash_id = gnucash_transaction_id.strip()
if gnucash_id:
self._assert_gnucash_id_available(gnucash_id)
data, claim = self.get_claim(member_id, claim_id)
payment = {
"payment_id": str(uuid4()),
"date": normalized_date,
"amount": money_text(selected_amount),
"method": "bank_transfer",
"gnucash_transaction_id": gnucash_id,
"reference": reference.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
allocation = {
"allocation_id": str(uuid4()),
"payment_id": payment["payment_id"],
"claim_id": claim_id,
"amount": money_text(selected_allocation),
}
data.payments.append(payment)
data.allocations.append(allocation)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_recorded",
summary=f"Zahlung eingegangen: {payment['amount']} EUR",
references={"claim_id": claim_id, "payment_id": str(payment["payment_id"])},
data={"allocation_amount": allocation["amount"]},
)
return payment
def allocate_payment(self, member_id: str, claim_id: str, *, payment_id: str, amount: str) -> dict:
data, _claim = self.get_claim(member_id, claim_id)
payment = next(
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
None,
)
if payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
try:
selected_amount = decimal_value(amount, "Zuordnung")
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected_amount <= 0 or selected_amount > available:
raise RepositoryError(f"Es sind nur {money_text(available)} EUR dieser Zahlung verfügbar.")
allocation = {
"allocation_id": str(uuid4()),
"payment_id": payment_id,
"claim_id": claim_id,
"amount": money_text(selected_amount),
}
data.allocations.append(allocation)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_allocated",
summary=f"Zahlung zugeordnet: {allocation['amount']} EUR",
references={"claim_id": claim_id, "payment_id": payment_id},
)
return allocation
def add_reminder(
self,
member_id: str,
claim_id: str,
*,
level: int,
detail: str = "",
fee: str = "0",
) -> dict:
if level < 1:
raise RepositoryError("Die Mahnstufe muss mindestens 1 sein.")
try:
selected_fee = decimal_value(fee, "Mahngebühr")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
if selected_fee < 0:
raise RepositoryError("Die Mahngebühr darf nicht negativ sein.")
data, claim = self.get_claim(member_id, claim_id)
if str(claim.get("status", "")) == "cancelled":
raise RepositoryError("Eine stornierte Forderung kann nicht gemahnt werden.")
reminder = {
"reminder_id": str(uuid4()),
"claim_id": claim_id,
"level": level,
"detail": detail.strip(),
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"fee_item_id": None,
}
if selected_fee > 0:
item = {
"item_id": str(uuid4()),
"type": "fee",
"description": f"Mahngebühr Stufe {level}",
"quantity": "1.00",
"unit_price": money_text(selected_fee),
"amount": money_text(selected_fee),
"created_at": reminder["created_at"],
}
materialize_claim_items(claim).append(item)
claim["amount"] = money_text(claim_total(claim))
reminder["fee_item_id"] = item["item_id"]
data.reminders.append(reminder)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="reminder_created",
summary=f"Mahnung Stufe {level} erfasst",
references={"claim_id": claim_id, "reminder_id": str(reminder["reminder_id"])},
data={"fee": money_text(selected_fee)},
)
return reminder
def cancel_claim(self, member_id: str, claim_id: str) -> None:
data, claim = self.get_claim(member_id, claim_id)
if claim_balance(data, claim) != claim_total(claim):
raise RepositoryError("Eine Forderung mit Zahlungszuordnungen kann nicht storniert werden.")
claim["status"] = "cancelled"
claim["cancelled_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="claim_cancelled",
summary=f"Forderung storniert: {claim.get('title', claim_id)}",
references={"claim_id": claim_id},
)
def _assert_gnucash_id_available(self, transaction_id: str) -> None:
selected = transaction_id.casefold()
for member in self.list_members():
try:
payments = self.get_contributions(member.member_id).payments
except RepositoryError:
continue
if any(
str(payment.get("gnucash_transaction_id", "")).casefold() == selected for payment in payments
):
raise RepositoryError(f"GnuCash-ID bereits verwendet: {transaction_id}")
def append_event(
self,
member_id: str,