Merge pull request #51 from yusufipk/feat/cardless-trial

feat(billing): let people try the product before handing over a card
This commit is contained in:
Yusuf İpek
2026-08-18 09:57:40 +03:00
committed by GitHub
40 changed files with 1647 additions and 149 deletions
+4
View File
@@ -38,6 +38,10 @@ OPENFRAME_REQUIRE_INVITE_CODE="true"
# default: the rows only pay for themselves if you are running a signup funnel.
# Everything is written to this instance's own database and sent nowhere.
OPENFRAME_ENABLE_ANALYTICS="false"
# The date the cardless trial replaced the card-first one, as an ISO date. Set it
# to have /admin/growth compare signup-to-paid either side of the switchover;
# leave it empty and that section is simply not shown.
OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT=""
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
# ============================================================================
+1 -1
View File
@@ -2,7 +2,7 @@
OpenFrame is a fair source video review and approval platform for teams that need clear feedback, version control, and client-friendly review links in one place. It supports collaborative review workflows out of the box and can be self-hosted with the Docker setup included in this repository.
Prefer not to self-host? You can try OpenFrame at [open-frame.net](https://open-frame.net) with a 7-day free trial, then continue on the hosted plan starting at $10.
Prefer not to self-host? You can try OpenFrame at [open-frame.net](https://open-frame.net) with a 7-day free trial that needs no card, then continue on the hosted plan starting at $10.
## Product Screenshot
+2 -1
View File
@@ -1,6 +1,6 @@
import { after } from 'next/server';
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { isInviteCodeRequired, isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getInvitationPreviewByToken } from '@/lib/invitations';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
import RegisterPageClient from './register-page-client';
@@ -40,6 +40,7 @@ export default async function RegisterPage({ searchParams }: RegisterPageProps)
return (
<RegisterPageClient
requireInviteCode={isInviteCodeRequired()}
trialOnSignup={isStripeFeatureEnabled()}
googleEnabled={googleEnabled}
githubEnabled={githubEnabled}
invitation={invitation}
@@ -21,6 +21,11 @@ export interface RegisterInvitation {
interface RegisterPageClientProps {
requireInviteCode: boolean;
/**
* Billing is on, so this account starts a free trial. False on a self-hosted
* instance, where there is nothing to trial and the promise would be a lie.
*/
trialOnSignup?: boolean;
googleEnabled: boolean;
githubEnabled: boolean;
invitation?: RegisterInvitation | null;
@@ -30,6 +35,7 @@ interface RegisterPageClientProps {
export default function RegisterPageClient({
requireInviteCode,
trialOnSignup = false,
googleEnabled,
githubEnabled,
invitation = null,
@@ -156,6 +162,15 @@ export default function RegisterPageClient({
Create Account
</CardTitle>
<CardDescription>Join OpenFrame to collaborate on video projects</CardDescription>
{/* The landing page CTA promises a trial and no card. Say it again here,
where the promise is actually kept, rather than making people take the
previous page's word for it. Invitees are joining someone else's
workspace, so the trial is not what brought them. */}
{trialOnSignup && !isInvitationFlow && (
<CardDescription>
Your 7-day free trial starts as soon as you create the account. No credit card.
</CardDescription>
)}
</CardHeader>
<CardContent>
{/* OAuth Buttons */}
+8 -4
View File
@@ -1,16 +1,20 @@
import { Header } from '@/components/layout';
import { Header, TrialBanner } from '@/components/layout';
import { auth } from '@/lib/auth';
import { hasAppNavigationAccess } from '@/lib/route-access';
import { getTrialNotice } from '@/lib/billing';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
const showAppNavigation = session?.user?.id
? await hasAppNavigationAccess(session.user.id)
: false;
const userId = session?.user?.id;
const [showAppNavigation, trialNotice] = await Promise.all([
userId ? hasAppNavigationAccess(userId) : false,
userId ? getTrialNotice(userId) : null,
]);
return (
<div className="relative flex min-h-screen flex-col">
<Header user={session?.user ?? null} showAppNavigation={showAppNavigation} />
{trialNotice ? <TrialBanner notice={trialNotice} /> : null}
<main className="flex-1">{children}</main>
</div>
);
@@ -56,7 +56,7 @@ interface BillingOverview {
hasRecoverableSubscription: boolean;
hasActiveTrial: boolean;
hasBillingAccess: boolean;
isTrialEligible: boolean;
isPaid: boolean;
priceId: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
@@ -365,14 +365,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
) : (
<>
{!billing.subscription.hasActiveSubscription &&
!billing.subscription.hasActiveTrial &&
billing.subscription.isTrialEligible &&
billing.checkoutAvailable ? (
billing.subscription.hasActiveTrial &&
billing.subscription.trialEndsAt ? (
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
<p className="text-sm font-semibold">Start your 7-day free trial</p>
<p className="text-sm font-semibold">
Your free trial runs until{' '}
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">
Get full access to all features no charge until the trial ends. Cancel
anytime.
Every feature is on and no card is on file. The trial covers one workspace and
one project. Subscribing starts your paid month straight away, so there is no
reason to do it before you are ready.
</p>
</div>
) : null}
@@ -388,10 +391,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: 'Subscription canceled. Access remains active until the end of the current billing period.'
: 'Paid account with workspace creation unlocked.'
: billing.subscription.hasActiveTrial
? 'Trial access is active.'
: billing.subscription.isTrialEligible
? "You haven't started your free trial yet."
: 'Billing access has ended.'}
? 'Free trial, no card required.'
: 'Billing access has ended.'}
</p>
</div>
<Badge
@@ -433,13 +434,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
</p>
) : null}
{/* Deliberately not conditioned on `billingAccessEndedAt`: an account that
only ever had the cardless trial never gets one written, and it is exactly
that account which most needs to be told its work is still recoverable. */}
{!billing.subscription.hasBillingAccess &&
billing.subscription.billingAccessEndedAt &&
billing.subscription.storageCleanupEligibleAt ? (
<p className="text-sm text-amber-700 dark:text-amber-400">
Stored media cleanup is scheduled after{' '}
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()}{' '}
unless billing is restored first.
Nothing has been deleted. Your projects and media are kept until{' '}
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()};
subscribe before then and everything is where you left it.
</p>
) : null}
@@ -479,8 +482,6 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Redirecting...
</>
) : billing.subscription.isTrialEligible ? (
'Start Free Trial'
) : (
'Upgrade with Stripe'
)}
+61
View File
@@ -240,6 +240,67 @@ export default async function AdminGrowthPage() {
</CardContent>
</Card>
{scoreboard.cohorts ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Card-first against cardless trial</CardTitle>
<p className="text-sm text-muted-foreground">
Accounts created in the {scoreboard.cohorts.windowDays} days either side of{' '}
{formatDate(scoreboard.cohorts.cutover)}, each given{' '}
{scoreboard.cohorts.observationDays} days from signup to convert. The rate to read is
signup to paid: dropping the card requirement multiplies trials, so trial to paid can
fall while more people pay.
</p>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Cohort</th>
<th className="py-2 pr-4 font-medium">Window</th>
<th className="py-2 pr-4 text-right font-medium">Signup</th>
<th className="py-2 pr-4 text-right font-medium">Trial</th>
<th className="py-2 pr-4 text-right font-medium">Paid</th>
<th className="py-2 pr-4 text-right font-medium">Signup to paid</th>
<th className="py-2 pr-4 text-right font-medium">Trial to paid</th>
</tr>
</thead>
<tbody>
{scoreboard.cohorts.rows.map((row) => (
<tr key={row.cohort} className="border-b last:border-0">
<td className="py-2 pr-4">
{row.cohort === 'CARDLESS' ? 'cardless' : 'card first'}
</td>
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
{formatDate(row.windowStart)} to {formatDate(row.windowEnd)}
</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
<td className="py-2 pr-4 text-right tabular-nums">
<Rate
rate={row.signups > 0 ? row.paid / row.signups : null}
of={row.signups}
/>
</td>
<td className="py-2 pr-4 text-right tabular-nums">
<Rate rate={row.trials > 0 ? row.paid / row.trials : null} of={row.trials} />
</td>
</tr>
))}
</tbody>
</table>
{scoreboard.cohorts.windowDays === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">
Nothing to compare yet. The first cardless signups reach the end of their{' '}
{scoreboard.cohorts.observationDays}-day window {scoreboard.cohorts.observationDays}{' '}
days after the switchover.
</p>
) : null}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="text-base">By source</CardTitle>
+28 -1
View File
@@ -15,8 +15,14 @@ import {
createVerificationToken,
isEmailVerificationEnabled,
sendVerificationEmail,
warnIfTrialsSkipVerification,
} from '@/lib/email-verification';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import {
isDisposableEmailDomain,
isValidEmailAddress,
normalizeEmail,
} from '@/lib/email-validation';
import { startCardlessTrial } from '@/lib/billing';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readRequestVisitor } from '@/lib/analytics/visitor';
@@ -49,6 +55,12 @@ export async function POST(request: NextRequest) {
return apiErrors.validationError('Invalid email format');
}
// Checked before the invitation branch reads its token, but only applied to
// people signing themselves up: an invited collaborator was vouched for by a
// paying customer, and refusing their address breaks that customer's review
// rather than stopping anyone from farming trials.
const isDisposableAddress = isDisposableEmailDomain(normalizedEmail);
// Allow registration via a valid invitation token OR global invite code.
let invitationIsValid = false;
let validatedInvitationToken: string | null = null;
@@ -85,6 +97,12 @@ export async function POST(request: NextRequest) {
}
}
if (!invitationIsValid && isDisposableAddress) {
return apiErrors.badRequest(
'Please sign up with a permanent email address. Disposable mailboxes are not accepted.'
);
}
if (!password || typeof password !== 'string' || password.length < 8 || password.length > 128) {
return apiErrors.badRequest('Password must be between 8 and 128 characters');
}
@@ -142,6 +160,15 @@ export async function POST(request: NextRequest) {
visitor: await readRequestVisitor(request),
});
// With SMTP configured the trial starts when the address is proven, not here.
// Without it there is no verification step to hang the trial on and the
// account is already marked verified above, so withholding the trial would
// just lock the user out of an instance that has billing switched on.
if (!emailVerificationRequired) {
warnIfTrialsSkipVerification();
await startCardlessTrial(user.id);
}
// Send verification email if SMTP is configured
if (emailVerificationRequired) {
const verificationToken = await createVerificationToken(normalizedEmail);
+4 -6
View File
@@ -1,11 +1,7 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import {
DEFAULT_TRIAL_PERIOD_DAYS,
getOrCreateStripeCustomerId,
getStripeCheckoutState,
} from '@/lib/billing';
import { getOrCreateStripeCustomerId, getStripeCheckoutState } from '@/lib/billing';
import { rateLimit } from '@/lib/rate-limit';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
@@ -73,11 +69,13 @@ export async function POST(request: NextRequest) {
metadata: {
userId: session.user.id,
},
// No trial here. The free trial is granted in the product when the email
// address is verified, so by the time anyone reaches checkout they have
// already had it and this subscription bills immediately.
subscription_data: {
metadata: {
userId: session.user.id,
},
...(checkoutState.isTrialEligible ? { trial_period_days: DEFAULT_TRIAL_PERIOD_DAYS } : {}),
},
});
+1 -1
View File
@@ -28,7 +28,7 @@ export async function GET() {
hasRecoverableSubscription: billing.subscription.hasRecoverableSubscription,
hasActiveTrial: billing.subscription.hasActiveTrial,
hasBillingAccess: billing.subscription.hasBillingAccess,
isTrialEligible: billing.subscription.isTrialEligible,
isPaid: billing.subscription.isPaid,
priceId: billing.subscription.stripePriceId,
currentPeriodEnd: billing.subscription.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: billing.subscription.cancelAtPeriodEnd ?? false,
+22 -1
View File
@@ -3,7 +3,8 @@ import { db } from '@/lib/db';
import { auth, checkWorkspaceAccess } from '@/lib/auth';
import { ProjectVisibility } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { buildBillingAccessWhereInput } from '@/lib/billing';
import { buildBillingAccessWhereInput, isPaidTier } from '@/lib/billing';
import { TRIAL_PROJECT_LIMIT } from '@/lib/trial-limits';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { logError } from '@/lib/logger';
@@ -161,6 +162,26 @@ export async function POST(request: NextRequest) {
return apiErrors.forbidden('Only workspace owners and admins can create projects');
}
// Counted against the workspace owner rather than the caller, because that is
// the account being billed: `ownerId` on the project below is the workspace
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
const owner = await db.user.findUnique({
where: { id: workspace.ownerId },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
});
if (owner && !isPaidTier(owner)) {
const ownedProjectCount = await db.project.count({
where: { ownerId: workspace.ownerId },
});
if (ownedProjectCount >= TRIAL_PROJECT_LIMIT) {
return apiErrors.forbidden(
'Your free trial covers one project at a time. Delete the existing project or subscribe to run more in parallel.'
);
}
}
const project = await db.$transaction(async (tx) => {
const createdProject = await tx.project.create({
data: {
+5 -5
View File
@@ -47,13 +47,13 @@ export default function RefundPolicyPage() {
<p>
All new accounts are eligible for a{' '}
<strong className="text-foreground">7-day free trial</strong> with full access to paid
features. We strongly encourage you to evaluate the Service fully during this period
before subscribing.
features. The trial requires no credit card and collects no payment. We strongly
encourage you to evaluate the Service fully during this period before subscribing.
</p>
<p className="mt-3">
You may cancel at any time during your free trial without being charged. If you do not
cancel before the trial ends, your chosen plan will automatically activate and payment
will be collected.
The trial never activates a paid plan by itself. When it ends, paid features stop
until you choose to subscribe, and a subscription is charged as soon as you complete
checkout.
</p>
</section>
+7 -6
View File
@@ -58,7 +58,7 @@ export default function TermsOfServicePage() {
OpenFrame is a video review and approval platform that enables creative professionals
and their clients to collaborate on video projects through timestamped comments,
annotations, version management, and approval workflows. The Service is offered on a
subscription basis with a free trial period.
subscription basis with a free trial period that requires no payment details.
</p>
</section>
@@ -96,11 +96,12 @@ export default function TermsOfServicePage() {
5. Subscriptions and Free Trial
</h2>
<p>
Certain features of the Service require a paid subscription. We offer a{' '}
<strong className="text-foreground">7-day free trial</strong> for new accounts, during
which you may access paid features at no charge. At the end of the trial period, your
subscription will automatically convert to a paid plan unless you cancel before the
trial ends.
Certain features of the Service require a paid subscription. Every new account
receives a <strong className="text-foreground">7-day free trial</strong>, which starts
once the account&apos;s email address is verified and requires no credit card. The
trial does not convert into a paid plan on its own and nothing is charged while it
runs. When it ends, access to paid features stops until you choose to subscribe, and a
subscription is billed as soon as you complete checkout.
</p>
<p className="mt-3">
Subscription fees are billed in advance on a monthly or annual basis depending on the
+10 -6
View File
@@ -90,6 +90,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const navbarRef = useRef<HTMLElement | null>(null);
const hostedCtaHref = isLoggedIn ? '/dashboard' : '/register';
// Same button, two audiences. Once you are signed in it goes to the dashboard,
// and offering a trial to someone who is already using the product reads as a
// mistake rather than as an offer.
const hostedCtaLabel = isLoggedIn ? 'Open dashboard' : 'Start 7-day free trial, no card required';
useEffect(() => {
const cleanupHandlers: Array<() => void> = [];
@@ -263,12 +267,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
href={hostedCtaHref}
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
>
Start free trial
{hostedCtaLabel}
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</CtaLink>
<p className="text-xs text-muted-foreground">
7-day free trial · Flat $10/mo no per-seat fees · No client accounts
No credit card · Flat $10/mo after, no per-seat fees · No client accounts
</p>
<a
@@ -677,7 +681,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
<span className="text-[#06b6d4]">/ month</span>
</div>
<p className="mb-6 text-sm text-muted-foreground">
Starts with a 7-day free trial. Cancel anytime.
Starts with a 7-day free trial. No credit card. Cancel anytime.
</p>
<ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80">
@@ -722,7 +726,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
href={hostedCtaHref}
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"
>
Start free trial
{isLoggedIn ? 'Open dashboard' : 'Start free trial'}
</CtaLink>
</div>
@@ -855,7 +859,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
},
{
q: 'Is there a free trial?',
a: 'Yes. Hosted Cloud starts with a 7-day free trial. After that it is a flat $10/mo no per-seat or per-client fees.',
a: 'Yes. Hosted Cloud starts with a 7-day free trial and never asks for a card to begin it. After that it is a flat $10/mo, with no per-seat or per-client fees.',
},
{
q: 'How is this different from sending a Google Drive link?',
@@ -906,7 +910,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
href={hostedCtaHref}
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02] md:min-w-[240px]"
>
Start free trial
{hostedCtaLabel}
</CtaLink>
</div>
</section>
+1
View File
@@ -1 +1,2 @@
export { Header } from './header';
export { TrialBanner } from './trial-banner';
+45
View File
@@ -0,0 +1,45 @@
import Link from 'next/link';
import type { TrialNotice } from '@/lib/billing';
function formatDate(value: Date) {
return value.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
}
/**
* The trial deadline, said once at the top of the app.
*
* The `ended` case is the one that matters: access stops at the trial's end date
* but nothing is deleted for another fifteen days, and an account that is not
* told this reads a locked workspace as lost work. So the deletion date is
* stated as reassurance rather than as a threat.
*/
export function TrialBanner({ notice }: { notice: TrialNotice }) {
const isEnded = notice.kind === 'ended';
return (
<div
className={
isEnded
? 'border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-300'
: 'border-b border-primary/30 bg-primary/5 px-4 py-2 text-sm text-foreground'
}
>
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-2 gap-y-1">
<span>
{isEnded
? `Your free trial ended on ${formatDate(notice.endsAt)}.`
: `Your free trial ends on ${formatDate(notice.endsAt)}.`}
</span>
{notice.contentKeptUntil ? (
<span className="text-muted-foreground">
Nothing has been deleted. Your projects and media are kept until{' '}
{formatDate(notice.contentKeptUntil)}.
</span>
) : null}
<Link href="/settings" className="font-medium underline underline-offset-4">
{isEnded ? 'Subscribe to pick up where you left off' : 'See plan'}
</Link>
</div>
</div>
);
}
+6 -4
View File
@@ -17,6 +17,7 @@ interface ComparisonPageProps {
export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
const hostedCtaHref = isLoggedIn ? '/dashboard' : '/register';
const hostedCtaLabel = isLoggedIn ? 'Open dashboard' : 'Start 7-day free trial, no card required';
const competitorName = getCompetitorName(page) ?? 'Alternatives';
const relatedSlugs = page.relatedSlugs.filter((slug) => slug in comparisonPageMap);
@@ -46,7 +47,7 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
href={hostedCtaHref}
className="group relative isolate inline-flex h-12 items-center justify-center overflow-hidden border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
>
Start free trial
{hostedCtaLabel}
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</CtaLink>
<a
@@ -143,8 +144,9 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
Pricing comparison
</h2>
<p className="mt-3 max-w-3xl text-sm text-muted-foreground md:text-base">
OpenFrame is $10/month flat with a 7-day free trial. You do not pay per team member,
collaborator, or guest reviewer. Self-hosting is free with Docker.
OpenFrame is $10/month flat with a 7-day free trial that never asks for a card. You
do not pay per team member, collaborator, or guest reviewer. Self-hosting is free
with Docker.
</p>
</div>
<PricingComparison
@@ -204,7 +206,7 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
href={hostedCtaHref}
className="inline-flex h-12 items-center justify-center border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground"
>
Start free trial
{hostedCtaLabel}
</CtaLink>
<a
href={seoConfig.githubUrl}
+3 -4
View File
@@ -12,10 +12,9 @@ interface CtaLinkProps {
/**
* A marketing call to action that reports the click.
*
* Only the signup CTA counts. The same button reads "Start free trial" and
* points at `/dashboard` once you are signed in, and an existing customer
* clicking through to their own dashboard is not a step in the acquisition
* funnel.
* Only the signup CTA counts. The same button points at `/dashboard` once you
* are signed in, and an existing customer clicking through to their own
* dashboard is not a step in the acquisition funnel.
*/
export function CtaLink({ href, className, children }: CtaLinkProps) {
return (
+145 -1
View File
@@ -77,6 +77,34 @@ export interface PaidAccountRow {
selfReported: AcquisitionChannel | null;
}
/**
* How long an account gets to convert before its cohort is scored.
*
* Fixed rather than "since signup" so the two cohorts are compared over equal
* time. Without it the newer cohort is measured over a shorter life than the
* older one and always looks worse, whatever the change did.
*/
export const COHORT_OBSERVATION_DAYS = 30;
export type TrialCohort = 'CARD_FIRST' | 'CARDLESS';
export interface CohortRow {
cohort: TrialCohort;
windowStart: Date;
windowEnd: Date;
signups: number;
trials: number;
paid: number;
}
export interface CohortComparison {
cutover: Date;
observationDays: number;
/** Length of each side's window. Equal by construction; reported so it can be judged. */
windowDays: number;
rows: CohortRow[];
}
export interface Scoreboard {
weeks: WeeklyRow[];
channels: ChannelRow[];
@@ -89,6 +117,8 @@ export interface Scoreboard {
currentActivePaid: number | null;
currentMrrCents: number | null;
currency: string;
/** Null until OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT names the switchover date. */
cohorts: CohortComparison | null;
}
interface WeeklyQueryRow {
@@ -195,6 +225,118 @@ export function conversionRates(row: {
};
}
/**
* The day the cardless trial replaced the card-first one, if it has been set.
*
* Kept in the environment rather than in code because it is a fact about a
* deployment, not about the product: a self-hosted instance never switched over
* at all, and the hosted one only knows the date once it has shipped.
*/
export function getCardlessTrialCutover(): Date | null {
const raw = process.env.OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT?.trim();
if (!raw) return null;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* The two equal-length windows either side of the cutover.
*
* The `after` window stops `COHORT_OBSERVATION_DAYS` short of now, because an
* account that signed up yesterday has not had its chance to convert yet and
* counting it would drag the new cohort's rate down for a month. The `before`
* window is then cut to the same length, ending at the cutover.
*/
export function cohortWindows(
cutover: Date,
now: Date,
observationDays: number = COHORT_OBSERVATION_DAYS
) {
const msPerDay = 24 * 60 * 60 * 1000;
const afterStart = cutover;
const afterEnd = new Date(now.getTime() - observationDays * msPerDay);
const spanMs = Math.max(0, afterEnd.getTime() - afterStart.getTime());
return {
afterStart,
afterEnd: new Date(afterStart.getTime() + spanMs),
beforeStart: new Date(cutover.getTime() - spanMs),
beforeEnd: cutover,
windowDays: Math.floor(spanMs / msPerDay),
};
}
interface CohortQueryRow {
cohort: string;
signups: number;
trials: number;
paid: number;
}
/**
* Card-first against cardless, on signup-to-paid rather than trial-to-paid.
*
* Trial-to-paid is the wrong ratio for this comparison and will mislead whoever
* reads it: handing out trials without a card multiplies the denominator, so the
* rate can halve while the number of paying customers goes up. Signups are the
* honest denominator because they are the one thing the change does not move.
*/
export async function getCohortComparison(
now: Date = new Date()
): Promise<CohortComparison | null> {
const cutover = getCardlessTrialCutover();
if (!cutover) return null;
const { afterStart, afterEnd, beforeStart, beforeEnd, windowDays } = cohortWindows(cutover, now);
const observationInterval = `${COHORT_OBSERVATION_DAYS} days`;
const rows = await db.$queryRaw<CohortQueryRow[]>`
SELECT CASE WHEN u."createdAt" >= ${cutover} THEN 'CARDLESS' ELSE 'CARD_FIRST' END AS cohort,
COUNT(*)::int AS signups,
COUNT(*) FILTER (WHERE t.started_at IS NOT NULL)::int AS trials,
COUNT(*) FILTER (WHERE p.paid_at IS NOT NULL)::int AS paid
FROM users u
LEFT JOIN LATERAL (
SELECT MIN(e.occurred_at) AS started_at
FROM analytics_events e
WHERE e.user_id = u.id
AND e.name::text = 'TRIAL_STARTED'
AND e.occurred_at <= u."createdAt" + ${observationInterval}::interval
) t ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(e.occurred_at) AS paid_at
FROM analytics_events e
WHERE e.user_id = u.id
AND e.name::text = 'SUBSCRIPTION_STARTED'
AND e.occurred_at <= u."createdAt" + ${observationInterval}::interval
) p ON TRUE
WHERE (u."createdAt" >= ${beforeStart} AND u."createdAt" < ${beforeEnd})
OR (u."createdAt" >= ${afterStart} AND u."createdAt" < ${afterEnd})
GROUP BY 1
`;
const byCohort = new Map(rows.map((row) => [row.cohort, row]));
const build = (cohort: TrialCohort, windowStart: Date, windowEnd: Date): CohortRow => {
const row = byCohort.get(cohort);
return {
cohort,
windowStart,
windowEnd,
signups: row?.signups ?? 0,
trials: row?.trials ?? 0,
paid: row?.paid ?? 0,
};
};
return {
cutover,
observationDays: COHORT_OBSERVATION_DAYS,
windowDays,
rows: [build('CARD_FIRST', beforeStart, beforeEnd), build('CARDLESS', afterStart, afterEnd)],
};
}
export async function getScoreboard(options?: { weeks?: number }): Promise<Scoreboard> {
const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52);
const now = new Date();
@@ -204,7 +346,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
const channelWindowStart = new Date(now);
channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS);
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats] = await Promise.all([
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats, cohorts] = await Promise.all([
// COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing
// view is deduped per visitor per day, so a visitor who came back on three
// days would otherwise be three weekly visitors. Rows with no anonymous id
@@ -256,6 +398,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
`,
getCachedStripeStats(),
getCohortComparison(now),
]);
const byWeek = new Map<number, WeeklyRow>();
@@ -337,5 +480,6 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
currentActivePaid: stripeStats?.activeSubscribers ?? null,
currentMrrCents: stripeStats?.mrrCents ?? null,
currency: stripeStats?.currency ?? 'usd',
cohorts,
};
}
+10 -1
View File
@@ -6,7 +6,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { hasBillingAccess } from '@/lib/billing';
import { hasBillingAccess, startCardlessTrial } from '@/lib/billing';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { isEmailVerificationEnabled } from '@/lib/email-verification';
@@ -163,6 +163,15 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
userId: user.id,
visitor: await readVisitorContextFromHeaders(),
});
// And the trial with it, for the same reason: the two places that grant it
// are the credentials signup route and the verification link, and a social
// signup goes through neither. Without this a Google account reaches a
// locked product and a checkout that no longer offers a trial, which is the
// opposite of what the signup page promised it. The address is already
// proven here: the signIn callback above turns away an OAuth profile that
// reports its email as unverified.
await startCardlessTrial(user.id);
},
},
});
+215 -14
View File
@@ -5,6 +5,8 @@ import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { TRIAL_WORKSPACE_LIMIT } from '@/lib/trial-limits';
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.ACTIVE,
@@ -23,6 +25,16 @@ const RECOVERABLE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.INCOMPLETE,
]);
// Statuses that mean no payment on this subscription has ever gone through.
// Stripe stamps a current period on an `incomplete` subscription all the same,
// so a checkout whose first charge failed leaves `stripeCurrentPeriodEnd` a
// month into the future with nothing paid behind it. Every other status in the
// enum follows at least one successful charge, or has no period end at all.
const UNPAID_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.INCOMPLETE,
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
]);
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
const STORAGE_CLEANUP_GRACE_DAYS = 15;
@@ -43,6 +55,22 @@ export function hasActiveTrial(trialEndsAt: Date | null | undefined, now: Date =
return Boolean(trialEndsAt && trialEndsAt.getTime() > now.getTime());
}
/**
* The trial end date to keep when a Stripe sync has none of its own.
*
* An unexpired trial is an entitlement the account already holds, so billing
* state may add access but must never take a trial back before it has run out.
* Without this, a trial user who starts a checkout and abandons the card step
* lands on an `incomplete` subscription carrying no `trial_end`, and the sync
* would write `trialEndsAt: null` over their remaining days and lock them out of
* a product they were still entitled to. Nothing can be farmed this way either:
* `billingTrialConsumedAt` is what makes the trial once-per-account, and it is
* never cleared.
*/
export function keepUnexpiredTrial(trialEndsAt: Date | null | undefined, now: Date = new Date()) {
return hasActiveTrial(trialEndsAt, now) ? (trialEndsAt ?? null) : null;
}
export function hasActiveSubscription(status: BillingSubscriptionStatus | null | undefined) {
if (!status) return false;
return ACTIVE_SUBSCRIPTION_STATUSES.has(status);
@@ -56,6 +84,41 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n
return RECOVERABLE_SUBSCRIPTION_STATUSES.has(status);
}
/**
* Whether this account is a paying customer, as opposed to one that merely has
* access right now.
*
* The cardless trial makes these two different questions for the first time: a
* trial account passes `hasBillingAccess` with no card and no Stripe customer
* behind it. Every ceiling that exists to bound what an unpaid account can cost
* us (storage, upload size, workspace count) hangs off this, not off access.
* A legacy Stripe trial counts as paid because a card was handed over for it.
*/
export function isPaidTier(
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'stripeCurrentPeriodEnd'>,
now: Date = new Date()
) {
if (!isStripeFeatureEnabled()) {
return true;
}
if (hasActiveSubscription(subject.subscriptionStatus)) {
return true;
}
// The period end alone is not proof of payment. Checked here and not in
// `hasBillingAccess`, which keeps granting access on a period end it did not
// question before: the cost of being wrong there is a customer locked out,
// while the cost of being wrong here is a free account holding 200 GB.
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
return false;
}
return Boolean(
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
);
}
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
if (!isStripeFeatureEnabled()) {
return true;
@@ -195,12 +258,52 @@ export function getBillingStatusLabel(status: BillingSubscriptionStatus) {
}
}
/**
* Grants the cardless trial, once per account, and reports whether this call is
* the one that granted it.
*
* Called where the email address is proven rather than where the account is
* created: an unverifiable address gets no trial, which is the cheapest abuse
* control available and the reason the two writes below can stay this simple.
*
* `billingTrialConsumedAt` is written here rather than only by the Stripe sync.
* It is the once-per-account marker, so a re-issued verification link, a second
* device or a replayed request all land on the `WHERE` clause and change nothing.
*/
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
// Without billing nothing is gated, so a trial would be a date nobody reads.
// Writing one anyway would consume the trial of a self-hosted instance that
// later switches billing on.
if (!isStripeFeatureEnabled()) {
return false;
}
const { count } = await db.user.updateMany({
where: { id: userId, trialEndsAt: null, billingTrialConsumedAt: null },
data: {
trialEndsAt: getDefaultTrialEndsAt(now),
billingTrialConsumedAt: now,
},
});
if (count === 0) {
return false;
}
await recordEvent({
name: 'TRIAL_STARTED',
dedupeKey: eventKey('TRIAL_STARTED', userId),
userId,
});
return true;
}
export async function getStripeCheckoutState(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
billingTrialConsumedAt: true,
},
});
@@ -211,7 +314,6 @@ export async function getStripeCheckoutState(userId: string) {
return {
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
isTrialEligible: !user.billingTrialConsumedAt,
};
}
@@ -268,15 +370,22 @@ export async function getWorkspaceCreationEligibility(userId: string) {
}
const billingAccess = hasBillingAccess(user);
const isPaid = isPaidTier(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
// A paying account creates as many workspaces as it wants. Everyone else gets
// one, which covers both the cardless trial and the pre-trial state where an
// account may set a workspace up before it can open it.
const canCreateWorkspace =
!isStripeFeatureEnabled() ||
billingAccess ||
(ownedWorkspaceCount === 0 && collaborationCount === 0);
isPaid ||
((billingAccess || collaborationCount === 0) && ownedWorkspaceCount < TRIAL_WORKSPACE_LIMIT);
let reason: string | null = null;
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
reason = 'Your free trial includes one workspace. Subscribe to create more.';
} else if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
reason =
'You are currently collaborating in someone elses workspace or project. Start a subscription to create a workspace of your own.';
} else {
@@ -297,7 +406,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
hasBillingAccess: billingAccess,
isTrialEligible: !user.billingTrialConsumedAt,
isPaid,
stripeCustomerId: user.stripeCustomerId,
stripeSubscriptionId: user.stripeSubscriptionId,
stripePriceId: user.stripePriceId,
@@ -325,6 +434,74 @@ export async function getBillingOverview(userId: string) {
};
}
/** How long before the trial runs out the countdown starts being shown. */
export const TRIAL_ENDING_NOTICE_DAYS = 3;
export interface TrialNotice {
/** `ending` while access is still live, `ended` once it has lapsed. */
kind: 'ending' | 'ended';
endsAt: Date;
/** When the cleanup job becomes eligible to delete this account's media. */
contentKeptUntil: Date | null;
}
/**
* The one-line trial status worth interrupting somebody with, or null.
*
* Both halves of the deadline are in one place because the useful message is the
* pair: an account is told when the trial runs out and, separately, that running
* out is not the moment its work disappears. The gap between those two dates is
* the fifteen-day cleanup grace period, and until now nothing in the product said
* it out loud, which made the end of a trial read as a deletion notice.
*/
export async function getTrialNotice(
userId: string,
now: Date = new Date()
): Promise<TrialNotice | null> {
if (!isStripeFeatureEnabled()) {
return null;
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
subscriptionStatus: true,
trialEndsAt: true,
stripeCurrentPeriodEnd: true,
billingAccessEndedAt: true,
},
});
// A paying account has a billing period, not a trial, and gets told about it
// in settings rather than in a banner on every page.
if (!user || isPaidTier(user, now)) {
return null;
}
const contentKeptUntil = getStorageCleanupEligibleAt(user);
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
return null;
}
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
}
const endsAt = getBillingAccessEndDate(user);
if (!endsAt || hasBillingAccess(user, now)) {
return null;
}
// Past the cleanup date there is nothing left to reassure anybody about.
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
return null;
}
return { kind: 'ended', endsAt, contentKeptUntil };
}
export async function getOrCreateStripeCustomerId(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
@@ -395,6 +572,8 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
select: {
id: true,
billingTrialConsumedAt: true,
// Read so a cardless trial that has not run out survives this sync.
trialEndsAt: true,
// Read for the funnel: the transition is what gets recorded, so the state
// being overwritten has to be captured before the update below.
subscriptionStatus: true,
@@ -430,6 +609,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
const effectiveCurrentPeriodEnd =
hasEntitledPrice && currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : null;
const effectiveTrialEnd = hasEntitledPrice && trialEnd ? new Date(trialEnd * 1000) : null;
// Stripe grants no trials any more, so `effectiveTrialEnd` is null for every
// subscription created after the cardless trial shipped, and this fallback is
// what stops an abandoned or failed checkout from erasing the days the account
// still had. Legacy card-backed trials keep arriving through the branch above.
const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
const hasAccess =
hasEntitledPrice &&
(hasActiveSubscription(mappedStatus) ||
@@ -444,14 +628,23 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
stripeCancelAtPeriodEnd: cancelAtPeriodEnd,
stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
subscriptionStatus: mappedStatus,
trialEndsAt: effectiveTrialEnd,
trialEndsAt: preservedTrialEnd,
billingTrialConsumedAt:
hasEntitledPrice && trialEnd
? (user.billingTrialConsumedAt ?? new Date())
: user.billingTrialConsumedAt,
billingAccessEndedAt: hasAccess
? null
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
// A live trial means access has not ended, whatever the subscription says.
// Stamping an end date here while the trial runs would date the storage
// cleanup from today and tell the user their work dies before their trial
// does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
// that has already elapsed is a reason to stamp the end date, not to skip it.
billingAccessEndedAt:
hasAccess || hasActiveTrial(preservedTrialEnd)
? null
: getInactiveBillingAccessEndedAt(
subscription,
hasEntitledPrice ? currentPeriodEnd : null
),
},
});
@@ -466,7 +659,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
after: {
status: mappedStatus,
cancelAtPeriodEnd,
trialEndsAt: effectiveTrialEnd,
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd,
},
});
@@ -554,6 +747,7 @@ export async function markSubscriptionCanceledByCustomerId(
stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: true,
billingTrialConsumedAt: true,
trialEndsAt: true,
},
});
@@ -561,17 +755,24 @@ export async function markSubscriptionCanceledByCustomerId(
return null;
}
// Losing the subscription does not retract a trial that has not run out. The
// account keeps the days it was given and lands back on the trial's own end
// date, which is also what the cancellation copy in settings promises.
const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
const updated = await db.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
trialEndsAt: null,
trialEndsAt: preservedTrialEnd,
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
stripeCancelAtPeriodEnd: false,
stripeCancelAt: null,
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
billingAccessEndedAt: preservedTrialEnd
? null
: (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
},
});
@@ -590,7 +791,7 @@ export async function markSubscriptionCanceledByCustomerId(
after: {
status: BillingSubscriptionStatus.CANCELED,
cancelAtPeriodEnd: false,
trialEndsAt: null,
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
},
});
+70
View File
@@ -26,3 +26,73 @@ export function isValidEmailAddress(email: string): boolean {
return labels.every((label) => label.length > 0 && label.length <= MAX_EMAIL_DOMAIN_LABEL_LENGTH);
}
/**
* Throwaway mailbox providers, refused at signup.
*
* The free trial is granted to any address somebody can read a link at, so a
* mailbox that costs nothing and expires in ten minutes is the cheapest way to
* take the trial repeatedly. This list is deliberately short and specific: it
* holds services whose entire purpose is a disposable inbox, and none of the
* forwarding or aliasing services (SimpleLogin, AnonAddy, Apple's Hide My Email,
* Fastmail masked addresses) that real paying customers use every day. A list
* that catches a genuine buyer costs far more than one that misses a scraper.
*/
const DISPOSABLE_EMAIL_DOMAINS = new Set([
'10minutemail.com',
'discard.email',
'dispostable.com',
'emailondeck.com',
'fakeinbox.com',
'getnada.com',
'grr.la',
'guerrillamail.com',
'guerrillamail.net',
'guerrillamail.org',
'harakirimail.com',
'inboxkitten.com',
'mailcatch.com',
'maildrop.cc',
'mailinator.com',
'mailnesia.com',
'mintemail.com',
'moakt.com',
'mohmal.com',
'nada.email',
'sharklasers.com',
'spam4.me',
'spamgourmet.com',
'temp-mail.org',
'tempinbox.com',
'tempmail.com',
'tempr.email',
'throwawaymail.com',
'tmpmail.org',
'trashmail.com',
'yopmail.com',
'yopmail.fr',
'yopmail.net',
]);
/**
* True when the address belongs to a known disposable mailbox provider.
*
* Parent domains are checked too, because several of these hand out per-visit
* subdomains (`anything.mailinator.com`) that would otherwise walk straight past
* an exact-match lookup.
*/
export function isDisposableEmailDomain(email: string): boolean {
const atIndex = email.lastIndexOf('@');
if (atIndex < 0) return false;
const domain = normalizeEmail(email.slice(atIndex + 1));
const labels = domain.split('.');
for (let index = 0; index < labels.length - 1; index += 1) {
if (DISPOSABLE_EMAIL_DOMAINS.has(labels.slice(index).join('.'))) {
return true;
}
}
return false;
}
+37 -10
View File
@@ -10,7 +10,8 @@ import {
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { isProductAnalyticsEnabled, isStripeFeatureEnabled } from '@/lib/feature-flags';
import { startCardlessTrial } from '@/lib/billing';
// Reduce window to 2 hours — shorter exposure in access logs and backups.
const TOKEN_EXPIRY_HOURS = 2;
@@ -29,6 +30,28 @@ export function isEmailVerificationEnabled(): boolean {
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASSWORD);
}
let warnedAboutUnverifiedTrials = false;
/**
* Says so, once, when an instance is handing out free trials to addresses nobody
* has proved.
*
* Billing switched on means the trial is worth something, and no SMTP means there
* is no verification step to hang it on, so every signup form submission mints
* seven days of storage. That combination is a deployment mistake rather than a
* choice, and it is invisible until the storage bill arrives.
*/
export function warnIfTrialsSkipVerification(): void {
if (warnedAboutUnverifiedTrials) return;
if (isEmailVerificationEnabled() || !isStripeFeatureEnabled()) return;
warnedAboutUnverifiedTrials = true;
logError(
'Free trials are being granted without email verification because SMTP is not configured while billing is enabled. Configure SMTP_HOST, SMTP_USER and SMTP_PASSWORD.',
new Error('Unverified trial signups')
);
}
/**
* Generate a secure random verification token, persist only its SHA-256 digest,
* and return the raw token (sent to the user via email).
@@ -79,21 +102,25 @@ export async function consumeVerificationToken(token: string): Promise<string |
// Return null so a replayed/stale token never produces a misleading success redirect.
if (user.count === 0) return null;
// Behind the flag so the extra lookup does not happen at all on a deployment
// that is not measuring. count > 0 above already means this is the one call
// that flipped the account, so a replayed link cannot reach here.
if (isProductAnalyticsEnabled()) {
const verified = await db.user.findUnique({
where: { email: record.identifier },
select: { id: true },
});
if (verified) {
// This is where the free trial begins: a proven address, before any card and
// before Stripe is involved at all. count > 0 above means this call is the one
// that flipped the account, so a replayed link cannot reach here, and
// `startCardlessTrial` refuses a second trial regardless.
const verified = await db.user.findUnique({
where: { email: record.identifier },
select: { id: true },
});
if (verified) {
if (isProductAnalyticsEnabled()) {
await recordEvent({
name: 'EMAIL_VERIFIED',
dedupeKey: eventKey('EMAIL_VERIFIED', verified.id),
userId: verified.id,
});
}
await startCardlessTrial(verified.id);
}
return record.identifier;
+5 -4
View File
@@ -7,7 +7,7 @@ import { competitorProfiles, openFrameProfile } from '@/lib/marketing/comparison
const commonOpenFrameWins = [
'$10/month flat hosted pricing — no per-member or per-guest fees',
'7-day free trial, then unlimited collaborators on one plan',
'7-day free trial with no credit card, then unlimited collaborators on one plan',
'Self-host for free with Docker when you need full data control',
'Voice notes and drawn annotations on the timeline',
'Formal approval requests with per-reviewer status',
@@ -148,7 +148,7 @@ function competitorPricingRows(competitorId: string): PricingRow[] {
},
{
label: 'Trial',
openframe: '7-day free trial on hosted',
openframe: '7-day free trial on hosted, no card required',
competitor: profile.pricingNotes[0] ?? 'See vendor site',
},
];
@@ -402,7 +402,8 @@ export const comparisonPages: ComparisonPageDefinition[] = [
},
{
question: 'Can I start free?',
answer: 'Yes. Use the 7-day hosted trial or self-host for free with Docker.',
answer:
'Yes. The 7-day hosted trial does not ask for a card, and self-hosting with Docker is free.',
},
{
question: 'Is OpenFrame only for video?',
@@ -662,7 +663,7 @@ export const comparisonPages: ComparisonPageDefinition[] = [
},
{
label: 'Trial',
openframe: '7-day free trial on hosted',
openframe: '7-day free trial on hosted, no card required',
competitor: 'Free plan — no card required',
},
],
+1 -1
View File
@@ -552,7 +552,7 @@ export const competitorProfiles: Record<string, CompetitorProfile> = {
export const openFrameProfile = {
name: 'OpenFrame',
tagline: 'Fair-source video review and approval with hosted and self-hosted options.',
pricingSummary: 'Self-hosted free · Hosted $10/mo after 7-day trial',
pricingSummary: 'Self-hosted free · Hosted $10/mo after a 7-day no-card trial',
trial: '7-day free trial on hosted plan',
strengths: [
'Timestamped text, voice, image, and drawn annotations',
+2 -1
View File
@@ -81,7 +81,8 @@ export function buildComparisonJsonLd({
'@type': 'Offer',
price: '10',
priceCurrency: 'USD',
description: '7-day free trial, then $10/month hosted plan. Self-hosted option is free.',
description:
'7-day free trial with no credit card, then $10/month hosted plan. Self-hosted option is free.',
},
url: seoConfig.url,
},
+36 -8
View File
@@ -3,10 +3,29 @@ import { db } from '@/lib/db';
import { apiErrors } from '@/lib/api-response';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
import { isPaidTier } from '@/lib/billing';
import { getStorageLimitBytes } from '@/lib/trial-limits';
// 200 GB expressed in bytes
export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024) * BigInt(1024);
/**
* The ceiling this particular account is held to.
*
* A cardless trial gets a much smaller one: it is the only thing standing between
* a throwaway signup and 200 GB of our storage. Reads the two billing columns
* directly rather than taking a flag from the caller, so no upload route can
* forget to pass it.
*/
async function getStorageLimitForUser(userId: string): Promise<bigint> {
const user = await db.user.findUnique({
where: { id: userId },
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
});
return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES);
}
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
const RESERVATION_TTL_MS = 30 * 60 * 1000;
@@ -61,8 +80,10 @@ export async function getUserStorageInfo(userId: string): Promise<{
limitBytes: bigint;
percentage: number;
}> {
const usedBytes = await getUserTotalStorageBytes(userId);
const limitBytes = PLAN_STORAGE_LIMIT_BYTES;
const [usedBytes, limitBytes] = await Promise.all([
getUserTotalStorageBytes(userId),
getStorageLimitForUser(userId),
]);
const percentage =
limitBytes > BigInt(0)
? Math.min(100, Number((usedBytes * BigInt(10000)) / limitBytes) / 100)
@@ -88,9 +109,12 @@ export async function enforceStorageQuota(
return null;
}
const usedBytes = await getUserTotalStorageBytes(userId);
const [usedBytes, limitBytes] = await Promise.all([
getUserTotalStorageBytes(userId),
getStorageLimitForUser(userId),
]);
if (usedBytes + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
if (usedBytes + incomingSizeBytes >= limitBytes) {
return apiErrors.storageExceeded() as NextResponse;
}
@@ -121,9 +145,13 @@ export async function reserveStorageQuota(
const expiresAt = new Date(Date.now() + reservationTtlMs);
// Fetch Bunny storage BEFORE entering the transaction to avoid holding the
// advisory lock during a potentially slow/failing HTTP call on cache miss.
const bunnyData = await getCachedUserBunnyStorage();
// Fetch Bunny storage and the account's ceiling BEFORE entering the transaction,
// to avoid holding the advisory lock during a potentially slow/failing HTTP call
// on cache miss or an extra round trip to Postgres.
const [bunnyData, limitBytes] = await Promise.all([
getCachedUserBunnyStorage(),
getStorageLimitForUser(userId),
]);
const bunnyBytes = BigInt(bunnyData[userId] ?? 0);
try {
@@ -166,7 +194,7 @@ export async function reserveStorageQuota(
const reservedBytes = resRow?.total ?? BigInt(0);
const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
if (totalUsed + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
if (totalUsed + incomingSizeBytes >= limitBytes) {
throw new QuotaExceededError();
}
+37
View File
@@ -0,0 +1,37 @@
// What a cardless trial is allowed to consume.
//
// The trial exists to let somebody run one real review cycle before paying: upload
// a cut, share it, collect feedback, upload the revision. Everything that costs us
// nothing (YouTube and Vimeo embeds, share links, guests, comments, approvals) is
// therefore unlimited, and the caps sit only on the two things that do cost money
// or invite abuse: how much we store, and how many workspaces one unpaid account
// can hold open.
//
// These take a plain `isPaid` boolean rather than a user row so this module stays
// free of imports from `lib/billing.ts`, which imports the limits back.
/** One workspace, so an unpaid account cannot park a whole agency here. */
export const TRIAL_WORKSPACE_LIMIT = 1;
/**
* One project at a time. There is no archive flag on Project, so "active" means
* "exists": deleting a project frees the slot.
*/
export const TRIAL_PROJECT_LIMIT = 1;
/**
* 3 GiB of direct uploads. Enough for a first cut plus two revisions at a real
* bitrate, small enough that a farm of throwaway accounts is not worth running.
* Anyone who hits it can still work through YouTube imports, which cost nothing.
*/
export const TRIAL_STORAGE_LIMIT_BYTES = BigInt(3) * BigInt(1024) * BigInt(1024) * BigInt(1024);
// There is deliberately no separate per-file ceiling for trials. The default
// per-file limit is 5 GiB and the trial's total is 3 GiB, so the quota check
// already refuses anything bigger, and a second limit would only add a second
// way to be told no.
export function getStorageLimitBytes(isPaid: boolean, planLimitBytes: bigint): bigint {
if (isPaid) return planLimitBytes;
return planLimitBytes < TRIAL_STORAGE_LIMIT_BYTES ? planLimitBytes : TRIAL_STORAGE_LIMIT_BYTES;
}
+93 -1
View File
@@ -8,7 +8,11 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
import { db } from '@/lib/db';
import { AT_RISK_SILENT_DAYS, getScoreboard } from '@/lib/analytics/scoreboard';
import {
AT_RISK_SILENT_DAYS,
getCohortComparison,
getScoreboard,
} from '@/lib/analytics/scoreboard';
import { createUser } from '../factories';
function daysAgo(days: number): Date {
@@ -179,3 +183,91 @@ describe('getScoreboard', () => {
expect(busyRow?.valueEvents30).toBe(2);
});
});
// The cohort comparison is another block of raw SQL, and the part most easily
// got wrong is the observation window: a conversion that arrives two months
// after signup belongs to neither cohort's score.
describe('getCohortComparison', () => {
const CUTOVER = '2026-03-01T00:00:00.000Z';
const NOW = new Date('2026-05-01T00:00:00.000Z');
async function seedAccount(params: { createdAt: string; trialAt?: string; paidAt?: string }) {
const user = await createUser();
await db.user.update({
where: { id: user.id },
data: { createdAt: new Date(params.createdAt) },
});
if (params.trialAt) {
await seedEvent({
name: 'TRIAL_STARTED',
occurredAt: new Date(params.trialAt),
userId: user.id,
});
}
if (params.paidAt) {
await seedEvent({
name: 'SUBSCRIPTION_STARTED',
occurredAt: new Date(params.paidAt),
userId: user.id,
});
}
return user;
}
it('is null on a deployment that never named a switchover date', async () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
expect(await getCohortComparison(NOW)).toBeNull();
});
it('splits accounts by the cutover and scores each within its 30 days', async () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
// Card first: one converted inside the window, one long after it.
await seedAccount({
createdAt: '2026-02-10T00:00:00.000Z',
paidAt: '2026-02-20T00:00:00.000Z',
});
await seedAccount({
createdAt: '2026-02-10T00:00:00.000Z',
paidAt: '2026-03-25T00:00:00.000Z',
});
// Cardless: both took the trial, one paid for it.
await seedAccount({
createdAt: '2026-03-10T00:00:00.000Z',
trialAt: '2026-03-10T00:00:00.000Z',
paidAt: '2026-03-20T00:00:00.000Z',
});
await seedAccount({
createdAt: '2026-03-15T00:00:00.000Z',
trialAt: '2026-03-15T00:00:00.000Z',
});
// Older than the matched window, and too new to have been observed yet.
await seedAccount({
createdAt: '2026-01-01T00:00:00.000Z',
paidAt: '2026-01-05T00:00:00.000Z',
});
await seedAccount({
createdAt: '2026-04-15T00:00:00.000Z',
paidAt: '2026-04-16T00:00:00.000Z',
});
const comparison = await getCohortComparison(NOW);
expect(comparison?.rows).toEqual([
expect.objectContaining({ cohort: 'CARD_FIRST', signups: 2, trials: 0, paid: 1 }),
expect.objectContaining({ cohort: 'CARDLESS', signups: 2, trials: 2, paid: 1 }),
]);
});
it('reports both cohorts as empty rows rather than omitting them', async () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
const comparison = await getCohortComparison(NOW);
expect(comparison?.rows.map((row) => row.cohort)).toEqual(['CARD_FIRST', 'CARDLESS']);
expect(comparison?.rows.every((row) => row.signups === 0)).toBe(true);
});
});
+39
View File
@@ -96,6 +96,45 @@ describe('consumeVerificationToken', () => {
expect(await db.verificationToken.count()).toBe(0);
});
// Verification is where the free trial begins, which is what makes a proven
// address the price of admission rather than a formality.
it('starts the seven day trial on the account it verifies', async () => {
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.billingTrialConsumedAt).toBeInstanceOf(Date);
const days =
(verified.trialEndsAt!.getTime() - verified.billingTrialConsumedAt!.getTime()) /
(24 * 60 * 60 * 1000);
expect(days).toBe(7);
});
it('does not hand a second trial to an account that already had one', async () => {
const consumedAt = new Date('2026-01-01T00:00:00.000Z');
const trialEndsAt = new Date('2026-01-08T00:00:00.000Z');
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt,
billingTrialConsumedAt: consumedAt,
});
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.trialEndsAt).toEqual(trialEndsAt);
expect(verified.billingTrialConsumedAt).toEqual(consumedAt);
});
it('refuses a replayed token and keeps the original verification timestamp', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
+53 -1
View File
@@ -14,6 +14,7 @@ import {
addWorkspaceMember,
createExpiredUser,
createProject,
createSubscribedUser,
createUser,
createVideo,
createWorkspace,
@@ -338,8 +339,10 @@ describe('POST /api/projects', () => {
expect(stored.allowDownloads).toBe(false);
});
// Subscribed rather than the default trial user: three projects is past the
// trial's ceiling, and this test is about slugs, not about billing.
it('gives two projects with the same name distinct slugs', async () => {
const owner = await createUser();
const owner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
@@ -355,6 +358,55 @@ describe('POST /api/projects', () => {
expect(slugs.sort()).toEqual(['same-name', 'same-name-1', 'same-name-2']);
});
it('refuses a second project while the owner is on a free trial', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('lets a paying owner past that ceiling', async () => {
const owner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(201);
expect(await db.project.count()).toBe(2);
});
// The ceiling belongs to the account being billed, so a workspace admin cannot
// spend somebody else's trial allowance either.
it('counts the ceiling against the workspace owner, not the caller', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
const admin = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('honours an explicit PUBLIC visibility', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
+56
View File
@@ -351,6 +351,62 @@ describe('POST /api/auth/register', () => {
expect(sentMail()).toEqual([]);
});
it('refuses a disposable mailbox with 400 and stores nothing', async () => {
const response = await post({
name: 'Throwaway Person',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(400);
expect(await db.user.count()).toBe(0);
});
// The block exists to stop trial farming, which is a self-signup problem. An
// invited collaborator was vouched for by a paying customer, so refusing their
// address would break that customer's review instead.
it('accepts a disposable mailbox when an invitation vouches for it', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
role: 'COMMENTATOR',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Invited Guest',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
expect(await db.user.count()).toBe(2);
});
// SMTP is configured in .env.test, so registration alone proves nothing about
// the address and grants no trial. Verification is what starts the clock.
it('leaves the trial unstarted until the address has been verified', async () => {
const response = await post({
name: 'Unverified Person',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({
where: { email: '[email protected]' },
});
expect(created.trialEndsAt).toBeNull();
expect(created.billingTrialConsumedAt).toBeNull();
});
it('reports the rate limit budget on a successful registration', async () => {
const response = await post({
name: 'Rate Limited',
+68 -29
View File
@@ -27,6 +27,7 @@ import { signedInAs, signedOut } from '../helpers/session';
import {
createExpiredUser,
createProject,
createSubscribedUser,
createUploadReservation,
createUser,
createVersion,
@@ -59,9 +60,47 @@ describe('PLAN_STORAGE_LIMIT_BYTES', () => {
});
});
// Everything else in this file bills a subscribed account, because the plan
// ceiling and the advisory lock are what those tests are about. This block is
// the other half: the same code paths, held to the trial ceiling instead.
describe('the trial ceiling', () => {
it('reports the trial limit rather than the plan limit for a trial account', async () => {
const user = await createUser();
expect((await getUserStorageInfo(user.id)).limitBytes).toBe(BigInt(3) * GIB);
});
it('refuses an upload that a paying account of the same size would be allowed', async () => {
const trialUser = await createUser();
const paidUser = await createSubscribedUser();
const fourGiB = BigInt(4) * GIB;
expect((await enforceStorageQuota(trialUser.id, fourGiB))?.status).toBe(507);
expect(await enforceStorageQuota(paidUser.id, fourGiB)).toBeNull();
});
it('holds a reservation to the trial ceiling too, not only the plain check', async () => {
const user = await createUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(2) * GIB });
const result = await reserveStorageQuota(user.id, BigInt(2) * GIB);
expect('error' in result).toBe(true);
expect((result as { error: Response }).error.status).toBe(507);
});
it('still lets a trial account upload inside its own ceiling', async () => {
const user = await createUser();
const result = await reserveStorageQuota(user.id, BigInt(1) * GIB);
expect('reservationId' in result).toBe(true);
});
});
describe('getUserTotalStorageBytes', () => {
it('is zero for a user with nothing stored', async () => {
const user = await createUser();
const user = await createSubscribedUser();
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(0));
});
@@ -93,7 +132,7 @@ describe('getUserTotalStorageBytes', () => {
it('ignores assets billed to somebody else and non-R2 providers', async () => {
const scenario = await seedProject();
const other = await createUser();
const other = await createSubscribedUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
@@ -121,9 +160,9 @@ describe('getUserTotalStorageBytes', () => {
// Versions are billed through the workspace owner, not through the project
// owner or the uploader, which is what the join in the raw SQL encodes.
it('sums r2 video versions through the workspace owner', async () => {
const workspaceOwner = await createUser();
const workspaceOwner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const projectOwner = await createUser();
const projectOwner = await createSubscribedUser();
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
@@ -146,7 +185,7 @@ describe('getUserTotalStorageBytes', () => {
});
it('counts active reservations and ignores expired ones', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1000) });
await createUploadReservation({
billedUserId: user.id,
@@ -158,7 +197,7 @@ describe('getUserTotalStorageBytes', () => {
});
it('adds the Bunny Stream bytes reported for the user', async () => {
const user = await createUser();
const user = await createSubscribedUser();
bunnyStorage({ [user.id]: 12_345 });
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(12_345));
@@ -167,7 +206,7 @@ describe('getUserTotalStorageBytes', () => {
describe('getUserStorageInfo', () => {
it('reports the percentage to two decimal places', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: BigInt(50) * GIB,
@@ -181,7 +220,7 @@ describe('getUserStorageInfo', () => {
});
it('clamps the percentage at 100 when usage exceeds the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES * BigInt(3),
@@ -193,7 +232,7 @@ describe('getUserStorageInfo', () => {
describe('enforceStorageQuota', () => {
it('allows an upload that stays under the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
expect(await enforceStorageQuota(user.id, BigInt(1024))).toBeNull();
});
@@ -201,7 +240,7 @@ describe('enforceStorageQuota', () => {
// The route uses `>=`, so a user sitting exactly on the limit is blocked
// rather than allowed one more byte.
it('rejects an upload that lands exactly on the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -213,7 +252,7 @@ describe('enforceStorageQuota', () => {
});
it('allows an upload one byte short of the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -224,7 +263,7 @@ describe('enforceStorageQuota', () => {
it('skips the check entirely when Stripe is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES,
@@ -236,7 +275,7 @@ describe('enforceStorageQuota', () => {
describe('reserveStorageQuota', () => {
it('writes a reservation row billed to the user with the requested size', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const result = await reserveStorageQuota(user.id, BigInt(4096));
@@ -250,7 +289,7 @@ describe('reserveStorageQuota', () => {
it('returns a null reservation id and writes nothing when Stripe is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const user = await createUser();
const user = await createSubscribedUser();
const result = await reserveStorageQuota(user.id, BigInt(4096));
@@ -259,7 +298,7 @@ describe('reserveStorageQuota', () => {
});
it('refuses a reservation that would cross the limit and writes no row', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -289,7 +328,7 @@ describe('reserveStorageQuota', () => {
});
it('counts Bunny Stream bytes against the reservation', async () => {
const user = await createUser();
const user = await createSubscribedUser();
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
const result = await reserveStorageQuota(user.id, BigInt(2048));
@@ -299,7 +338,7 @@ describe('reserveStorageQuota', () => {
});
it('ignores an expired reservation when computing headroom', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -312,8 +351,8 @@ describe('reserveStorageQuota', () => {
});
it('does not let one user reservations reduce another user headroom', async () => {
const heavy = await createUser();
const light = await createUser();
const heavy = await createSubscribedUser();
const light = await createSubscribedUser();
await createUploadReservation({
billedUserId: heavy.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -328,7 +367,7 @@ describe('reserveStorageQuota', () => {
// start before either commits, so without serialisation both read the same
// "used" figure, both see enough headroom, and the user ends up over quota.
it('serialises two concurrent reservations so only one fits the remaining headroom', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(30) * GIB;
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
// 30 GiB of headroom, and each request wants 20 GiB.
@@ -358,7 +397,7 @@ describe('reserveStorageQuota', () => {
});
it('grants both concurrent reservations when there is room for both', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const request = BigInt(20) * GIB;
const results = await Promise.all([
@@ -372,7 +411,7 @@ describe('reserveStorageQuota', () => {
});
it('serialises five concurrent reservations, granting exactly the number that fit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(50) * GIB;
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
const request = BigInt(20) * GIB;
@@ -394,8 +433,8 @@ describe('reserveStorageQuota', () => {
// Different users hash to different advisory lock keys, so they must not
// block each other.
it('does not serialise reservations for different users', async () => {
const first = await createUser();
const second = await createUser();
const first = await createSubscribedUser();
const second = await createSubscribedUser();
const request = BigInt(150) * GIB;
const results = await Promise.all([
@@ -410,7 +449,7 @@ describe('reserveStorageQuota', () => {
describe('releaseStorageReservation', () => {
it('deletes the reservation and frees the headroom', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const result = await reserveStorageQuota(user.id, BigInt(10) * GIB);
const reservationId = 'reservationId' in result ? result.reservationId : null;
expect(reservationId).toBeTruthy();
@@ -423,7 +462,7 @@ describe('releaseStorageReservation', () => {
});
it('is a no-op for a null id', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1) });
await releaseStorageReservation(null);
@@ -434,8 +473,8 @@ describe('releaseStorageReservation', () => {
// The billedUserId argument scopes the delete, so a caller cannot release
// another user's reservation by guessing its id.
it('refuses to delete a reservation belonging to a different billed user', async () => {
const owner = await createUser();
const attacker = await createUser();
const owner = await createSubscribedUser();
const attacker = await createSubscribedUser();
const reservation = await createUploadReservation({
billedUserId: owner.id,
sizeBytes: BigInt(4096),
@@ -466,7 +505,7 @@ describe('GET /api/settings/storage', () => {
});
it('serialises the byte counts as strings so BigInt survives JSON', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: BigInt(20) * GIB,
+21 -2
View File
@@ -21,6 +21,7 @@ import {
addWorkspaceMember,
createExpiredUser,
createProject,
createSubscribedUser,
createUser,
createVideo,
createWorkspace,
@@ -145,8 +146,10 @@ describe('POST /api/workspaces', () => {
expect(stored.ownerId).toBe(user.id);
});
// Subscribed rather than the default trial user: three workspaces is past the
// trial's ceiling, and this test is about slugs, not about billing.
it('gives same-named workspaces distinct slugs', async () => {
const user = await createUser();
const user = await createSubscribedUser();
signedInAs(user);
for (let index = 0; index < 3; index += 1) {
@@ -221,8 +224,10 @@ describe('POST /api/workspaces', () => {
expect(await db.workspace.count()).toBe(1);
});
// The name has always promised a subscriber; it used to be handed a trial user,
// which passed only because nothing distinguished the two.
it('lets a subscribed user create any number of workspaces', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createWorkspace({ ownerId: user.id });
await createWorkspace({ ownerId: user.id });
signedInAs(user);
@@ -236,6 +241,20 @@ describe('POST /api/workspaces', () => {
expect(await db.workspace.count()).toBe(3);
});
it('refuses a second workspace while the owner is on a free trial', async () => {
const user = await createUser();
await createWorkspace({ ownerId: user.id });
signedInAs(user);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Second' } })
);
expect(response.status).toBe(403);
expect(await db.workspace.count()).toBe(1);
});
it('lets a self-hosted instance with billing disabled create freely', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const expired = await createExpiredUser();
+9 -7
View File
@@ -63,13 +63,15 @@ test('a new account can be registered with the invite code and then signed in',
await page.getByLabel('Password').fill(E2E_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
// /settings, not /onboarding, and that is the real product behaviour rather
// than a test artefact: POST /api/auth/register does not set `trialEndsAt`, so
// with OPENFRAME_ENABLE_STRIPE on a brand new account has no billing access,
// and requireBillingAccessOrRedirect() on /dashboard sends it to billing
// before it ever sees the onboarding wizard.
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
// /onboarding rather than /settings, which is the whole point of the cardless
// trial: registration grants the trial (here at signup, because SMTP is unset
// and there is no verification step to hang it on), so
// requireBillingAccessOrRedirect() on /dashboard lets the account through to
// the wizard instead of parking it on billing.
await expect(page).toHaveURL(/\/onboarding$/);
await expect(
page.getByRole('heading', { name: 'Welcome to OpenFrame, Freshly!', level: 2 })
).toBeVisible();
// The Seed fixture only tracks rows it created itself, so remove this one by
// hand rather than leaving it behind for the next run.
+3 -2
View File
@@ -100,8 +100,9 @@ export class Seed {
/**
* A user whose trial ran out and who has no subscription, so
* hasBillingAccess() is false. `billingTrialConsumedAt` is set, which is what
* makes /settings offer `Upgrade with Stripe` rather than `Start Free Trial`.
* hasBillingAccess() is false and /settings offers `Upgrade with Stripe`.
* `billingTrialConsumedAt` is set because the trial is once per account and
* this one has had it.
*/
async expiredUser(): Promise<User> {
const tag = uniqueTag();
+65 -2
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { conversionRates } from '@/lib/analytics/scoreboard';
import { afterEach, describe, it, expect, vi } from 'vitest';
import {
COHORT_OBSERVATION_DAYS,
cohortWindows,
conversionRates,
getCardlessTrialCutover,
} from '@/lib/analytics/scoreboard';
const WEEK = {
visitors: 200,
@@ -34,3 +39,61 @@ describe('conversionRates', () => {
expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0);
});
});
describe('getCardlessTrialCutover', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('is null when the deployment has not named a switchover date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
expect(getCardlessTrialCutover()).toBeNull();
});
it('is null rather than an Invalid Date when the value is not a date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', 'last tuesday');
expect(getCardlessTrialCutover()).toBeNull();
});
it('reads an ISO date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '2026-03-01');
expect(getCardlessTrialCutover()?.toISOString()).toBe('2026-03-01T00:00:00.000Z');
});
});
describe('cohortWindows', () => {
const CUTOVER = new Date('2026-03-01T00:00:00.000Z');
// The new cohort's window stops short of now, because an account that signed
// up yesterday has not had its 30 days to convert. Counting it would hold the
// new cohort to a shorter life than the old one and make it look worse.
it('ends the new window a full observation period before now', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
expect(windows.afterStart.toISOString()).toBe('2026-03-01T00:00:00.000Z');
expect(windows.afterEnd.toISOString()).toBe('2026-04-01T00:00:00.000Z');
expect(COHORT_OBSERVATION_DAYS).toBe(30);
});
it('gives the old cohort a window of exactly the same length', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
expect(windows.beforeEnd.toISOString()).toBe('2026-03-01T00:00:00.000Z');
expect(windows.beforeStart.toISOString()).toBe('2026-01-29T00:00:00.000Z');
expect(windows.windowDays).toBe(31);
expect(windows.afterEnd.getTime() - windows.afterStart.getTime()).toBe(
windows.beforeEnd.getTime() - windows.beforeStart.getTime()
);
});
it('collapses both windows to nothing before the first cohort is observable', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-03-10T00:00:00.000Z'));
expect(windows.windowDays).toBe(0);
expect(windows.afterEnd.toISOString()).toBe(windows.afterStart.toISOString());
expect(windows.beforeStart.toISOString()).toBe(windows.beforeEnd.toISOString());
});
});
+351 -7
View File
@@ -17,18 +17,22 @@ import {
hasActiveTrial,
hasBillingAccess,
hasRecoverableSubscription,
isPaidTier,
keepUnexpiredTrial,
mapStripeSubscriptionStatus,
markSubscriptionCanceledByCustomerId,
selectAuthoritativeSubscription,
startCardlessTrial,
syncStripeCustomerSubscriptions,
syncStripeSubscriptionToUser,
} from '@/lib/billing';
const dbMock = vi.hoisted(() => ({
user: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() },
projectMember: { count: vi.fn() },
analyticsEvent: { createMany: vi.fn() },
}));
const stripeMock = vi.hoisted(() => ({
@@ -117,6 +121,136 @@ describe('hasActiveTrial', () => {
});
});
describe('keepUnexpiredTrial', () => {
it('keeps a trial that has not run out', () => {
const future = new Date(NOW.getTime() + DAY_MS);
expect(keepUnexpiredTrial(future, NOW)).toBe(future);
});
it('drops a trial that has already run out', () => {
expect(keepUnexpiredTrial(new Date(NOW.getTime() - 1), NOW)).toBeNull();
});
it('drops a trial that ends exactly now', () => {
expect(keepUnexpiredTrial(new Date(NOW.getTime()), NOW)).toBeNull();
});
it('returns null rather than undefined when there is no trial', () => {
expect(keepUnexpiredTrial(null, NOW)).toBeNull();
expect(keepUnexpiredTrial(undefined, NOW)).toBeNull();
});
});
describe('isPaidTier', () => {
it('counts an active subscription as paid', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null },
NOW
)
).toBe(true);
});
// A Stripe trial was card-backed, so it is a customer in waiting rather than
// an unpaid account, and it keeps the full plan ceilings.
it('counts a Stripe trial as paid', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null },
NOW
)
).toBe(true);
});
it('counts a lapsed status inside a paid period as paid', () => {
expect(
isPaidTier(
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
},
NOW
)
).toBe(true);
});
// The whole point of the split: this account has access and no card behind it.
it('does not count a cardless trial as paid', () => {
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
NOW
)
).toBe(false);
});
// A checkout whose first charge never went through. Stripe hands back a
// subscription carrying a period end a month out, and reading that as payment
// would have given a free account the full 200 GB and unlimited projects.
it('does not count an incomplete subscription as paid, period end or not', () => {
expect(
isPaidTier(
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
},
NOW
)
).toBe(false);
});
it('does not count an expired incomplete subscription as paid', () => {
expect(
isPaidTier(
{
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
},
NOW
)
).toBe(false);
});
// A card that failed on renewal is a customer, not a free account: the period
// it is inside was paid for. Kept as a test because the fix above is one
// `Set.has` away from catching this case too.
it('still counts a past due subscription inside its paid period as paid', () => {
expect(
isPaidTier(
{
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
},
NOW
)
).toBe(true);
});
it('does not count an expired paid period as paid', () => {
expect(
isPaidTier(
{
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
},
NOW
)
).toBe(false);
});
it('treats everyone as paid when billing is switched off entirely', () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
expect(
isPaidTier(
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
NOW
)
).toBe(true);
});
});
describe('hasActiveSubscription', () => {
const expected: Record<BillingSubscriptionStatus, boolean> = {
FREE: false,
@@ -592,6 +726,8 @@ describe('database backed billing helpers', () => {
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
dbMock.user.findUnique.mockReset();
dbMock.user.update.mockReset();
dbMock.user.updateMany.mockReset();
dbMock.analyticsEvent.createMany.mockReset();
dbMock.workspace.count.mockReset();
dbMock.workspaceMember.count.mockReset();
dbMock.projectMember.count.mockReset();
@@ -611,36 +747,31 @@ describe('database backed billing helpers', () => {
await expect(getStripeCheckoutState('u1')).rejects.toThrow('User u1 not found');
});
it('reports an active subscriber as active, recoverable and trial-consumed', async () => {
it('reports an active subscriber as active and recoverable', async () => {
dbMock.user.findUnique.mockResolvedValue({
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
billingTrialConsumedAt: new Date('2025-06-01T00:00:00Z'),
});
await expect(getStripeCheckoutState('u1')).resolves.toEqual({
hasActiveSubscription: true,
hasRecoverableSubscription: true,
isTrialEligible: false,
});
});
it('reports a past_due subscriber as recoverable but not active', async () => {
dbMock.user.findUnique.mockResolvedValue({
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
billingTrialConsumedAt: null,
});
await expect(getStripeCheckoutState('u1')).resolves.toEqual({
hasActiveSubscription: false,
hasRecoverableSubscription: true,
isTrialEligible: true,
});
});
it('reports a canceled subscriber as neither active nor recoverable', async () => {
dbMock.user.findUnique.mockResolvedValue({
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
billingTrialConsumedAt: null,
});
const state = await getStripeCheckoutState('u1');
@@ -650,6 +781,64 @@ describe('database backed billing helpers', () => {
});
});
describe('startCardlessTrial', () => {
it('dates the trial from now and marks it consumed in the same write', async () => {
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
await expect(startCardlessTrial('u1')).resolves.toBe(true);
const call = dbMock.user.updateMany.mock.calls[0][0];
expect(call.data.trialEndsAt.getTime()).toBe(
NOW.getTime() + DEFAULT_TRIAL_PERIOD_DAYS * DAY_MS
);
expect(call.data.billingTrialConsumedAt.getTime()).toBe(NOW.getTime());
});
// The guard is the whole once-per-account rule. Without it a re-issued
// verification link, or a second one opened on a phone, extends the trial.
it('only writes to an account that has never had a trial', async () => {
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
await startCardlessTrial('u1');
expect(dbMock.user.updateMany.mock.calls[0][0].where).toEqual({
id: 'u1',
trialEndsAt: null,
billingTrialConsumedAt: null,
});
});
it('reports no trial and records nothing when the guard matched no rows', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
dbMock.user.updateMany.mockResolvedValue({ count: 0 });
await expect(startCardlessTrial('u1')).resolves.toBe(false);
expect(dbMock.analyticsEvent.createMany).not.toHaveBeenCalled();
});
it('records the trial once, keyed on the account', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
await startCardlessTrial('u1');
expect(dbMock.analyticsEvent.createMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [expect.objectContaining({ name: 'TRIAL_STARTED', dedupeKey: 'TRIAL_STARTED:u1' })],
})
);
});
// Nothing is gated without billing, so a trial date would be noise. Worse, it
// would consume the trial of an instance that switches billing on later.
it('grants nothing when billing is switched off entirely', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
await expect(startCardlessTrial('u1')).resolves.toBe(false);
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
});
});
describe('getWorkspaceCreationEligibility', () => {
function mockEligibility(options: {
user?: Record<string, unknown> | null;
@@ -684,6 +873,55 @@ describe('database backed billing helpers', () => {
await expect(getWorkspaceCreationEligibility('u1')).rejects.toThrow('User u1 not found');
});
it('lets an account on a cardless trial create its first workspace', async () => {
mockEligibility({
user: {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS),
billingTrialConsumedAt: NOW,
stripeCustomerId: null,
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
stripeCancelAtPeriodEnd: null,
stripeCancelAt: null,
billingAccessEndedAt: null,
},
owned: 0,
});
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(true);
expect(result.subscription.isPaid).toBe(false);
});
// The trial ceiling. Access alone used to be enough for any number of these.
it('refuses a second workspace on a cardless trial', async () => {
mockEligibility({
user: {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS),
billingTrialConsumedAt: NOW,
stripeCustomerId: null,
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
stripeCancelAtPeriodEnd: null,
stripeCancelAt: null,
billingAccessEndedAt: null,
},
owned: 1,
});
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
expect(result.reason).toBe(
'Your free trial includes one workspace. Subscribe to create more.'
);
});
it('allows creation while billing access holds, whatever the counts are', async () => {
mockEligibility({
user: {
@@ -1002,6 +1240,26 @@ describe('database backed billing helpers', () => {
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});
// A legacy Stripe trial that has already elapsed is a reason to stamp the
// access end date, not to skip it. Treating any non-null trial date as live
// would leave a lapsed account looking like it still had access, and the
// cleanup job would never come for its storage.
it('still ends access when the Stripe trial it reports is already over', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
trialEndsAt: null,
});
const elapsedTrialEnd = Math.floor((NOW.getTime() - 5 * DAY_MS) / 1000);
await syncStripeSubscriptionToUser(
stripeSub({ status: 'canceled', current_period_end: null, trial_end: elapsedTrialEnd })
);
expect((updateData().trialEndsAt as Date).getTime()).toBe(elapsedTrialEnd * 1000);
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});
it('marks the trial consumed the first time an entitled trial is seen', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
const trialEnd = Math.floor(NOW.getTime() / 1000) + 5 * 86_400;
@@ -1064,6 +1322,68 @@ describe('database backed billing helpers', () => {
expect(updateData().stripePriceId).toBeNull();
});
// The abandoned-checkout case. Stripe grants no trials any more, so every
// sync arrives with trial_end null, and writing that through would erase the
// days a cardless trial still had left.
it('keeps a cardless trial that has not run out when Stripe reports none', async () => {
const trialEndsAt = new Date(NOW.getTime() + 4 * DAY_MS);
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: NOW,
trialEndsAt,
});
await syncStripeSubscriptionToUser(
stripeSub({ status: 'incomplete', current_period_end: null })
);
expect(updateData().trialEndsAt).toBe(trialEndsAt);
});
it('does not date the storage cleanup from today while that trial runs', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: NOW,
trialEndsAt: new Date(NOW.getTime() + 4 * DAY_MS),
});
await syncStripeSubscriptionToUser(
stripeSub({ status: 'incomplete', current_period_end: null })
);
expect(updateData().billingAccessEndedAt).toBeNull();
});
it('clears a trial that has already run out', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
trialEndsAt: new Date(NOW.getTime() - DAY_MS),
});
await syncStripeSubscriptionToUser(
stripeSub({ status: 'incomplete', current_period_end: null })
);
expect(updateData().trialEndsAt).toBeNull();
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
});
it('still prefers the trial end Stripe reports over the stored one', async () => {
const stripeTrialEnd = Math.floor(NOW.getTime() / 1000) + 10 * 86_400;
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
billingTrialConsumedAt: null,
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
});
await syncStripeSubscriptionToUser(
stripeSub({ status: 'trialing', trial_end: stripeTrialEnd })
);
expect((updateData().trialEndsAt as Date).getTime()).toBe(stripeTrialEnd * 1000);
});
});
describe('markSubscriptionCanceledByCustomerId', () => {
@@ -1101,6 +1421,30 @@ describe('database backed billing helpers', () => {
expect(updateData().billingAccessEndedAt).toBe(periodEnd);
});
// Cancelling a subscription does not retract days the account was already
// given, and the settings copy promises exactly this.
it('keeps a trial that has not run out and leaves access open', async () => {
const trialEndsAt = new Date(NOW.getTime() + 3 * DAY_MS);
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', trialEndsAt });
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBe(trialEndsAt);
expect(updateData().billingAccessEndedAt).toBeNull();
});
it('still ends access when the trial has already run out', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
trialEndsAt: new Date(NOW.getTime() - DAY_MS),
});
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBeNull();
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});
it('prefers an explicit endedAt over the period end', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1' });
const periodEnd = new Date('2026-02-01T00:00:00.000Z');
+49 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import {
isDisposableEmailDomain,
isValidEmailAddress,
normalizeEmail,
} from '@/lib/email-validation';
describe('normalizeEmail', () => {
it.each([
@@ -93,3 +97,47 @@ describe('isValidEmailAddress', () => {
expect(isValidEmailAddress('ab')).toBe(false);
});
});
describe('isDisposableEmailDomain', () => {
it.each([
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
])('refuses %s', (email) => {
expect(isDisposableEmailDomain(email)).toBe(true);
});
// Several of these providers hand out a fresh subdomain per visit, so an
// exact-match lookup would let every one of them through.
it('follows a disposable provider into its subdomains', () => {
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
});
it('is not fooled by a domain that merely ends with the same letters', () => {
expect(isDisposableEmailDomain('[email protected]')).toBe(false);
expect(isDisposableEmailDomain('[email protected]')).toBe(false);
});
it.each([
'[email protected]',
'[email protected]',
// Forwarding and masking services are what privacy-minded paying customers
// actually use. Blocking them would cost real revenue.
'[email protected]',
'[email protected]',
'[email protected]',
])('accepts %s', (email) => {
expect(isDisposableEmailDomain(email)).toBe(false);
});
it('ignores case and surrounding whitespace in the domain', () => {
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
});
it('returns false for a string with no domain at all', () => {
expect(isDisposableEmailDomain('someone')).toBe(false);
expect(isDisposableEmailDomain('')).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
TRIAL_PROJECT_LIMIT,
TRIAL_STORAGE_LIMIT_BYTES,
TRIAL_WORKSPACE_LIMIT,
getStorageLimitBytes,
} from '@/lib/trial-limits';
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
describe('trial ceilings', () => {
it('holds a trial to one workspace and one project', () => {
expect(TRIAL_WORKSPACE_LIMIT).toBe(1);
expect(TRIAL_PROJECT_LIMIT).toBe(1);
});
it('caps trial storage at 3 GiB', () => {
expect(TRIAL_STORAGE_LIMIT_BYTES).toBe(BigInt(3) * GIB);
});
});
describe('getStorageLimitBytes', () => {
const PLAN_LIMIT = BigInt(200) * GIB;
it('gives a paying account the whole plan allowance', () => {
expect(getStorageLimitBytes(true, PLAN_LIMIT)).toBe(BigInt(214748364800));
});
it('holds an unpaid account to the trial ceiling', () => {
expect(getStorageLimitBytes(false, PLAN_LIMIT)).toBe(BigInt(3221225472));
});
// A self-hosted instance can configure a plan allowance below the trial one.
// Handing a trial account more storage than the plan itself grants would be a
// strange way to run out of disk.
it('never raises an account above a plan allowance smaller than the trial ceiling', () => {
const tinyPlan = BigInt(512) * BigInt(1024) * BigInt(1024);
expect(getStorageLimitBytes(false, tinyPlan)).toBe(BigInt(536870912));
});
});