mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #73 from yusufipk/fix/download-unload-guard
feat(billing): defer the cardless trial for invited collaborators
This commit is contained in:
@@ -67,6 +67,7 @@ interface BillingOverview {
|
|||||||
};
|
};
|
||||||
workspaceCreation: {
|
workspaceCreation: {
|
||||||
canCreateWorkspace: boolean;
|
canCreateWorkspace: boolean;
|
||||||
|
canStartTrial?: boolean;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
ownedWorkspaceCount: number;
|
ownedWorkspaceCount: number;
|
||||||
invitedWorkspaceCount: number;
|
invitedWorkspaceCount: number;
|
||||||
@@ -147,7 +148,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
const [testing, setTesting] = useState<string | null>(null);
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
const [billing, setBilling] = useState<BillingOverview | null>(null);
|
const [billing, setBilling] = useState<BillingOverview | null>(null);
|
||||||
const [billingLoading, setBillingLoading] = useState(true);
|
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 [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
|
||||||
const [storageLoading, setStorageLoading] = useState(true);
|
const [storageLoading, setStorageLoading] = useState(true);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
@@ -278,6 +279,29 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
[showMessage]
|
[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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto py-8 px-4 space-y-6">
|
<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>
|
</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
|
<Button
|
||||||
|
variant={billing.workspaceCreation.canStartTrial ? 'outline' : 'default'}
|
||||||
onClick={() => handleBillingRedirect('/api/billing/checkout')}
|
onClick={() => handleBillingRedirect('/api/billing/checkout')}
|
||||||
disabled={!billing.checkoutAvailable || billingAction !== null}
|
disabled={!billing.checkoutAvailable || billingAction !== null}
|
||||||
>
|
>
|
||||||
@@ -488,6 +526,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
'Upgrade with Stripe'
|
'Upgrade with Stripe'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -15,12 +15,35 @@ export default function NewWorkspacePage({
|
|||||||
}: {
|
}: {
|
||||||
workspaceCreation: {
|
workspaceCreation: {
|
||||||
canCreateWorkspace: boolean;
|
canCreateWorkspace: boolean;
|
||||||
|
canStartTrial?: boolean;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isStartingTrial, setIsStartingTrial] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -76,7 +99,11 @@ export default function NewWorkspacePage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">
|
<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>
|
</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
{workspaceCreation.canCreateWorkspace
|
{workspaceCreation.canCreateWorkspace
|
||||||
@@ -139,9 +166,27 @@ export default function NewWorkspacePage({
|
|||||||
You can still create and manage projects inside workspaces where you are already a
|
You can still create and manage projects inside workspaces where you are already a
|
||||||
member.
|
member.
|
||||||
</p>
|
</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">
|
<Button asChild className="w-full">
|
||||||
<Link href="/settings">Open Billing Settings</Link>
|
<Link href="/settings">Open Billing Settings</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
isValidEmailAddress,
|
isValidEmailAddress,
|
||||||
normalizeEmail,
|
normalizeEmail,
|
||||||
} from '@/lib/email-validation';
|
} from '@/lib/email-validation';
|
||||||
import { startCardlessTrial } from '@/lib/billing';
|
import { startCardlessTrialOnSignup } from '@/lib/billing';
|
||||||
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
||||||
import { readRequestVisitor } from '@/lib/analytics/visitor';
|
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.
|
// just lock the user out of an instance that has billing switched on.
|
||||||
if (!emailVerificationRequired) {
|
if (!emailVerificationRequired) {
|
||||||
warnIfTrialsSkipVerification();
|
warnIfTrialsSkipVerification();
|
||||||
await startCardlessTrial(user.id);
|
await startCardlessTrialOnSignup(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send verification email if SMTP is configured
|
// Send verification email if SMTP is configured
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ const eslintConfig = defineConfig([
|
|||||||
'test-results/**',
|
'test-results/**',
|
||||||
'reports/**',
|
'reports/**',
|
||||||
'.stryker-tmp/**',
|
'.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,
|
prettier,
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
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 { isInviteCodeRequired } from '@/lib/feature-flags';
|
||||||
import { isEmailVerificationEnabled } from '@/lib/email-verification';
|
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
|
// opposite of what the signup page promised it. The address is already
|
||||||
// proven here: the signIn callback above turns away an OAuth profile that
|
// proven here: the signIn callback above turns away an OAuth profile that
|
||||||
// reports its email as unverified.
|
// reports its email as unverified.
|
||||||
await startCardlessTrial(user.id);
|
await startCardlessTrialOnSignup(user.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+119
-3
@@ -1,6 +1,6 @@
|
|||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
import { BillingSubscriptionStatus } from '@prisma/client';
|
import { BillingSubscriptionStatus, InvitationStatus } from '@prisma/client';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
@@ -336,6 +336,12 @@ export function buildEffectiveBillingStatusWhereInput(
|
|||||||
* `billingTrialConsumedAt` is written here rather than only by the Stripe sync.
|
* `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
|
* 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.
|
* 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()) {
|
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
|
||||||
// Without billing nothing is gated, so a trial would be a date nobody reads.
|
// 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;
|
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) {
|
export async function getStripeCheckoutState(userId: string) {
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -439,6 +508,12 @@ export async function getWorkspaceCreationEligibility(userId: string) {
|
|||||||
const billingAccess = hasBillingAccess(user);
|
const billingAccess = hasBillingAccess(user);
|
||||||
const isPaid = isPaidTier(user);
|
const isPaid = isPaidTier(user);
|
||||||
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
|
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
|
// 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
|
// 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 (!canCreateWorkspace && isStripeFeatureEnabled()) {
|
||||||
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
|
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
|
||||||
reason = 'Your free trial includes one workspace. Subscribe to create more.';
|
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 =
|
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 {
|
} else {
|
||||||
reason = 'Your trial has ended. Start a subscription to create and keep owning workspaces.';
|
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 {
|
return {
|
||||||
canCreateWorkspace,
|
canCreateWorkspace,
|
||||||
|
canStartTrial,
|
||||||
reason,
|
reason,
|
||||||
ownedWorkspaceCount,
|
ownedWorkspaceCount,
|
||||||
invitedWorkspaceCount,
|
invitedWorkspaceCount,
|
||||||
@@ -493,6 +569,7 @@ export async function getBillingOverview(userId: string) {
|
|||||||
return {
|
return {
|
||||||
workspaceCreation: {
|
workspaceCreation: {
|
||||||
canCreateWorkspace: billing.canCreateWorkspace,
|
canCreateWorkspace: billing.canCreateWorkspace,
|
||||||
|
canStartTrial: billing.canStartTrial,
|
||||||
reason: billing.reason,
|
reason: billing.reason,
|
||||||
ownedWorkspaceCount: billing.ownedWorkspaceCount,
|
ownedWorkspaceCount: billing.ownedWorkspaceCount,
|
||||||
invitedWorkspaceCount: billing.invitedWorkspaceCount,
|
invitedWorkspaceCount: billing.invitedWorkspaceCount,
|
||||||
@@ -547,6 +624,7 @@ export async function getTrialNotice(
|
|||||||
|
|
||||||
const contentKeptUntil = getStorageCleanupEligibleAt(user);
|
const contentKeptUntil = getStorageCleanupEligibleAt(user);
|
||||||
|
|
||||||
|
const notice = ((): TrialNotice | null => {
|
||||||
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
|
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
|
||||||
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
|
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
|
||||||
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
|
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
|
||||||
@@ -567,6 +645,44 @@ export async function getTrialNotice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { kind: 'ended', endsAt, contentKeptUntil };
|
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) {
|
export async function getOrCreateStripeCustomerId(userId: string) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||||
import { isProductAnalyticsEnabled, isStripeFeatureEnabled } from '@/lib/feature-flags';
|
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.
|
// Reduce window to 2 hours — shorter exposure in access logs and backups.
|
||||||
const TOKEN_EXPIRY_HOURS = 2;
|
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;
|
return record.identifier;
|
||||||
|
|||||||
@@ -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 approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
|
||||||
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
|
import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
|
||||||
import * as billingPortalRoute from '@/app/api/billing/portal/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 billingRoute from '@/app/api/billing/route';
|
||||||
import * as commentRoute from '@/app/api/comments/[commentId]/route';
|
import * as commentRoute from '@/app/api/comments/[commentId]/route';
|
||||||
import * as feedbackRoute from '@/app/api/feedback/route';
|
import * as feedbackRoute from '@/app/api/feedback/route';
|
||||||
@@ -148,7 +149,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
|
|||||||
// The count guard
|
// The count guard
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
|
// 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
|
* 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' },
|
headers: { origin: 'http://localhost:3000' },
|
||||||
},
|
},
|
||||||
{ file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' },
|
{ 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',
|
file: 'comments/[commentId]/route.ts',
|
||||||
module: commentRoute,
|
module: commentRoute,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
// fails a test rather than a security review.
|
// fails a test rather than a security review.
|
||||||
|
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { InvitationScope } from '@prisma/client';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { db } from '@/lib/db';
|
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 { POST as resendVerification } from '@/app/api/auth/verify-email/resend/route';
|
||||||
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
||||||
import { mailTo, sentMail } from '../helpers/mail';
|
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 TWO_HOURS_MS = 2 * 60 * 60 * 1000;
|
||||||
const MINUTE_MS = 60 * 1000;
|
const MINUTE_MS = 60 * 1000;
|
||||||
@@ -117,6 +118,54 @@ describe('consumeVerificationToken', () => {
|
|||||||
expect(days).toBe(7);
|
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 () => {
|
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 consumedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||||
const trialEndsAt = new Date('2026-01-08T00:00:00.000Z');
|
const trialEndsAt = new Date('2026-01-08T00:00:00.000Z');
|
||||||
|
|||||||
@@ -407,6 +407,53 @@ describe('POST /api/auth/register', () => {
|
|||||||
expect(created.billingTrialConsumedAt).toBeNull();
|
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 () => {
|
it('reports the rate limit budget on a successful registration', async () => {
|
||||||
const response = await post({
|
const response = await post({
|
||||||
name: 'Rate Limited',
|
name: 'Rate Limited',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DELETE as removeWorkspaceMember,
|
DELETE as removeWorkspaceMember,
|
||||||
PATCH as patchWorkspaceMember,
|
PATCH as patchWorkspaceMember,
|
||||||
} from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
|
} from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
|
||||||
|
import { startCardlessTrial } from '@/lib/billing';
|
||||||
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
|
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
|
||||||
import { signedInAs, signedOut } from '../helpers/session';
|
import { signedInAs, signedOut } from '../helpers/session';
|
||||||
import {
|
import {
|
||||||
@@ -180,6 +181,43 @@ describe('POST /api/workspaces', () => {
|
|||||||
expect(await db.workspace.count()).toBe(1);
|
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 () => {
|
it('refuses a second workspace for an expired user', async () => {
|
||||||
const expired = await createExpiredUser();
|
const expired = await createExpiredUser();
|
||||||
await createWorkspace({ ownerId: expired.id });
|
await createWorkspace({ ownerId: expired.id });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getOrCreateStripeCustomerId,
|
getOrCreateStripeCustomerId,
|
||||||
getStorageCleanupEligibleAt,
|
getStorageCleanupEligibleAt,
|
||||||
getStripeCheckoutState,
|
getStripeCheckoutState,
|
||||||
|
getTrialNotice,
|
||||||
getWorkspaceCreationEligibility,
|
getWorkspaceCreationEligibility,
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
hasActiveTrial,
|
hasActiveTrial,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
markSubscriptionCanceledByCustomerId,
|
markSubscriptionCanceledByCustomerId,
|
||||||
selectAuthoritativeSubscription,
|
selectAuthoritativeSubscription,
|
||||||
startCardlessTrial,
|
startCardlessTrial,
|
||||||
|
startCardlessTrialOnSignup,
|
||||||
syncStripeCustomerSubscriptions,
|
syncStripeCustomerSubscriptions,
|
||||||
syncStripeSubscriptionToUser,
|
syncStripeSubscriptionToUser,
|
||||||
} from '@/lib/billing';
|
} from '@/lib/billing';
|
||||||
@@ -35,6 +37,7 @@ const dbMock = vi.hoisted(() => ({
|
|||||||
workspace: { count: vi.fn() },
|
workspace: { count: vi.fn() },
|
||||||
workspaceMember: { count: vi.fn() },
|
workspaceMember: { count: vi.fn() },
|
||||||
projectMember: { count: vi.fn() },
|
projectMember: { count: vi.fn() },
|
||||||
|
invitation: { count: vi.fn() },
|
||||||
analyticsEvent: { createMany: vi.fn() },
|
analyticsEvent: { createMany: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -818,6 +821,7 @@ describe('database backed billing helpers', () => {
|
|||||||
dbMock.workspace.count.mockReset();
|
dbMock.workspace.count.mockReset();
|
||||||
dbMock.workspaceMember.count.mockReset();
|
dbMock.workspaceMember.count.mockReset();
|
||||||
dbMock.projectMember.count.mockReset();
|
dbMock.projectMember.count.mockReset();
|
||||||
|
dbMock.invitation.count.mockReset();
|
||||||
stripeMock.customers.create.mockReset();
|
stripeMock.customers.create.mockReset();
|
||||||
stripeMock.subscriptions.list.mockReset();
|
stripeMock.subscriptions.list.mockReset();
|
||||||
dbMock.user.update.mockImplementation(async (args: { data: unknown }) => args.data);
|
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', () => {
|
describe('getWorkspaceCreationEligibility', () => {
|
||||||
function mockEligibility(options: {
|
function mockEligibility(options: {
|
||||||
user?: Record<string, unknown> | null;
|
user?: Record<string, unknown> | null;
|
||||||
owned?: number;
|
owned?: number;
|
||||||
invited?: number;
|
invited?: number;
|
||||||
projectOnly?: number;
|
projectOnly?: number;
|
||||||
|
/** Whether the once-per-account trial has already been spent and run out. */
|
||||||
|
consumed?: boolean;
|
||||||
}) {
|
}) {
|
||||||
dbMock.user.findUnique.mockResolvedValue(
|
dbMock.user.findUnique.mockResolvedValue(
|
||||||
options.user === undefined
|
options.user === undefined
|
||||||
? {
|
? {
|
||||||
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||||
trialEndsAt: null,
|
trialEndsAt: null,
|
||||||
billingTrialConsumedAt: null,
|
billingTrialConsumedAt: options.consumed
|
||||||
|
? new Date(NOW.getTime() - 30 * DAY_MS)
|
||||||
|
: null,
|
||||||
stripeCustomerId: null,
|
stripeCustomerId: null,
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
@@ -1045,7 +1218,7 @@ describe('database backed billing helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('blocks an expired owner who already has a workspace', async () => {
|
it('blocks an expired owner who already has a workspace', async () => {
|
||||||
mockEligibility({ owned: 1 });
|
mockEligibility({ owned: 1, consumed: true });
|
||||||
|
|
||||||
const result = await getWorkspaceCreationEligibility('u1');
|
const result = await getWorkspaceCreationEligibility('u1');
|
||||||
|
|
||||||
@@ -1053,27 +1226,41 @@ describe('database backed billing helpers', () => {
|
|||||||
expect(result.reason).toContain('Your trial has ended');
|
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 });
|
mockEligibility({ owned: 0, invited: 1 });
|
||||||
|
|
||||||
const result = await getWorkspaceCreationEligibility('u1');
|
const result = await getWorkspaceCreationEligibility('u1');
|
||||||
|
|
||||||
expect(result.canCreateWorkspace).toBe(false);
|
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 });
|
mockEligibility({ owned: 0, projectOnly: 2 });
|
||||||
|
|
||||||
const result = await getWorkspaceCreationEligibility('u1');
|
const result = await getWorkspaceCreationEligibility('u1');
|
||||||
|
|
||||||
expect(result.canCreateWorkspace).toBe(false);
|
expect(result.canCreateWorkspace).toBe(false);
|
||||||
expect(result.reason).toContain('currently collaborating');
|
expect(result.canStartTrial).toBe(true);
|
||||||
expect(result.projectOnlyCollaborationCount).toBe(2);
|
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 () => {
|
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(
|
expect((await getWorkspaceCreationEligibility('u1')).reason).toContain(
|
||||||
'Your trial has ended'
|
'Your trial has ended'
|
||||||
@@ -1136,6 +1323,7 @@ describe('database backed billing helpers', () => {
|
|||||||
|
|
||||||
expect(overview.workspaceCreation).toEqual({
|
expect(overview.workspaceCreation).toEqual({
|
||||||
canCreateWorkspace: true,
|
canCreateWorkspace: true,
|
||||||
|
canStartTrial: false,
|
||||||
reason: null,
|
reason: null,
|
||||||
ownedWorkspaceCount: 2,
|
ownedWorkspaceCount: 2,
|
||||||
invitedWorkspaceCount: 1,
|
invitedWorkspaceCount: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user