Accrue.Billing.Dunning (accrue v0.3.0)

Copy Markdown View Source

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 to terminal_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

decision()

@type decision() :: {:sweep, :unpaid | :canceled} | :hold | :skip

policy()

@type policy() :: keyword()

Functions

compute_terminal_action(sub, policy)

@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}.

grace_elapsed?(past_due_since, grace_days, now)

@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.