mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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"
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,9 +391,7 @@ 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."
|
||||
? 'Free trial, no card required.'
|
||||
: 'Billing access has ended.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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'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
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export { Header } from './header';
|
||||
export { TrialBanner } from './trial-banner';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+214
-13
@@ -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 else’s 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
|
||||
// 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),
|
||||
: 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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
// 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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user