feat: add SEPA direct debit exports and notifications

This commit is contained in:
Marcel Peterkau
2026-07-30 01:00:32 +02:00
parent f4c8ae4f35
commit 3d3b845d9f
8 changed files with 984 additions and 0 deletions
+78
View File
@@ -983,6 +983,84 @@ class MemberRepository:
)
return payment
def record_combined_payment(
self,
member_id: str,
*,
payment_date: str,
allocations: dict[str, str],
reference: str = "",
method: str = "bank_transfer",
actor_name: str = "CCMA",
) -> dict:
"""Record one payment and atomically allocate it to multiple claims."""
try:
normalized_date = normalize_date_input(payment_date, "Zahlungsdatum")
except DateValidationError as exc:
raise RepositoryError(str(exc)) from exc
if not normalized_date:
raise RepositoryError("Zahlungsdatum ist erforderlich.")
if not allocations:
raise RepositoryError("Mindestens eine Zuordnung ist erforderlich.")
data = self.get_contributions(member_id)
claims_by_id = {str(claim.get("claim_id", "")): claim for claim in data.claims}
selected_allocations: dict[str, Decimal] = {}
for claim_id, raw_amount in allocations.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_amount = decimal_value(raw_amount, "Zuordnung")
except ValueError as exc:
raise RepositoryError(str(exc)) from exc
available = max(claim_balance(data, claim), Decimal("0"))
if selected_amount <= 0 or selected_amount > available:
raise RepositoryError(
f"{claim.get('title', 'Forderung')} hat nur "
f"{money_text(available)} EUR offen."
)
selected_allocations[claim_id] = selected_amount
total = sum(selected_allocations.values(), Decimal("0"))
now = datetime.now().astimezone().isoformat(timespec="seconds")
payment = {
"payment_id": str(uuid4()),
"date": normalized_date,
"amount": money_text(total),
"method": method.strip() or "bank_transfer",
"gnucash_transaction_id": "",
"reference": reference.strip(),
"created_at": now,
}
for claim_id, amount in selected_allocations.items():
data.allocations.append(
{
"allocation_id": str(uuid4()),
"payment_id": payment["payment_id"],
"claim_id": claim_id,
"amount": money_text(amount),
}
)
data.payments.append(payment)
self.save_contributions(member_id, data)
self.append_event(
member_id,
event_type="payment_recorded",
summary=f"Zahlung eingegangen: {payment['amount']} EUR",
actor_type="system" if method == "dummy" else "user",
actor_name=actor_name,
references={"payment_id": str(payment["payment_id"])},
data={
"allocated_amount": payment["amount"],
"claim_ids": list(selected_allocations),
"method": payment["method"],
},
)
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(