from datetime import date, timedelta from ccma.domain.contributions import claim_balance, claim_status, money_text from ccma.rules.api import RuleAction, RuleContext, task RULE_ID = "reminder-due" ORDER = 60 DEFAULT_POLICY = { "grace_days_after_due": 7, "levels": [ {"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}, {"level": 2, "name": "Erste Mahnung", "fee": "5.00", "payment_deadline_days": 14}, {"level": 3, "name": "Zweite Mahnung", "fee": "5.00", "payment_deadline_days": 14}, ], } def evaluate(context: RuleContext) -> list[RuleAction]: """Owns the whole lifecycle of an overdue claim: a plain overdue notice during the grace period, then either the postal dunning escalation or -- for members paying by SEPA direct debit, where sending a dunning letter makes no sense -- a distinct "check the direct debit" notice. Falls back to the plain overdue notice whenever no escalation applies (dunning hold active, or between reminder levels), so there is always exactly one finding for an overdue claim, never both an overdue notice and a reminder notice at once.""" policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0))) grace_days = int(policy.get("grace_days_after_due", 7)) actions: list[RuleAction] = [] for claim in context.contributions.claims: claim_id = str(claim.get("claim_id", "")) if not claim_id or claim_status(context.contributions, claim, today=context.today) not in { "open", "partially_paid", "overdue", }: continue if claim_balance(context.contributions, claim) <= 0: continue try: due = date.fromisoformat(str(claim.get("due_date", ""))) except ValueError: continue days_overdue = (context.today - due).days if days_overdue < 0: continue # not yet due; claim-status handles the "due soon" notice escalation = None if days_overdue >= grace_days and not _hold_is_active(claim, context.today): if context.member.mandate_active: escalation = _sepa_escalation(context, claim, claim_id, days_overdue) else: escalation = _reminder_action(context, claim, claim_id, levels, policy) actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue)) return actions def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_overdue: int) -> RuleAction: title = str(claim.get("title") or "Beitragsforderung") claim_type = str(claim.get("type", "")) claim_key = str(claim.get("claim_key") or claim_id) return task( rule_id=RULE_ID, member=context.member, key_suffix=f"overdue:{claim_key}", severity="error", title=f"{context.member.display_name}: {title} überfällig", detail=f"Fälligkeit war vor {days_overdue} Tagen.", due_date=due, code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue", ) def _sepa_escalation(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction: pending = _latest_pending_reminder(context.contributions.reminders, claim_id, context.today) if pending: return _sepa_awaiting_action(context, claim, claim_id, *pending) return _sepa_action(context, claim, claim_id, days_overdue) def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction: balance = money_text(claim_balance(context.contributions, claim)) return task( rule_id=RULE_ID, member=context.member, key_suffix=f"{claim_id}:sepa", severity="warning", code="sepa_debit_overdue", title=f"{context.member.display_name}: Lastschrift überfällig – Einzug prüfen", detail=( f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR, seit " f"{days_overdue} Tagen überfällig. Das Mitglied zahlt per SEPA-Lastschriftmandat, eine " "postalische Mahnung ist hier nicht vorgesehen -- bitte den Lastschrifteinzug prüfen " "bzw. erneut anstoßen." ), ) def _sepa_awaiting_action( context: RuleContext, claim, claim_id: str, reminder: dict, trigger_date: date ) -> RuleAction: """A Rücklastschrift-Mahnung (or any reminder) was already sent for this SEPA claim and its own deadline hasn't expired yet -- staff already engaged, so this must not keep nagging "check the direct debit" as if nothing had happened.""" name = str(reminder.get("name") or "Rücklastschrift") balance = money_text(claim_balance(context.contributions, claim)) return task( rule_id=RULE_ID, member=context.member, key_suffix=f"{claim_id}:sepa-awaiting", severity="info", code="sepa_debit_awaiting_deadline", title=f"{context.member.display_name}: {name} versandt, Rücklastschriftklärung läuft", detail=( f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. " f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen." ), due_date=trigger_date, ) def _latest_pending_reminder( reminders: list[dict], claim_id: str, today: date ) -> tuple[dict, date] | None: best: tuple[dict, date] | None = None for item in reminders: if str(item.get("claim_id", "")) != claim_id or str(item.get("status", "")) != "sent": continue try: deadline = date.fromisoformat(str(item.get("payment_deadline", ""))) except ValueError: continue if today > deadline: continue if best is None or deadline > best[1]: best = (item, deadline) return best def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None: reminders = [ item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id ] sent_levels = { int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent" } next_level = next( (definition for definition in levels if int(definition.get("level", 0)) not in sent_levels), None, ) if not next_level: return None level = int(next_level.get("level", 0)) trigger_date = _trigger_date(claim, sent_levels, level, policy) if not trigger_date: return None if context.today < trigger_date: # A reminder for the previous level was already sent and its own payment # deadline hasn't expired yet -- this is being handled, not neglected, so it # must not show up as the same "error" severity as a plain unaddressed overdue # claim (that fallback is what the caller uses when this returns None). return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date) draft_exists = any( int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"} for item in reminders ) name = str(next_level.get("name") or f"Mahnung Stufe {level}") balance = money_text(claim_balance(context.contributions, claim)) title = ( f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand" if draft_exists else f"{context.member.display_name}: {name} fällig" ) detail = ( f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. " f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR." ) return task( rule_id=RULE_ID, member=context.member, key_suffix=f"{claim_id}:level-{level}", severity="warning", code="reminder_due", title=title, detail=detail, due_date=trigger_date, ) def _awaiting_deadline_action( context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date ) -> RuleAction: previous_level = level - 1 previous = sent_levels.get(previous_level) or {} name = str(previous.get("name") or f"Mahnstufe {previous_level}") balance = money_text(claim_balance(context.contributions, claim)) return task( rule_id=RULE_ID, member=context.member, key_suffix=f"{claim_id}:level-{previous_level}:awaiting", severity="info", code="reminder_awaiting_deadline", title=f"{context.member.display_name}: {name} versandt, Frist läuft noch", detail=( f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. " f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen." ), due_date=trigger_date, ) def _trigger_date(claim, sent_levels, level: int, policy) -> date | None: if level == 1: try: due_date = date.fromisoformat(str(claim.get("due_date", ""))) except ValueError: return None return due_date + timedelta(days=int(policy.get("grace_days_after_due", 7))) previous = sent_levels.get(level - 1) if not previous: return None try: return date.fromisoformat(str(previous.get("payment_deadline", ""))) except ValueError: return None def _hold_is_active(claim, today: date) -> bool: hold = claim.get("dunning_hold") or {} if not hold.get("active"): return False until = hold.get("until") if not until: return True try: return date.fromisoformat(str(until)) >= today except ValueError: return True