fix(admin): show the cardless trial as a trial in the admin panel

The cardless trial writes trialEndsAt and nothing else, because there is no
Stripe subscription behind it to report trialing. subscriptionStatus stays
FREE, so every admin view reading that column alone showed a live trial as a
free account: On Trial sat at zero, Free Users counted the trials, the user
table badged them Free with an open-ended Active access, the Trialing filter
returned nobody and the growth scoreboard left them out of the paid accounts
table.

Access has always been resolved from the date (hasBillingAccess), so the
display now follows the same date through getEffectiveBillingStatus. Only FREE
is overridden: any other status means Stripe has an opinion worth showing.
This commit is contained in:
2026-08-18 18:55:41 +03:00
parent bf46860feb
commit 60c208b3b3
7 changed files with 282 additions and 22 deletions
+33 -10
View File
@@ -5,7 +5,9 @@ import { auth } from '@/lib/auth';
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
import {
buildBillingAccessWhereInput,
buildEffectiveBillingStatusWhereInput,
getBillingStatusLabel,
getEffectiveBillingStatus,
hasBillingAccess,
} from '@/lib/billing';
import { redirect } from 'next/navigation';
@@ -71,7 +73,9 @@ function getOwnBillingAccess(
user.stripeCancelAtPeriodEnd || user.subscriptionStatus === BillingSubscriptionStatus.CANCELED;
let endsAt: Date | null = null;
if (user.subscriptionStatus === BillingSubscriptionStatus.TRIALING) {
// The effective status, so a cardless trial (stored as FREE) still shows the
// date its access runs out instead of an open-ended "Active access".
if (getEffectiveBillingStatus(user, now) === BillingSubscriptionStatus.TRIALING) {
endsAt = user.trialEndsAt;
} else if (isEnding) {
endsAt = user.stripeCurrentPeriodEnd ?? user.stripeCancelAt;
@@ -114,6 +118,20 @@ const STATUS_FILTERS: BillingSubscriptionStatus[] = [
BillingSubscriptionStatus.FREE,
];
// Sorting by subscription happens in memory (see canSortInDb), so the order the
// database would have used for the enum has to be spelled out. Same order as the
// enum is declared in the schema.
const STATUS_SORT_ORDER: BillingSubscriptionStatus[] = [
BillingSubscriptionStatus.FREE,
BillingSubscriptionStatus.TRIALING,
BillingSubscriptionStatus.ACTIVE,
BillingSubscriptionStatus.PAST_DUE,
BillingSubscriptionStatus.CANCELED,
BillingSubscriptionStatus.UNPAID,
BillingSubscriptionStatus.INCOMPLETE,
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
];
const ACCESS_FILTERS: Array<{ value: AccessFilter; label: string }> = [
{ value: 'ALL', label: 'All Access' },
{ value: 'ACTIVE', label: 'Has Access' },
@@ -219,7 +237,9 @@ function getSortIndicator(
function canSortInDb(sortBy: SortBy): boolean {
return (
sortBy === 'user' ||
sortBy === 'subscription' ||
// 'subscription' is deliberately absent: it sorts on the effective status,
// which lives on trialEndsAt as much as on the stored column, so a cardless
// trial would otherwise sort among the free accounts it is not shown with.
sortBy === 'joinedDate' ||
sortBy === 'workspacesOwned' ||
sortBy === 'projectsOwned' ||
@@ -237,10 +257,6 @@ function getUsersOrderBy(
return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker];
}
if (sortBy === 'subscription') {
return [{ subscriptionStatus: sortDirection }, createdAtTieBreaker];
}
if (sortBy === 'joinedDate') {
return [{ createdAt: sortDirection }];
}
@@ -319,7 +335,7 @@ export default async function AdminUsersPage({
}
if (statusFilter !== 'ALL') {
filters.push({ subscriptionStatus: statusFilter });
filters.push(buildEffectiveBillingStatusWhereInput(statusFilter, now));
}
if (accessFilter !== 'ALL') {
@@ -388,6 +404,7 @@ export default async function AdminUsersPage({
email: string | null;
createdAt: Date;
subscriptionStatus: BillingSubscriptionStatus;
effectiveStatus: BillingSubscriptionStatus;
trialEndsAt: Date | null;
stripeCurrentPeriodEnd: Date | null;
stripeCancelAtPeriodEnd: boolean;
@@ -412,6 +429,7 @@ export default async function AdminUsersPage({
paginatedUsers = users.map((user) => ({
...user,
effectiveStatus: getEffectiveBillingStatus(user, now),
invitedMembersCount: user.ownedWorkspaces.reduce(
(total, workspace) => total + workspace._count.members,
0
@@ -425,6 +443,7 @@ export default async function AdminUsersPage({
const usersWithMetrics = users.map((user) => ({
...user,
effectiveStatus: getEffectiveBillingStatus(user, now),
invitedMembersCount: user.ownedWorkspaces.reduce(
(total, workspace) => total + workspace._count.members,
0
@@ -437,7 +456,11 @@ export default async function AdminUsersPage({
const sortedUsers = usersWithMetrics.sort((a, b) => {
let comparison = 0;
if (sortBy === 'invitedMembers') {
if (sortBy === 'subscription') {
comparison =
STATUS_SORT_ORDER.indexOf(a.effectiveStatus) -
STATUS_SORT_ORDER.indexOf(b.effectiveStatus);
} else if (sortBy === 'invitedMembers') {
comparison = a.invitedMembersCount - b.invitedMembersCount;
} else if (sortBy === 'bunnyUpload') {
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
@@ -758,8 +781,8 @@ export default async function AdminUsersPage({
return (
<TableCell>
<div className="flex flex-col items-start gap-1">
<Badge variant={getBillingStatusVariant(user.subscriptionStatus)}>
{getBillingStatusLabel(user.subscriptionStatus)}
<Badge variant={getBillingStatusVariant(user.effectiveStatus)}>
{getBillingStatusLabel(user.effectiveStatus)}
</Badge>
{access.hasAppAccess ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
+17 -6
View File
@@ -4,6 +4,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
import { isBunnyUploadsEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { buildCardlessTrialWhereInput } from '@/lib/billing';
import { logError } from '@/lib/logger';
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
@@ -551,10 +552,17 @@ export const getCachedStripeStats = unstable_cache(
if (!isStripeBillingEnabled()) return null;
try {
const statusCounts = await db.user.groupBy({
by: ['subscriptionStatus'],
_count: { id: true },
});
const now = new Date();
// The cardless trial leaves `subscriptionStatus` at FREE, so the group-by
// alone counted every trial as a free user and reported "On Trial" as zero.
// Counted separately and moved across the two buckets below.
const [statusCounts, cardlessTrialUsers] = await Promise.all([
db.user.groupBy({
by: ['subscriptionStatus'],
_count: { id: true },
}),
db.user.count({ where: buildCardlessTrialWhereInput(now) }),
]);
const counts: Record<string, number> = {};
for (const row of statusCounts) {
@@ -562,10 +570,13 @@ export const getCachedStripeStats = unstable_cache(
}
const activeSubscribers = counts['ACTIVE'] ?? 0;
const trialingUsers = counts['TRIALING'] ?? 0;
const trialingUsers = (counts['TRIALING'] ?? 0) + cardlessTrialUsers;
const pastDueUsers = counts['PAST_DUE'] ?? 0;
const canceledUsers = counts['CANCELED'] ?? 0;
const freeUsers = counts['FREE'] ?? 0;
// Clamped because the two queries above see two different snapshots: a signup
// landing between them can be counted as a trial without having been counted
// as free, which would otherwise report a negative number of free users.
const freeUsers = Math.max(0, (counts['FREE'] ?? 0) - cardlessTrialUsers);
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED belonged to none of the five buckets
// above, so those users were counted nowhere and the totals silently did not add
// up to the user table.
+11 -2
View File
@@ -379,7 +379,14 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
SELECT u.id AS user_id,
u.name,
u.email,
u."subscriptionStatus"::text AS status,
-- A cardless trial has no Stripe subscription to carry the status,
-- so it sits at FREE with only a date to go on. Reported as the
-- trial it is, and matched by the WHERE below for the same reason.
CASE
WHEN u."subscriptionStatus"::text = 'FREE' AND u."trialEndsAt" > NOW()
THEN 'TRIALING'
ELSE u."subscriptionStatus"::text
END AS status,
ua.channel,
ua.self_reported,
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '7 days')::int
@@ -393,7 +400,9 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
ON e.user_id = u.id
AND e.name::text = ANY(${[...VALUE_EVENT_NAMES]}::text[])
WHERE u."subscriptionStatus"::text IN ('ACTIVE', 'TRIALING')
GROUP BY u.id, u.name, u.email, u."subscriptionStatus", ua.channel, ua.self_reported
OR (u."subscriptionStatus"::text = 'FREE' AND u."trialEndsAt" > NOW())
GROUP BY u.id, u.name, u.email, u."subscriptionStatus", u."trialEndsAt", ua.channel,
ua.self_reported
ORDER BY MAX(e.occurred_at) ASC NULLS FIRST
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
`,
+67
View File
@@ -258,6 +258,73 @@ export function getBillingStatusLabel(status: BillingSubscriptionStatus) {
}
}
/**
* A `where` matching the accounts whose only entitlement is a running cardless
* trial: no Stripe subscription behind them, so `subscriptionStatus` is FREE.
*/
export function buildCardlessTrialWhereInput(now: Date = new Date()): Prisma.UserWhereInput {
return {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: { gt: now },
};
}
/**
* The status to show for an account, which is not always the one Stripe stored.
*
* The cardless trial writes `trialEndsAt` and nothing else, because there is no
* Stripe subscription behind it to report `trialing`. `subscriptionStatus` stays
* FREE, so anything reading that column alone showed a running trial as a free
* account: the admin dashboard counted every trial under "Free Users" and left
* "On Trial" at zero. Access is already resolved from the date in
* `hasBillingAccess`, so what is displayed follows the same date.
*
* Only FREE is overridden. Every other status means Stripe has an opinion about
* this account (an abandoned checkout leaves INCOMPLETE while the trial runs on),
* and that opinion is the more useful of the two to show.
*/
export function getEffectiveBillingStatus(
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'trialEndsAt'>,
now: Date = new Date()
): BillingSubscriptionStatus {
if (
subject.subscriptionStatus === BillingSubscriptionStatus.FREE &&
hasActiveTrial(subject.trialEndsAt, now)
) {
return BillingSubscriptionStatus.TRIALING;
}
return subject.subscriptionStatus;
}
/**
* A `where` that filters on the displayed status rather than the stored one, so
* an admin asking for "Trialing" is handed the cardless trials and one asking
* for "Free" is not.
*/
export function buildEffectiveBillingStatusWhereInput(
status: BillingSubscriptionStatus,
now: Date = new Date()
): Prisma.UserWhereInput {
if (status === BillingSubscriptionStatus.TRIALING) {
return {
OR: [
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING },
buildCardlessTrialWhereInput(now),
],
};
}
if (status === BillingSubscriptionStatus.FREE) {
return {
subscriptionStatus: BillingSubscriptionStatus.FREE,
OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: now } }],
};
}
return { subscriptionStatus: status };
}
/**
* Grants the cardless trial, once per account, and reports whether this call is
* the one that granted it.
+23 -1
View File
@@ -162,7 +162,8 @@ describe('getScoreboard', () => {
const busy = await createUser({ subscriptionStatus: 'ACTIVE' });
const silent = await createUser({ subscriptionStatus: 'ACTIVE' });
const trialing = await createUser({ subscriptionStatus: 'TRIALING' });
await createUser({ subscriptionStatus: 'FREE' });
// Free with nothing left to run, so it stays out of the table.
await createUser({ subscriptionStatus: 'FREE', trialEndsAt: null });
await seedEvent({ name: 'VIDEO_ADDED', occurredAt: daysAgo(2), userId: busy.id });
await seedEvent({ name: 'SHARE_LINK_CREATED', occurredAt: daysAgo(20), userId: busy.id });
@@ -185,6 +186,27 @@ describe('getScoreboard', () => {
expect(busyRow?.valueEvents7).toBe(1);
expect(busyRow?.valueEvents30).toBe(2);
});
// A cardless trial has no Stripe subscription to hold the status, so it sits at
// FREE with only a date behind it. Filtering on the status column alone left
// every trial account out of this table and out of the at-risk list with it.
it('includes a cardless trial and reports it as trialing', async () => {
const cardless = await createUser({
subscriptionStatus: 'FREE',
trialEndsAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
const expired = await createUser({
subscriptionStatus: 'FREE',
trialEndsAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
});
const scoreboard = await getScoreboard({ weeks: 4 });
const ids = scoreboard.paidAccounts.map((row) => row.userId);
expect(ids).toEqual([cardless.id]);
expect(ids).not.toContain(expired.id);
expect(scoreboard.paidAccounts[0]?.status).toBe('TRIALING');
});
});
// The cohort comparison is another block of raw SQL, and the part most easily
+44 -3
View File
@@ -869,9 +869,11 @@ describe('getCachedStripeStats', () => {
await createUser({ subscriptionStatus: 'TRIALING' });
await createUser({ subscriptionStatus: 'PAST_DUE' });
await createUser({ subscriptionStatus: 'CANCELED' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
await createUser({ subscriptionStatus: 'FREE' });
// Free means free: no trial left to run, or these would be counted as the
// cardless trials they would then be.
await createUser({ subscriptionStatus: 'FREE', trialEndsAt: null });
await createUser({ subscriptionStatus: 'FREE', trialEndsAt: null });
await createUser({ subscriptionStatus: 'FREE', trialEndsAt: null });
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'eur' });
expect(await getCachedStripeStats()).toEqual({
@@ -887,6 +889,45 @@ describe('getCachedStripeStats', () => {
expect(stripe.retrievedPriceIds).toEqual(['price_admin_stats_test']);
});
// The cardless trial writes only trialEndsAt, so the account sits at FREE with no
// Stripe subscription behind it. Counting the status column alone reported every
// one of them as a free user and left "On Trial" at zero on the dashboard.
it('counts a cardless trial as trialing rather than as free', async () => {
await createUser({ subscriptionStatus: 'FREE', trialEndsAt: new Date(Date.now() + 60_000) });
await createUser({ subscriptionStatus: 'FREE', trialEndsAt: null });
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
const stats = await getCachedStripeStats();
expect(stats?.trialingUsers).toBe(1);
expect(stats?.freeUsers).toBe(1);
});
it('counts an expired trial back as a free user', async () => {
await createUser({ subscriptionStatus: 'FREE', trialEndsAt: new Date(Date.now() - 60_000) });
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
const stats = await getCachedStripeStats();
expect(stats?.trialingUsers).toBe(0);
expect(stats?.freeUsers).toBe(1);
});
// A Stripe trial is already TRIALING and is not sitting in the FREE bucket, so it
// must not be added on top of the cardless count.
it('does not double count a Stripe trial that also carries a trial end date', async () => {
await createUser({
subscriptionStatus: 'TRIALING',
trialEndsAt: new Date(Date.now() + 60_000),
});
stubStripePrice({ unit_amount: 1900, currency: 'usd' });
const stats = await getCachedStripeStats();
expect(stats?.trialingUsers).toBe(1);
expect(stats?.freeUsers).toBe(0);
});
// UNPAID, INCOMPLETE and INCOMPLETE_EXPIRED are real values of the enum that used to
// belong to none of the reported buckets, so those users were counted nowhere and the
// five totals silently did not add up to the user table. They must not be folded into
+87
View File
@@ -4,11 +4,14 @@ import { BillingSubscriptionStatus } from '@prisma/client';
import {
DEFAULT_TRIAL_PERIOD_DAYS,
buildBillingAccessWhereInput,
buildCardlessTrialWhereInput,
buildEffectiveBillingStatusWhereInput,
buildExpiredBillingWhereInput,
getBillingAccessEndDate,
getBillingOverview,
getBillingStatusLabel,
getDefaultTrialEndsAt,
getEffectiveBillingStatus,
getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt,
getStripeCheckoutState,
@@ -546,6 +549,90 @@ describe('getBillingStatusLabel', () => {
});
});
describe('getEffectiveBillingStatus', () => {
const future = new Date(NOW.getTime() + DAY_MS);
const past = new Date(NOW.getTime() - DAY_MS);
// The bug this exists for: a cardless trial writes trialEndsAt and nothing
// else, so the admin panel read every live trial as a free account.
it('reports a cardless trial as trialing', () => {
expect(
getEffectiveBillingStatus(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, trialEndsAt: future },
NOW
)
).toBe(BillingSubscriptionStatus.TRIALING);
});
it('reports an expired trial as free again', () => {
expect(
getEffectiveBillingStatus(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, trialEndsAt: past },
NOW
)
).toBe(BillingSubscriptionStatus.FREE);
});
it('reports a free account with no trial as free', () => {
expect(
getEffectiveBillingStatus(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, trialEndsAt: null },
NOW
)
).toBe(BillingSubscriptionStatus.FREE);
});
// Anything Stripe has an opinion about keeps that opinion. An abandoned
// checkout leaves INCOMPLETE while the trial runs on, and INCOMPLETE is the
// more useful half of that to show.
it.each(ALL_STATUSES.filter((status) => status !== BillingSubscriptionStatus.FREE))(
'leaves the stored status %s alone even during a running trial',
(status) => {
expect(
getEffectiveBillingStatus({ subscriptionStatus: status, trialEndsAt: future }, NOW)
).toBe(status);
}
);
});
describe('buildCardlessTrialWhereInput', () => {
it('matches a free account whose trial is still running', () => {
expect(buildCardlessTrialWhereInput(NOW)).toEqual({
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: { gt: NOW },
});
});
});
describe('buildEffectiveBillingStatusWhereInput', () => {
it('folds cardless trials into the trialing filter', () => {
expect(buildEffectiveBillingStatusWhereInput(BillingSubscriptionStatus.TRIALING, NOW)).toEqual({
OR: [
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING },
{ subscriptionStatus: BillingSubscriptionStatus.FREE, trialEndsAt: { gt: NOW } },
],
});
});
it('keeps cardless trials out of the free filter', () => {
expect(buildEffectiveBillingStatusWhereInput(BillingSubscriptionStatus.FREE, NOW)).toEqual({
subscriptionStatus: BillingSubscriptionStatus.FREE,
OR: [{ trialEndsAt: null }, { trialEndsAt: { lte: NOW } }],
});
});
it.each(
ALL_STATUSES.filter(
(status) =>
status !== BillingSubscriptionStatus.FREE && status !== BillingSubscriptionStatus.TRIALING
)
)('matches the stored column alone for %s', (status) => {
expect(buildEffectiveBillingStatusWhereInput(status, NOW)).toEqual({
subscriptionStatus: status,
});
});
});
describe('selectAuthoritativeSubscription', () => {
const ENTITLED_PRICE = 'price_entitled';