mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 19:35:30 +02:00
218 lines
9.1 KiB
Python
218 lines
9.1 KiB
Python
from __future__ import annotations
|
||
|
||
import csv
|
||
import io
|
||
import re
|
||
import unicodedata
|
||
import xml.etree.ElementTree as ET
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from ccma.domain.contributions import claim_balance
|
||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
|
||
|
||
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
|
||
SEPA_CHARACTERS = frozenset(
|
||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||
"':?,-(+.)/ ÄäÖöÜüß&*$%"
|
||
)
|
||
ET.register_namespace("", PAIN_NAMESPACE)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class DirectDebit:
|
||
member_id: str
|
||
member_number: str
|
||
account_holder: str
|
||
iban: str
|
||
bic: str
|
||
mandate_reference: str
|
||
mandate_signed_at: str
|
||
amount: Decimal
|
||
purpose: str
|
||
claim_ids: tuple[str, ...]
|
||
|
||
|
||
def _safe(value: Any, maximum: int) -> str:
|
||
replacements = str.maketrans({"’": "'", "‘": "'", "´": "'", "`": "'", "–": "-", "—": "-"})
|
||
source = str(value).translate(replacements)
|
||
normalized: list[str] = []
|
||
for character in source:
|
||
if character in SEPA_CHARACTERS:
|
||
normalized.append(character)
|
||
continue
|
||
decomposed = unicodedata.normalize("NFKD", character)
|
||
replacement = "".join(
|
||
part for part in decomposed if not unicodedata.combining(part) and part in SEPA_CHARACTERS
|
||
)
|
||
normalized.append(replacement or " ")
|
||
return re.sub(r"\s+", " ", "".join(normalized)).strip()[:maximum]
|
||
|
||
|
||
def pending_direct_debits(
|
||
repository: MemberRepository, *, due_until: date, due_from: date | None = None
|
||
) -> tuple[list[DirectDebit], list[str]]:
|
||
if due_from and due_from > due_until:
|
||
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
|
||
debits: list[DirectDebit] = []
|
||
warnings: list[str] = []
|
||
for member in repository.list_members():
|
||
if not member.mandate_active:
|
||
continue
|
||
missing = [
|
||
label
|
||
for value, label in (
|
||
(member.iban, "IBAN"),
|
||
(member.mandate_reference, "Mandatsreferenz"),
|
||
(member.mandate_signed_at, "Mandatsdatum"),
|
||
)
|
||
if not str(value).strip()
|
||
]
|
||
if missing:
|
||
warnings.append(f"{member.member_number or member.display_name}: {', '.join(missing)} fehlt.")
|
||
continue
|
||
try:
|
||
validate_iban(member.iban)
|
||
date.fromisoformat(member.mandate_signed_at)
|
||
except (RepositoryError, ValueError) as exc:
|
||
warnings.append(f"{member.member_number or member.display_name}: {exc}")
|
||
continue
|
||
data = repository.get_contributions(member.member_id)
|
||
selected = []
|
||
amount = Decimal("0")
|
||
for claim in data.claims:
|
||
if str(claim.get("status", "")) == "cancelled":
|
||
continue
|
||
try:
|
||
claim_due = date.fromisoformat(str(claim.get("due_date", "")))
|
||
except ValueError:
|
||
continue
|
||
balance = claim_balance(data, claim)
|
||
if (due_from is None or claim_due >= due_from) and claim_due <= due_until and balance > 0:
|
||
selected.append(claim)
|
||
amount += balance
|
||
if not selected:
|
||
continue
|
||
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
|
||
purpose = _safe(f"{member.member_number} " + ", ".join(dict.fromkeys(titles)), 140)
|
||
debits.append(
|
||
DirectDebit(
|
||
member_id=member.member_id,
|
||
member_number=member.member_number,
|
||
account_holder=member.account_holder.strip() or member.display_name,
|
||
iban=member.iban.replace(" ", "").upper(),
|
||
bic=member.bic.replace(" ", "").upper(),
|
||
mandate_reference=_safe(member.mandate_reference, 35),
|
||
mandate_signed_at=member.mandate_signed_at,
|
||
amount=amount.quantize(Decimal("0.01")),
|
||
purpose=purpose,
|
||
claim_ids=tuple(str(claim.get("claim_id", "")) for claim in selected),
|
||
)
|
||
)
|
||
return sorted(debits, key=lambda item: (item.member_number, item.account_holder)), warnings
|
||
|
||
|
||
def csv_text(debits: list[DirectDebit], *, collection_date: date) -> str:
|
||
target = io.StringIO(newline="")
|
||
writer = csv.writer(target, delimiter=";", lineterminator="\r\n")
|
||
writer.writerow(
|
||
["Einzugsdatum", "Mitgliedsnummer", "Kontoinhaber", "IBAN", "BIC", "Betrag",
|
||
"Waehrung", "Mandatsreferenz", "Mandatsdatum", "Verwendungszweck"]
|
||
)
|
||
for debit in debits:
|
||
writer.writerow(
|
||
[collection_date.isoformat(), debit.member_number, debit.account_holder, debit.iban,
|
||
debit.bic, f"{debit.amount:.2f}".replace(".", ","), "EUR",
|
||
debit.mandate_reference, debit.mandate_signed_at, debit.purpose]
|
||
)
|
||
return target.getvalue()
|
||
|
||
|
||
def write_csv(path: Path | str, debits: list[DirectDebit], *, collection_date: date) -> None:
|
||
Path(path).write_text(csv_text(debits, collection_date=collection_date), encoding="utf-8-sig")
|
||
|
||
|
||
def _el(parent: ET.Element, name: str, text: Any | None = None) -> ET.Element:
|
||
child = ET.SubElement(parent, f"{{{PAIN_NAMESPACE}}}{name}")
|
||
if text is not None:
|
||
child.text = str(text)
|
||
return child
|
||
|
||
|
||
def pain008_bytes(
|
||
debits: list[DirectDebit], *, collection_date: date, organization: dict[str, Any],
|
||
message_id: str | None = None, created_at: datetime | None = None,
|
||
) -> bytes:
|
||
if not debits:
|
||
raise RepositoryError("Es gibt keine Lastschriften für den Export.")
|
||
creditor_name = _safe(organization.get("name", ""), 70)
|
||
creditor_iban = str(organization.get("iban", "")).replace(" ", "").upper()
|
||
creditor_bic = str(organization.get("bic", "")).replace(" ", "").upper()
|
||
creditor_id = _safe(organization.get("creditor_id", ""), 35)
|
||
missing = [label for value, label in (
|
||
(creditor_name, "Vereinsname"), (creditor_iban, "Vereins-IBAN"),
|
||
(creditor_bic, "Vereins-BIC"), (creditor_id, "Gläubiger-ID"),
|
||
) if not value]
|
||
if missing:
|
||
raise RepositoryError("Für den SEPA-XML-Export fehlen in den Optionen: " + ", ".join(missing) + ".")
|
||
validate_iban(creditor_iban)
|
||
timestamp = created_at or datetime.now().astimezone()
|
||
msg_id = _safe(message_id or f"CCMA-{timestamp:%Y%m%d%H%M%S}-{uuid4().hex[:8]}", 35)
|
||
total = sum((item.amount for item in debits), Decimal("0"))
|
||
document = ET.Element(f"{{{PAIN_NAMESPACE}}}Document")
|
||
initiation = _el(document, "CstmrDrctDbtInitn")
|
||
header = _el(initiation, "GrpHdr")
|
||
for name, value in (("MsgId", msg_id), ("CreDtTm", timestamp.isoformat(timespec="seconds")),
|
||
("NbOfTxs", len(debits)), ("CtrlSum", f"{total:.2f}")):
|
||
_el(header, name, value)
|
||
_el(_el(header, "InitgPty"), "Nm", creditor_name)
|
||
payment = _el(initiation, "PmtInf")
|
||
for name, value in (("PmtInfId", _safe(f"{msg_id}-1", 35)), ("PmtMtd", "DD"),
|
||
("BtchBookg", "true"), ("NbOfTxs", len(debits)),
|
||
("CtrlSum", f"{total:.2f}")):
|
||
_el(payment, name, value)
|
||
payment_type = _el(payment, "PmtTpInf")
|
||
_el(_el(payment_type, "SvcLvl"), "Cd", "SEPA")
|
||
_el(_el(payment_type, "LclInstrm"), "Cd", "CORE")
|
||
_el(payment_type, "SeqTp", "RCUR")
|
||
_el(payment, "ReqdColltnDt", collection_date.isoformat())
|
||
_el(_el(payment, "Cdtr"), "Nm", creditor_name)
|
||
_el(_el(_el(payment, "CdtrAcct"), "Id"), "IBAN", creditor_iban)
|
||
_el(_el(_el(payment, "CdtrAgt"), "FinInstnId"), "BICFI", creditor_bic)
|
||
_el(payment, "ChrgBr", "SLEV")
|
||
for index, debit in enumerate(debits, start=1):
|
||
transaction = _el(payment, "DrctDbtTxInf")
|
||
_el(_el(transaction, "PmtId"), "EndToEndId", _safe(f"{debit.member_number}-{index}", 35))
|
||
amount = _el(transaction, "InstdAmt", f"{debit.amount:.2f}")
|
||
amount.set("Ccy", "EUR")
|
||
direct_debit = _el(transaction, "DrctDbtTx")
|
||
mandate = _el(direct_debit, "MndtRltdInf")
|
||
_el(mandate, "MndtId", debit.mandate_reference)
|
||
_el(mandate, "DtOfSgntr", debit.mandate_signed_at)
|
||
other = _el(_el(_el(_el(direct_debit, "CdtrSchmeId"), "Id"), "PrvtId"), "Othr")
|
||
_el(other, "Id", creditor_id)
|
||
_el(_el(other, "SchmeNm"), "Prtry", "SEPA")
|
||
financial = _el(_el(transaction, "DbtrAgt"), "FinInstnId")
|
||
if debit.bic:
|
||
_el(financial, "BICFI", debit.bic)
|
||
else:
|
||
_el(_el(financial, "Othr"), "Id", "NOTPROVIDED")
|
||
_el(_el(transaction, "Dbtr"), "Nm", _safe(debit.account_holder, 70))
|
||
_el(_el(_el(transaction, "DbtrAcct"), "Id"), "IBAN", debit.iban)
|
||
_el(_el(transaction, "RmtInf"), "Ustrd", debit.purpose)
|
||
ET.indent(document, space=" ")
|
||
return ET.tostring(document, encoding="utf-8", xml_declaration=True)
|
||
|
||
|
||
def write_pain008(
|
||
path: Path | str, debits: list[DirectDebit], *, collection_date: date,
|
||
organization: dict[str, Any],
|
||
) -> None:
|
||
Path(path).write_bytes(
|
||
pain008_bytes(debits, collection_date=collection_date, organization=organization)
|
||
)
|