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:
2026-09-01 15:07:54 +03:00
parent cba8163286
commit 4b3c3934dd
12 changed files with 684 additions and 47 deletions
+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 });