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 {
|
||||
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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user