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());
});
});