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]
Summary
Functions
Enqueues a dunning step for delivery, threading the campaign anchor.
Delivers one dunning step and chains the next.
Functions
@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.
Delivers one dunning step and chains the next.
- Cancel-guard FIRST: reload the row;
{:cancel, :recovered}(deliver nothing) when not past_due OR the campaign anchor isnil. - Deliver the current step's email exactly once (outside any transaction).
- Resolve the next step via the pure resolver and enqueue it with the SAME anchor; enqueue nothing when the journey is exhausted.