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.
This commit is contained in:
yusufipk
2026-08-05 19:40:36 +03:00
parent b8d68a9196
commit 39e81042bb
34 changed files with 1541 additions and 134 deletions
+65 -2
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { conversionRates } from '@/lib/analytics/scoreboard';
import { afterEach, describe, it, expect, vi } from 'vitest';
import {
COHORT_OBSERVATION_DAYS,
cohortWindows,
conversionRates,
getCardlessTrialCutover,
} from '@/lib/analytics/scoreboard';
const WEEK = {
visitors: 200,
@@ -34,3 +39,61 @@ describe('conversionRates', () => {
expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0);
});
});
describe('getCardlessTrialCutover', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('is null when the deployment has not named a switchover date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
expect(getCardlessTrialCutover()).toBeNull();
});
it('is null rather than an Invalid Date when the value is not a date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', 'last tuesday');
expect(getCardlessTrialCutover()).toBeNull();
});
it('reads an ISO date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '2026-03-01');
expect(getCardlessTrialCutover()?.toISOString()).toBe('2026-03-01T00:00:00.000Z');
});
});
describe('cohortWindows', () => {
const CUTOVER = new Date('2026-03-01T00:00:00.000Z');
// The new cohort's window stops short of now, because an account that signed
// up yesterday has not had its 30 days to convert. Counting it would hold the
// new cohort to a shorter life than the old one and make it look worse.
it('ends the new window a full observation period before now', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
expect(windows.afterStart.toISOString()).toBe('2026-03-01T00:00:00.000Z');
expect(windows.afterEnd.toISOString()).toBe('2026-04-01T00:00:00.000Z');
expect(COHORT_OBSERVATION_DAYS).toBe(30);
});
it('gives the old cohort a window of exactly the same length', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
expect(windows.beforeEnd.toISOString()).toBe('2026-03-01T00:00:00.000Z');
expect(windows.beforeStart.toISOString()).toBe('2026-01-29T00:00:00.000Z');
expect(windows.windowDays).toBe(31);
expect(windows.afterEnd.getTime() - windows.afterStart.getTime()).toBe(
windows.beforeEnd.getTime() - windows.beforeStart.getTime()
);
});
it('collapses both windows to nothing before the first cohort is observable', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-03-10T00:00:00.000Z'));
expect(windows.windowDays).toBe(0);
expect(windows.afterEnd.toISOString()).toBe(windows.afterStart.toISOString());
expect(windows.beforeStart.toISOString()).toBe(windows.beforeEnd.toISOString());
});
});
+309 -7
View File
@@ -17,18 +17,22 @@ import {
hasActiveTrial,
hasBillingAccess,
hasRecoverableSubscription,
isPaidTier,
keepUnexpiredTrial,
mapStripeSubscriptionStatus,
markSubscriptionCanceledByCustomerId,
selectAuthoritativeSubscription,
startCardlessTrial,
syncStripeCustomerSubscriptions,
syncStripeSubscriptionToUser,
} from '@/lib/billing';
const dbMock = vi.hoisted(() => ({
user: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() },
projectMember: { count: vi.fn() },
analyticsEvent: { createMany: vi.fn() },
}));
const stripeMock = vi.hoisted(() => ({
@@ -117,6 +121,94 @@ 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);
});
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 +684,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 +705,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 +739,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 +831,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 +1198,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 +1280,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 +1379,30 @@ describe('database backed billing helpers', () => {
expect(updateData().billingAccessEndedAt).toBe(periodEnd);
});
// Cancelling a subscription does not retract days the account was already
// given, and the settings copy promises exactly this.
it('keeps a trial that has not run out and leaves access open', async () => {
const trialEndsAt = new Date(NOW.getTime() + 3 * DAY_MS);
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', trialEndsAt });
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBe(trialEndsAt);
expect(updateData().billingAccessEndedAt).toBeNull();
});
it('still ends access when the trial has already run out', async () => {
dbMock.user.findUnique.mockResolvedValue({
id: 'u1',
trialEndsAt: new Date(NOW.getTime() - DAY_MS),
});
await markSubscriptionCanceledByCustomerId('cus_1');
expect(updateData().trialEndsAt).toBeNull();
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
});
it('prefers an explicit endedAt over the period end', async () => {
dbMock.user.findUnique.mockResolvedValue({ id: 'u1' });
const periodEnd = new Date('2026-02-01T00:00:00.000Z');
+49 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import {
isDisposableEmailDomain,
isValidEmailAddress,
normalizeEmail,
} from '@/lib/email-validation';
describe('normalizeEmail', () => {
it.each([
@@ -93,3 +97,47 @@ describe('isValidEmailAddress', () => {
expect(isValidEmailAddress('ab')).toBe(false);
});
});
describe('isDisposableEmailDomain', () => {
it.each([
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
])('refuses %s', (email) => {
expect(isDisposableEmailDomain(email)).toBe(true);
});
// Several of these providers hand out a fresh subdomain per visit, so an
// exact-match lookup would let every one of them through.
it('follows a disposable provider into its subdomains', () => {
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
});
it('is not fooled by a domain that merely ends with the same letters', () => {
expect(isDisposableEmailDomain('[email protected]')).toBe(false);
expect(isDisposableEmailDomain('[email protected]')).toBe(false);
});
it.each([
'[email protected]',
'[email protected]',
// Forwarding and masking services are what privacy-minded paying customers
// actually use. Blocking them would cost real revenue.
'[email protected]',
'[email protected]',
'[email protected]',
])('accepts %s', (email) => {
expect(isDisposableEmailDomain(email)).toBe(false);
});
it('ignores case and surrounding whitespace in the domain', () => {
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
});
it('returns false for a string with no domain at all', () => {
expect(isDisposableEmailDomain('someone')).toBe(false);
expect(isDisposableEmailDomain('')).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
TRIAL_PROJECT_LIMIT,
TRIAL_STORAGE_LIMIT_BYTES,
TRIAL_WORKSPACE_LIMIT,
getStorageLimitBytes,
} from '@/lib/trial-limits';
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
describe('trial ceilings', () => {
it('holds a trial to one workspace and one project', () => {
expect(TRIAL_WORKSPACE_LIMIT).toBe(1);
expect(TRIAL_PROJECT_LIMIT).toBe(1);
});
it('caps trial storage at 3 GiB', () => {
expect(TRIAL_STORAGE_LIMIT_BYTES).toBe(BigInt(3) * GIB);
});
});
describe('getStorageLimitBytes', () => {
const PLAN_LIMIT = BigInt(200) * GIB;
it('gives a paying account the whole plan allowance', () => {
expect(getStorageLimitBytes(true, PLAN_LIMIT)).toBe(BigInt(214748364800));
});
it('holds an unpaid account to the trial ceiling', () => {
expect(getStorageLimitBytes(false, PLAN_LIMIT)).toBe(BigInt(3221225472));
});
// A self-hosted instance can configure a plan allowance below the trial one.
// Handing a trial account more storage than the plan itself grants would be a
// strange way to run out of disk.
it('never raises an account above a plan allowance smaller than the trial ceiling', () => {
const tinyPlan = BigInt(512) * BigInt(1024) * BigInt(1024);
expect(getStorageLimitBytes(false, tinyPlan)).toBe(BigInt(536870912));
});
});