+ )}
+ {workspaceCreation.canStartTrial ? (
+
+ ) : (
+
+ )}
)}
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index 932b732..cabac3e 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -22,7 +22,7 @@ import {
isValidEmailAddress,
normalizeEmail,
} from '@/lib/email-validation';
-import { startCardlessTrial } from '@/lib/billing';
+import { startCardlessTrialOnSignup } from '@/lib/billing';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readRequestVisitor } from '@/lib/analytics/visitor';
@@ -166,7 +166,7 @@ export async function POST(request: NextRequest) {
// just lock the user out of an instance that has billing switched on.
if (!emailVerificationRequired) {
warnIfTrialsSkipVerification();
- await startCardlessTrial(user.id);
+ await startCardlessTrialOnSignup(user.id);
}
// Send verification email if SMTP is configured
diff --git a/app/api/billing/trial/route.ts b/app/api/billing/trial/route.ts
new file mode 100644
index 0000000..752cfef
--- /dev/null
+++ b/app/api/billing/trial/route.ts
@@ -0,0 +1,52 @@
+import { NextRequest } from 'next/server';
+import { auth } from '@/lib/auth';
+import { apiErrors, successResponse } from '@/lib/api-response';
+import { startCardlessTrial } from '@/lib/billing';
+import { rateLimit } from '@/lib/rate-limit';
+import { isStripeFeatureEnabled } from '@/lib/feature-flags';
+import { isTrustedSameOriginRequest } from '@/lib/request-origin';
+import { logError } from '@/lib/logger';
+import { db } from '@/lib/db';
+
+/**
+ * The explicit claim of a deferred cardless trial.
+ *
+ * An invited collaborator has their trial held back at signup; nothing else in
+ * the product is allowed to start it as a side effect, because the clock spends
+ * the account's only trial. This endpoint is the one place the user says "start
+ * it now", from the workspace-creation and billing screens.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ const limited = await rateLimit(request, 'mutate');
+ if (limited) return limited;
+
+ if (!isTrustedSameOriginRequest(request)) {
+ return apiErrors.forbidden('Invalid request origin');
+ }
+
+ const session = await auth();
+ if (!session?.user?.id) {
+ return apiErrors.unauthorized();
+ }
+
+ if (!isStripeFeatureEnabled()) {
+ return apiErrors.badRequest('Stripe billing is disabled by this host');
+ }
+
+ const started = await startCardlessTrial(session.user.id);
+ if (!started) {
+ return apiErrors.conflict('Your free trial has already been used');
+ }
+
+ const user = await db.user.findUnique({
+ where: { id: session.user.id },
+ select: { trialEndsAt: true },
+ });
+
+ return successResponse({ trialEndsAt: user?.trialEndsAt ?? null });
+ } catch (error) {
+ logError('billing.trial.start', error);
+ return apiErrors.internalError();
+ }
+}
diff --git a/lib/auth.ts b/lib/auth.ts
index c43a679..7059721 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -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, startCardlessTrial } from '@/lib/billing';
+import { hasBillingAccess, startCardlessTrialOnSignup } from '@/lib/billing';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { isEmailVerificationEnabled } from '@/lib/email-verification';
@@ -171,7 +171,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// 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);
+ await startCardlessTrialOnSignup(user.id);
},
},
});
diff --git a/lib/billing.ts b/lib/billing.ts
index 49f660e..69b4bac 100644
--- a/lib/billing.ts
+++ b/lib/billing.ts
@@ -1,6 +1,6 @@
import type { Prisma } from '@prisma/client';
import type Stripe from 'stripe';
-import { BillingSubscriptionStatus } from '@prisma/client';
+import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
@@ -336,6 +336,12 @@ export function buildEffectiveBillingStatusWhereInput(
* `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.
+ *
+ * Signup goes through `startCardlessTrialOnSignup` instead, which holds the trial
+ * back for an account that only exists because somebody invited it. This is the
+ * unconditional grant, reached later only when that account explicitly asks for
+ * its deferred trial through the start-trial endpoint. It is never started as a
+ * side effect of some other action; the clock costs the account its only trial.
*/
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
// Without billing nothing is gated, so a trial would be a date nobody reads.
@@ -366,6 +372,69 @@ export async function startCardlessTrial(userId: string, now: Date = new Date())
return true;
}
+/**
+ * Whether this account arrived as somebody else's collaborator.
+ *
+ * An invited member works inside the inviter's workspace on the inviter's
+ * billing, so a trial handed to them at signup buys them nothing and is spent
+ * before they have seen the product on an account of their own. Worse, it is
+ * spent for good: `billingTrialConsumedAt` is never cleared, so the day they
+ * consider becoming a customer themselves the trial is already gone.
+ *
+ * Two signals, because the invitation lands at different points on the two
+ * signup paths. The credentials route accepts the token inside the same request
+ * that creates the account, so by the time the trial is considered the
+ * membership row exists. An OAuth signup creates the account on the way out to
+ * the provider and accepts the invitation only on the way back, so there the
+ * pending invitation is the only thing to go on.
+ */
+async function arrivedAsCollaborator(userId: string, now: Date) {
+ const user = await db.user.findUnique({
+ where: { id: userId },
+ select: { email: true },
+ });
+
+ const [workspaceMemberships, projectMemberships, pendingInvitations] = await Promise.all([
+ db.workspaceMember.count({
+ where: { userId, workspace: { ownerId: { not: userId } } },
+ }),
+ db.projectMember.count({
+ where: { userId, project: { ownerId: { not: userId } } },
+ }),
+ user?.email
+ ? db.invitation.count({
+ where: {
+ email: user.email,
+ status: InvitationStatus.PENDING,
+ expiresAt: { gt: now },
+ },
+ })
+ : Promise.resolve(0),
+ ]);
+
+ return workspaceMemberships > 0 || projectMemberships > 0 || pendingInvitations > 0;
+}
+
+/**
+ * The trial as granted at signup: to everyone except an invited collaborator,
+ * whose clock is deferred until they own something of their own.
+ *
+ * Nothing is lost by waiting. The deferred trial stays claimable forever: the
+ * account starts it whenever it chooses through the start-trial endpoint, which
+ * the workspace-creation and billing screens point at.
+ */
+export async function startCardlessTrialOnSignup(userId: string, now: Date = new Date()) {
+ if (!isStripeFeatureEnabled()) {
+ return false;
+ }
+
+ if (await arrivedAsCollaborator(userId, now)) {
+ return false;
+ }
+
+ return startCardlessTrial(userId, now);
+}
+
export async function getStripeCheckoutState(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
@@ -439,6 +508,12 @@ export async function getWorkspaceCreationEligibility(userId: string) {
const billingAccess = hasBillingAccess(user);
const isPaid = isPaidTier(user);
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
+ // An invited collaborator whose trial was deferred at signup. Their trial is
+ // still owed, but starting it is their call, not a side effect of clicking
+ // "create workspace": the clock costs them their only trial, so it runs only
+ // after they ask for it through the explicit start-trial endpoint.
+ const canStartTrial =
+ isStripeFeatureEnabled() && !billingAccess && !user.trialEndsAt && !user.billingTrialConsumedAt;
// 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
@@ -452,9 +527,9 @@ export async function getWorkspaceCreationEligibility(userId: string) {
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
reason = 'Your free trial includes one workspace. Subscribe to create more.';
- } else if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
+ } else if (canStartTrial && 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.';
+ 'You are collaborating in someone else’s workspace, so your free trial has not started yet. Start it to create a workspace of your own.';
} else {
reason = 'Your trial has ended. Start a subscription to create and keep owning workspaces.';
}
@@ -462,6 +537,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
return {
canCreateWorkspace,
+ canStartTrial,
reason,
ownedWorkspaceCount,
invitedWorkspaceCount,
@@ -493,6 +569,7 @@ export async function getBillingOverview(userId: string) {
return {
workspaceCreation: {
canCreateWorkspace: billing.canCreateWorkspace,
+ canStartTrial: billing.canStartTrial,
reason: billing.reason,
ownedWorkspaceCount: billing.ownedWorkspaceCount,
invitedWorkspaceCount: billing.invitedWorkspaceCount,
@@ -547,26 +624,65 @@ export async function getTrialNotice(
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) {
+ const notice = ((): TrialNotice | null => {
+ 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;
}
- return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
- }
+ // Past the cleanup date there is nothing left to reassure anybody about.
+ if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
+ return null;
+ }
- const endsAt = getBillingAccessEndDate(user);
- if (!endsAt || hasBillingAccess(user, now)) {
+ return { kind: 'ended', endsAt, contentKeptUntil };
+ })();
+
+ // Neither sentence is true for a guest in somebody else's workspace: no
+ // deadline is coming for them, and the media the banner promises to keep is
+ // not theirs and is not at risk. They were reading "your projects and media
+ // are kept until" about a paying customer's work. Checked last so the queries
+ // only run for the few accounts a banner was about to be shown to.
+ if (notice && (await isCollaboratorWithNothingOfTheirOwn(userId, now))) {
return null;
}
- // Past the cleanup date there is nothing left to reassure anybody about.
- if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
- return null;
- }
+ return notice;
+}
- return { kind: 'ended', endsAt, contentKeptUntil };
+/**
+ * Somebody who only ever works inside workspaces they do not own.
+ *
+ * Ownership is what makes billing personal: the storage, the projects and the
+ * cleanup deadline all hang off the owning account. An account that owns none of
+ * that, and reaches the product entirely through a workspace whose owner is
+ * paying, has nothing of its own on the line.
+ */
+async function isCollaboratorWithNothingOfTheirOwn(userId: string, now: Date) {
+ const [ownedWorkspaceCount, collaborationCount] = await Promise.all([
+ db.workspace.count({ where: { ownerId: userId } }),
+ db.workspace.count({
+ where: {
+ ownerId: { not: userId },
+ owner: buildBillingAccessWhereInput(now),
+ OR: [
+ { members: { some: { userId } } },
+ { projects: { some: { members: { some: { userId } } } } },
+ ],
+ },
+ }),
+ ]);
+
+ return ownedWorkspaceCount === 0 && collaborationCount > 0;
}
export async function getOrCreateStripeCustomerId(userId: string) {
diff --git a/lib/email-verification.ts b/lib/email-verification.ts
index 065bf4e..f8aee32 100644
--- a/lib/email-verification.ts
+++ b/lib/email-verification.ts
@@ -11,7 +11,7 @@ import {
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled, isStripeFeatureEnabled } from '@/lib/feature-flags';
-import { startCardlessTrial } from '@/lib/billing';
+import { startCardlessTrialOnSignup } from '@/lib/billing';
// Reduce window to 2 hours — shorter exposure in access logs and backups.
const TOKEN_EXPIRY_HOURS = 2;
@@ -120,7 +120,7 @@ export async function consumeVerificationToken(token: string): Promise {
+ it('returns 401 without a session', async () => {
+ signedOut();
+
+ const response = await callRoute(startTrialRoute, startTrialRequest());
+
+ expect(response.status).toBe(401);
+ });
+
+ it('rejects a cross-origin request', async () => {
+ const response = await callRoute(
+ startTrialRoute,
+ apiRequest('/api/billing/trial', { method: 'POST', headers: { origin: 'https://evil.test' } })
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ // The whole point of the endpoint: an invited collaborator whose trial was
+ // deferred at signup claims it here, explicitly, and nowhere else.
+ it('starts the deferred trial for a collaborator who asks for it', async () => {
+ const host = await seedProject();
+ const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
+ await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
+ signedInAs(invited);
+
+ const response = await callRoute(startTrialRoute, startTrialRequest());
+
+ expect(response.status).toBe(200);
+ const data = await readData<{ trialEndsAt: string | null }>(response);
+ expect(data.trialEndsAt).not.toBeNull();
+
+ const after = await db.user.findUniqueOrThrow({ where: { id: invited.id } });
+ expect(after.billingTrialConsumedAt).not.toBeNull();
+ expect(after.trialEndsAt!.getTime()).toBeGreaterThan(Date.now());
+ });
+
+ // Once per account. An expired user already spent theirs; asking again must
+ // not reset the clock.
+ it('refuses a second trial to an account that already spent one', async () => {
+ const expired = await createExpiredUser();
+ signedInAs(expired);
+
+ const response = await callRoute(startTrialRoute, startTrialRequest());
+
+ expect(response.status).toBe(409);
+ const after = await db.user.findUniqueOrThrow({ where: { id: expired.id } });
+ expect(after.trialEndsAt?.getTime()).toBeLessThan(Date.now());
+ });
+});
diff --git a/tests/api/email-verification.test.ts b/tests/api/email-verification.test.ts
index 1ca1854..6b25654 100644
--- a/tests/api/email-verification.test.ts
+++ b/tests/api/email-verification.test.ts
@@ -7,6 +7,7 @@
// fails a test rather than a security review.
import { createHash } from 'node:crypto';
+import { InvitationScope } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import nodemailer from 'nodemailer';
import { db } from '@/lib/db';
@@ -20,7 +21,7 @@ import { GET as verifyEmail } from '@/app/api/auth/verify-email/route';
import { POST as resendVerification } from '@/app/api/auth/verify-email/resend/route';
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
import { mailTo, sentMail } from '../helpers/mail';
-import { createUser } from '../factories';
+import { addWorkspaceMember, createInvitation, createUser, seedProject } from '../factories';
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
@@ -117,6 +118,54 @@ describe('consumeVerificationToken', () => {
expect(days).toBe(7);
});
+ // An invited collaborator works inside the inviter's workspace on the inviter's
+ // billing, so a trial handed over here would be spent before they had seen the
+ // product on an account of their own, and `billingTrialConsumedAt` is never
+ // cleared. It waits until they create a workspace of their own.
+ it('holds the trial back for somebody who verified as an invited member', async () => {
+ const host = await seedProject();
+ const user = await createUser({
+ email: 'ada@example.com',
+ emailVerified: null,
+ trialEndsAt: null,
+ billingTrialConsumedAt: null,
+ });
+ await addWorkspaceMember({ workspaceId: host.workspace.id, userId: user.id });
+ const token = await createVerificationToken('ada@example.com');
+
+ await consumeVerificationToken(token);
+
+ const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(verified.emailVerified).toBeInstanceOf(Date);
+ expect(verified.trialEndsAt).toBeNull();
+ expect(verified.billingTrialConsumedAt).toBeNull();
+ });
+
+ // The OAuth half of the same case: the account exists before the invitation is
+ // accepted, so the still-open invitation is the only signal there is.
+ it('holds the trial back while an invitation to that address is still open', async () => {
+ const host = await seedProject();
+ const user = await createUser({
+ email: 'ada@example.com',
+ emailVerified: null,
+ trialEndsAt: null,
+ billingTrialConsumedAt: null,
+ });
+ await createInvitation({
+ email: 'ada@example.com',
+ scope: InvitationScope.WORKSPACE,
+ workspaceId: host.workspace.id,
+ invitedById: host.owner.id,
+ });
+ const token = await createVerificationToken('ada@example.com');
+
+ await consumeVerificationToken(token);
+
+ const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
+ expect(verified.trialEndsAt).toBeNull();
+ expect(verified.billingTrialConsumedAt).toBeNull();
+ });
+
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');
diff --git a/tests/api/register.test.ts b/tests/api/register.test.ts
index ddba084..209e314 100644
--- a/tests/api/register.test.ts
+++ b/tests/api/register.test.ts
@@ -407,6 +407,53 @@ describe('POST /api/auth/register', () => {
expect(created.billingTrialConsumedAt).toBeNull();
});
+ // Registering through an invitation is the one case where the trial is held
+ // back even on an instance with no SMTP: the account is verified and created,
+ // but it joined somebody else's workspace and does not need a trial to work
+ // there. Creating a workspace of its own is what starts the clock.
+ it('grants no trial to an invited collaborator even without a verification step', async () => {
+ vi.stubEnv('SMTP_HOST', '');
+ vi.stubEnv('SMTP_USER', '');
+ vi.stubEnv('SMTP_PASSWORD', '');
+ const scenario = await seedProject();
+ const invitation = await createInvitation({
+ invitedById: scenario.owner.id,
+ scope: 'PROJECT',
+ projectId: scenario.project.id,
+ email: 'invited@example.com',
+ role: 'COMMENTATOR',
+ });
+ signedOut();
+
+ const response = await callRoute(
+ register,
+ registerRequest({
+ name: 'Invited Guest',
+ email: 'invited@example.com',
+ password: PASSWORD,
+ invitationToken: invitation.token,
+ })
+ );
+
+ expect(response.status).toBe(201);
+ const created = await db.user.findUniqueOrThrow({ where: { email: 'invited@example.com' } });
+ expect(created.emailVerified).toBeInstanceOf(Date);
+ expect(created.trialEndsAt).toBeNull();
+ expect(created.billingTrialConsumedAt).toBeNull();
+ });
+
+ it('starts the trial for somebody signing themselves up without SMTP', async () => {
+ vi.stubEnv('SMTP_HOST', '');
+ vi.stubEnv('SMTP_USER', '');
+ vi.stubEnv('SMTP_PASSWORD', '');
+
+ await post({ name: 'Self Hosted', email: 'solo@example.com', password: PASSWORD });
+
+ const created = await db.user.findUniqueOrThrow({ where: { email: 'solo@example.com' } });
+ expect(created.trialEndsAt).toBeInstanceOf(Date);
+ expect(created.billingTrialConsumedAt).toBeInstanceOf(Date);
+ });
+
it('reports the rate limit budget on a successful registration', async () => {
const response = await post({
name: 'Rate Limited',
diff --git a/tests/api/workspaces.test.ts b/tests/api/workspaces.test.ts
index 70f2ee8..22e8a7b 100644
--- a/tests/api/workspaces.test.ts
+++ b/tests/api/workspaces.test.ts
@@ -14,6 +14,7 @@ import {
DELETE as removeWorkspaceMember,
PATCH as patchWorkspaceMember,
} from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
+import { startCardlessTrial } from '@/lib/billing';
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
@@ -180,6 +181,43 @@ describe('POST /api/workspaces', () => {
expect(await db.workspace.count()).toBe(1);
});
+ // An invited collaborator's trial is deferred, and nothing starts it as a side
+ // effect: the create is refused until they claim the trial explicitly.
+ it('refuses a workspace to a collaborator whose trial is still unclaimed', async () => {
+ const host = await seedProject();
+ const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
+ await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
+ signedInAs(invited);
+
+ const response = await callRoute(
+ createWorkspaceRoute,
+ apiRequest('/api/workspaces', { body: { name: 'My Own' } })
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.workspace.count({ where: { ownerId: invited.id } })).toBe(0);
+ const after = await db.user.findUniqueOrThrow({ where: { id: invited.id } });
+ expect(after.trialEndsAt).toBeNull();
+ expect(after.billingTrialConsumedAt).toBeNull();
+ });
+
+ it('lets that collaborator create a workspace once they start their trial', async () => {
+ const host = await seedProject();
+ const invited = await createUser({ trialEndsAt: null, billingTrialConsumedAt: null });
+ await addWorkspaceMember({ workspaceId: host.workspace.id, userId: invited.id });
+ signedInAs(invited);
+
+ await startCardlessTrial(invited.id);
+
+ const response = await callRoute(
+ createWorkspaceRoute,
+ apiRequest('/api/workspaces', { body: { name: 'My Own' } })
+ );
+
+ expect(response.status).toBe(201);
+ expect(await db.workspace.count({ where: { ownerId: invited.id } })).toBe(1);
+ });
+
it('refuses a second workspace for an expired user', async () => {
const expired = await createExpiredUser();
await createWorkspace({ ownerId: expired.id });
diff --git a/tests/unit/lib/billing.test.ts b/tests/unit/lib/billing.test.ts
index 5a6ac63..ddaec7d 100644
--- a/tests/unit/lib/billing.test.ts
+++ b/tests/unit/lib/billing.test.ts
@@ -15,6 +15,7 @@ import {
getOrCreateStripeCustomerId,
getStorageCleanupEligibleAt,
getStripeCheckoutState,
+ getTrialNotice,
getWorkspaceCreationEligibility,
hasActiveSubscription,
hasActiveTrial,
@@ -26,6 +27,7 @@ import {
markSubscriptionCanceledByCustomerId,
selectAuthoritativeSubscription,
startCardlessTrial,
+ startCardlessTrialOnSignup,
syncStripeCustomerSubscriptions,
syncStripeSubscriptionToUser,
} from '@/lib/billing';
@@ -35,6 +37,7 @@ const dbMock = vi.hoisted(() => ({
workspace: { count: vi.fn() },
workspaceMember: { count: vi.fn() },
projectMember: { count: vi.fn() },
+ invitation: { count: vi.fn() },
analyticsEvent: { createMany: vi.fn() },
}));
@@ -818,6 +821,7 @@ describe('database backed billing helpers', () => {
dbMock.workspace.count.mockReset();
dbMock.workspaceMember.count.mockReset();
dbMock.projectMember.count.mockReset();
+ dbMock.invitation.count.mockReset();
stripeMock.customers.create.mockReset();
stripeMock.subscriptions.list.mockReset();
dbMock.user.update.mockImplementation(async (args: { data: unknown }) => args.data);
@@ -926,19 +930,188 @@ describe('database backed billing helpers', () => {
});
});
+ describe('startCardlessTrialOnSignup', () => {
+ function mockSignup(options: {
+ email?: string | null;
+ workspaceMemberships?: number;
+ projectMemberships?: number;
+ pendingInvitations?: number;
+ }) {
+ dbMock.user.findUnique.mockResolvedValue({
+ email: 'email' in options ? options.email : 'new@example.com',
+ });
+ dbMock.workspaceMember.count.mockResolvedValue(options.workspaceMemberships ?? 0);
+ dbMock.projectMember.count.mockResolvedValue(options.projectMemberships ?? 0);
+ dbMock.invitation.count.mockResolvedValue(options.pendingInvitations ?? 0);
+ dbMock.user.updateMany.mockResolvedValue({ count: 1 });
+ }
+
+ it('grants the trial to somebody who signed themselves up', async () => {
+ mockSignup({});
+
+ await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(true);
+ expect(dbMock.user.updateMany).toHaveBeenCalled();
+ });
+
+ // The credentials route accepts the invitation in the same request that
+ // creates the account, so the membership is what gives the collaborator away.
+ it('holds the trial back for a member of somebody else workspace', async () => {
+ mockSignup({ workspaceMemberships: 1 });
+
+ await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
+ expect(dbMock.user.updateMany).not.toHaveBeenCalled();
+ });
+
+ it('holds the trial back for a member of somebody else project', async () => {
+ mockSignup({ projectMemberships: 1 });
+
+ await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
+ expect(dbMock.user.updateMany).not.toHaveBeenCalled();
+ });
+
+ // An OAuth signup creates the account before the invitation is accepted, so
+ // there the still-pending invitation is the only signal available.
+ it('holds the trial back while an invitation to this address is pending', async () => {
+ mockSignup({ pendingInvitations: 1 });
+
+ await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
+ expect(dbMock.user.updateMany).not.toHaveBeenCalled();
+ });
+
+ it('only counts invitations that are still open', async () => {
+ mockSignup({});
+
+ await startCardlessTrialOnSignup('u1');
+
+ expect(dbMock.invitation.count).toHaveBeenCalledWith({
+ where: {
+ email: 'new@example.com',
+ status: 'PENDING',
+ expiresAt: { gt: NOW },
+ },
+ });
+ });
+
+ it('does not look for invitations when the account has no address', async () => {
+ mockSignup({ email: null });
+
+ await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(true);
+ expect(dbMock.invitation.count).not.toHaveBeenCalled();
+ });
+
+ it('grants nothing when billing is switched off entirely', async () => {
+ vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
+ mockSignup({});
+
+ await expect(startCardlessTrialOnSignup('u1')).resolves.toBe(false);
+ expect(dbMock.user.findUnique).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getTrialNotice', () => {
+ function mockNotice(options: {
+ trialEndsAt?: Date | null;
+ status?: BillingSubscriptionStatus;
+ billingAccessEndedAt?: Date | null;
+ ownedWorkspaces?: number;
+ collaborations?: number;
+ }) {
+ dbMock.user.findUnique.mockResolvedValue({
+ subscriptionStatus: options.status ?? BillingSubscriptionStatus.FREE,
+ trialEndsAt: options.trialEndsAt ?? null,
+ stripeCurrentPeriodEnd: null,
+ billingAccessEndedAt: options.billingAccessEndedAt ?? null,
+ });
+ dbMock.workspace.count
+ .mockResolvedValueOnce(options.ownedWorkspaces ?? 1)
+ .mockResolvedValueOnce(options.collaborations ?? 0);
+ }
+
+ it('says nothing while the trial still has more than the notice window left', async () => {
+ mockNotice({ trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS) });
+
+ await expect(getTrialNotice('u1')).resolves.toBeNull();
+ });
+
+ it('counts down once the trial is inside the notice window', async () => {
+ mockNotice({ trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS) });
+
+ const notice = await getTrialNotice('u1');
+
+ expect(notice?.kind).toBe('ending');
+ });
+
+ it('reports the trial as ended along with the date the media is kept until', async () => {
+ const endedAt = new Date(NOW.getTime() - 2 * DAY_MS);
+ mockNotice({ trialEndsAt: endedAt, billingAccessEndedAt: endedAt });
+
+ const notice = await getTrialNotice('u1');
+
+ expect(notice?.kind).toBe('ended');
+ expect(notice?.contentKeptUntil?.getTime()).toBe(endedAt.getTime() + 15 * DAY_MS);
+ });
+
+ // The banner is about this account's own deadline and its own media. A guest
+ // in a paying customer's workspace has neither, and was being told a paying
+ // customer's work would be deleted.
+ it('says nothing to a collaborator who owns no workspace of their own', async () => {
+ mockNotice({
+ trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
+ ownedWorkspaces: 0,
+ collaborations: 1,
+ });
+
+ await expect(getTrialNotice('u1')).resolves.toBeNull();
+ });
+
+ it('still warns a collaborator who also owns a workspace', async () => {
+ mockNotice({
+ trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
+ ownedWorkspaces: 1,
+ collaborations: 1,
+ });
+
+ expect((await getTrialNotice('u1'))?.kind).toBe('ending');
+ });
+
+ // A solo account that has not set anything up yet is not a collaborator, and
+ // its deadline is real.
+ it('still warns an account that owns nothing and collaborates nowhere', async () => {
+ mockNotice({
+ trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
+ ownedWorkspaces: 0,
+ collaborations: 0,
+ });
+
+ expect((await getTrialNotice('u1'))?.kind).toBe('ending');
+ });
+
+ it('leaves the ownership queries unrun when there is no notice to show', async () => {
+ mockNotice({ trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS) });
+
+ await getTrialNotice('u1');
+
+ expect(dbMock.workspace.count).not.toHaveBeenCalled();
+ });
+ });
+
describe('getWorkspaceCreationEligibility', () => {
function mockEligibility(options: {
user?: Record | null;
owned?: number;
invited?: number;
projectOnly?: number;
+ /** Whether the once-per-account trial has already been spent and run out. */
+ consumed?: boolean;
}) {
dbMock.user.findUnique.mockResolvedValue(
options.user === undefined
? {
subscriptionStatus: BillingSubscriptionStatus.FREE,
trialEndsAt: null,
- billingTrialConsumedAt: null,
+ billingTrialConsumedAt: options.consumed
+ ? new Date(NOW.getTime() - 30 * DAY_MS)
+ : null,
stripeCustomerId: null,
stripeSubscriptionId: null,
stripePriceId: null,
@@ -1045,7 +1218,7 @@ describe('database backed billing helpers', () => {
});
it('blocks an expired owner who already has a workspace', async () => {
- mockEligibility({ owned: 1 });
+ mockEligibility({ owned: 1, consumed: true });
const result = await getWorkspaceCreationEligibility('u1');
@@ -1053,27 +1226,41 @@ describe('database backed billing helpers', () => {
expect(result.reason).toContain('Your trial has ended');
});
- it('blocks an expired user who only collaborates in someone else workspace', async () => {
+ // The deferred trial stays the collaborator's to spend, but never as a side
+ // effect: the workspace door stays shut until they explicitly start it, which
+ // is what `canStartTrial` tells the UI to offer.
+ it('blocks a collaborator whose trial is still unclaimed but offers to start it', async () => {
mockEligibility({ owned: 0, invited: 1 });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
- expect(result.reason).toContain('currently collaborating');
+ expect(result.canStartTrial).toBe(true);
+ expect(result.reason).toContain('Start it to create a workspace of your own');
});
- it('counts project-only collaboration towards the same block', async () => {
+ it('offers the same deferred trial to a project-only collaborator', async () => {
mockEligibility({ owned: 0, projectOnly: 2 });
const result = await getWorkspaceCreationEligibility('u1');
expect(result.canCreateWorkspace).toBe(false);
- expect(result.reason).toContain('currently collaborating');
+ expect(result.canStartTrial).toBe(true);
expect(result.projectOnlyCollaborationCount).toBe(2);
});
+ it('blocks a collaborator whose own trial has already run out', async () => {
+ mockEligibility({ owned: 0, invited: 1, consumed: true });
+
+ const result = await getWorkspaceCreationEligibility('u1');
+
+ expect(result.canCreateWorkspace).toBe(false);
+ expect(result.canStartTrial).toBe(false);
+ expect(result.reason).toContain('Your trial has ended');
+ });
+
it('prefers the trial-ended reason when the user both owns and collaborates', async () => {
- mockEligibility({ owned: 1, invited: 1 });
+ mockEligibility({ owned: 1, invited: 1, consumed: true });
expect((await getWorkspaceCreationEligibility('u1')).reason).toContain(
'Your trial has ended'
@@ -1136,6 +1323,7 @@ describe('database backed billing helpers', () => {
expect(overview.workspaceCreation).toEqual({
canCreateWorkspace: true,
+ canStartTrial: false,
reason: null,
ownedWorkspaceCount: 2,
invitedWorkspaceCount: 1,
From 59a64141eeaf0d20a42c40dc05908fca74c88194 Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Tue, 1 Sep 2026 15:09:53 +0300
Subject: [PATCH 2/3] chore(lint): ignore .claude worktree checkouts in eslint
A worktree parked under .claude/worktrees is a separate checkout; eslint
scanning it fails bun run check on files outside this tree.
---
eslint.config.mjs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a5273d0..dd3b20b 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -21,6 +21,9 @@ const eslintConfig = defineConfig([
'test-results/**',
'reports/**',
'.stryker-tmp/**',
+ // Git worktrees checked out under .claude/worktrees are separate checkouts,
+ // not part of this tree; linting them fails the run on their files.
+ '.claude/**',
]),
prettier,
{
From 54e99cb4abaa49672886668eb8c252f58a5ad5ca Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Tue, 1 Sep 2026 15:17:51 +0300
Subject: [PATCH 3/3] test(api): register billing/trial in the auth matrix
---
tests/api/auth-matrix.test.ts | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts
index e9f9f78..3ecb072 100644
--- a/tests/api/auth-matrix.test.ts
+++ b/tests/api/auth-matrix.test.ts
@@ -50,6 +50,7 @@ import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/rou
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
import * as billingPortalRoute from '@/app/api/billing/portal/route';
+import * as billingTrialRoute from '@/app/api/billing/trial/route';
import * as billingRoute from '@/app/api/billing/route';
import * as commentRoute from '@/app/api/comments/[commentId]/route';
import * as feedbackRoute from '@/app/api/feedback/route';
@@ -148,7 +149,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
// The count guard
// ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
-const EXPECTED_ROUTE_MODULE_COUNT = 66;
+const EXPECTED_ROUTE_MODULE_COUNT = 67;
/**
* Routes that are public by design, and why. Everything else must reject an
@@ -417,6 +418,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
headers: { origin: 'http://localhost:3000' },
},
{ file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' },
+ {
+ file: 'billing/trial/route.ts',
+ module: billingTrialRoute,
+ url: () => '/api/billing/trial',
+ headers: { origin: 'http://localhost:3000' },
+ },
{
file: 'comments/[commentId]/route.ts',
module: commentRoute,