mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
feat: add per-member payment frequencies
This commit is contained in:
@@ -15,7 +15,8 @@
|
|||||||
"Offene Forderungen von Mitgliedern mit aktivem Lastschriftmandat können für einen frei wählbaren Fälligkeitszeitraum gesammelt und als bankfähige SEPA-XML-Datei oder als CSV-Übersicht exportiert werden; unvollständige Mandate werden dabei verständlich ausgewiesen.",
|
"Offene Forderungen von Mitgliedern mit aktivem Lastschriftmandat können für einen frei wählbaren Fälligkeitszeitraum gesammelt und als bankfähige SEPA-XML-Datei oder als CSV-Übersicht exportiert werden; unvollständige Mandate werden dabei verständlich ausgewiesen.",
|
||||||
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
|
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
|
||||||
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
|
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
|
||||||
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert."
|
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert.",
|
||||||
|
"Die Zahlweise kann pro Mitglied als monatlich, quartalsweise, halbjährlich oder jährlich festgelegt werden; Hausmeister und Lastschriftläufe erzeugen und berücksichtigen die dazu passenden Beitragsforderungen."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ MEMBERSHIP_STATUS_LABELS = {
|
|||||||
"ended": "BEENDET",
|
"ended": "BEENDET",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PAYMENT_FREQUENCY_LABELS = {
|
||||||
|
"monthly": "MONATLICH",
|
||||||
|
"quarterly": "QUARTALSWEISE",
|
||||||
|
"semiannual": "HALBJÄHRLICH",
|
||||||
|
"annual": "JÄHRLICH",
|
||||||
|
}
|
||||||
|
|
||||||
ASSET_STATUS_LABELS = {
|
ASSET_STATUS_LABELS = {
|
||||||
"available": "VERFUEGBAR",
|
"available": "VERFUEGBAR",
|
||||||
"issued": "AUSGEGEBEN",
|
"issued": "AUSGEGEBEN",
|
||||||
@@ -91,7 +98,7 @@ class Member:
|
|||||||
accepted_at: str = ""
|
accepted_at: str = ""
|
||||||
membership_started_at: str = ""
|
membership_started_at: str = ""
|
||||||
membership_ended_at: str = ""
|
membership_ended_at: str = ""
|
||||||
payment_frequency: str = "annual"
|
payment_frequency: str = "semiannual"
|
||||||
contribution_rule_id: str = "standard-2022"
|
contribution_rule_id: str = "standard-2022"
|
||||||
honorary: bool = False
|
honorary: bool = False
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
@@ -183,7 +190,7 @@ class Member:
|
|||||||
membership_ended_at=str(membership.get("ended_at", "")),
|
membership_ended_at=str(membership.get("ended_at", "")),
|
||||||
honorary=bool(membership.get("honorary", False)),
|
honorary=bool(membership.get("honorary", False)),
|
||||||
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
|
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
|
||||||
payment_frequency=str(contribution.get("payment_frequency", "annual")),
|
payment_frequency=str(contribution.get("payment_frequency", "semiannual")),
|
||||||
notes=str(data.get("notes", "")),
|
notes=str(data.get("notes", "")),
|
||||||
created_at=str(data.get("created_at", _iso_now())),
|
created_at=str(data.get("created_at", _iso_now())),
|
||||||
updated_at=str(data.get("updated_at", _iso_now())),
|
updated_at=str(data.get("updated_at", _iso_now())),
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
|
|||||||
RULE_ID = "contribution-claims"
|
RULE_ID = "contribution-claims"
|
||||||
ORDER = 40
|
ORDER = 40
|
||||||
CENT = Decimal("0.01")
|
CENT = Decimal("0.01")
|
||||||
|
MONTH_NAMES = (
|
||||||
|
"",
|
||||||
|
"Januar",
|
||||||
|
"Februar",
|
||||||
|
"März",
|
||||||
|
"April",
|
||||||
|
"Mai",
|
||||||
|
"Juni",
|
||||||
|
"Juli",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"Oktober",
|
||||||
|
"November",
|
||||||
|
"Dezember",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def evaluate(context: RuleContext):
|
def evaluate(context: RuleContext):
|
||||||
@@ -85,6 +100,26 @@ def _membership_claims(
|
|||||||
("first-half", 1, 6, _due_date(year, configured_due_dates[0], "01-31")),
|
("first-half", 1, 6, _due_date(year, configured_due_dates[0], "01-31")),
|
||||||
("second-half", 7, 12, _due_date(year, configured_due_dates[1], "07-31")),
|
("second-half", 7, 12, _due_date(year, configured_due_dates[1], "07-31")),
|
||||||
]
|
]
|
||||||
|
elif member.payment_frequency == "quarterly":
|
||||||
|
periods = [
|
||||||
|
(
|
||||||
|
f"quarter-{quarter}",
|
||||||
|
first_month,
|
||||||
|
first_month + 2,
|
||||||
|
_recurring_due_date(year, first_month, rule.get("annual_due"), "01-31"),
|
||||||
|
)
|
||||||
|
for quarter, first_month in enumerate((1, 4, 7, 10), start=1)
|
||||||
|
]
|
||||||
|
elif member.payment_frequency == "monthly":
|
||||||
|
periods = [
|
||||||
|
(
|
||||||
|
f"month-{month:02d}",
|
||||||
|
month,
|
||||||
|
month,
|
||||||
|
_recurring_due_date(year, month, rule.get("annual_due"), "01-31"),
|
||||||
|
)
|
||||||
|
for month in range(1, 13)
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
|
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
|
||||||
|
|
||||||
@@ -168,7 +203,21 @@ def _due_date(year: int, value, fallback: str) -> date:
|
|||||||
return date(year, month, day)
|
return date(year, month, day)
|
||||||
|
|
||||||
|
|
||||||
|
def _recurring_due_date(year: int, month: int, value, fallback: str) -> date:
|
||||||
|
try:
|
||||||
|
_configured_month, day = (int(part) for part in str(value or fallback).split("-", 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
day = int(fallback.split("-", 1)[1])
|
||||||
|
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
|
||||||
|
|
||||||
|
|
||||||
def _title(year: int, suffix: str) -> str:
|
def _title(year: int, suffix: str) -> str:
|
||||||
|
if suffix.startswith("month-"):
|
||||||
|
month = int(suffix.removeprefix("month-"))
|
||||||
|
return f"Mitgliedsbeitrag {MONTH_NAMES[month]} {year}"
|
||||||
|
if suffix.startswith("quarter-"):
|
||||||
|
quarter = int(suffix.removeprefix("quarter-"))
|
||||||
|
return f"Mitgliedsbeitrag {quarter}. Quartal {year}"
|
||||||
if suffix == "first-half":
|
if suffix == "first-half":
|
||||||
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
|
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
|
||||||
if suffix == "second-half":
|
if suffix == "second-half":
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from ccma.domain.models import (
|
|||||||
ASSET_OWNER_TYPE_LABELS,
|
ASSET_OWNER_TYPE_LABELS,
|
||||||
ASSET_STATUS_LABELS,
|
ASSET_STATUS_LABELS,
|
||||||
MEMBERSHIP_STATUS_LABELS,
|
MEMBERSHIP_STATUS_LABELS,
|
||||||
|
PAYMENT_FREQUENCY_LABELS,
|
||||||
Asset,
|
Asset,
|
||||||
ContributionData,
|
ContributionData,
|
||||||
Event,
|
Event,
|
||||||
@@ -391,6 +392,8 @@ class MemberRepository:
|
|||||||
raise RepositoryError(
|
raise RepositoryError(
|
||||||
"Ein aktives Lastschriftmandat benötigt IBAN, Mandatsreferenz und Erteilungsdatum."
|
"Ein aktives Lastschriftmandat benötigt IBAN, Mandatsreferenz und Erteilungsdatum."
|
||||||
)
|
)
|
||||||
|
if member.payment_frequency not in PAYMENT_FREQUENCY_LABELS:
|
||||||
|
raise RepositoryError("Ungültige Zahlweise.")
|
||||||
if member.member_number != existing.member_number:
|
if member.member_number != existing.member_number:
|
||||||
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
|
self._assert_member_number_available(member.member_number, exclude_member_id=member.member_id)
|
||||||
changes = self._summarize_changes(existing, member)
|
changes = self._summarize_changes(existing, member)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from ccma.domain.contributions import (
|
|||||||
payment_allocated_total,
|
payment_allocated_total,
|
||||||
)
|
)
|
||||||
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
|
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
|
||||||
from ccma.domain.models import ASSET_STATUS_LABELS, Event
|
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event
|
||||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
from ccma.ui.dialogs import IntegrityWarningDialog
|
from ccma.ui.dialogs import IntegrityWarningDialog
|
||||||
@@ -204,11 +204,22 @@ class MemberTab(ttk.Frame):
|
|||||||
state="readonly",
|
state="readonly",
|
||||||
width=39,
|
width=39,
|
||||||
).grid(row=len(fields), column=1, sticky="ew", pady=5)
|
).grid(row=len(fields), column=1, sticky="ew", pady=5)
|
||||||
|
self._add_variable("payment_frequency", tk.StringVar(), "data")
|
||||||
|
ttk.Label(data_tab, text="Zahlweise").grid(
|
||||||
|
row=len(fields) + 1, column=0, sticky="w", pady=5, padx=(0, 12)
|
||||||
|
)
|
||||||
|
ttk.Combobox(
|
||||||
|
data_tab,
|
||||||
|
textvariable=self.variables["payment_frequency"],
|
||||||
|
values=list(PAYMENT_FREQUENCY_LABELS.values()),
|
||||||
|
state="readonly",
|
||||||
|
width=39,
|
||||||
|
).grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
|
||||||
ttk.Label(data_tab, text="Interne Notiz").grid(
|
ttk.Label(data_tab, text="Interne Notiz").grid(
|
||||||
row=len(fields) + 1, column=0, sticky="nw", pady=5, padx=(0, 12)
|
row=len(fields) + 2, column=0, sticky="nw", pady=5, padx=(0, 12)
|
||||||
)
|
)
|
||||||
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
|
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
|
||||||
self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
|
self.notes_text.grid(row=len(fields) + 2, column=1, sticky="ew", pady=5)
|
||||||
self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
|
self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
|
||||||
data_tab.columnconfigure(1, weight=1)
|
data_tab.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
@@ -474,6 +485,8 @@ class MemberTab(ttk.Frame):
|
|||||||
value = getattr(self.member, key)
|
value = getattr(self.member, key)
|
||||||
if key == "status":
|
if key == "status":
|
||||||
variable.set(display_label(STATUS_LABELS, str(value)))
|
variable.set(display_label(STATUS_LABELS, str(value)))
|
||||||
|
elif key == "payment_frequency":
|
||||||
|
variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
|
||||||
else:
|
else:
|
||||||
variable.set(format_date_for_display(value) if key in date_fields else value)
|
variable.set(format_date_for_display(value) if key in date_fields else value)
|
||||||
if self.notes_text is not None:
|
if self.notes_text is not None:
|
||||||
@@ -688,6 +701,8 @@ class MemberTab(ttk.Frame):
|
|||||||
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
||||||
if key == "status":
|
if key == "status":
|
||||||
value = storage_key(STATUS_LABELS, value)
|
value = storage_key(STATUS_LABELS, value)
|
||||||
|
elif key == "payment_frequency":
|
||||||
|
value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
|
||||||
setattr(self.member, key, value)
|
setattr(self.member, key, value)
|
||||||
if self.notes_text is not None:
|
if self.notes_text is not None:
|
||||||
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
|
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
|
||||||
|
|||||||
@@ -40,9 +40,32 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
|||||||
assert raw["person"]["nickname"] == "Enchantress"
|
assert raw["person"]["nickname"] == "Enchantress"
|
||||||
assert raw["schema_version"] == 1
|
assert raw["schema_version"] == 1
|
||||||
assert raw["membership"]["ended_at"] == ""
|
assert raw["membership"]["ended_at"] == ""
|
||||||
|
assert raw["contribution_profile"]["payment_frequency"] == "semiannual"
|
||||||
assert raw["content_hash"]
|
assert raw["content_hash"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_member_without_payment_frequency_defaults_to_semiannual(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(first_name="Legacy", last_name="Member")
|
||||||
|
member_path = repository.members_root / member.member_id / "member.json"
|
||||||
|
raw = json.loads(member_path.read_text(encoding="utf-8"))
|
||||||
|
raw["contribution_profile"].pop("payment_frequency")
|
||||||
|
member_path.write_text(json.dumps(raw), encoding="utf-8")
|
||||||
|
|
||||||
|
assert repository.get_member(member.member_id).payment_frequency == "semiannual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_rejects_unknown_payment_frequency(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(first_name="Invalid", last_name="Frequency")
|
||||||
|
member.payment_frequency = "weekly"
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="Ungültige Zahlweise"):
|
||||||
|
repository.save_member(member)
|
||||||
|
|
||||||
|
|
||||||
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
|
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path)
|
repository = MemberRepository(tmp_path)
|
||||||
repository.initialize()
|
repository.initialize()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -96,6 +97,43 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
|
|||||||
assert state["last_completed_run"] == "2026-04-15:000002"
|
assert state["last_completed_run"] == "2026-04-15:000002"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("frequency", "expected_suffixes", "expected_amount"),
|
||||||
|
[
|
||||||
|
("monthly", [f"month-{month:02d}" for month in range(1, 13)], "12.50"),
|
||||||
|
("quarterly", [f"quarter-{quarter}" for quarter in range(1, 5)], "37.50"),
|
||||||
|
("semiannual", ["first-half", "second-half"], "75.00"),
|
||||||
|
("annual", ["annual"], "150.00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_housekeeper_creates_claims_for_each_payment_frequency(
|
||||||
|
tmp_path, frequency, expected_suffixes, expected_amount
|
||||||
|
) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
member = repository.create_member(
|
||||||
|
first_name=frequency,
|
||||||
|
last_name="Frequency",
|
||||||
|
birth_date="1990-01-01",
|
||||||
|
)
|
||||||
|
member.status = "active"
|
||||||
|
member.accepted_at = "2026-01-01"
|
||||||
|
member.membership_started_at = "2026-01-01"
|
||||||
|
member.payment_frequency = frequency
|
||||||
|
repository.save_member(member)
|
||||||
|
|
||||||
|
Housekeeper(repository).run(today=date(2026, 12, 31))
|
||||||
|
|
||||||
|
membership_claims = [
|
||||||
|
claim
|
||||||
|
for claim in repository.get_contributions(member.member_id).claims
|
||||||
|
if claim.get("type") == "membership_fee"
|
||||||
|
]
|
||||||
|
assert [claim["claim_key"].rsplit(":", 1)[-1] for claim in membership_claims] == expected_suffixes
|
||||||
|
assert {claim["amount"] for claim in membership_claims} == {expected_amount}
|
||||||
|
assert sum(Decimal(claim["amount"]) for claim in membership_claims) == Decimal("150.00")
|
||||||
|
|
||||||
|
|
||||||
def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None:
|
def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None:
|
||||||
repository = MemberRepository(tmp_path)
|
repository = MemberRepository(tmp_path)
|
||||||
repository.initialize()
|
repository.initialize()
|
||||||
@@ -103,6 +141,7 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
|
|||||||
member.status = "active"
|
member.status = "active"
|
||||||
member.accepted_at = "2024-04-15"
|
member.accepted_at = "2024-04-15"
|
||||||
member.membership_started_at = "2024-04-15"
|
member.membership_started_at = "2024-04-15"
|
||||||
|
member.payment_frequency = "annual"
|
||||||
repository.save_member(member)
|
repository.save_member(member)
|
||||||
|
|
||||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||||
@@ -138,6 +177,7 @@ def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_pat
|
|||||||
member.status = "active"
|
member.status = "active"
|
||||||
member.accepted_at = "2021-04-15"
|
member.accepted_at = "2021-04-15"
|
||||||
member.membership_started_at = "2021-04-15"
|
member.membership_started_at = "2021-04-15"
|
||||||
|
member.payment_frequency = "annual"
|
||||||
repository.save_member(member)
|
repository.save_member(member)
|
||||||
|
|
||||||
settings = housekeeper_module.HousekeeperSettings.from_values(
|
settings = housekeeper_module.HousekeeperSettings.from_values(
|
||||||
@@ -165,6 +205,7 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
|
|||||||
member.status = "active"
|
member.status = "active"
|
||||||
member.accepted_at = "2024-04-15"
|
member.accepted_at = "2024-04-15"
|
||||||
member.membership_started_at = "2024-04-15"
|
member.membership_started_at = "2024-04-15"
|
||||||
|
member.payment_frequency = "annual"
|
||||||
repository.save_member(member)
|
repository.save_member(member)
|
||||||
|
|
||||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||||
@@ -185,6 +226,7 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -
|
|||||||
member.accepted_at = "2024-01-01"
|
member.accepted_at = "2024-01-01"
|
||||||
member.membership_started_at = "2024-01-01"
|
member.membership_started_at = "2024-01-01"
|
||||||
member.membership_ended_at = "2026-12-31"
|
member.membership_ended_at = "2026-12-31"
|
||||||
|
member.payment_frequency = "annual"
|
||||||
repository.save_member(member)
|
repository.save_member(member)
|
||||||
|
|
||||||
Housekeeper(repository).run(today=date(2026, 6, 21))
|
Housekeeper(repository).run(today=date(2026, 6, 21))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from email.parser import BytesParser
|
|||||||
from email.policy import default
|
from email.policy import default
|
||||||
|
|
||||||
from ccma.domain.models import ContributionData
|
from ccma.domain.models import ContributionData
|
||||||
|
from ccma.services.housekeeper import Housekeeper
|
||||||
from ccma.services.sepa import PAIN_NAMESPACE, _safe, csv_text, pain008_bytes, pending_direct_debits
|
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.services.sepa_mail import debit_mail_bytes, generate_debit_mails
|
||||||
from ccma.storage.repository import MemberRepository
|
from ccma.storage.repository import MemberRepository
|
||||||
@@ -61,6 +62,27 @@ def test_pending_debits_select_due_open_balance(tmp_path):
|
|||||||
assert debits[0].claim_ids == ("due",)
|
assert debits[0].claim_ids == ("due",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
|
||||||
|
repository, member = _repository(tmp_path)
|
||||||
|
member.accepted_at = "2025-01-01"
|
||||||
|
member.membership_started_at = "2025-01-01"
|
||||||
|
member.payment_frequency = "monthly"
|
||||||
|
repository.save_member(member)
|
||||||
|
repository.save_contributions(member.member_id, ContributionData())
|
||||||
|
|
||||||
|
Housekeeper(repository).run(today=date(2026, 3, 31))
|
||||||
|
debits, warnings = pending_direct_debits(
|
||||||
|
repository,
|
||||||
|
due_from=date(2026, 1, 1),
|
||||||
|
due_until=date(2026, 3, 31),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert warnings == []
|
||||||
|
assert len(debits) == 1
|
||||||
|
assert debits[0].amount == Decimal("37.50")
|
||||||
|
assert len(debits[0].claim_ids) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_pending_debits_exclude_claims_before_lower_date(tmp_path):
|
def test_pending_debits_exclude_claims_before_lower_date(tmp_path):
|
||||||
repository, _member = _repository(tmp_path)
|
repository, _member = _repository(tmp_path)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user