diff --git a/src/pages/admin/dashboard/revenue.vue b/src/pages/admin/dashboard/revenue.vue
index 203dcf7c30..87ad29b9cd 100644
--- a/src/pages/admin/dashboard/revenue.vue
+++ b/src/pages/admin/dashboard/revenue.vue
@@ -780,7 +780,7 @@ displayStore.defaultBack = '/dashboard'
0
- Active subscription organizations
+ Paid subscriptions, excluding trials
diff --git a/src/pages/admin/dashboard/users.vue b/src/pages/admin/dashboard/users.vue
index 08ff9a87e7..5d64f7f9de 100644
--- a/src/pages/admin/dashboard/users.vue
+++ b/src/pages/admin/dashboard/users.vue
@@ -1699,7 +1699,7 @@ displayStore.defaultBack = '/dashboard'
0
- Active subscription organizations
+ Paid subscriptions, excluding trials
diff --git a/supabase/functions/_backend/plugin_runtime/utils/pg.ts b/supabase/functions/_backend/plugin_runtime/utils/pg.ts
index 228b8ce485..27ab078fa2 100644
--- a/supabase/functions/_backend/plugin_runtime/utils/pg.ts
+++ b/supabase/functions/_backend/plugin_runtime/utils/pg.ts
@@ -2404,6 +2404,11 @@ export async function getAdminPayingOrgBreakdown(c: Context): Promise>>(app => {
+ app.get('/', async c => c.json(await getAdminPayingOrgBreakdown(c)))
+ })
+}
+
let adminHeaders: Record
let soloPlan: {
name: string
@@ -1004,6 +1010,108 @@ describe('global stats core snapshots', () => {
expect(historical?.plan_credits).toBe(1)
expect(latest?.plan_credits).toBe(3)
})
+
+ it.concurrent('does not count trial orgs as paid via subscription', async () => {
+ const trialOrgId = randomUUID()
+ const paidOrgId = randomUUID()
+ const trialAppId = `com.admin.stats.paidsub.trial.${trialOrgId.slice(0, 8)}`
+ const paidAppId = `com.admin.stats.paidsub.paid.${paidOrgId.slice(0, 8)}`
+ const trialCustomerId = `cus_admin_stats_paidsub_trial_${trialOrgId.slice(0, 8)}`
+ const paidCustomerId = `cus_admin_stats_paidsub_paid_${paidOrgId.slice(0, 8)}`
+ const orgIds = [trialOrgId, paidOrgId]
+ const appIds = [trialAppId, paidAppId]
+ const customerIds = [trialCustomerId, paidCustomerId]
+ const past = new Date(NOW - (30 * DAY_IN_MS)).toISOString()
+ const future = new Date(NOW + (30 * DAY_IN_MS)).toISOString()
+
+ try {
+ await Promise.all([
+ resetAndSeedAppData(trialAppId, {
+ orgId: trialOrgId,
+ stripeCustomerId: trialCustomerId,
+ planProductId: PRODUCT_ID,
+ }),
+ resetAndSeedAppData(paidAppId, {
+ orgId: paidOrgId,
+ stripeCustomerId: paidCustomerId,
+ planProductId: PRODUCT_ID,
+ }),
+ ])
+
+ await executeSQL(`
+ UPDATE public.stripe_info
+ SET status = 'succeeded'::public.stripe_status,
+ is_good_plan = true,
+ paid_at = NULL,
+ canceled_at = NULL,
+ trial_at = $2::timestamptz,
+ subscription_anchor_end = $2::timestamptz
+ WHERE customer_id = $1
+ `, [trialCustomerId, future])
+
+ await executeSQL(`
+ UPDATE public.stripe_info
+ SET status = 'succeeded'::public.stripe_status,
+ is_good_plan = true,
+ paid_at = $2::timestamptz,
+ canceled_at = NULL,
+ trial_at = $2::timestamptz,
+ subscription_anchor_end = $3::timestamptz
+ WHERE customer_id = $1
+ `, [paidCustomerId, past, future])
+
+ // Same membership filters as getAdminPayingOrgBreakdown. Scoped to these
+ // two customers so parallel test files cannot change the assertion.
+ const included = await executeSQL<{ customer_id: string }>(`
+ SELECT si.customer_id
+ FROM public.stripe_info si
+ INNER JOIN public.plans p ON p.stripe_id = si.product_id
+ INNER JOIN public.orgs o ON o.customer_id = si.customer_id
+ WHERE si.customer_id = ANY($1::text[])
+ AND si.is_good_plan = true
+ AND si.paid_at IS NOT NULL
+ AND si.paid_at < NOW()
+ AND si.trial_at <= NOW()
+ AND si.status IN (
+ 'succeeded'::public.stripe_status,
+ 'canceled'::public.stripe_status,
+ 'deleted'::public.stripe_status
+ )
+ AND (si.canceled_at IS NULL OR si.canceled_at > NOW())
+ AND si.subscription_anchor_end > NOW()
+ ORDER BY si.customer_id
+ `, [customerIds])
+
+ expect(included.map(row => row.customer_id)).toEqual([paidCustomerId])
+
+ const expectedTotal = await executeSQL<{ count: number }>(`
+ SELECT COUNT(DISTINCT o.id)::int AS count
+ FROM public.stripe_info si
+ INNER JOIN public.plans p ON p.stripe_id = si.product_id
+ INNER JOIN public.orgs o ON o.customer_id = si.customer_id
+ WHERE si.is_good_plan = true
+ AND si.paid_at IS NOT NULL
+ AND si.paid_at < NOW()
+ AND si.trial_at <= NOW()
+ AND si.status IN (
+ 'succeeded'::public.stripe_status,
+ 'canceled'::public.stripe_status,
+ 'deleted'::public.stripe_status
+ )
+ AND (si.canceled_at IS NULL OR si.canceled_at > NOW())
+ AND si.subscription_anchor_end > NOW()
+ `)
+
+ const live = await getPayingOrgBreakdownDirect()
+ expect(live.paying_orgs_subscription).toBe(Number(expectedTotal[0]?.count) || 0)
+ }
+ finally {
+ await Promise.all(appIds.map(appId => resetAppData(appId)))
+ await executeSQL('DELETE FROM public.org_users WHERE org_id = ANY($1::uuid[])', [orgIds])
+ await executeSQL('DELETE FROM public.orgs WHERE id = ANY($1::uuid[])', [orgIds])
+ await executeSQL('DELETE FROM public.stripe_info WHERE customer_id = ANY($1::text[])', [customerIds])
+ }
+ }, 90000)
})
describe('/private/admin_stats', () => {