Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pages/admin/dashboard/revenue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ displayStore.defaultBack = '/dashboard'
0
</p>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
Active subscription organizations
Paid subscriptions, excluding trials
</p>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/admin/dashboard/users.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1699,7 +1699,7 @@ displayStore.defaultBack = '/dashboard'
0
</p>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
Active subscription organizations
Paid subscriptions, excluding trials
</p>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions supabase/functions/_backend/plugin_runtime/utils/pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@
* both explicitly so transient replica/Hyperdrive failures retain their
* PostgreSQL SQLSTATE and network diagnostics.
*/
export function serializePostgresError(

Check failure on line 666 in supabase/functions/_backend/plugin_runtime/utils/pg.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AaCvpIIkxXEneeMyCkFi&open=AaCvpIIkxXEneeMyCkFi&pullRequest=3372
error: unknown,
seen = new WeakSet<object>(),
depth = 0,
Expand Down Expand Up @@ -2404,6 +2404,11 @@
FROM public.stripe_info si
INNER JOIN public.plans p ON p.stripe_id = si.product_id
WHERE si.is_good_plan = true
-- Trials are stored as succeeded + is_good_plan. Require a real payment
-- and an ended trial so this card matches "Paid via Subscription".
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,
Expand Down
5 changes: 5 additions & 0 deletions supabase/functions/_backend/utils/pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2067,6 +2067,11 @@ export async function getAdminPayingOrgBreakdown(c: Context): Promise<AdminPayin
FROM public.stripe_info si
INNER JOIN public.plans p ON p.stripe_id = si.product_id
WHERE si.is_good_plan = true
-- Trials are stored as succeeded + is_good_plan. Require a real payment
-- and an ended trial so this card matches "Paid via Subscription".
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,
Expand Down
110 changes: 109 additions & 1 deletion tests/admin-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Hono } from 'hono/tiny'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { globalStatsTestUtils } from '../supabase/functions/_backend/triggers/global_stats.ts'
import { REQUIRED_GLOBAL_STATS_SHARDS } from '../supabase/functions/_backend/utils/global_stats.ts'
import { getAdminGlobalStatsTrend, getAdminOnboardingFunnel } from '../supabase/functions/_backend/utils/pg.ts'
import { getAdminGlobalStatsTrend, getAdminOnboardingFunnel, getAdminPayingOrgBreakdown } from '../supabase/functions/_backend/utils/pg.ts'
import { BASE_URL, executeSQL, fetchTestRequest, getAuthHeadersForCredentials, getEndpointUrl, getSupabaseClient, POSTGRES_URL, PRODUCT_ID, resetAndSeedAppData, resetAppData, TEST_EMAIL, USER_ADMIN_EMAIL, USER_ID, USER_PASSWORD_HASH } from './test-utils.ts'

const DAY_IN_MS = 24 * 60 * 60 * 1000
Expand Down Expand Up @@ -130,6 +130,12 @@ async function getGlobalStatsTrendDirect(startDate: string, endDate: string) {
})
}

async function getPayingOrgBreakdownDirect() {
return requestDirectAdminStats<Awaited<ReturnType<typeof getAdminPayingOrgBreakdown>>>(app => {
app.get('/', async c => c.json(await getAdminPayingOrgBreakdown(c)))
})
}

let adminHeaders: Record<string, string>
let soloPlan: {
name: string
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading