Merge pull request #73 from yusufipk/fix/download-unload-guard

feat(billing): defer the cardless trial for invited collaborators
This commit is contained in:
Yusuf İpek
2026-09-01 15:24:50 +03:00
committed by GitHub
14 changed files with 695 additions and 48 deletions
@@ -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<string | null>(null);
const [billing, setBilling] = useState<BillingOverview | null>(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<StorageInfo | null>(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 (
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
@@ -475,7 +499,21 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
)}
</Button>
) : (
<>
{billing.workspaceCreation.canStartTrial ? (
<Button onClick={handleStartTrial} disabled={billingAction !== null}>
{billingAction === 'trial' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : null}
<Button
variant={billing.workspaceCreation.canStartTrial ? 'outline' : 'default'}
onClick={() => handleBillingRedirect('/api/billing/checkout')}
disabled={!billing.checkoutAvailable || billingAction !== null}
>
@@ -488,6 +526,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
'Upgrade with Stripe'
)}
</Button>
</>
)}
</div>
</>
@@ -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({
)}
</div>
<CardTitle className="text-2xl">
{workspaceCreation.canCreateWorkspace ? 'Create New Workspace' : 'Upgrade Required'}
{workspaceCreation.canCreateWorkspace
? 'Create New Workspace'
: workspaceCreation.canStartTrial
? 'Start Your Free Trial'
: 'Upgrade Required'}
</CardTitle>
<CardDescription className="text-base">
{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.
</p>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{workspaceCreation.canStartTrial ? (
<Button className="w-full" onClick={handleStartTrial} disabled={isStartingTrial}>
{isStartingTrial ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Starting Trial...
</>
) : (
'Start Free Trial'
)}
</Button>
) : (
<Button asChild className="w-full">
<Link href="/settings">Open Billing Settings</Link>
</Button>
)}
</div>
)}
</CardContent>
+2 -2
View File
@@ -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
+52
View File
@@ -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();
}
}
+3
View File
@@ -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,
{
+2 -2
View File
@@ -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);
},
},
});
+119 -3
View File
@@ -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 elses workspace or project. Start a subscription to create a workspace of your own.';
'You are collaborating in someone elses 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,6 +624,7 @@ export async function getTrialNotice(
const contentKeptUntil = getStorageCleanupEligibleAt(user);
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) {
@@ -567,6 +645,44 @@ export async function getTrialNotice(
}
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;
}
return notice;
}
/**
* 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) {
+2 -2
View File
@@ -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<string |
});
}
await startCardlessTrial(verified.id);
await startCardlessTrialOnSignup(verified.id);
}
return record.identifier;
+8 -1
View File
@@ -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,
+63
View File
@@ -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());
});
});
+50 -1
View File
@@ -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: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
await addWorkspaceMember({ workspaceId: host.workspace.id, userId: user.id });
const token = await createVerificationToken('[email protected]');
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: '[email protected]',
emailVerified: null,
trialEndsAt: null,
billingTrialConsumedAt: null,
});
await createInvitation({
email: '[email protected]',
scope: InvitationScope.WORKSPACE,
workspaceId: host.workspace.id,
invitedById: host.owner.id,
});
const token = await createVerificationToken('[email protected]');
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');
+47
View File
@@ -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: '[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);
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
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: '[email protected]', password: PASSWORD });
const created = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
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',
+38
View File
@@ -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 });
+195 -7
View File
@@ -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 : '[email protected]',
});
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: '[email protected]',
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<string, unknown> | 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,