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_action(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_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 _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 or context.today < trigger_date: return None 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 _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