mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #59 from yusufipk/fix/admin-cardless-trial-visibility
fix(admin): show the cardless trial as a trial in the admin panel
This commit is contained in:
+33
-10
@@ -5,7 +5,9 @@ import { auth } from '@/lib/auth';
|
|||||||
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
import { isBunnyUploadsFeatureEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||||
import {
|
import {
|
||||||
buildBillingAccessWhereInput,
|
buildBillingAccessWhereInput,
|
||||||
|
buildEffectiveBillingStatusWhereInput,
|
||||||
getBillingStatusLabel,
|
getBillingStatusLabel,
|
||||||
|
getEffectiveBillingStatus,
|
||||||
hasBillingAccess,
|
hasBillingAccess,
|
||||||
} from '@/lib/billing';
|
} from '@/lib/billing';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
@@ -71,7 +73,9 @@ function getOwnBillingAccess(
|
|||||||
user.stripeCancelAtPeriodEnd || user.subscriptionStatus === BillingSubscriptionStatus.CANCELED;
|
user.stripeCancelAtPeriodEnd || user.subscriptionStatus === BillingSubscriptionStatus.CANCELED;
|
||||||
|
|
||||||
let endsAt: Date | null = null;
|
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;
|
endsAt = user.trialEndsAt;
|
||||||
} else if (isEnding) {
|
} else if (isEnding) {
|
||||||
endsAt = user.stripeCurrentPeriodEnd ?? user.stripeCancelAt;
|
endsAt = user.stripeCurrentPeriodEnd ?? user.stripeCancelAt;
|
||||||
@@ -114,6 +118,20 @@ const STATUS_FILTERS: BillingSubscriptionStatus[] = [
|
|||||||
BillingSubscriptionStatus.FREE,
|
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 }> = [
|
const ACCESS_FILTERS: Array<{ value: AccessFilter; label: string }> = [
|
||||||
{ value: 'ALL', label: 'All Access' },
|
{ value: 'ALL', label: 'All Access' },
|
||||||
{ value: 'ACTIVE', label: 'Has Access' },
|
{ value: 'ACTIVE', label: 'Has Access' },
|
||||||
@@ -219,7 +237,9 @@ function getSortIndicator(
|
|||||||
function canSortInDb(sortBy: SortBy): boolean {
|
function canSortInDb(sortBy: SortBy): boolean {
|
||||||
return (
|
return (
|
||||||
sortBy === 'user' ||
|
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 === 'joinedDate' ||
|
||||||
sortBy === 'workspacesOwned' ||
|
sortBy === 'workspacesOwned' ||
|
||||||
sortBy === 'projectsOwned' ||
|
sortBy === 'projectsOwned' ||
|
||||||
@@ -237,10 +257,6 @@ function getUsersOrderBy(
|
|||||||
return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker];
|
return [{ name: sortDirection }, { email: sortDirection }, createdAtTieBreaker];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortBy === 'subscription') {
|
|
||||||
return [{ subscriptionStatus: sortDirection }, createdAtTieBreaker];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortBy === 'joinedDate') {
|
if (sortBy === 'joinedDate') {
|
||||||
return [{ createdAt: sortDirection }];
|
return [{ createdAt: sortDirection }];
|
||||||
}
|
}
|
||||||
@@ -319,7 +335,7 @@ export default async function AdminUsersPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (statusFilter !== 'ALL') {
|
if (statusFilter !== 'ALL') {
|
||||||
filters.push({ subscriptionStatus: statusFilter });
|
filters.push(buildEffectiveBillingStatusWhereInput(statusFilter, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessFilter !== 'ALL') {
|
if (accessFilter !== 'ALL') {
|
||||||
@@ -388,6 +404,7 @@ export default async function AdminUsersPage({
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
subscriptionStatus: BillingSubscriptionStatus;
|
subscriptionStatus: BillingSubscriptionStatus;
|
||||||
|
effectiveStatus: BillingSubscriptionStatus;
|
||||||
trialEndsAt: Date | null;
|
trialEndsAt: Date | null;
|
||||||
stripeCurrentPeriodEnd: Date | null;
|
stripeCurrentPeriodEnd: Date | null;
|
||||||
stripeCancelAtPeriodEnd: boolean;
|
stripeCancelAtPeriodEnd: boolean;
|
||||||
@@ -412,6 +429,7 @@ export default async function AdminUsersPage({
|
|||||||
|
|
||||||
paginatedUsers = users.map((user) => ({
|
paginatedUsers = users.map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
|
effectiveStatus: getEffectiveBillingStatus(user, now),
|
||||||
invitedMembersCount: user.ownedWorkspaces.reduce(
|
invitedMembersCount: user.ownedWorkspaces.reduce(
|
||||||
(total, workspace) => total + workspace._count.members,
|
(total, workspace) => total + workspace._count.members,
|
||||||
0
|
0
|
||||||
@@ -425,6 +443,7 @@ export default async function AdminUsersPage({
|
|||||||
|
|
||||||
const usersWithMetrics = users.map((user) => ({
|
const usersWithMetrics = users.map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
|
effectiveStatus: getEffectiveBillingStatus(user, now),
|
||||||
invitedMembersCount: user.ownedWorkspaces.reduce(
|
invitedMembersCount: user.ownedWorkspaces.reduce(
|
||||||
(total, workspace) => total + workspace._count.members,
|
(total, workspace) => total + workspace._count.members,
|
||||||
0
|
0
|
||||||
@@ -437,7 +456,11 @@ export default async function AdminUsersPage({
|
|||||||
const sortedUsers = usersWithMetrics.sort((a, b) => {
|
const sortedUsers = usersWithMetrics.sort((a, b) => {
|
||||||
let comparison = 0;
|
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;
|
comparison = a.invitedMembersCount - b.invitedMembersCount;
|
||||||
} else if (sortBy === 'bunnyUpload') {
|
} else if (sortBy === 'bunnyUpload') {
|
||||||
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
|
comparison = a.bunnyUploadBytes - b.bunnyUploadBytes;
|
||||||
@@ -758,8 +781,8 @@ export default async function AdminUsersPage({
|
|||||||
return (
|
return (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<Badge variant={getBillingStatusVariant(user.subscriptionStatus)}>
|
<Badge variant={getBillingStatusVariant(user.effectiveStatus)}>
|
||||||
{getBillingStatusLabel(user.subscriptionStatus)}
|
{getBillingStatusLabel(user.effectiveStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{access.hasAppAccess ? (
|
{access.hasAppAccess ? (
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
|||||||
+17
-6
@@ -4,6 +4,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
|||||||
import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
|
import { ListObjectsV2Command, type ListObjectsV2CommandInput } from '@aws-sdk/client-s3';
|
||||||
import { isBunnyUploadsEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
import { isBunnyUploadsEnabled, isStripeBillingEnabled } from '@/lib/feature-flags';
|
||||||
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
||||||
|
import { buildCardlessTrialWhereInput } from '@/lib/billing';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
||||||
@@ -551,10 +552,17 @@ export const getCachedStripeStats = unstable_cache(
|
|||||||
if (!isStripeBillingEnabled()) return null;
|
if (!isStripeBillingEnabled()) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statusCounts = await db.user.groupBy({
|
const now = new Date();
|
||||||
by: ['subscriptionStatus'],
|
// The cardless trial leaves `subscriptionStatus` at FREE, so the group-by
|
||||||
_count: { id: true },
|
// 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> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const row of statusCounts) {
|
for (const row of statusCounts) {
|
||||||
@@ -562,10 +570,13 @@ export const getCachedStripeStats = unstable_cache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeSubscribers = counts['ACTIVE'] ?? 0;
|
const activeSubscribers = counts['ACTIVE'] ?? 0;
|
||||||
const trialingUsers = counts['TRIALING'] ?? 0;
|
const trialingUsers = (counts['TRIALING'] ?? 0) + cardlessTrialUsers;
|
||||||
const pastDueUsers = counts['PAST_DUE'] ?? 0;
|
const pastDueUsers = counts['PAST_DUE'] ?? 0;
|
||||||
const canceledUsers = counts['CANCELED'] ?? 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
|
// 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
|
// above, so those users were counted nowhere and the totals silently did not add
|
||||||
// up to the user table.
|
// up to the user table.
|
||||||
|
|||||||
@@ -379,7 +379,14 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
|
|||||||
SELECT u.id AS user_id,
|
SELECT u.id AS user_id,
|
||||||
u.name,
|
u.name,
|
||||||
u.email,
|
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.channel,
|
||||||
ua.self_reported,
|
ua.self_reported,
|
||||||
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '7 days')::int
|
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
|
ON e.user_id = u.id
|
||||||
AND e.name::text = ANY(${[...VALUE_EVENT_NAMES]}::text[])
|
AND e.name::text = ANY(${[...VALUE_EVENT_NAMES]}::text[])
|
||||||
WHERE u."subscriptionStatus"::text IN ('ACTIVE', 'TRIALING')
|
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
|
ORDER BY MAX(e.occurred_at) ASC NULLS FIRST
|
||||||
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
|
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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
|
* Grants the cardless trial, once per account, and reports whether this call is
|
||||||
* the one that granted it.
|
* the one that granted it.
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ describe('getScoreboard', () => {
|
|||||||
const busy = await createUser({ subscriptionStatus: 'ACTIVE' });
|
const busy = await createUser({ subscriptionStatus: 'ACTIVE' });
|
||||||
const silent = await createUser({ subscriptionStatus: 'ACTIVE' });
|
const silent = await createUser({ subscriptionStatus: 'ACTIVE' });
|
||||||
const trialing = await createUser({ subscriptionStatus: 'TRIALING' });
|
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: 'VIDEO_ADDED', occurredAt: daysAgo(2), userId: busy.id });
|
||||||
await seedEvent({ name: 'SHARE_LINK_CREATED', occurredAt: daysAgo(20), 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?.valueEvents7).toBe(1);
|
||||||
expect(busyRow?.valueEvents30).toBe(2);
|
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
|
// The cohort comparison is another block of raw SQL, and the part most easily
|
||||||
|
|||||||
@@ -869,9 +869,11 @@ describe('getCachedStripeStats', () => {
|
|||||||
await createUser({ subscriptionStatus: 'TRIALING' });
|
await createUser({ subscriptionStatus: 'TRIALING' });
|
||||||
await createUser({ subscriptionStatus: 'PAST_DUE' });
|
await createUser({ subscriptionStatus: 'PAST_DUE' });
|
||||||
await createUser({ subscriptionStatus: 'CANCELED' });
|
await createUser({ subscriptionStatus: 'CANCELED' });
|
||||||
await createUser({ subscriptionStatus: 'FREE' });
|
// Free means free: no trial left to run, or these would be counted as the
|
||||||
await createUser({ subscriptionStatus: 'FREE' });
|
// cardless trials they would then be.
|
||||||
await createUser({ subscriptionStatus: 'FREE' });
|
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' });
|
const stripe = stubStripePrice({ unit_amount: 1900, currency: 'eur' });
|
||||||
|
|
||||||
expect(await getCachedStripeStats()).toEqual({
|
expect(await getCachedStripeStats()).toEqual({
|
||||||
@@ -887,6 +889,45 @@ describe('getCachedStripeStats', () => {
|
|||||||
expect(stripe.retrievedPriceIds).toEqual(['price_admin_stats_test']);
|
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
|
// 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
|
// 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
|
// five totals silently did not add up to the user table. They must not be folded into
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { BillingSubscriptionStatus } from '@prisma/client';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TRIAL_PERIOD_DAYS,
|
DEFAULT_TRIAL_PERIOD_DAYS,
|
||||||
buildBillingAccessWhereInput,
|
buildBillingAccessWhereInput,
|
||||||
|
buildCardlessTrialWhereInput,
|
||||||
|
buildEffectiveBillingStatusWhereInput,
|
||||||
buildExpiredBillingWhereInput,
|
buildExpiredBillingWhereInput,
|
||||||
getBillingAccessEndDate,
|
getBillingAccessEndDate,
|
||||||
getBillingOverview,
|
getBillingOverview,
|
||||||
getBillingStatusLabel,
|
getBillingStatusLabel,
|
||||||
getDefaultTrialEndsAt,
|
getDefaultTrialEndsAt,
|
||||||
|
getEffectiveBillingStatus,
|
||||||
getOrCreateStripeCustomerId,
|
getOrCreateStripeCustomerId,
|
||||||
getStorageCleanupEligibleAt,
|
getStorageCleanupEligibleAt,
|
||||||
getStripeCheckoutState,
|
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', () => {
|
describe('selectAuthoritativeSubscription', () => {
|
||||||
const ENTITLED_PRICE = 'price_entitled';
|
const ENTITLED_PRICE = 'price_entitled';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user