mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
+17
-6
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user