Pure step resolver for the built-in dunning campaign (DUN-02, D-11).
No side effects, no DB, no job queue, no Stripe, no clock — both now
and campaign_started_at are passed in. The Accrue.Workers.DunningStep
worker (Plan 05) reads the wall clock once and hands the timestamps to
this module, then asks one question after delivering each step: "given
the configured journey and how long the campaign has been running, what
should I enqueue next, and when?"
Keeping the resolver pure is deliberate:
- the property test (
(steps, started_at, now)generators) needs no sandbox and runsasync: true, and - the Phase-131
Accrue.Dunning.Enginebehaviour is a clean extraction of this exact contract (this plan does NOT introduce the behaviour — it only ships the pure function it will wrap).
Step contract (from Plan 01 config)
[after_days: non_neg_integer(), key: atom(), template: module() | atom()]after_days is an ABSOLUTE offset from the campaign anchor, and the
step list is ordered with strictly-increasing, unique after_days. This
module does not re-validate that invariant — Accrue.Config enforces it
at boot; the resolver assumes a well-formed ordered list and is total
over any input regardless.
Resolution
elapsed = DateTime.diff(now, campaign_started_at, :second). The next
step is the FIRST step whose after_days * 86_400 is greater than or
equal to elapsed — a step whose boundary has not yet passed is still
pending. This is what gives day-0 its "first step immediately" semantics:
with a after_days: 0 first step at zero elapsed, 0 >= 0 holds, so the
resolver returns that step with schedule_in == 0. A step strictly
behind elapsed has already been delivered and is skipped. The delay
until the next step is schedule_in = max(0, after_days_seconds - elapsed),
which is 0 on day-0 (immediate send) and can never go negative under
clock skew or a stale anchor — that clamp is the T-128-05 mitigation
(Oban rejects negative delays).
Return values
{:next, step, schedule_in}— the next step to send (the original keyword fromsteps) and the non-negative number of seconds until it should be delivered.schedule_in == 0means "send now".:done— the journey is exhausted (every step's boundary has already elapsed) or the step list is empty. The worker chain stops.
Summary
Functions
Resolve the next dunning step and the delay until it.
Types
@type result() :: {:next, step(), non_neg_integer()} | :done
@type step() :: keyword()
Functions
@spec next_step([step()], DateTime.t(), DateTime.t()) :: result()
Resolve the next dunning step and the delay until it.
Pure: campaign_started_at and now are arguments — this function
never reads a clock or touches the DB/Oban/Stripe.
Returns {:next, step, schedule_in} for the first step whose absolute
after_days boundary is still in the future relative to now, or
:done when the journey is exhausted (or steps is empty).
Examples
iex> anchor = ~U[2026-01-01 00:00:00Z]
iex> steps = [[after_days: 0, key: :reminder, template: A], [after_days: 5, key: :final, template: B]]
iex> Accrue.Dunning.Campaign.next_step(steps, anchor, anchor)
{:next, [after_days: 0, key: :reminder, template: A], 0}
iex> anchor = ~U[2026-01-01 00:00:00Z]
iex> Accrue.Dunning.Campaign.next_step([], anchor, anchor)
:done