mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 06:55:17 +02:00
feat: add SEPA direct debit exports and notifications
This commit is contained in:
@@ -147,6 +147,36 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_combined_dummy_payment_settles_multiple_claims(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
data.claims.append(
|
||||
{
|
||||
"claim_id": "claim-2",
|
||||
"title": "Zweite Forderung",
|
||||
"amount": "50.00",
|
||||
"due_date": "2019-12-31",
|
||||
"status": "open",
|
||||
}
|
||||
)
|
||||
repository.save_contributions(member.member_id, data)
|
||||
|
||||
payment = repository.record_combined_payment(
|
||||
member.member_id,
|
||||
payment_date="2019-12-31",
|
||||
allocations={"claim-1": "100.00", "claim-2": "50.00"},
|
||||
method="dummy",
|
||||
reference="Altbestand",
|
||||
)
|
||||
|
||||
changed = repository.get_contributions(member.member_id)
|
||||
assert payment["amount"] == "150.00"
|
||||
assert payment["method"] == "dummy"
|
||||
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("150.00")
|
||||
assert all(claim_balance(changed, claim) == 0 for claim in changed.claims)
|
||||
assert repository.get_events(member.member_id)[-1].data["method"] == "dummy"
|
||||
|
||||
|
||||
def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path, amount="12.50")
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.sepa import PAIN_NAMESPACE, _safe, csv_text, pain008_bytes, pending_direct_debits
|
||||
from ccma.services.sepa_mail import debit_mail_bytes, generate_debit_mails
|
||||
from ccma.storage.repository import MemberRepository
|
||||
from ccma.ui.sepa_dialog import _next_weekday
|
||||
|
||||
|
||||
def _repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Ada", last_name="Lovelace", member_number="C3-42")
|
||||
member.status = "active"
|
||||
member.email = "ada@example.org"
|
||||
member.account_holder = "Ada Lovelace"
|
||||
member.iban = "DE89370400440532013000"
|
||||
member.bic = "COBADEFFXXX"
|
||||
member.mandate_reference = "MANDAT-42"
|
||||
member.mandate_signed_at = "2025-01-10"
|
||||
member.mandate_active = True
|
||||
repository.save_member(member)
|
||||
repository.save_contributions(
|
||||
member.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "due",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
},
|
||||
{
|
||||
"claim_id": "future",
|
||||
"title": "Mitgliedsbeitrag 2027",
|
||||
"amount": "150.00",
|
||||
"due_date": "2027-01-31",
|
||||
"status": "open",
|
||||
},
|
||||
],
|
||||
payments=[{"payment_id": "payment", "amount": "25.00"}],
|
||||
allocations=[{"payment_id": "payment", "claim_id": "due", "amount": "25.00"}],
|
||||
),
|
||||
)
|
||||
return repository, member
|
||||
|
||||
|
||||
def test_pending_debits_select_due_open_balance(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
assert warnings == []
|
||||
assert len(debits) == 1
|
||||
assert debits[0].member_id == member.member_id
|
||||
assert debits[0].amount == Decimal("125.00")
|
||||
assert debits[0].claim_ids == ("due",)
|
||||
|
||||
|
||||
def test_pending_debits_exclude_claims_before_lower_date(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
|
||||
debits, warnings = pending_direct_debits(
|
||||
repository,
|
||||
due_from=date(2026, 2, 1),
|
||||
due_until=date(2026, 12, 31),
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert debits == []
|
||||
|
||||
|
||||
def test_next_weekday_moves_weekend_to_monday():
|
||||
assert _next_weekday(date(2026, 8, 1)) == date(2026, 8, 3)
|
||||
assert _next_weekday(date(2026, 8, 3)) == date(2026, 8, 3)
|
||||
|
||||
|
||||
def test_sepa_text_normalizes_unsupported_characters():
|
||||
assert _safe("Sébastien O’Connor – Müller", 70) == "Sebastien O'Connor - Müller"
|
||||
|
||||
|
||||
def test_csv_is_semicolon_separated_and_uses_decimal_comma(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
text = csv_text(debits, collection_date=date(2026, 8, 3))
|
||||
|
||||
assert "Einzugsdatum;Mitgliedsnummer" in text
|
||||
assert "125,00;EUR;MANDAT-42" in text
|
||||
|
||||
|
||||
def test_pain008_contains_control_sum_mandate_and_creditor(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
organization = {
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
|
||||
content = pain008_bytes(
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
organization=organization,
|
||||
message_id="CCMA-TEST",
|
||||
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
root = ET.fromstring(content)
|
||||
ns = {"p": PAIN_NAMESPACE}
|
||||
|
||||
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "125.00"
|
||||
assert root.findtext(".//p:MndtId", namespaces=ns) == "MANDAT-42"
|
||||
assert (
|
||||
root.findtext(".//p:DrctDbtTx/p:CdtrSchmeId//p:Othr/p:Id", namespaces=ns)
|
||||
== "DE98ZZZ09999999999"
|
||||
)
|
||||
assert root.find("p:CstmrDrctDbtInitn/p:PmtInf/p:CdtrSchmeId", ns) is None
|
||||
assert root.findtext(".//p:ReqdColltnDt", namespaces=ns) == "2026-08-03"
|
||||
|
||||
|
||||
def test_pain008_contains_one_logical_batch_with_multiple_transactions(tmp_path):
|
||||
repository, _member = _repository(tmp_path)
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
organization = {
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
|
||||
root = ET.fromstring(
|
||||
pain008_bytes(
|
||||
[debit, debit],
|
||||
collection_date=date(2026, 8, 3),
|
||||
organization=organization,
|
||||
message_id="CCMA-BATCH-TEST",
|
||||
)
|
||||
)
|
||||
ns = {"p": PAIN_NAMESPACE}
|
||||
|
||||
assert len(root.findall("p:CstmrDrctDbtInitn", ns)) == 1
|
||||
assert len(root.findall(".//p:PmtInf", ns)) == 1
|
||||
assert len(root.findall(".//p:DrctDbtTxInf", ns)) == 2
|
||||
assert root.findtext(".//p:GrpHdr/p:NbOfTxs", namespaces=ns) == "2"
|
||||
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "250.00"
|
||||
|
||||
|
||||
def test_debit_mail_is_thunderbird_draft(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
debit=debit,
|
||||
collection_date=date(2026, 8, 3),
|
||||
creditor_id="DE98ZZZ09999999999",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
|
||||
)
|
||||
message = BytesParser(policy=default).parsebytes(content)
|
||||
|
||||
assert message["To"] == "ada@example.org"
|
||||
assert str(message["X-Mozilla-Draft-Info"]).strip().startswith("internal/draft")
|
||||
assert "125.00 Euro" in message.get_content()
|
||||
assert "MANDAT-42" in message.get_content()
|
||||
|
||||
|
||||
def test_generated_mail_is_exported_archived_and_logged(tmp_path):
|
||||
repository, member = _repository(tmp_path)
|
||||
organization = repository.get_configuration()["organization"]
|
||||
organization.update(
|
||||
{
|
||||
"name": "Chaos Computer Club Mannheim e.V.",
|
||||
"email": "verwaltung@example.org",
|
||||
"iban": "DE89370400440532013000",
|
||||
"bic": "COBADEFFXXX",
|
||||
"creditor_id": "DE98ZZZ09999999999",
|
||||
}
|
||||
)
|
||||
repository.save_organization(organization)
|
||||
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||
|
||||
generated, warnings = generate_debit_mails(
|
||||
repository,
|
||||
debits,
|
||||
collection_date=date(2026, 8, 3),
|
||||
output_directory=tmp_path / "mail-export",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
assert generated[0].export_path.is_file()
|
||||
assert generated[0].archive_path.is_file()
|
||||
assert generated[0].archive_path.read_bytes() == generated[0].export_path.read_bytes()
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.event_type == "sepa_notification_generated"
|
||||
assert event.references["document"].startswith("documents/SEPA/")
|
||||
Reference in New Issue
Block a user