From 94c580a87ce22b5c62f0c61172e553260943b122 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 18:06:02 +0200 Subject: [PATCH] No card-trial offer before approving an AI client Put between sign-up and Approve (#842), the offer halved the share of sign-ups from Claude that approved the connection: about 80% an hour before it, 40-42% in the two hours after. Of eight who opened the checkout from it, one paid and five neither paid nor came back to approve. Sign-ups from Claude go straight back to approve again. The way-back helpers on /start-trial and the licence activation page stay (inert without the offer), as does the plan-limit answer in the MCP setup tools. --- packages/frontend/src/app/login/page.tsx | 33 +--- .../e2e/card-trial-claude-signup.spec.ts | 149 ------------------ .../e2e/neutral-signup-and-billing.spec.ts | 5 +- 3 files changed, 5 insertions(+), 182 deletions(-) delete mode 100644 packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index 34b12601..cc4b6bb0 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -19,7 +19,6 @@ import { cardTrialEligible, parsePlanIntent, readCardTrialPrompt, - saveAuthorizationReturn, savePlanIntent, parsePromoCode, savePromoCode, @@ -178,31 +177,6 @@ function LoginForm() { return true; }; - /** - * Cloud only: someone who signed up from "Connect" in an AI client (the - * redirect is the pending authorization) is offered the card trial before - * approving, once. Every way out of /start-trial (skip, a cancelled or a - * completed checkout) returns to this authorization, which the backend - * keeps for 30 minutes. Resolves true when it has navigated. - */ - const offerCardTrialBeforeAuthorization = async ( - u: { id?: string; role?: string } | null | undefined, - token: string, - ): Promise => { - if (!isCloudMode || !returningToAuthorization || !u?.id || u.role !== 'ADMIN') return false; - if (readCardTrialPrompt(u.id) !== null) return false; - try { - const lic = await license.getStatus(token); - if (!cardTrialEligible({ isCloud: true, role: u.role, license: lic })) return false; - } catch { - return false; - } - writeCardTrialPrompt(u.id, 'shown'); - saveAuthorizationReturn(redirectTo); - router.push('/start-trial'); - return true; - }; - // Surface a failure the SSO callback redirected back with. useEffect(() => { if (errorParam) setError(errorParam); @@ -331,7 +305,6 @@ function LoginForm() { login(result.accessToken, result.user); if (isCloudMode && needsLicenseSetup && returningToAuthorization) { await license.activateTrial(result.accessToken).catch(() => undefined); - if (await offerCardTrialBeforeAuthorization(result.user, result.accessToken)) return; goTo(redirectTo); } else if (isCloudMode && needsLicenseSetup) { // Cloud mode: auto-activate trial for verified users @@ -379,9 +352,9 @@ function LoginForm() { if (isCloudMode && returningToAuthorization) { // Verification already created the trial; the user is in the middle - // of connecting an AI client: offer the card trial once, then back - // to approve. - if (await offerCardTrialBeforeAuthorization(verifiedUser, authToken)) return; + // of connecting an AI client, so take them straight back to approve. + // No card-trial offer here: put before Approve it halved the share of + // directory sign-ups that connected Claude (3 Oct 2026). goTo(redirectTo); } else if (isCloudMode) { // Cloud mode: auto-activate trial diff --git a/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts b/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts deleted file mode 100644 index 34bea59f..00000000 --- a/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; - -/** - * Sign-up from "Connect" in Claude: after verifying the email, a new admin on - * a fresh free trial is offered the card trial once, then always returns to - * the pending authorization (/auth/login, served by the backend), whichever - * way they leave the offer: - * - "Continue without payment details" → back to approve; - * - a completed checkout → the licence site returns to - * /settings/license/activate#key=…, which activates and goes back; - * - a cancelled checkout → Stripe returns to /start-trial, skip → back. - * The authorization must never be stranded, which is what an earlier version - * of the trial offer did in this flow. - */ - -const CLOUD_INFO = { deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }; -const NEUTRAL = { verificationRequired: true, message: 'Check your inbox.' }; -const TRIAL_END = new Date(Date.now() + 7 * 86_400_000).toISOString(); -const KEY = 'AMCP-AB12-CD34-EF56-0789'; - -interface Calls { - paths: string[]; - checkout: unknown[]; - setKey: unknown[]; -} - -async function mockCloud(page: Page, opts: { role?: string; license?: Record } = {}): Promise { - const calls: Calls = { paths: [], checkout: [], setKey: [] }; - const user = { id: 'u-claude', email: 'claude@example.test', name: 'Jane', role: opts.role ?? 'ADMIN', organizationId: 'o1' }; - await page.route(/\/(api|health)\//, async (route) => { - const req = route.request(); - const p = new URL(req.url()).pathname; - calls.paths.push(p); - const json = (body: unknown, status = 200) => route.fulfill({ status, json: body }); - if (p === '/health/server-info') return json(CLOUD_INFO); - if (p === '/api/auth/register') return json(NEUTRAL, 201); - if (p === '/api/auth/login') return json({ accessToken: 't', user: { ...user, emailVerified: false }, needsLicenseSetup: true }); - if (p === '/api/auth/verify-email') return json({ message: 'ok', emailVerified: true }); - if (p === '/api/license/status') { - return json(opts.license ?? { plan: 'trial', status: 'active', expiresAt: TRIAL_END, trialDaysLeft: 7, features: {} }); - } - if (p === '/api/license/checkout-link') { - calls.checkout.push(req.postDataJSON()); - return json({ url: 'https://checkout.example.test/start?intent=i1' }); - } - if (p === '/api/license/key' || p === '/api/license/activate') { - calls.setKey.push(req.postDataJSON()); - return json({ message: 'License activated successfully.' }); - } - if (p.endsWith('/users/me')) return json({ ...user, emailVerified: true }); - return json({}); - }); - // The backend's authorization page and Stripe Checkout, stubbed. - await page.route((url) => url.pathname === '/auth/login', (route) => - route.fulfill({ status: 200, contentType: 'text/html', body: '

Authorize AnythingMCP

' }), - ); - await page.route('https://checkout.example.test/**', (route) => - route.fulfill({ status: 200, contentType: 'text/html', body: '

Stripe Checkout

' }), - ); - return calls; -} - -async function signUpFromClaude(page: Page) { - await page.goto('/login?mode=register&redirect=%2Fauth%2Flogin'); - await page.locator('#auth-name').fill('Jane'); - await page.locator('#auth-email').fill('claude@example.test'); - await page.locator('#auth-password').fill('Str0ng#Passw0rd'); - await page.locator('#auth-confirm-password').fill('Str0ng#Passw0rd'); - await page.getByRole('checkbox').check(); - await page.getByRole('button', { name: 'Create Account' }).click(); - await expect(page.getByRole('heading', { name: 'Verify Your Email' })).toBeVisible(); - await page.locator('input[autocomplete="one-time-code"]').fill('123456'); - await page.getByRole('button', { name: 'Verify Email' }).click(); -} - -test.describe('card trial when signing up from Claude', () => { - test('is offered once after verifying, and skipping goes back to approve', async ({ page }) => { - const calls = await mockCloud(page); - await signUpFromClaude(page); - - await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); - await expect(page.getByText('you go straight back to finish connecting your AI client')).toBeVisible(); - await page.getByRole('button', { name: 'Try free without payment details' }).click(); - - await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); - await expect(page.getByRole('heading', { name: 'Authorize AnythingMCP' })).toBeVisible(); - expect(calls.checkout).toHaveLength(0); - // The way back is spent: a later visit to /start-trial does not detour again. - expect(await page.evaluate(() => localStorage.getItem('amcp_card_trial_return'))).toBeNull(); - }); - - test('a completed checkout activates the licence and goes back to approve', async ({ page }) => { - const calls = await mockCloud(page); - await signUpFromClaude(page); - await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); - - await page.getByRole('button', { name: 'Start free trial with card' }).click(); - await expect(page.getByRole('heading', { name: 'Stripe Checkout' })).toBeVisible(); - expect(calls.checkout).toEqual([expect.objectContaining({ trial: true })]); - - // The licence site's success page hands the key over in the fragment. - await page.goto(`/settings/license/activate#key=${KEY}`); - await expect(page.getByText('Taking you back to finish connecting your AI client')).toBeVisible(); - await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); - expect(JSON.stringify(calls.setKey)).toContain(KEY); - }); - - test('a cancelled checkout comes back to the offer, and skipping still goes back to approve', async ({ page }) => { - await mockCloud(page); - await signUpFromClaude(page); - await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); - await page.getByRole('button', { name: 'Start free trial with card' }).click(); - await expect(page.getByRole('heading', { name: 'Stripe Checkout' })).toBeVisible(); - - // Stripe's cancel URL for a card trial. - await page.goto('/start-trial'); - await page.getByRole('button', { name: 'Continue without payment details' }).click(); - await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); - }); - - test('a member who cannot subscribe goes straight back to approve', async ({ page }) => { - const calls = await mockCloud(page, { role: 'EDITOR' }); - await signUpFromClaude(page); - await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); - expect(calls.paths).not.toContain('/api/license/checkout-link'); - }); - - test('without a running trial to convert, goes straight back to approve', async ({ page }) => { - await mockCloud(page, { license: { plan: 'cloud_team', status: 'active', expiresAt: TRIAL_END } }); - await signUpFromClaude(page); - await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 }); - }); - - test('an expired way back is not followed', async ({ page }) => { - // A stale entry (older than the 30 minutes the backend keeps an - // authorization) must not send a later visitor to a dead consent page. - await mockCloud(page); - await page.context().addCookies([{ name: 'amcp_token', value: 't', url: test.info().project.use.baseURL ?? 'http://localhost:3100' }]); - await page.addInitScript(() => { - localStorage.setItem('amcp_token', 't'); - localStorage.setItem('amcp_user', JSON.stringify({ id: 'u-claude', email: 'claude@example.test', role: 'ADMIN', organizationId: 'o1', emailVerified: true })); - localStorage.setItem('amcp_card_trial_return', JSON.stringify({ path: '/auth/login', savedAt: Date.now() - 31 * 60 * 1000 })); - }); - await page.goto('/start-trial'); - await expect(page.getByText('you go straight back to finish connecting your AI client')).toHaveCount(0); - await page.getByRole('button', { name: 'Continue without payment details' }).click(); - await expect(page).not.toHaveURL(/\/auth\/login/); - }); -}); diff --git a/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts b/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts index 4213ff9c..c42d541a 100644 --- a/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts +++ b/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts @@ -83,9 +83,8 @@ test.describe('sign-up from an AI client (Claude "Connect")', () => { // The MCP authorization page links "Create an account" with // redirect=/auth/login. After verifying, the new user must land back on that // page (still inside the pending authorization) to approve with one click, - // not on the welcome wizard, which used to strand the connection half way. - // The card-trial offer may come first, but only with a guaranteed way back - // (card-trial-claude-signup.spec.ts); here there is no trial to convert. + // not on the welcome wizard or the card-trial offer, which strand the connection half way. + // Nor the card-trial offer: before Approve it cost half the connections. test('after verifying the email, goes straight back to the authorization page', async ({ page }) => { const calls: string[] = []; await page.route(/\/(api|health)\//, async (route) => {