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
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from datetime import date
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from ccma.domain.models import ContributionData
CENT = Decimal("0.01")
CLAIM_STATUS_LABELS = {
"open": "OFFEN",
"partially_paid": "TEILBEZAHLT",
"paid": "BEZAHLT",
"overpaid": "ÜBERZAHLT",
"overdue": "ÜBERFÄLLIG",
"cancelled": "STORNIERT",
}
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
text = str(value).strip().replace(",", ".")
try:
return Decimal(text).quantize(CENT, rounding=ROUND_HALF_UP)
except (InvalidOperation, ValueError) as exc:
raise ValueError(f"{field_name} ist kein gültiger Geldbetrag.") from exc
def money_text(value: Decimal | str) -> str:
return f"{decimal_value(value):.2f}"
def claim_items(claim: dict[str, Any]) -> list[dict[str, Any]]:
items = claim.get("items")
if isinstance(items, list) and items:
return items
amount = decimal_value(claim.get("amount", "0"))
return [
{
"item_id": "legacy-base",
"type": "base",
"description": str(claim.get("title") or "Forderung"),
"quantity": "1.00",
"unit_price": money_text(amount),
"amount": money_text(amount),
}
]
def materialize_claim_items(claim: dict[str, Any]) -> list[dict[str, Any]]:
if not isinstance(claim.get("items"), list) or not claim["items"]:
claim["items"] = claim_items(claim)
return claim["items"]
def claim_total(claim: dict[str, Any]) -> Decimal:
return sum((decimal_value(item.get("amount", "0")) for item in claim_items(claim)), Decimal("0"))
def allocated_total(data: ContributionData, claim_id: str) -> Decimal:
return sum(
(
decimal_value(allocation.get("amount", "0"))
for allocation in data.allocations
if str(allocation.get("claim_id", "")) == claim_id
),
Decimal("0"),
)
def payment_allocated_total(data: ContributionData, payment_id: str) -> Decimal:
return sum(
(
decimal_value(allocation.get("amount", "0"))
for allocation in data.allocations
if str(allocation.get("payment_id", "")) == payment_id
),
Decimal("0"),
)
def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal:
return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT)
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
if str(claim.get("status", "")) == "cancelled":
return "cancelled"
total = claim_total(claim)
paid = allocated_total(data, str(claim.get("claim_id", "")))
balance = total - paid
if balance < 0:
return "overpaid"
if balance == 0:
return "paid"
if paid > 0:
return "partially_paid"
try:
due = date.fromisoformat(str(claim.get("due_date", "")))
except ValueError:
due = None
if due and due < (today or date.today()):
return "overdue"
return "open"
+3
View File
@@ -138,6 +138,7 @@ class ContributionData:
claims: list[dict[str, Any]] = field(default_factory=list)
payments: 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)
schema_version: int = 1
def to_dict(self) -> dict[str, Any]:
@@ -146,6 +147,7 @@ class ContributionData:
"claims": self.claims,
"payments": self.payments,
"allocations": self.allocations,
"reminders": self.reminders,
}
@classmethod
@@ -155,6 +157,7 @@ class ContributionData:
claims=list(data.get("claims") or []),
payments=list(data.get("payments") or []),
allocations=list(data.get("allocations") or []),
reminders=list(data.get("reminders") or []),
)