From 39e81042bb5799d983f4d27da9fec04cbbbb1009 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 5 Aug 2026 19:40:36 +0300 Subject: [PATCH 1/5] feat(billing): let people try the product before handing over a card The trial now starts inside the product, at email verification, and Stripe grants none at all: checkout creates a subscription that bills immediately. Verifying an address is what buys the seven days, which is also the cheapest abuse control there is. An unexpired trial is treated as an entitlement the account already holds, so a Stripe sync can add access but never retracts a trial that has not run out. That matters most for the abandoned checkout: the resulting incomplete subscription carries no trial_end, and writing it through would have erased the days the account still had and locked it out. Unpaid accounts are bounded by what they can cost us rather than by what they can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports, share links, guests, comments and approvals stay unlimited, because those are the parts worth trying and they cost nothing. isPaidTier() is the new seam; hasBillingAccess() answers a different question now that access no longer implies a card. Signup CTAs, the pricing card, the comparison pages, the terms and the refund policy all said the trial converts to a paid plan by itself. It no longer does, so they say what happens instead. Settings and a banner name both dates that matter: when the trial ends, and the fifteen days after that during which nothing is deleted. /admin/growth compares the two funnels on signup to paid within a fixed 30 day window, not trial to paid. Dropping the card requirement multiplies trials, so the old ratio can fall while more people actually pay, and reading it that way would retire the change for the wrong reason. --- .env.example | 4 + README.md | 2 +- app/(dashboard)/layout.tsx | 12 +- .../settings/settings-page-client.tsx | 35 +- app/admin/growth/page.tsx | 61 ++++ app/api/auth/register/route.ts | 29 +- app/api/billing/checkout/route.ts | 10 +- app/api/billing/route.ts | 2 +- app/api/projects/route.ts | 23 +- app/refund/page.tsx | 10 +- app/terms/page.tsx | 13 +- components/LandingPage.tsx | 10 +- components/layout/index.ts | 1 + components/layout/trial-banner.tsx | 45 +++ components/marketing/comparison-page.tsx | 9 +- lib/analytics/scoreboard.ts | 146 +++++++- lib/billing.ts | 211 +++++++++++- lib/email-validation.ts | 70 ++++ lib/email-verification.ts | 47 ++- lib/marketing/comparison-pages.ts | 9 +- lib/marketing/metadata.ts | 3 +- lib/storage-quota.ts | 44 ++- lib/trial-limits.ts | 37 ++ tests/api/analytics-scoreboard.test.ts | 94 +++++- tests/api/email-verification.test.ts | 39 +++ tests/api/projects.test.ts | 54 ++- tests/api/register.test.ts | 56 ++++ tests/api/storage-quota.test.ts | 97 ++++-- tests/api/workspaces.test.ts | 23 +- tests/e2e/fixtures.ts | 5 +- tests/unit/lib/analytics-scoreboard.test.ts | 67 +++- tests/unit/lib/billing.test.ts | 316 +++++++++++++++++- tests/unit/lib/email-validation.test.ts | 50 ++- tests/unit/lib/trial-limits.test.ts | 41 +++ 34 files changed, 1541 insertions(+), 134 deletions(-) create mode 100644 components/layout/trial-banner.tsx create mode 100644 lib/trial-limits.ts create mode 100644 tests/unit/lib/trial-limits.test.ts diff --git a/.env.example b/.env.example index 5d0d2f2..2f7d1d8 100644 --- a/.env.example +++ b/.env.example @@ -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" # ============================================================================ diff --git a/README.md b/README.md index 79f6dfd..2e6eadc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index b186a81..23e7de7 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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 (
+ {trialNotice ? : null}
{children}
); diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 212c8d1..6249bf8 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -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 ? (
-

Start your 7-day free trial

+

+ Your free trial runs until{' '} + {new Date(billing.subscription.trialEndsAt).toLocaleDateString()} +

- 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.

) : null} @@ -388,10 +391,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo : 'Subscription canceled. Access remains active until the end of the current billing period.' : 'Paid account with workspace creation unlocked.' : billing.subscription.hasActiveTrial - ? 'Trial access is active.' - : billing.subscription.isTrialEligible - ? "You haven't started your free trial yet." - : 'Billing access has ended.'} + ? 'Free trial, no card required.' + : 'Billing access has ended.'}

) : 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 ? (

- 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.

) : null} @@ -479,8 +482,6 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo Redirecting... - ) : billing.subscription.isTrialEligible ? ( - 'Start Free Trial' ) : ( 'Upgrade with Stripe' )} diff --git a/app/admin/growth/page.tsx b/app/admin/growth/page.tsx index 003cb1e..5d24bfc 100644 --- a/app/admin/growth/page.tsx +++ b/app/admin/growth/page.tsx @@ -240,6 +240,67 @@ export default async function AdminGrowthPage() { + {scoreboard.cohorts ? ( + + + Card-first against cardless trial +

+ 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. +

+
+ + + + + + + + + + + + + + + {scoreboard.cohorts.rows.map((row) => ( + + + + + + + + + + ))} + +
CohortWindowSignupTrialPaidSignup to paidTrial to paid
+ {row.cohort === 'CARDLESS' ? 'cardless' : 'card first'} + + {formatDate(row.windowStart)} to {formatDate(row.windowEnd)} + {row.signups}{row.trials}{row.paid} + 0 ? row.paid / row.signups : null} + of={row.signups} + /> + + 0 ? row.paid / row.trials : null} of={row.trials} /> +
+ {scoreboard.cohorts.windowDays === 0 ? ( +

+ 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. +

+ ) : null} +
+
+ ) : null} + By source diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 801a8e3..932b732 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -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); diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 81c44dd..d8d6b52 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -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 } : {}), }, }); diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts index 3cde6e9..4720207 100644 --- a/app/api/billing/route.ts +++ b/app/api/billing/route.ts @@ -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, diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index f35ad8d..746d55a 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -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: { diff --git a/app/refund/page.tsx b/app/refund/page.tsx index 0bdfb30..e5c15b0 100644 --- a/app/refund/page.tsx +++ b/app/refund/page.tsx @@ -47,13 +47,13 @@ export default function RefundPolicyPage() {

All new accounts are eligible for a{' '} 7-day free trial 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.

- 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.

diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 4d6f50e..937570d 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -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.

@@ -96,11 +96,12 @@ export default function TermsOfServicePage() { 5. Subscriptions and Free Trial

- Certain features of the Service require a paid subscription. We offer a{' '} - 7-day free trial 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 7-day free trial, 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.

Subscription fees are billed in advance on a monthly or annual basis depending on the diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index 4a0cb6d..a99c3ef 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -263,12 +263,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 + Start 7-day free trial, no card required

- 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

/ month

- Starts with a 7-day free trial. Cancel anytime. + Starts with a 7-day free trial. No credit card. Cancel anytime.