Pure policy module for BILL-15 dunning (D4-02 hybrid).
No side effects, no DB, no Stripe calls — Accrue.Jobs.DunningSweeper
owns those. This module's job is to answer one question for each
candidate subscription: "given the configured policy, what should we
ask the processor facade to do next?"
The sweeper is a thin grace-period overlay on top of Stripe Smart
Retries. Stripe still owns the retry cadence. Accrue only asks the
processor to move the subscription to the terminal action once the
grace window has elapsed and we have not already asked (tracked via
dunning_sweep_attempted_at).
Policy shape
[
mode: :stripe_smart_retries | :disabled,
grace_days: pos_integer(),
terminal_action: :unpaid | :canceled,
telemetry_prefix: [atom()]
]Decisions
:skip— do nothing (not past_due, already swept, or disabled).:hold— past_due but still inside the grace window.{:sweep, terminal_action}— grace elapsed; sweeper should ask the processor facade to move the subscription toterminal_action.
Local subscription status is NEVER touched by the sweeper (D2-29 — Stripe is canonical; the webhook flips the row).
Summary
Functions
Pure decision function. Given a subscription row and a dunning policy,
returns whether the sweeper should :skip, :hold, or
{:sweep, terminal_action}.
Returns true when now is more than grace_days past past_due_since.
Types
@type decision() :: {:sweep, :unpaid | :canceled} | :hold | :skip
@type policy() :: keyword()
Functions
@spec compute_terminal_action(Accrue.Billing.Subscription.t(), policy()) :: decision()
Pure decision function. Given a subscription row and a dunning policy,
returns whether the sweeper should :skip, :hold, or
{:sweep, terminal_action}.
@spec grace_elapsed?(DateTime.t() | nil, pos_integer(), DateTime.t()) :: boolean()
Returns true when now is more than grace_days past past_due_since.
A nil past_due_since returns false — with no recorded start of
the past_due window, there is no grace to elapse.