mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 11:14:52 +02:00
feat: add itemized claims and payments
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user