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
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 ofdunning.step_sentevents for this campaign:next_step_eta—DateTime.t()or nil when no pending Oban job:failure_reason— data map from the most-recentinvoice.payment_failedevent for this subscription's campaign, or nil when no matched invoice or payment failure event exists (pre-v1.44 campaigns withoutinvoice_idincampaign_starteddata also return nil)
Options
:since—%DateTime{}lower bound (inclusive ondunning_campaign_started_at):until—%DateTime{}upper bound (inclusive ondunning_campaign_started_at)
@spec campaign_timeline( String.t(), keyword() ) :: [Accrue.Events.Event.t()]
Returns all dunning events for a subscription in chronological order.
@spec campaign_timeline_grouped( String.t(), keyword() ) :: [{String.t() | nil, [Accrue.Events.Event.t()]}]
Returns dunning events for a subscription grouped into campaign arcs.
@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 oninserted_at).:until—%DateTime{}upper bound (inclusive oninserted_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}
Returns a map of invoices for a given subscription, keyed by Stripe processor_id.
@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 ofmrr_value_centsfromdunning.recoveredlost= sum ofmrr_value_centsfromdunning.exhausted
Options
:since—%DateTime{}lower bound (inclusive), inclusive oninserted_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}]
}
@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 oninserted_at).:until—%DateTime{}upper bound (inclusive oninserted_at).