Accrue.Analytics.Dunning (accrue v1.3.0)

Copy Markdown View Source

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.

Summary

Functions

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

Returns all dunning events for a subscription in chronological order.

Returns dunning events for a subscription grouped into campaign arcs.

Three-stage dunning funnel computed from the accrue_events ledger.

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

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

Computes the arithmetic recovery rate from the dunning funnel.

Functions

at_risk_subscriptions(opts \\ [])

(since 1.3.0)
@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_etaDateTime.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(subscription_id, opts \\ [])

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

Returns all dunning events for a subscription in chronological order.

campaign_timeline_grouped(subscription_id, opts \\ [])

(since 1.3.0)
@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(opts \\ [])

(since 1.3.0)
@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(subscription_id, opts \\ [])

(since 1.3.0)
@spec invoices_for_campaign(
  String.t(),
  keyword()
) :: %{required(String.t()) => map()}

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

recovered_vs_lost_mrr(opts \\ [])

(since 1.3.0)
@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(opts \\ [])

(since 1.3.0)
@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).