# `Accrue.Workers.DunningStep`
[🔗](https://github.com/szTheory/accrue/blob/accrue-v1.5.1/lib/accrue/workers/dunning_step.ex#L1)

Durable, cancel-guarded, Oban-unique dunning-campaign step worker
(DUN-02, DUN-05; D-10, D-11, D-16).

This is the self-propelling scheduling engine for the built-in dunning
campaign. The webhook reducer (Plan 06) enqueues the day-0 step; from
there each `perform/1` delivers one step's email, asks the pure
`Accrue.Dunning.Campaign` resolver what comes next, and enqueues the
following step — threading the SAME `campaign_started_at` anchor so the
entire chain shares one campaign identity.

## Cancel-guard FIRST (D-11)

Every `perform/1` reloads the live subscription row BEFORE delivering
anything. If the sub is no longer past_due, or its campaign anchor is
`nil`, the step returns `{:cancel, :recovered}` and delivers NOTHING.
This is the backstop for any job that races the Plan-06 proactive
`Oban.cancel_all_jobs` (cancel-on-recovery) or arrives after an
out-of-order recovery webhook — a recovered customer is never emailed.

## Once-per-step uniqueness (D-16)

Each step is keyed `[:subscription_id, :step_key, :campaign_started_at]`
with `period: :infinity` and `:completed` included in the unique
`states`. A duplicate enqueue returns `{:ok, %Oban.Job{conflict?: true}}`
— a step can NEVER be enqueued twice across retries, redeliveries, or
duplicate webhooks. `period: :infinity` (not the finite window the
metered precedent uses) is required because Stripe Smart Retries span
weeks; a completed prior step must still block a week-2 redelivery.

## Oban-safe scalar args (D-10)

Oban persists job `args` as JSONB, so `campaign_started_at` is carried as
an ISO8601 STRING (never a `%DateTime{}` struct) and parsed back with
`DateTime.from_iso8601/1`. The string is NEVER atomized (no
string-to-atom conversion of any kind) — that would be an atom-table
exhaustion vector on DB-sourced input. All wall-clock reads use
`Accrue.Clock.utc_now/0` for Fake-lane determinism (Phase 130).

## Observability (DUN-08, Phase 129)

Each delivered step records a `dunning.step_sent` `accrue_events` ledger
entry (the audit trail + the substrate the recovered-vs-lost counter folds
over) AND fires `[:accrue, :ops, :dunning_step_sent]` telemetry (live
operator metrics). Both carry only safe IDs + bounded values
(`subscription_id`, `step_key`, `step_index`) — never customer email, card
data, or amounts (V7 PII-exclusion contract).

## Scope fence

This worker does NOT introduce the `Accrue.Dunning.Engine` behaviour
(DUN-03, Phase 131) — step resolution is a direct call to the pure
resolver, keeping the future engine seam clean.

## Host wiring

Accrue does not start its own Oban instance. The host wires the queue
(shared with the sweeper):

    config :my_app, Oban, queues: [accrue_dunning: 2]

# `enqueue_step`

```elixir
@spec enqueue_step(binary(), atom(), DateTime.t(), map()) ::
  {:ok, Oban.Job.t()} | {:error, term()}
```

Enqueues a dunning step for delivery, threading the campaign anchor.

Used by both this worker's internal chaining and the Plan-06 webhook
reducer that seeds the day-0 step. The D-16 `unique` keyword keys the job
on `[:subscription_id, :step_key, :campaign_started_at]` so the SAME step
can never be enqueued twice (returns `{:ok, %Oban.Job{conflict?: true}}`
on a duplicate).

`campaign_started_at` is coerced to an ISO8601 string so the args stay
Oban-JSON-safe. `extra` carries scalar reference IDs only (`:customer_id`,
`:invoice_id`) — no structs, no PII.

# `perform`

Delivers one dunning step and chains the next.

1. Cancel-guard FIRST: reload the row; `{:cancel, :recovered}` (deliver
   nothing) when not past_due OR the campaign anchor is `nil`.
2. Deliver the current step's email exactly once (outside any
   transaction).
3. Resolve the next step via the pure resolver and enqueue it with the
   SAME anchor; enqueue nothing when the journey is exhausted.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
