mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Once the direct debit bounced and the board sent the Rücklastschrift reminder,
the claim is expected as a transfer by the deadline that letter states. Three
places still treated it as a claim the mandate covers, and the housekeeper's was
the one the board kept running into: after the reminder's deadline lapsed, the
finding went back to "Lastschrift überfällig -- Einzug prüfen, eine postalische
Mahnung ist hier nicht vorgesehen", for a claim that had just been dunned.
The rule now asks whether the claim was dunned before treating it as one for the
direct debit. If it was, it continues in the ordinary dunning sequence: the
running deadline shows as the usual "Frist läuft noch" note, and once that has
passed the next dunning level comes due. The SEPA-specific pending-reminder
detour that used to cover the deadline window is gone with it -- the ordinary
path reports the same thing.
The SEPA run now skips a dunned claim as well, instead of quietly collecting the
money the letter asked the member to transfer (which can bounce a second time,
with a second fee). The skip is reported like the incomplete mandates are, so
nothing disappears from the run without saying why; the dialog's wording is no
longer specific to mandates.
And a dunning mail asks for a transfer even from a member with an active
mandate. The shipped template spells the bank details out, but the ready-made
{{payment.instructions}} paragraph, offered by the template editor for exactly
this mail, told them "wir ziehen den Betrag ein, du musst nichts weiter tun" --
in the letter demanding payment.
Reverting the sent reminder is what puts the claim back into the direct-debit
run; the read of "dunned" is a sent reminder, not a draft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
7.1 KiB
Python
220 lines
7.1 KiB
Python
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",
|
|
"credit": "GUTSCHRIFT",
|
|
"cancelled": "STORNIERT",
|
|
}
|
|
|
|
DONATION_STATUS_LABELS = {
|
|
"open": "OFFEN",
|
|
"partially_allocated": "TEILWEISE ZUGEORDNET",
|
|
"allocated": "ZUGEORDNET",
|
|
"overallocated": "ÜBERZUGEORDNET",
|
|
}
|
|
|
|
CONTRIBUTION_OVERRIDE_KIND_LABELS = {
|
|
"amount": "BETRAG",
|
|
"percent": "PROZENT",
|
|
}
|
|
|
|
|
|
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 allocation_effect(data: ContributionData, allocation: dict[str, Any]) -> Decimal:
|
|
amount = decimal_value(allocation.get("amount", "0"))
|
|
if str(allocation.get("credit_id", "")):
|
|
return -amount
|
|
return amount
|
|
|
|
|
|
def allocated_total(data: ContributionData, claim_id: str) -> Decimal:
|
|
return sum(
|
|
(
|
|
allocation_effect(data, allocation)
|
|
for allocation in data.allocations
|
|
if str(allocation.get("claim_id", "")) == claim_id
|
|
),
|
|
Decimal("0"),
|
|
)
|
|
|
|
|
|
def claim_settled_total(data: ContributionData, claim: dict[str, Any]) -> Decimal:
|
|
allocated = allocated_total(data, str(claim.get("claim_id", "")))
|
|
if claim_total(claim) < 0:
|
|
return abs(allocated).quantize(CENT)
|
|
return allocated.quantize(CENT)
|
|
|
|
|
|
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 credit_allocated_total(data: ContributionData, credit_id: str) -> Decimal:
|
|
return sum(
|
|
(
|
|
decimal_value(allocation.get("amount", "0"))
|
|
for allocation in data.allocations
|
|
if str(allocation.get("credit_id", "")) == credit_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 donation_allocated_total(data: ContributionData, donation_id: str) -> Decimal:
|
|
return sum(
|
|
(
|
|
decimal_value(allocation.get("amount", "0"))
|
|
for allocation in data.allocations
|
|
if str(allocation.get("donation_id", "")) == donation_id
|
|
),
|
|
Decimal("0"),
|
|
)
|
|
|
|
|
|
def donation_amount(donation: dict[str, Any]) -> Decimal:
|
|
return decimal_value(donation.get("amount", "0"))
|
|
|
|
|
|
def donation_balance(data: ContributionData, donation: dict[str, Any]) -> Decimal:
|
|
donation_id = str(donation.get("donation_id", ""))
|
|
return (donation_amount(donation) - donation_allocated_total(data, donation_id)).quantize(CENT)
|
|
|
|
|
|
def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
|
|
balance = donation_balance(data, donation)
|
|
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
|
|
if balance < 0:
|
|
return "overallocated"
|
|
if balance == 0:
|
|
return "allocated"
|
|
if allocated > 0:
|
|
return "partially_allocated"
|
|
return "open"
|
|
|
|
|
|
def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
|
|
return sum(
|
|
(decimal_value(item.get("amount", "0")) for item in reminder.get("items") or []),
|
|
Decimal("0"),
|
|
)
|
|
|
|
|
|
def claim_was_dunned(data: ContributionData, claim: dict[str, Any]) -> bool:
|
|
"""True once a reminder for this claim has actually gone out. Such a claim has
|
|
left the direct-debit track: the collection failed (or never happened), the member
|
|
was asked in writing to pay it, and the money is expected as a transfer by the
|
|
stated deadline. Quietly collecting it a second time is exactly what the dunning
|
|
letter says will not happen, so both the SEPA run and the housekeeper ask this
|
|
before treating a claim as one the mandate still covers."""
|
|
claim_id = str(claim.get("claim_id", ""))
|
|
if not claim_id:
|
|
return False
|
|
return any(
|
|
str(reminder.get("claim_id", "")) == claim_id and str(reminder.get("status", "")) == "sent"
|
|
for reminder in data.reminders
|
|
)
|
|
|
|
|
|
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
|
|
valid_from = str(override.get("valid_from", ""))
|
|
valid_until = str(override.get("valid_until", "") or "")
|
|
if valid_from and month < valid_from:
|
|
return False
|
|
if valid_until and month > valid_until:
|
|
return False
|
|
return True
|
|
|
|
|
|
def contribution_override_ranges_overlap(
|
|
valid_from: str, valid_until: str, other_from: str, other_until: str
|
|
) -> bool:
|
|
"""Month ranges ('YYYY-MM', empty valid_until/other_until = open-ended) overlap
|
|
if each range starts no later than the other one ends."""
|
|
starts_before_other_ends = not other_until or valid_from <= other_until
|
|
other_starts_before_ends = not valid_until or other_from <= valid_until
|
|
return starts_before_other_ends and other_starts_before_ends
|
|
|
|
|
|
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 total < 0:
|
|
return "credit"
|
|
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"
|