Accrue.Workers.DunningStep (accrue v1.3.0)

Copy Markdown View Source

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

enqueue_step(subscription_id, step_key, campaign_started_at, extra \\ %{})

@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(job)

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.