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
+93 -1
View File
@@ -8,7 +8,11 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
import { db } from '@/lib/db';
import { AT_RISK_SILENT_DAYS, getScoreboard } from '@/lib/analytics/scoreboard';
import {
AT_RISK_SILENT_DAYS,
getCohortComparison,
getScoreboard,
} from '@/lib/analytics/scoreboard';
import { createUser } from '../factories';
function daysAgo(days: number): Date {
@@ -153,3 +157,91 @@ describe('getScoreboard', () => {
expect(busyRow?.valueEvents30).toBe(2);
});
});
// The cohort comparison is another block of raw SQL, and the part most easily
// got wrong is the observation window: a conversion that arrives two months
// after signup belongs to neither cohort's score.
describe('getCohortComparison', () => {
const CUTOVER = '2026-03-01T00:00:00.000Z';
const NOW = new Date('2026-05-01T00:00:00.000Z');
async function seedAccount(params: { createdAt: string; trialAt?: string; paidAt?: string }) {
const user = await createUser();
await db.user.update({
where: { id: user.id },
data: { createdAt: new Date(params.createdAt) },
});
if (params.trialAt) {
await seedEvent({
name: 'TRIAL_STARTED',
occurredAt: new Date(params.trialAt),
userId: user.id,
});
}
if (params.paidAt) {
await seedEvent({
name: 'SUBSCRIPTION_STARTED',
occurredAt: new Date(params.paidAt),
userId: user.id,
});
}
return user;
}
it('is null on a deployment that never named a switchover date', async () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
expect(await getCohortComparison(NOW)).toBeNull();
});
it('splits accounts by the cutover and scores each within its 30 days', async () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
// Card first: one converted inside the window, one long after it.
await seedAccount({
createdAt: '2026-02-10T00:00:00.000Z',
paidAt: '2026-02-20T00:00:00.000Z',
});
await seedAccount({
createdAt: '2026-02-10T00:00:00.000Z',
paidAt: '2026-03-25T00:00:00.000Z',
});
// Cardless: both took the trial, one paid for it.
await seedAccount({
createdAt: '2026-03-10T00:00:00.000Z',
trialAt: '2026-03-10T00:00:00.000Z',
paidAt: '2026-03-20T00:00:00.000Z',
});
await seedAccount({
createdAt: '2026-03-15T00:00:00.000Z',
trialAt: '2026-03-15T00:00:00.000Z',
});
// Older than the matched window, and too new to have been observed yet.
await seedAccount({
createdAt: '2026-01-01T00:00:00.000Z',
paidAt: '2026-01-05T00:00:00.000Z',
});
await seedAccount({
createdAt: '2026-04-15T00:00:00.000Z',
paidAt: '2026-04-16T00:00:00.000Z',
});
const comparison = await getCohortComparison(NOW);
expect(comparison?.rows).toEqual([
expect.objectContaining({ cohort: 'CARD_FIRST', signups: 2, trials: 0, paid: 1 }),
expect.objectContaining({ cohort: 'CARDLESS', signups: 2, trials: 2, paid: 1 }),
]);
});
it('reports both cohorts as empty rows rather than omitting them', async () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
const comparison = await getCohortComparison(NOW);
expect(comparison?.rows.map((row) => row.cohort)).toEqual(['CARD_FIRST', 'CARDLESS']);
expect(comparison?.rows.every((row) => row.signups === 0)).toBe(true);
});
});
+39
View File
@@ -96,6 +96,45 @@ describe('consumeVerificationToken', () => {
expect(await db.verificationToken.count()).toBe(0);
});
// Verification is where the free trial begins, which is what makes a proven
// address the price of admission rather than a formality.
it('starts the seven day trial on the account it verifies', async () => {
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.billingTrialConsumedAt).toBeInstanceOf(Date);
const days =
(verified.trialEndsAt!.getTime() - verified.billingTrialConsumedAt!.getTime()) /
(24 * 60 * 60 * 1000);
expect(days).toBe(7);
});
it('does not hand a second trial to an account that already had one', async () => {
const consumedAt = new Date('2026-01-01T00:00:00.000Z');
const trialEndsAt = new Date('2026-01-08T00:00:00.000Z');
const user = await createUser({
email: '[email protected]',
emailVerified: null,
trialEndsAt,
billingTrialConsumedAt: consumedAt,
});
const token = await createVerificationToken('[email protected]');
await consumeVerificationToken(token);
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
expect(verified.trialEndsAt).toEqual(trialEndsAt);
expect(verified.billingTrialConsumedAt).toEqual(consumedAt);
});
it('refuses a replayed token and keeps the original verification timestamp', async () => {
const user = await createUser({ email: '[email protected]', emailVerified: null });
const token = await createVerificationToken('[email protected]');
+53 -1
View File
@@ -14,6 +14,7 @@ import {
addWorkspaceMember,
createExpiredUser,
createProject,
createSubscribedUser,
createUser,
createVideo,
createWorkspace,
@@ -338,8 +339,10 @@ describe('POST /api/projects', () => {
expect(stored.allowDownloads).toBe(false);
});
// Subscribed rather than the default trial user: three projects is past the
// trial's ceiling, and this test is about slugs, not about billing.
it('gives two projects with the same name distinct slugs', async () => {
const owner = await createUser();
const owner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
@@ -355,6 +358,55 @@ describe('POST /api/projects', () => {
expect(slugs.sort()).toEqual(['same-name', 'same-name-1', 'same-name-2']);
});
it('refuses a second project while the owner is on a free trial', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('lets a paying owner past that ceiling', async () => {
const owner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(201);
expect(await db.project.count()).toBe(2);
});
// The ceiling belongs to the account being billed, so a workspace admin cannot
// spend somebody else's trial allowance either.
it('counts the ceiling against the workspace owner, not the caller', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
const admin = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('honours an explicit PUBLIC visibility', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
+56
View File
@@ -351,6 +351,62 @@ describe('POST /api/auth/register', () => {
expect(sentMail()).toEqual([]);
});
it('refuses a disposable mailbox with 400 and stores nothing', async () => {
const response = await post({
name: 'Throwaway Person',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(400);
expect(await db.user.count()).toBe(0);
});
// The block exists to stop trial farming, which is a self-signup problem. An
// invited collaborator was vouched for by a paying customer, so refusing their
// address would break that customer's review instead.
it('accepts a disposable mailbox when an invitation vouches for it', async () => {
const scenario = await seedProject();
const invitation = await createInvitation({
invitedById: scenario.owner.id,
scope: 'PROJECT',
projectId: scenario.project.id,
email: '[email protected]',
role: 'COMMENTATOR',
});
signedOut();
const response = await callRoute(
register,
registerRequest({
name: 'Invited Guest',
email: '[email protected]',
password: PASSWORD,
invitationToken: invitation.token,
})
);
expect(response.status).toBe(201);
expect(await db.user.count()).toBe(2);
});
// SMTP is configured in .env.test, so registration alone proves nothing about
// the address and grants no trial. Verification is what starts the clock.
it('leaves the trial unstarted until the address has been verified', async () => {
const response = await post({
name: 'Unverified Person',
email: '[email protected]',
password: PASSWORD,
});
expect(response.status).toBe(201);
const created = await db.user.findUniqueOrThrow({
where: { email: '[email protected]' },
});
expect(created.trialEndsAt).toBeNull();
expect(created.billingTrialConsumedAt).toBeNull();
});
it('reports the rate limit budget on a successful registration', async () => {
const response = await post({
name: 'Rate Limited',
+68 -29
View File
@@ -27,6 +27,7 @@ import { signedInAs, signedOut } from '../helpers/session';
import {
createExpiredUser,
createProject,
createSubscribedUser,
createUploadReservation,
createUser,
createVersion,
@@ -59,9 +60,47 @@ describe('PLAN_STORAGE_LIMIT_BYTES', () => {
});
});
// Everything else in this file bills a subscribed account, because the plan
// ceiling and the advisory lock are what those tests are about. This block is
// the other half: the same code paths, held to the trial ceiling instead.
describe('the trial ceiling', () => {
it('reports the trial limit rather than the plan limit for a trial account', async () => {
const user = await createUser();
expect((await getUserStorageInfo(user.id)).limitBytes).toBe(BigInt(3) * GIB);
});
it('refuses an upload that a paying account of the same size would be allowed', async () => {
const trialUser = await createUser();
const paidUser = await createSubscribedUser();
const fourGiB = BigInt(4) * GIB;
expect((await enforceStorageQuota(trialUser.id, fourGiB))?.status).toBe(507);
expect(await enforceStorageQuota(paidUser.id, fourGiB)).toBeNull();
});
it('holds a reservation to the trial ceiling too, not only the plain check', async () => {
const user = await createUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(2) * GIB });
const result = await reserveStorageQuota(user.id, BigInt(2) * GIB);
expect('error' in result).toBe(true);
expect((result as { error: Response }).error.status).toBe(507);
});
it('still lets a trial account upload inside its own ceiling', async () => {
const user = await createUser();
const result = await reserveStorageQuota(user.id, BigInt(1) * GIB);
expect('reservationId' in result).toBe(true);
});
});
describe('getUserTotalStorageBytes', () => {
it('is zero for a user with nothing stored', async () => {
const user = await createUser();
const user = await createSubscribedUser();
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(0));
});
@@ -93,7 +132,7 @@ describe('getUserTotalStorageBytes', () => {
it('ignores assets billed to somebody else and non-R2 providers', async () => {
const scenario = await seedProject();
const other = await createUser();
const other = await createSubscribedUser();
const video = await createVideo({ projectId: scenario.project.id });
await createVideoAsset({
videoId: video.id,
@@ -121,9 +160,9 @@ describe('getUserTotalStorageBytes', () => {
// Versions are billed through the workspace owner, not through the project
// owner or the uploader, which is what the join in the raw SQL encodes.
it('sums r2 video versions through the workspace owner', async () => {
const workspaceOwner = await createUser();
const workspaceOwner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const projectOwner = await createUser();
const projectOwner = await createSubscribedUser();
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
@@ -146,7 +185,7 @@ describe('getUserTotalStorageBytes', () => {
});
it('counts active reservations and ignores expired ones', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1000) });
await createUploadReservation({
billedUserId: user.id,
@@ -158,7 +197,7 @@ describe('getUserTotalStorageBytes', () => {
});
it('adds the Bunny Stream bytes reported for the user', async () => {
const user = await createUser();
const user = await createSubscribedUser();
bunnyStorage({ [user.id]: 12_345 });
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(12_345));
@@ -167,7 +206,7 @@ describe('getUserTotalStorageBytes', () => {
describe('getUserStorageInfo', () => {
it('reports the percentage to two decimal places', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: BigInt(50) * GIB,
@@ -181,7 +220,7 @@ describe('getUserStorageInfo', () => {
});
it('clamps the percentage at 100 when usage exceeds the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES * BigInt(3),
@@ -193,7 +232,7 @@ describe('getUserStorageInfo', () => {
describe('enforceStorageQuota', () => {
it('allows an upload that stays under the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
expect(await enforceStorageQuota(user.id, BigInt(1024))).toBeNull();
});
@@ -201,7 +240,7 @@ describe('enforceStorageQuota', () => {
// The route uses `>=`, so a user sitting exactly on the limit is blocked
// rather than allowed one more byte.
it('rejects an upload that lands exactly on the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -213,7 +252,7 @@ describe('enforceStorageQuota', () => {
});
it('allows an upload one byte short of the limit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -224,7 +263,7 @@ describe('enforceStorageQuota', () => {
it('skips the check entirely when Stripe is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES,
@@ -236,7 +275,7 @@ describe('enforceStorageQuota', () => {
describe('reserveStorageQuota', () => {
it('writes a reservation row billed to the user with the requested size', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const result = await reserveStorageQuota(user.id, BigInt(4096));
@@ -250,7 +289,7 @@ describe('reserveStorageQuota', () => {
it('returns a null reservation id and writes nothing when Stripe is disabled', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const user = await createUser();
const user = await createSubscribedUser();
const result = await reserveStorageQuota(user.id, BigInt(4096));
@@ -259,7 +298,7 @@ describe('reserveStorageQuota', () => {
});
it('refuses a reservation that would cross the limit and writes no row', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -289,7 +328,7 @@ describe('reserveStorageQuota', () => {
});
it('counts Bunny Stream bytes against the reservation', async () => {
const user = await createUser();
const user = await createSubscribedUser();
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
const result = await reserveStorageQuota(user.id, BigInt(2048));
@@ -299,7 +338,7 @@ describe('reserveStorageQuota', () => {
});
it('ignores an expired reservation when computing headroom', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -312,8 +351,8 @@ describe('reserveStorageQuota', () => {
});
it('does not let one user reservations reduce another user headroom', async () => {
const heavy = await createUser();
const light = await createUser();
const heavy = await createSubscribedUser();
const light = await createSubscribedUser();
await createUploadReservation({
billedUserId: heavy.id,
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
@@ -328,7 +367,7 @@ describe('reserveStorageQuota', () => {
// start before either commits, so without serialisation both read the same
// "used" figure, both see enough headroom, and the user ends up over quota.
it('serialises two concurrent reservations so only one fits the remaining headroom', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(30) * GIB;
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
// 30 GiB of headroom, and each request wants 20 GiB.
@@ -358,7 +397,7 @@ describe('reserveStorageQuota', () => {
});
it('grants both concurrent reservations when there is room for both', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const request = BigInt(20) * GIB;
const results = await Promise.all([
@@ -372,7 +411,7 @@ describe('reserveStorageQuota', () => {
});
it('serialises five concurrent reservations, granting exactly the number that fit', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(50) * GIB;
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
const request = BigInt(20) * GIB;
@@ -394,8 +433,8 @@ describe('reserveStorageQuota', () => {
// Different users hash to different advisory lock keys, so they must not
// block each other.
it('does not serialise reservations for different users', async () => {
const first = await createUser();
const second = await createUser();
const first = await createSubscribedUser();
const second = await createSubscribedUser();
const request = BigInt(150) * GIB;
const results = await Promise.all([
@@ -410,7 +449,7 @@ describe('reserveStorageQuota', () => {
describe('releaseStorageReservation', () => {
it('deletes the reservation and frees the headroom', async () => {
const user = await createUser();
const user = await createSubscribedUser();
const result = await reserveStorageQuota(user.id, BigInt(10) * GIB);
const reservationId = 'reservationId' in result ? result.reservationId : null;
expect(reservationId).toBeTruthy();
@@ -423,7 +462,7 @@ describe('releaseStorageReservation', () => {
});
it('is a no-op for a null id', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1) });
await releaseStorageReservation(null);
@@ -434,8 +473,8 @@ describe('releaseStorageReservation', () => {
// The billedUserId argument scopes the delete, so a caller cannot release
// another user's reservation by guessing its id.
it('refuses to delete a reservation belonging to a different billed user', async () => {
const owner = await createUser();
const attacker = await createUser();
const owner = await createSubscribedUser();
const attacker = await createSubscribedUser();
const reservation = await createUploadReservation({
billedUserId: owner.id,
sizeBytes: BigInt(4096),
@@ -466,7 +505,7 @@ describe('GET /api/settings/storage', () => {
});
it('serialises the byte counts as strings so BigInt survives JSON', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createUploadReservation({
billedUserId: user.id,
sizeBytes: BigInt(20) * GIB,
+21 -2
View File
@@ -21,6 +21,7 @@ import {
addWorkspaceMember,
createExpiredUser,
createProject,
createSubscribedUser,
createUser,
createVideo,
createWorkspace,
@@ -145,8 +146,10 @@ describe('POST /api/workspaces', () => {
expect(stored.ownerId).toBe(user.id);
});
// Subscribed rather than the default trial user: three workspaces is past the
// trial's ceiling, and this test is about slugs, not about billing.
it('gives same-named workspaces distinct slugs', async () => {
const user = await createUser();
const user = await createSubscribedUser();
signedInAs(user);
for (let index = 0; index < 3; index += 1) {
@@ -221,8 +224,10 @@ describe('POST /api/workspaces', () => {
expect(await db.workspace.count()).toBe(1);
});
// The name has always promised a subscriber; it used to be handed a trial user,
// which passed only because nothing distinguished the two.
it('lets a subscribed user create any number of workspaces', async () => {
const user = await createUser();
const user = await createSubscribedUser();
await createWorkspace({ ownerId: user.id });
await createWorkspace({ ownerId: user.id });
signedInAs(user);
@@ -236,6 +241,20 @@ describe('POST /api/workspaces', () => {
expect(await db.workspace.count()).toBe(3);
});
it('refuses a second workspace while the owner is on a free trial', async () => {
const user = await createUser();
await createWorkspace({ ownerId: user.id });
signedInAs(user);
const response = await callRoute(
createWorkspaceRoute,
apiRequest('/api/workspaces', { body: { name: 'Second' } })
);
expect(response.status).toBe(403);
expect(await db.workspace.count()).toBe(1);
});
it('lets a self-hosted instance with billing disabled create freely', async () => {
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
const expired = await createExpiredUser();
+3 -2
View File
@@ -100,8 +100,9 @@ export class Seed {
/**
* A user whose trial ran out and who has no subscription, so
* hasBillingAccess() is false. `billingTrialConsumedAt` is set, which is what
* makes /settings offer `Upgrade with Stripe` rather than `Start Free Trial`.
* hasBillingAccess() is false and /settings offers `Upgrade with Stripe`.
* `billingTrialConsumedAt` is set because the trial is once per account and
* this one has had it.
*/
async expiredUser(): Promise<User> {
const tag = uniqueTag();
+65 -2
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { conversionRates } from '@/lib/analytics/scoreboard';
import { afterEach, describe, it, expect, vi } from 'vitest';
import {
COHORT_OBSERVATION_DAYS,
cohortWindows,
conversionRates,
getCardlessTrialCutover,
} from '@/lib/analytics/scoreboard';
const WEEK = {
visitors: 200,
@@ -34,3 +39,61 @@ describe('conversionRates', () => {
expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0);
});
});
describe('getCardlessTrialCutover', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('is null when the deployment has not named a switchover date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
expect(getCardlessTrialCutover()).toBeNull();
});
it('is null rather than an Invalid Date when the value is not a date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', 'last tuesday');
expect(getCardlessTrialCutover()).toBeNull();
});
it('reads an ISO date', () => {
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '2026-03-01');
expect(getCardlessTrialCutover()?.toISOString()).toBe('2026-03-01T00:00:00.000Z');
});
});
describe('cohortWindows', () => {
const CUTOVER = new Date('2026-03-01T00:00:00.000Z');
// The new cohort's window stops short of now, because an account that signed
// up yesterday has not had its 30 days to convert. Counting it would hold the
// new cohort to a shorter life than the old one and make it look worse.
it('ends the new window a full observation period before now', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
expect(windows.afterStart.toISOString()).toBe('2026-03-01T00:00:00.000Z');
expect(windows.afterEnd.toISOString()).toBe('2026-04-01T00:00:00.000Z');
expect(COHORT_OBSERVATION_DAYS).toBe(30);
});
it('gives the old cohort a window of exactly the same length', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
expect(windows.beforeEnd.toISOString()).toBe('2026-03-01T00:00:00.000Z');
expect(windows.beforeStart.toISOString()).toBe('2026-01-29T00:00:00.000Z');
expect(windows.windowDays).toBe(31);
expect(windows.afterEnd.getTime() - windows.afterStart.getTime()).toBe(
windows.beforeEnd.getTime() - windows.beforeStart.getTime()
);
});
it('collapses both windows to nothing before the first cohort is observable', () => {
const windows = cohortWindows(CUTOVER, new Date('2026-03-10T00:00:00.000Z'));
expect(windows.windowDays).toBe(0);
expect(windows.afterEnd.toISOString()).toBe(windows.afterStart.toISOString());
expect(windows.beforeStart.toISOString()).toBe(windows.beforeEnd.toISOString());
});
});
+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));
});
});