# `Accrue.Analytics.Dunning`
[🔗](https://github.com/szTheory/accrue/blob/accrue-v1.5.1/lib/accrue/analytics/dunning.ex#L1)

Analytics context for Dunning.

Provides MRR-based recovery vs lost metrics without adding new database
tables, querying directly against the `accrue_events` ledger via Ecto JSONB
aggregations.

For details on cutoff-date semantics, performance thresholds (such as adding
expression indexes at ~100k events), and open-shape map contracts, refer
to the [Analytics Guide](guides/analytics.md).

# `at_risk_subscriptions`
*since 1.3.0* 

```elixir
@spec at_risk_subscriptions(keyword()) :: [map()]
```

Returns subscriptions currently in an active dunning campaign, enriched
with next-step ETA and last-failure reason.

Window opts (`:since`, `:until`) filter by `dunning_campaign_started_at`.
Uses a NOT EXISTS ledger tiebreaker to exclude subscriptions that have
recovered even when the schema anchor column hasn't been cleared yet
(projection-lag race). Pre-v1.44 campaigns without `invoice_id` in
`campaign_started` data return `nil` for `failure_reason`.

## Return shape

Each map contains:
- `:subscription_id` — Accrue UUID
- `:customer_id` — Accrue UUID
- `:customer_label` — customer email (or name, or nil)
- `:days_in_campaign` — integer days since campaign start (truncated)
- `:current_step` — count of `dunning.step_sent` events for this campaign
- `:next_step_eta` — `DateTime.t()` or nil when no pending Oban job
- `:failure_reason` — data map from the most-recent `invoice.payment_failed` event
  for this subscription's campaign, or nil when no matched invoice or payment failure
  event exists (pre-v1.44 campaigns without `invoice_id` in `campaign_started` data
  also return nil)

## Options

  * `:since` — `%DateTime{}` lower bound (inclusive on `dunning_campaign_started_at`)
  * `:until` — `%DateTime{}` upper bound (inclusive on `dunning_campaign_started_at`)

# `campaign_timeline`
*since 1.3.0* 

```elixir
@spec campaign_timeline(
  String.t(),
  keyword()
) :: [Accrue.Events.Event.t()]
```

Returns all dunning events for a subscription in chronological order.

# `campaign_timeline_grouped`
*since 1.3.0* 

```elixir
@spec campaign_timeline_grouped(
  String.t(),
  keyword()
) :: [{String.t() | nil, [Accrue.Events.Event.t()]}]
```

Returns dunning events for a subscription grouped into campaign arcs.

# `funnel`
*since 1.3.0* 

```elixir
@spec funnel(keyword()) :: %{
  entered: non_neg_integer(),
  recovered: non_neg_integer(),
  exhausted: non_neg_integer(),
  active: non_neg_integer()
}
```

Three-stage dunning funnel computed from the `accrue_events` ledger.

Returns a flat map of DISTINCT-`(subject_id, campaign_anchor)`-tuple counts:

    %{entered: N, recovered: N, exhausted: N, active: N}

Counts DISTINCT `(subject_id, campaign_anchor)` tuples per stage so that a
subscription cycling dunning multiple times in the window is NOT
double-counted on any single stage. The three filter predicates are
mutually exclusive, which guarantees the invariant
`recovered + exhausted + active <= entered`. (Strictly less when a tuple
flags BOTH recovered AND exhausted — physically impossible by construction
but defensively handled.)

Pre-Phase-144 events without `campaign_anchor` fall through under a
per-subject sentinel `"__legacy__"` — "earliest known single-row stage
attribution". This UNDER-counts `entered` if a subject cycled multiple
legacy campaigns. Backfill is architecturally impossible: the
`accrue_events` immutability trigger rejects updates (SQLSTATE 45A01).
Documented cutoff label in `guides/analytics.md` lands with Phase 148.

Runs as a SINGLE Ecto query — one `Repo.one/1` call wrapping a `subquery/1`.
No concurrent task per stage; the Postgres planner converts the two-level
GROUP BY into one HashAggregate over the inner subquery.

## Options

  * `:since` — `%DateTime{}` lower bound (inclusive on `inserted_at`).
  * `:until` — `%DateTime{}` upper bound (inclusive on `inserted_at`).

Window-bounding is applied to the inner subquery so events are excluded
BEFORE the `(subject_id, campaign_anchor)` grouping.

## Examples

    # One subject cycles dunning 3 times in the window:
    # anchor_1 → recovered, anchor_2 → exhausted, anchor_3 → still active.
    iex> Accrue.Analytics.Dunning.funnel()
    %{entered: 3, recovered: 1, exhausted: 1, active: 1}

# `invoices_for_campaign`
*since 1.3.0* 

```elixir
@spec invoices_for_campaign(
  String.t(),
  keyword()
) :: %{required(String.t()) =&gt; map()}
```

Returns a map of invoices for a given subscription, keyed by Stripe processor_id.

# `recovered_vs_lost_mrr`
*since 1.3.0* 

```elixir
@spec recovered_vs_lost_mrr(keyword()) :: %{
  recovered: [%{currency: String.t(), cents: non_neg_integer()}],
  lost: [%{currency: String.t(), cents: non_neg_integer()}]
}
```

Folds the `accrue_events` ledger into lists of recovered and lost MRR,
grouped by currency.

This adds NO new table: it groups by the two confirmed-transition lifecycle
ledger types written by the campaign:

  * `recovered` = sum of `mrr_value_cents` from `dunning.recovered`
  * `lost`      = sum of `mrr_value_cents` from `dunning.exhausted`

## Options

  * `:since` — `%DateTime{}` lower bound (inclusive), inclusive on
    `inserted_at >= since`.
  * `:until` — `%DateTime{}` upper bound (inclusive), `inserted_at <= until`.

## Examples

    iex> Accrue.Analytics.Dunning.recovered_vs_lost_mrr()
    %{
      recovered: [%{currency: "usd", cents: 12000}],
      lost: [%{currency: "usd", cents: 3000}]
    }

# `recovery_rate`
*since 1.3.0* 

```elixir
@spec recovery_rate(keyword()) :: %{
  rate: float() | nil,
  recovered: non_neg_integer(),
  total_concluded: non_neg_integer()
}
```

Computes the arithmetic recovery rate from the dunning funnel.

Calculates the rate as `recovered / (recovered + exhausted)`.
Returns `%{rate: 0.0..1.0 | nil, recovered: N, total_concluded: N}`.
If `total_concluded` is 0, `rate` is `nil` to prevent division by zero.

## Options

  * `:since` — `%DateTime{}` lower bound (inclusive on `inserted_at`).
  * `:until` — `%DateTime{}` upper bound (inclusive on `inserted_at`).

---

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