diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 9a568e1..96b3744 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -67,6 +67,7 @@ interface BillingOverview { }; workspaceCreation: { canCreateWorkspace: boolean; + canStartTrial?: boolean; reason: string | null; ownedWorkspaceCount: number; invitedWorkspaceCount: number; @@ -147,7 +148,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo const [testing, setTesting] = useState(null); const [billing, setBilling] = useState(null); const [billingLoading, setBillingLoading] = useState(true); - const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | null>(null); + const [billingAction, setBillingAction] = useState<'checkout' | 'portal' | 'trial' | null>(null); const [storageInfo, setStorageInfo] = useState(null); const [storageLoading, setStorageLoading] = useState(true); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -278,6 +279,29 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo [showMessage] ); + const handleStartTrial = useCallback(async () => { + setBillingAction('trial'); + try { + const res = await fetch('/api/billing/trial', { method: 'POST' }); + const data = await res.json(); + + if (!res.ok) { + showMessage('error', data.error || 'Failed to start your free trial'); + return; + } + + const billingRes = await fetch('/api/billing'); + if (billingRes.ok) { + setBilling((await billingRes.json()).data); + } + showMessage('success', 'Your free trial has started'); + } catch { + showMessage('error', 'Failed to start your free trial'); + } finally { + setBillingAction(null); + } + }, [showMessage]); + if (loading) { return (
@@ -475,19 +499,34 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo )} ) : ( - + <> + {billing.workspaceCreation.canStartTrial ? ( + + ) : null} + + )}
diff --git a/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx b/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx index d6a344a..1adc6cd 100644 --- a/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx +++ b/app/(dashboard)/workspaces/new/new-workspace-page-client.tsx @@ -15,12 +15,35 @@ export default function NewWorkspacePage({ }: { workspaceCreation: { canCreateWorkspace: boolean; + canStartTrial?: boolean; reason: string | null; }; }) { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); + const [isStartingTrial, setIsStartingTrial] = useState(false); const [error, setError] = useState(''); + + const handleStartTrial = async () => { + setIsStartingTrial(true); + setError(''); + + try { + const response = await fetch('/api/billing/trial', { method: 'POST' }); + const data = await response.json(); + + if (!response.ok) { + setError(data.error || 'Failed to start your free trial'); + return; + } + + router.refresh(); + } catch { + setError('Something went wrong. Please try again.'); + } finally { + setIsStartingTrial(false); + } + }; const [formData, setFormData] = useState({ name: '', description: '', @@ -76,7 +99,11 @@ export default function NewWorkspacePage({ )} - {workspaceCreation.canCreateWorkspace ? 'Create New Workspace' : 'Upgrade Required'} + {workspaceCreation.canCreateWorkspace + ? 'Create New Workspace' + : workspaceCreation.canStartTrial + ? 'Start Your Free Trial' + : 'Upgrade Required'} {workspaceCreation.canCreateWorkspace @@ -139,9 +166,27 @@ export default function NewWorkspacePage({ You can still create and manage projects inside workspaces where you are already a member.

- + {error && ( +
+ {error} +
+ )} + {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/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, { 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 { // 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, diff --git a/tests/api/billing-trial.test.ts b/tests/api/billing-trial.test.ts new file mode 100644 index 0000000..4894efe --- /dev/null +++ b/tests/api/billing-trial.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { db } from '@/lib/db'; +import { POST as startTrialRoute } from '@/app/api/billing/trial/route'; +import { apiRequest, callRoute, readData } from '../helpers/request'; +import { signedInAs, signedOut } from '../helpers/session'; +import { addWorkspaceMember, createExpiredUser, createUser, seedProject } from '../factories'; + +const ORIGIN_HEADERS = { origin: 'http://localhost:3000' }; + +function startTrialRequest() { + return apiRequest('/api/billing/trial', { method: 'POST', headers: ORIGIN_HEADERS }); +} + +describe('POST /api/billing/trial', () => { + 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,