mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(billing): defer the cardless trial for invited collaborators
An account that signs up through an invitation works on the inviter's billing, so handing it a trial at signup spent its only trial before it owned anything. The trial is now held back for collaborators and claimed only explicitly: a Start Free Trial button on the new-workspace and billing screens calls the new POST /api/billing/trial endpoint, which grants the once-per-account trial atomically. Nothing starts the clock as a side effect, and pure collaborators no longer see a trial-ending banner about work that is not theirs.
This commit is contained in:
@@ -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.
|
||||
|
||||
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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user