From 686e96a5416dcd692675a28dfd9957df2654f3d8 Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 15:35:51 +0200 Subject: [PATCH 1/7] Cloud: offer a Stripe card trial after sign-up Backend: POST /api/license/checkout-link (Cloud only, ADMIN, throttled) asks the licence site for a Stripe Checkout intent on behalf of the signed-in admin. Email and workspace come from the session; a card trial ends when the workspace's free trial would have, and after the trial the link is a plain purchase. A consented Google Ads click id is passed as ad metadata. Paying workspaces get 409 (plan changes go through the billing portal); licence-site failures map to a generic 502. Frontend: new /start-trial page with a plan picker (Starter, Team, Business; monthly or yearly), shown once to cloud admins on a running trial, before the welcome wizard, with a small "Continue without payment details" link that keeps the no-card trial. The plan picked on the pricing page is kept through email verification. The trial banner, licence wall (after the trial) and licence settings open the checkout. Self-hosted behaviour is unchanged. --- .../src/license/license-checkout.spec.ts | 333 ++++++++++++++++++ .../backend/src/license/license-checkout.ts | 84 +++++ .../src/license/license.controller.spec.ts | 1 + .../backend/src/license/license.controller.ts | 95 ++++- .../backend/src/license/license.service.ts | 60 ++++ packages/frontend/src/app/login/page.tsx | 48 +++ .../src/app/settings/license/page.tsx | 53 ++- .../frontend/src/app/start-trial/page.tsx | 164 +++++++++ .../frontend/src/components/license-wall.tsx | 39 +- .../src/components/onboarding-redirect.tsx | 31 +- .../frontend/src/components/plan-picker.tsx | 145 ++++++++ .../frontend/src/components/trial-banner.tsx | 69 +++- packages/frontend/src/lib/api.ts | 14 + packages/frontend/src/lib/card-trial.ts | 224 ++++++++++++ .../frontend/src/lib/use-card-checkout.ts | 47 +++ .../frontend/tests/e2e/card-trial.spec.ts | 265 ++++++++++++++ 16 files changed, 1647 insertions(+), 25 deletions(-) create mode 100644 packages/backend/src/license/license-checkout.spec.ts create mode 100644 packages/backend/src/license/license-checkout.ts create mode 100644 packages/frontend/src/app/start-trial/page.tsx create mode 100644 packages/frontend/src/components/plan-picker.tsx create mode 100644 packages/frontend/src/lib/card-trial.ts create mode 100644 packages/frontend/src/lib/use-card-checkout.ts create mode 100644 packages/frontend/tests/e2e/card-trial.spec.ts diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts new file mode 100644 index 00000000..28cd28e6 --- /dev/null +++ b/packages/backend/src/license/license-checkout.spec.ts @@ -0,0 +1,333 @@ +/** + * The card-trial checkout link (Cloud only). + * + * A trial user is offered Stripe Checkout with a trial that ends when their + * free trial would have. The licence site builds the checkout; this server + * vouches for who is buying. These tests pin what must never drift: the + * email and workspace come from the session, the trial end is the free + * trial's own, self-hosted never sees the route, only admins can open it, a + * paying workspace is not sold a second subscription, and upstream failures + * reach the browser as one clean 502. + */ +import axios from 'axios'; +import { + BadGatewayException, + ConflictException, + ExecutionContext, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from '../auth/roles.guard'; +import { LicenseController } from './license.controller'; +import { LicenseService } from './license.service'; +import { + CheckoutUnavailableError, + cardTrialEnd, + checkoutAdMetadata, + cloudFrontendOrigin, +} from './license-checkout'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const NOW = new Date('2026-10-01T10:00:00.000Z'); +const IN_FIVE_DAYS = new Date('2026-10-06T10:00:00.000Z'); + +function trialLicense(overrides: Record = {}) { + return { + licenseKey: 'AMCP-TRIA-L000-0000-0001', + plan: 'trial', + status: 'active', + features: null, + expiresAt: IN_FIVE_DAYS, + lastVerifiedAt: null, + instanceId: 'instance-1', + ...overrides, + }; +} + +describe('cardTrialEnd', () => { + it('ends the card trial exactly when the free trial ends', () => { + expect(cardTrialEnd(trialLicense(), NOW)?.toISOString()).toBe(IN_FIVE_DAYS.toISOString()); + }); + + it('answers null (pay now) once the free trial is over', () => { + expect(cardTrialEnd(trialLicense({ expiresAt: new Date(NOW.getTime() - 1000) }), NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ expiresAt: NOW }), NOW)).toBeNull(); + }); + + it('answers null for anything that is not a live trial', () => { + expect(cardTrialEnd(null, NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ plan: 'cloud_team' }) as any, NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ status: 'expired' }) as any, NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ expiresAt: null }) as any, NOW)).toBeNull(); + }); +}); + +describe('checkoutAdMetadata', () => { + it('passes a consented click id and nothing else', () => { + expect( + checkoutAdMetadata({ gclid: 'abc', ad_consent: 'granted', captured_at: '2026-09-01T00:00:00Z' }), + ).toEqual({ ad_consent: 'granted', gclid: 'abc' }); + }); + + it('sends nothing without consent or without an id', () => { + expect(checkoutAdMetadata(null)).toBeUndefined(); + expect(checkoutAdMetadata({ gclid: 'abc', ad_consent: 'denied' } as any)).toBeUndefined(); + expect(checkoutAdMetadata({ ad_consent: 'granted' })).toBeUndefined(); + }); +}); + +describe('cloudFrontendOrigin', () => { + it('uses FRONTEND_URL without a trailing slash', () => { + expect(cloudFrontendOrigin({ FRONTEND_URL: 'https://cloud.example.com/' } as any)).toBe( + 'https://cloud.example.com', + ); + }); + + it('falls back to the public cloud URL', () => { + expect(cloudFrontendOrigin({} as any)).toBe('https://cloud.anythingmcp.com'); + }); +}); + +describe('LicenseService.createCheckoutIntent', () => { + const originalToken = process.env.LICENSE_SERVICE_TOKEN; + const payload = { + email: 'admin@example.com', + plan: 'cloud_team' as const, + billingPeriod: 'monthly' as const, + returnUrl: 'https://cloud.example.com/settings/license/activate', + organizationId: 'org-1', + }; + + function makeService() { + const siteSettings = { get: jest.fn(async () => null), set: jest.fn() }; + const deployment = { isCloud: () => true, isSelfHosted: () => false }; + return new LicenseService({} as any, siteSettings as any, deployment as any); + } + + function httpError(status?: number, data?: any) { + return { + response: status === undefined ? undefined : { status, data }, + message: `HTTP ${status}`, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + process.env.TRIAL_RETRY_BASE_MS = '1'; + process.env.LICENSE_SERVICE_TOKEN = 'service-token'; + }); + + afterAll(() => { + delete process.env.TRIAL_RETRY_BASE_MS; + if (originalToken === undefined) delete process.env.LICENSE_SERVICE_TOKEN; + else process.env.LICENSE_SERVICE_TOKEN = originalToken; + }); + + it('posts the contract with the service token and a timeout, and returns the URL', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1', expiresAt: 'x' }, + } as any); + + await expect(makeService().createCheckoutIntent(payload)).resolves.toEqual({ + url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1', + }); + const [url, body, config] = mockedAxios.post.mock.calls[0] as any[]; + expect(url).toMatch(/\/api\/stripe\/checkout-intent$/); + expect(body).toEqual(payload); + expect(config.headers['x-amcp-service-token']).toBe('service-token'); + expect(config.timeout).toBeGreaterThan(0); + }); + + it('retries once on an upstream fault', async () => { + mockedAxios.post + .mockRejectedValueOnce(httpError(503)) + .mockResolvedValueOnce({ data: { url: 'https://anythingmcp.com/c' } } as any); + + await expect(makeService().createCheckoutIntent(payload)).resolves.toEqual({ + url: 'https://anythingmcp.com/c', + }); + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + + it('does not retry a validation error and reports it as unavailable', async () => { + mockedAxios.post.mockRejectedValue(httpError(400, { error: 'bad plan' })); + + const err = await makeService() + .createCheckoutIntent(payload) + .catch((e) => e); + expect(err).toBeInstanceOf(CheckoutUnavailableError); + expect(err.upstreamStatus).toBe(400); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('refuses an answer without a URL', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any); + await expect(makeService().createCheckoutIntent(payload)).rejects.toBeInstanceOf( + CheckoutUnavailableError, + ); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('does not call the licence site at all without a service token', async () => { + delete process.env.LICENSE_SERVICE_TOKEN; + await expect(makeService().createCheckoutIntent(payload)).rejects.toBeInstanceOf( + CheckoutUnavailableError, + ); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); +}); + +describe('LicenseController.checkoutLink', () => { + const originalFrontend = process.env.FRONTEND_URL; + const adminReq = { + user: { sub: 'u1', email: 'admin@example.com', role: 'ADMIN', organizationId: 'org-1' }, + }; + + function makeController(opts: { isCloud?: boolean; license?: any; click?: any; fail?: boolean } = {}) { + const licenseService = { + getCurrentLicense: jest.fn(async () => (opts.license === undefined ? trialLicense() : opts.license)), + createCheckoutIntent: jest.fn(async () => { + if (opts.fail) throw new CheckoutUnavailableError('Checkout intent failed: HTTP 500', 500); + return { url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1' }; + }), + }; + const productEvents = { clickIdForUser: jest.fn(async () => opts.click ?? null) }; + const controller = new LicenseController( + licenseService as any, + {} as any, + {} as any, + {} as any, + { isCloud: () => opts.isCloud ?? true } as any, + productEvents as any, + ); + return { controller, licenseService, productEvents }; + } + + beforeEach(() => { + process.env.FRONTEND_URL = 'https://cloud.example.com'; + jest.useFakeTimers({ now: NOW, doNotFake: ['nextTick', 'setImmediate'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + if (originalFrontend === undefined) delete process.env.FRONTEND_URL; + else process.env.FRONTEND_URL = originalFrontend; + }); + + it('builds the checkout from the session, with the free trial end as trialEnd', async () => { + const { controller, licenseService, productEvents } = makeController({ + click: { gclid: 'g-1', ad_consent: 'granted' }, + }); + + const res = await controller.checkoutLink(adminReq, { + plan: 'team', + billingPeriod: 'yearly', + trial: true, + }); + + expect(res).toEqual({ url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1' }); + expect(licenseService.getCurrentLicense).toHaveBeenCalledWith('org-1'); + expect(productEvents.clickIdForUser).toHaveBeenCalledWith('u1'); + expect(licenseService.createCheckoutIntent).toHaveBeenCalledWith({ + email: 'admin@example.com', + plan: 'cloud_team', + billingPeriod: 'yearly', + trialEnd: IN_FIVE_DAYS.toISOString(), + returnUrl: 'https://cloud.example.com/settings/license/activate', + organizationId: 'org-1', + adMetadata: { ad_consent: 'granted', gclid: 'g-1' }, + }); + }); + + it('ignores an email or workspace smuggled into the body', async () => { + const { controller, licenseService } = makeController(); + + await controller.checkoutLink(adminReq, { + plan: 'starter', + billingPeriod: 'monthly', + trial: false, + email: 'victim@example.com', + organizationId: 'org-other', + } as any); + + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload.email).toBe('admin@example.com'); + expect(payload.organizationId).toBe('org-1'); + expect(licenseService.getCurrentLicense).toHaveBeenCalledWith('org-1'); + }); + + it('asks to pay now when no trial is requested', async () => { + const { controller, licenseService } = makeController(); + await controller.checkoutLink(adminReq, { plan: 'business', billingPeriod: 'monthly', trial: false }); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload).not.toHaveProperty('trialEnd'); + expect(payload).not.toHaveProperty('adMetadata'); + }); + + it('asks to pay now when the trial is requested after the free trial ended', async () => { + const { controller, licenseService } = makeController({ + license: null, // getCurrentLicense returns active licences only; an ended trial is expired + }); + await controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload).not.toHaveProperty('trialEnd'); + + const late = makeController({ + license: trialLicense({ expiresAt: new Date(NOW.getTime() - 60_000) }), + }); + await late.controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }); + const [latePayload] = late.licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(latePayload).not.toHaveProperty('trialEnd'); + }); + + it('does not exist on a self-hosted install', async () => { + const { controller, licenseService } = makeController({ isCloud: false }); + await expect( + controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(licenseService.getCurrentLicense).not.toHaveBeenCalled(); + expect(licenseService.createCheckoutIntent).not.toHaveBeenCalled(); + }); + + it('refuses a non-admin, in the handler as well as in the guard', async () => { + const { controller, licenseService } = makeController(); + const memberReq = { user: { ...adminReq.user, role: 'EDITOR' } }; + await expect( + controller.checkoutLink(memberReq, { plan: 'team', billingPeriod: 'monthly', trial: true }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(licenseService.createCheckoutIntent).not.toHaveBeenCalled(); + + const guard = new RolesGuard(new Reflector()); + const context = { + getHandler: () => LicenseController.prototype.checkoutLink, + getClass: () => LicenseController, + switchToHttp: () => ({ getRequest: () => memberReq }), + } as unknown as ExecutionContext; + expect(() => guard.canActivate(context)).toThrow(ForbiddenException); + }); + + it('does not sell a second subscription to a paying workspace', async () => { + const { controller, licenseService } = makeController({ + license: trialLicense({ plan: 'cloud_team', expiresAt: null }), + }); + await expect( + controller.checkoutLink(adminReq, { plan: 'business', billingPeriod: 'monthly', trial: false }), + ).rejects.toBeInstanceOf(ConflictException); + expect(licenseService.createCheckoutIntent).not.toHaveBeenCalled(); + }); + + it('maps any licence-site failure to a generic 502', async () => { + const { controller } = makeController({ fail: true }); + const err = await controller + .checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true }) + .catch((e) => e); + expect(err).toBeInstanceOf(BadGatewayException); + expect(err.message).not.toMatch(/HTTP 500/); + }); +}); diff --git a/packages/backend/src/license/license-checkout.ts b/packages/backend/src/license/license-checkout.ts new file mode 100644 index 00000000..0f7cd724 --- /dev/null +++ b/packages/backend/src/license/license-checkout.ts @@ -0,0 +1,84 @@ +import type { LicenseInfo } from './license.service'; +import type { AttributionClickId } from '../audit/signup-attribution'; + +/** + * Checkout links for AnythingMCP Cloud (DEPLOYMENT_MODE=cloud only). + * + * The licence site (anythingmcp.com) owns Stripe. This server asks it for a + * checkout intent on behalf of the signed-in admin, server to server, and + * hands the browser the one-time URL it answers with. The browser goes through + * Stripe Checkout and comes back to /settings/license/activate#key=…, which + * the existing activation page handles. + */ + +export const CHECKOUT_PLANS = ['starter', 'team', 'business'] as const; +export type CheckoutPlan = (typeof CHECKOUT_PLANS)[number]; + +export const CHECKOUT_BILLING_PERIODS = ['monthly', 'yearly'] as const; +export type CheckoutBillingPeriod = (typeof CHECKOUT_BILLING_PERIODS)[number]; + +/** Body of POST {licence site}/api/stripe/checkout-intent (the agreed contract). */ +export interface CheckoutIntentPayload { + email: string; + plan: `cloud_${CheckoutPlan}`; + billingPeriod: CheckoutBillingPeriod; + /** Present: a card trial ending then (the site clamps it). Absent: pay now. */ + trialEnd?: string; + returnUrl: string; + organizationId?: string; + adMetadata?: { + ad_consent: 'granted'; + gclid?: string; + gbraid?: string; + wbraid?: string; + }; +} + +/** + * When a card trial should end: exactly when the workspace's free trial would + * have, so adding a card never shortens or lengthens the seven days the user + * was promised. Only a live 'trial' licence qualifies; anything else (trial + * over, a paid plan, no licence) answers null, which means "pay now". + */ +export function cardTrialEnd(license: LicenseInfo | null, now: Date = new Date()): Date | null { + if (!license || license.plan !== 'trial' || license.status !== 'active') return null; + if (!license.expiresAt) return null; + const end = new Date(license.expiresAt); + if (Number.isNaN(end.getTime()) || end.getTime() <= now.getTime()) return null; + return end; +} + +/** + * The ad metadata the licence site may attach to the checkout, so a purchase + * can be reported to Google Ads. Only a click id stored with ad consent + * granted (clickIdForUser already guarantees that; checked again here), and + * only the id fields, never the capture timestamp. + */ +export function checkoutAdMetadata( + click: AttributionClickId | null | undefined, +): CheckoutIntentPayload['adMetadata'] | undefined { + if (!click || click.ad_consent !== 'granted') return undefined; + const out: NonNullable = { ad_consent: 'granted' }; + for (const key of ['gclid', 'gbraid', 'wbraid'] as const) { + const value = click[key]; + if (typeof value === 'string' && value) out[key] = value; + } + return out.gclid || out.gbraid || out.wbraid ? out : undefined; +} + +/** The cloud frontend origin the licence site sends the buyer back to. */ +export function cloudFrontendOrigin(env: NodeJS.ProcessEnv = process.env): string { + const configured = env.FRONTEND_URL || env.CLOUD_PUBLIC_URL || 'https://cloud.anythingmcp.com'; + return configured.replace(/\/+$/, ''); +} + +/** Raised when the licence site cannot give us a checkout URL. */ +export class CheckoutUnavailableError extends Error { + constructor( + message: string, + readonly upstreamStatus?: number, + ) { + super(message); + this.name = 'CheckoutUnavailableError'; + } +} diff --git a/packages/backend/src/license/license.controller.spec.ts b/packages/backend/src/license/license.controller.spec.ts index 838d967a..56f3bb95 100644 --- a/packages/backend/src/license/license.controller.spec.ts +++ b/packages/backend/src/license/license.controller.spec.ts @@ -28,6 +28,7 @@ describe('LicenseController — activate-trial is idempotent', () => { {} as any, { findById: jest.fn(async () => user) } as any, { isCloud: () => true } as any, + {} as any, ); return { controller, licenseService }; } diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index 60794e2f..c94ba423 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -7,18 +7,33 @@ import { Req, UseGuards, BadRequestException, + BadGatewayException, + ConflictException, ForbiddenException, + NotFoundException, + HttpCode, Logger, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; -import { IsString, IsOptional, Matches } from 'class-validator'; +import { Throttle } from '@nestjs/throttler'; +import { IsBoolean, IsIn, IsString, IsOptional, Matches } from 'class-validator'; import { Roles, RolesGuard } from '../auth/roles.guard'; import { LicenseService } from './license.service'; import { LicenseGuardService } from './license-guard.service'; import { AuthService } from '../auth/auth.service'; import { UsersService } from '../users/users.service'; import { DeploymentService } from '../common/deployment.service'; +import { ProductEventService } from '../audit/product-event.service'; +import { + CHECKOUT_BILLING_PERIODS, + CHECKOUT_PLANS, + CheckoutBillingPeriod, + CheckoutPlan, + cardTrialEnd, + checkoutAdMetadata, + cloudFrontendOrigin, +} from './license-checkout'; class SetLicenseKeyDto { @IsString() @@ -28,6 +43,17 @@ class SetLicenseKeyDto { licenseKey: string; } +class CheckoutLinkDto { + @IsIn(CHECKOUT_PLANS as unknown as string[]) + plan: CheckoutPlan; + + @IsIn(CHECKOUT_BILLING_PERIODS as unknown as string[]) + billingPeriod: CheckoutBillingPeriod; + + @IsBoolean() + trial: boolean; +} + class BillingPortalDto { @IsOptional() @IsString() @@ -45,6 +71,7 @@ export class LicenseController { private readonly authService: AuthService, private readonly usersService: UsersService, private readonly deployment: DeploymentService, + private readonly productEvents: ProductEventService, ) {} @Get('status') @@ -157,6 +184,72 @@ export class LicenseController { } } + @Post('checkout-link') + @HttpCode(200) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles('ADMIN') + // Each call mints a checkout intent on the licence site: a person clicks + // this a few times at most, a loop should not be able to do more. + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Stripe Checkout link for a cloud plan, optionally as a card trial ending with the free trial (cloud, ADMIN)', + }) + async checkoutLink(@Req() req: any, @Body() dto: CheckoutLinkDto): Promise<{ url: string }> { + // Cloud only. Self-hosted buys its licence on the pricing page and has no + // workspace billing here; answer as if the route did not exist. + if (!this.deployment.isCloud()) { + throw new NotFoundException(); + } + // RolesGuard already refuses other roles; checked again because a + // checkout binds the workspace's billing to this person's email. + if (req.user?.role !== 'ADMIN') { + throw new ForbiddenException('Only workspace administrators can start a subscription'); + } + + // Who pays and for which workspace comes from the session, never from the + // body (the DTO has no such fields and the global pipe rejects extras). + const email: string | undefined = req.user?.email; + const organizationId: string | undefined = req.user?.organizationId; + if (!email || !organizationId) { + throw new BadRequestException('No workspace to subscribe'); + } + + const current = await this.licenseService.getCurrentLicense(organizationId); + // A paying workspace changes plan in the billing portal. A second checkout + // would add a second subscription beside the first and bill both. + if (current && current.plan !== 'trial') { + throw new ConflictException( + 'This workspace already has a subscription. Change your plan in the billing portal.', + ); + } + + // A card trial ends when the free trial would have. Asked for after the + // trial is over, it is a plain purchase: no trialEnd means pay now. + const trialEnd = dto.trial ? cardTrialEnd(current) : null; + const adMetadata = checkoutAdMetadata( + await this.productEvents.clickIdForUser(req.user.sub).catch(() => null), + ); + + try { + return await this.licenseService.createCheckoutIntent({ + email, + plan: `cloud_${dto.plan}`, + billingPeriod: dto.billingPeriod, + ...(trialEnd && { trialEnd: trialEnd.toISOString() }), + returnUrl: `${cloudFrontendOrigin()}/settings/license/activate`, + organizationId, + ...(adMetadata && { adMetadata }), + }); + } catch (err: any) { + this.logger.warn(`Checkout link for org ${organizationId} failed: ${err?.message ?? err}`); + throw new BadGatewayException( + 'The checkout could not be opened right now. Please try again in a moment.', + ); + } + } + @Post('verify') @UseGuards(AuthGuard('jwt'), RolesGuard) @Roles('ADMIN') diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index 43c2afb3..da65964c 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -4,6 +4,7 @@ import * as crypto from 'crypto'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; import { SiteSettingsService } from '../settings/site-settings.service'; +import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout'; const LICENSE_API_URL = process.env.NODE_ENV === 'production' @@ -19,6 +20,8 @@ const LICENSE_API_URL = const TRIAL_RETRY_ATTEMPTS = 3; /** Read at call time so a test (or an operator) can shrink the wait. */ const trialRetryBaseMs = () => Number(process.env.TRIAL_RETRY_BASE_MS ?? 600); +/** A checkout link is asked for by someone waiting on a spinner: one retry, no more. */ +const CHECKOUT_RETRY_ATTEMPTS = 2; export interface LicenseInfo { licenseKey: string; @@ -124,6 +127,63 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { } } + // ── Stripe Checkout (Cloud) ──────────────────────────────────────────────── + + /** + * Ask the licence site for a one-time Stripe Checkout URL (Cloud only; the + * caller checks the deployment mode). The licence site owns Stripe; it + * accepts this call only with our service token, so the email and workspace + * in the payload are the ones this server vouches for. + * + * A user is waiting on the other end, so the retry is short: one more try + * on throttling, an upstream fault or no answer at all. A duplicate intent + * is harmless (it simply expires unused). Every failure comes out as a + * CheckoutUnavailableError; the upstream detail stays in our log. + */ + async createCheckoutIntent(payload: CheckoutIntentPayload): Promise<{ url: string }> { + const headers = this.serviceHeaders(); + if (!headers['x-amcp-service-token']) { + this.logger.error('Checkout link requested but LICENSE_SERVICE_TOKEN is not set.'); + throw new CheckoutUnavailableError('Licence service token is not configured'); + } + + let lastErr: any; + for (let attempt = 1; attempt <= CHECKOUT_RETRY_ATTEMPTS; attempt++) { + try { + const { data } = await axios.post(`${this.apiBase}/api/stripe/checkout-intent`, payload, { + timeout: 10000, + headers, + }); + const url = typeof data?.url === 'string' ? data.url : ''; + if (!/^https?:\/\//i.test(url)) { + throw new CheckoutUnavailableError('Checkout intent answered without a URL'); + } + return { url }; + } catch (err: any) { + lastErr = err; + if ( + err instanceof CheckoutUnavailableError || + attempt === CHECKOUT_RETRY_ATTEMPTS || + !this.isRetriableLicenseError(err) + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, trialRetryBaseMs())); + } + } + + if (lastErr instanceof CheckoutUnavailableError) { + this.logger.warn(`Checkout intent failed: ${lastErr.message}`); + throw lastErr; + } + const status: number | undefined = lastErr?.response?.status; + const detail = lastErr?.response?.data?.error || lastErr?.message || 'no response'; + this.logger.warn( + `Checkout intent failed (${status ?? lastErr?.code ?? 'no response'}) for org ${payload.organizationId}: ${detail}`, + ); + throw new CheckoutUnavailableError(`Checkout intent failed: ${detail}`, status); + } + // ── Community License Request (sends key via email) ─────────────────────── async requestCommunityLicense( diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index 3b3c5722..35ca1c21 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -14,6 +14,13 @@ import { cn } from '@/lib/utils'; import { safeRedirect } from '@/lib/safe-redirect'; import { captureSignupAttribution, clearSignupAttribution, getSignupAttribution } from '@/lib/attribution'; import { pushSignUpVerified } from '@/lib/conversion'; +import { + cardTrialEligible, + parsePlanIntent, + readCardTrialPrompt, + savePlanIntent, + writeCardTrialPrompt, +} from '@/lib/card-trial'; type SetupStep = 'auth' | 'verify-email' | 'check-inbox' | 'license-choice' | 'license-email-sent' | 'license-key' | 'trial-activated'; @@ -73,6 +80,8 @@ function LoginForm() { const modeParam = searchParams.get('mode'); // 'register' or 'login' const ssoCode = searchParams.get('sso'); const errorParam = searchParams.get('error'); + const planParam = searchParams.get('plan'); + const periodParam = searchParams.get('period'); const [ssoProviders, setSsoProviders] = useState([]); const [ssoExchanging, setSsoExchanging] = useState(Boolean(ssoCode)); @@ -98,6 +107,43 @@ function LoginForm() { if (isCloudMode) captureSignupAttribution(); }, [isCloudMode]); + // Cloud only: the pricing page opens sign-up with the plan the visitor + // picked (`?plan=cloud_team&period=yearly`). Kept in localStorage so it + // survives email verification, which may happen days later in another tab, + // and preselects that plan on the card-trial offer. + useEffect(() => { + if (!isCloudMode) return; + const intent = parsePlanIntent(planParam, periodParam); + if (intent) savePlanIntent(intent); + }, [isCloudMode, planParam, periodParam]); + + /** + * Cloud only: right after the trial starts, an admin headed for the + * dashboard is offered the card trial (/start-trial) instead of the + * "Trial activated" card, once. A sign-in on its way somewhere specific + * (an OAuth consent, an install link) is left alone. + */ + const offerCardTrial = ( + u: { id?: string; role?: string } | null | undefined, + trial: { plan: string; expiresAt: string | null; trialDaysLeft: number }, + ): boolean => { + if (!isCloudMode || redirectTo !== '/' || !u?.id) return false; + const eligible = cardTrialEligible({ + isCloud: true, + role: u.role, + license: { + plan: trial.plan, + status: 'active', + expiresAt: trial.expiresAt, + trialDaysLeft: trial.trialDaysLeft, + }, + }); + if (!eligible || readCardTrialPrompt(u.id) !== null) return false; + writeCardTrialPrompt(u.id, 'shown'); + router.push('/start-trial'); + return true; + }; + // Surface a failure the SSO callback redirected back with. useEffect(() => { if (errorParam) setError(errorParam); @@ -217,6 +263,7 @@ function LoginForm() { setAuthToken(result.accessToken); try { const trialResult = await license.activateTrial(result.accessToken); + if (offerCardTrial(result.user, trialResult)) return; setTrialDaysLeft(trialResult.trialDaysLeft); setSetupStep('trial-activated'); } catch { @@ -252,6 +299,7 @@ function LoginForm() { // Cloud mode: auto-activate trial try { const trialResult = await license.activateTrial(authToken); + if (offerCardTrial(storedUser, trialResult)) return; setTrialDaysLeft(trialResult.trialDaysLeft); setSetupStep('trial-activated'); } catch (trialErr: any) { diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx index aafb4d46..cab128b2 100644 --- a/packages/frontend/src/app/settings/license/page.tsx +++ b/packages/frontend/src/app/settings/license/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; +import Link from 'next/link'; import { useAuth } from '@/lib/auth-context'; import { license } from '@/lib/api'; import { usePricingUrl } from '@/lib/use-pricing-url'; @@ -10,6 +11,7 @@ import { Card } from '@/components/ui/card'; import { cn } from '@/lib/utils'; import { useEdition, notifyEditionChanged } from '@/lib/use-edition'; import { EditionCard } from '@/components/edition-card'; +import { cardTrialDisplayEnd, cardTrialEligible, formatTrialEnd } from '@/lib/card-trial'; interface LicenseStatus { plan: string | null; @@ -42,6 +44,11 @@ export default function LicenseSettingsPage() { !!status?.plan && status.plan !== 'trial' && status.plan !== 'community'; + // Cloud admin on a running free trial: offer the card trial (/start-trial). + const cardTrialOffer = cardTrialEligible({ isCloud, role: user?.role, license: status }); + const cardTrialDate = cardTrialOffer + ? formatTrialEnd(cardTrialDisplayEnd(status?.expiresAt), 'long') + : null; const loadStatus = async () => { try { @@ -348,17 +355,41 @@ export default function LicenseSettingsPage() { {isCloud && status?.plan === 'trial' && (

Upgrade Plan

-

- Upgrade to a paid plan to continue using AnythingMCP Cloud after your trial ends. -

- - View Plans - + {cardTrialOffer ? ( + <> +

+ Add a payment method to keep AnythingMCP Cloud running after your trial. Nothing is + charged before {cardTrialDate ?? 'your trial ends'}, and you can cancel anytime. +

+
+ + Add payment method + + + Compare plans + +
+ + ) : ( + <> +

+ Upgrade to a paid plan to continue using AnythingMCP Cloud after your trial ends. +

+ + View Plans + + + )}
)} diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx new file mode 100644 index 00000000..6b8c0c52 --- /dev/null +++ b/packages/frontend/src/app/start-trial/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; +import { license } from '@/lib/api'; +import { LogoIcon } from '@/components/logo-icon'; +import { PlanPicker } from '@/components/plan-picker'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { + DEFAULT_SELECTION, + cardTrialDisplayEnd, + cardTrialEligible, + formatTrialEnd, + planById, + readPlanIntent, + writeCardTrialPrompt, + type PlanSelection, +} from '@/lib/card-trial'; +import { useCardCheckout } from '@/lib/use-card-checkout'; + +/** + * Cloud only: the card-trial offer shown once after sign-up (see + * OnboardingRedirect). "Start free trial" opens Stripe Checkout with a trial + * that ends when the free trial would have; nothing is charged today. The + * no-card trial the user already has stays one small link away, in the + * corner, on purpose. + * + * Anyone it does not apply to (self-hosted, non-admins, no live trial) is + * sent to the dashboard. + */ +export default function StartTrialPage() { + const { token, user, isLoading, deploymentMode, deploymentModeLoaded } = useAuth(); + const router = useRouter(); + const checkout = useCardCheckout(); + const [trialEnd, setTrialEnd] = useState(null); + const [ready, setReady] = useState(false); + const [selection, setSelection] = useState(DEFAULT_SELECTION); + + useEffect(() => { + if (isLoading || !deploymentModeLoaded) return; + if (!token || !user) { + router.replace('/login?redirect=/start-trial'); + return; + } + if (deploymentMode !== 'cloud' || user.role !== 'ADMIN') { + router.replace('/'); + return; + } + let live = true; + license + .getStatus(token) + .then((lic) => { + if (!live) return; + if (!cardTrialEligible({ isCloud: true, role: user.role, license: lic })) { + router.replace('/'); + return; + } + setTrialEnd(cardTrialDisplayEnd(lic.expiresAt)); + setSelection(readPlanIntent() ?? DEFAULT_SELECTION); + setReady(true); + }) + .catch(() => { + if (live) router.replace('/'); + }); + return () => { + live = false; + }; + }, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router]); + + const handleStart = async () => { + if (!user) return; + const ok = await checkout.start(selection, true); + if (ok) writeCardTrialPrompt(user.id, 'checkout'); + }; + + const handleSkip = () => { + if (user) writeCardTrialPrompt(user.id, 'skipped'); + // The dashboard decides what comes next (usually the /welcome wizard). + router.replace('/'); + }; + + if (!ready) { + return ( +
+
Loading…
+
+ ); + } + + const endLong = formatTrialEnd(trialEnd, 'long'); + const plan = planById(selection.plan); + + return ( +
+
+
+ + + + + AnythingMCP + +
+ {/* Deliberately quiet: a plain text link in the corner, not a button. */} + +
+ +
+
+

+ 7-day free trial +

+

+ Start your 7-day free trial +

+

+ €0 today.{' '} + {endLong ? ( + <> + Your plan starts on {endLong} unless you cancel. + + ) : ( + 'Your plan starts when the trial ends unless you cancel.' + )}{' '} + Cancel anytime. +

+
+ + + + +
+ + {checkout.error && ( +

+ {checkout.error} +

+ )} +

+ {plan.name}, billed {selection.period === 'yearly' ? 'yearly' : 'monthly'} from{' '} + {endLong ?? 'the end of your trial'}. Secure checkout by Stripe. Cancel before then and + you pay nothing. +

+
+
+
+
+ ); +} diff --git a/packages/frontend/src/components/license-wall.tsx b/packages/frontend/src/components/license-wall.tsx index b2d20051..3ff59d26 100644 --- a/packages/frontend/src/components/license-wall.tsx +++ b/packages/frontend/src/components/license-wall.tsx @@ -7,6 +7,9 @@ import { license } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; import { usePricingUrl } from '@/lib/use-pricing-url'; import { useManagePlan } from '@/lib/use-manage-plan'; +import { useCardCheckout } from '@/lib/use-card-checkout'; +import { DEFAULT_SELECTION, planById, readPlanIntent, type PlanSelection } from '@/lib/card-trial'; +import { PlanPicker } from '@/components/plan-picker'; import { LogoIcon } from '@/components/logo-icon'; import { buttonVariants } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -17,7 +20,7 @@ import { cn } from '@/lib/utils'; type BlockReason = 'no-license' | 'trial-ended' | 'expired' | 'lapsed'; export function LicenseWall() { - const { token, deploymentMode } = useAuth(); + const { token, user, deploymentMode } = useAuth(); const managePlan = useManagePlan(); const pricingUrl = usePricingUrl(); const [reason, setReason] = useState(null); @@ -25,6 +28,16 @@ export function LicenseWall() { const [startErr, setStartErr] = useState(null); const pathname = usePathname(); const isCloud = deploymentMode === 'cloud'; + const checkout = useCardCheckout(); + const [selection, setSelection] = useState(DEFAULT_SELECTION); + // Cloud admins whose trial ended pick a plan and pay right here (Stripe + // Checkout, no trial left). Other roles cannot subscribe the workspace and + // keep the pricing link. + const buyInPlace = isCloud && reason === 'trial-ended' && user?.role === 'ADMIN'; + + useEffect(() => { + if (buyInPlace) setSelection(readPlanIntent() ?? DEFAULT_SELECTION); + }, [buyInPlace]); // Start the trial in place (no navigation) so a failed auto-activation on // signup doesn't strand the user on this wall. On success the block clears. @@ -120,7 +133,7 @@ export function LicenseWall() { return (
-
+
@@ -151,6 +164,28 @@ export function LicenseWall() { View Plans & Purchase License + ) : buyInPlace ? ( + <> +
+ +
+ + {checkout.error &&

{checkout.error}

} + + Compare plans + + ) : reason === 'lapsed' ? ( { if (isLoading || !token || !user) return; + // Both gates below depend on the deployment mode, which is only a guess + // ('self-hosted') until /health/server-info answers. Evaluating on the + // guess recorded the path as done and never looked again with the real + // mode, so a cloud admin could miss the card-trial offer. + if (!deploymentModeLoaded) return; if (isExcluded(pathname)) return; // Dedupe per (path, userId) to avoid hammering the API on every render. @@ -93,6 +104,22 @@ export function OnboardingRedirect() { const licenseBlocking = isCloud && (noPlan || trialEnded || expired); if (licenseBlocking) return; + // The card-trial offer, once, before the wizard. Marked as shown + // before navigating so a failure on that page can never loop back. + if ( + isCloud && + pathname === '/' && + cardTrialEligible({ isCloud, role: me?.role ?? user.role, license: lic }) && + readCardTrialPrompt(user.id) === null + ) { + writeCardTrialPrompt(user.id, 'shown'); + // Coming back to the dashboard must be evaluated afresh (for the + // wizard), not skipped as already seen. + lastRun.current = null; + router.replace('/start-trial'); + return; + } + const hasConnector = (connList?.length ?? 0) > 0; const wizardDone = onboarding.onboardingCompletedAt !== null; @@ -119,7 +146,7 @@ export function OnboardingRedirect() { return () => { cancelled = true; }; - }, [isLoading, token, user, pathname, deploymentMode, router]); + }, [isLoading, token, user, pathname, deploymentMode, deploymentModeLoaded, router]); return null; } diff --git a/packages/frontend/src/components/plan-picker.tsx b/packages/frontend/src/components/plan-picker.tsx new file mode 100644 index 00000000..cc18df1e --- /dev/null +++ b/packages/frontend/src/components/plan-picker.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { + CLOUD_PLANS, + formatEur, + formatPlanPrice, + yearlyPerMonth, + type BillingPeriod, + type PlanSelection, +} from '@/lib/card-trial'; +import { cn } from '@/lib/utils'; + +/** Monthly / yearly switch. Yearly is ten months' price for twelve. */ +function PeriodToggle({ + value, + onChange, + disabled, +}: { + value: BillingPeriod; + onChange: (p: BillingPeriod) => void; + disabled?: boolean; +}) { + const option = (p: BillingPeriod, label: React.ReactNode) => ( + + ); + return ( +
+ {option('monthly', 'Monthly')} + {option( + 'yearly', + <> + Yearly · 2 months free + , + )} +
+ ); +} + +/** + * The three Cloud plans with a billing-period switch, as a radio group. + * `compact` lays the plans out as rows (for the licence wall's narrow modal). + */ +export function PlanPicker({ + value, + onChange, + compact = false, + disabled = false, +}: { + value: PlanSelection; + onChange: (next: PlanSelection) => void; + compact?: boolean; + disabled?: boolean; +}) { + return ( +
+
+ onChange({ ...value, period })} + disabled={disabled} + /> +
+ +
+ {CLOUD_PLANS.map((plan) => { + const selected = value.plan === plan.id; + return ( + + ); + })} +
+

Prices in EUR, VAT included.

+
+ ); +} diff --git a/packages/frontend/src/components/trial-banner.tsx b/packages/frontend/src/components/trial-banner.tsx index bfd0d9cf..14866e43 100644 --- a/packages/frontend/src/components/trial-banner.tsx +++ b/packages/frontend/src/components/trial-banner.tsx @@ -1,21 +1,35 @@ 'use client'; import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { license } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; import { usePricingUrl } from '@/lib/use-pricing-url'; +import { useCardCheckout } from '@/lib/use-card-checkout'; +import { + cardTrialDisplayEnd, + cardTrialEligible, + formatTrialEnd, + readPlanIntent, +} from '@/lib/card-trial'; export function TrialBanner() { - const { token } = useAuth(); + const { token, user, deploymentMode } = useAuth(); + const router = useRouter(); const pricingUrl = usePricingUrl(); + const checkout = useCardCheckout(); const [daysLeft, setDaysLeft] = useState(null); const [plan, setPlan] = useState(null); + const [status, setStatus] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); const [connectors, setConnectors] = useState(null); useEffect(() => { if (!token) return; license.getStatus(token).then((status) => { setPlan(status.plan); + setStatus(status.status); + setExpiresAt(status.expiresAt); if (status.trialDaysLeft !== undefined) { setDaysLeft(status.trialDaysLeft); } @@ -47,6 +61,28 @@ export function TrialBanner() { ? ` Keep your ${connectors} connector${connectors === 1 ? '' : 's'} running —` : ''; + // Cloud admins on a running trial add a card instead (the card trial ends + // with the free trial, nothing is charged before). Everyone else, and + // self-hosted, keeps the pricing link. + const offerCard = cardTrialEligible({ + isCloud: deploymentMode === 'cloud', + role: user?.role, + license: { plan, status: status ?? '', expiresAt, trialDaysLeft: daysLeft }, + }); + const chargeDate = formatTrialEnd(cardTrialDisplayEnd(expiresAt), 'short'); + + const addPaymentMethod = async () => { + // With a plan picked on the pricing page, straight to Checkout; + // otherwise to the plan picker first. + const intent = readPlanIntent(); + if (!intent) { + router.push('/start-trial'); + return; + } + const ok = await checkout.start(intent, true); + if (!ok) window.location.assign(pricingUrl); + }; + return (
{countdown}{value} {' '} - - Upgrade now - + {offerCard ? ( + + ) : ( + + Upgrade now + + )}
); } diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index c03baf5a..ccbf125d 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1221,6 +1221,20 @@ export const license = { body: { returnUrl }, token, }), + /** + * Cloud only (ADMIN): a one-time Stripe Checkout URL for a plan. `trial` + * asks for a card trial ending with the free trial; after the trial it is + * a plain purchase. The buyer and workspace come from the session. + */ + checkoutLink: ( + token: string, + body: { plan: 'starter' | 'team' | 'business'; billingPeriod: 'monthly' | 'yearly'; trial: boolean }, + ) => + request<{ url: string }>('/api/license/checkout-link', { + method: 'POST', + body, + token, + }), getInstanceId: () => request<{ instanceId: string }>('/api/license/instance-id'), getUsage: (token?: string) => diff --git a/packages/frontend/src/lib/card-trial.ts b/packages/frontend/src/lib/card-trial.ts new file mode 100644 index 00000000..7100f968 --- /dev/null +++ b/packages/frontend/src/lib/card-trial.ts @@ -0,0 +1,224 @@ +import { storage } from './storage'; + +/** + * The card trial on AnythingMCP Cloud: right after sign-up an admin is offered + * Stripe Checkout with a 7-day trial (card required, nothing charged today), + * ending when their free trial would have. Everything here is Cloud only; + * self-hosted builds never call it. + * + * Pure helpers live here so they can be tested without a browser; the pieces + * that touch storage go through `storage`, which never throws. + */ + +export type CloudPlanId = 'starter' | 'team' | 'business'; +export type BillingPeriod = 'monthly' | 'yearly'; + +export interface PlanSelection { + plan: CloudPlanId; + period: BillingPeriod; +} + +export interface CloudPlan { + id: CloudPlanId; + name: string; + /** EUR incl. VAT per month, on monthly billing. */ + monthly: number; + /** EUR incl. VAT per year, on yearly billing. */ + yearly: number; + summary: string; + popular?: boolean; +} + +/** + * Mirrors anythingmcp.com/pricing and the live Stripe prices (Cloud plans, + * EUR incl. VAT). Keep in step with the marketing site when a price changes: + * the checkout charges what Stripe says, this only describes it. + */ +export const CLOUD_PLANS: readonly CloudPlan[] = [ + { + id: 'starter', + name: 'Starter', + monthly: 19, + yearly: 190, + summary: '5 connectors · 3 MCP servers · 1 user', + }, + { + id: 'team', + name: 'Team', + monthly: 49, + yearly: 490, + summary: '15 connectors · 10 MCP servers · up to 3 users', + popular: true, + }, + { + id: 'business', + name: 'Business', + monthly: 99, + yearly: 990, + summary: 'Unlimited connectors and MCP servers · up to 10 users', + }, +]; + +export const DEFAULT_SELECTION: PlanSelection = { plan: 'team', period: 'monthly' }; + +export function planById(id: CloudPlanId): CloudPlan { + return CLOUD_PLANS.find((p) => p.id === id) ?? CLOUD_PLANS[1]; +} + +/** "19 €", "15.83 €" (non-breaking space, as on the invoice). */ +export function formatEur(amount: number): string { + const text = Number.isInteger(amount) ? String(amount) : amount.toFixed(2); + return `${text} €`; +} + +/** The price line for a plan: "49 €/month" or "490 €/year". */ +export function formatPlanPrice(plan: CloudPlan, period: BillingPeriod): string { + return period === 'yearly' ? `${formatEur(plan.yearly)}/year` : `${formatEur(plan.monthly)}/month`; +} + +/** The monthly equivalent of a yearly price, rounded to the cent: 190 → 15.83. */ +export function yearlyPerMonth(plan: CloudPlan): number { + return Math.round((plan.yearly / 12) * 100) / 100; +} + +/** What yearly billing saves over twelve months of monthly billing. */ +export function yearlySaving(plan: CloudPlan): number { + return plan.monthly * 12 - plan.yearly; +} + +// ── Plan intent from the pricing page ─────────────────────────────────────── + +/** + * The pricing page opens sign-up as `?mode=register&plan=cloud_team&period=yearly`. + * Accepts `cloud_` or the bare tier; anything else is no intent at all. + * A missing or unknown period falls back to monthly. + */ +export function parsePlanIntent( + plan: string | null | undefined, + period: string | null | undefined, +): PlanSelection | null { + if (!plan) return null; + const tier = plan.trim().toLowerCase().replace(/^cloud_/, ''); + if (tier !== 'starter' && tier !== 'team' && tier !== 'business') return null; + const p = (period ?? '').trim().toLowerCase(); + return { plan: tier, period: p === 'yearly' || p === 'annual' ? 'yearly' : 'monthly' }; +} + +const INTENT_KEY = 'amcp_plan_intent'; +/** + * Long enough to survive email verification, which may happen days later and + * in another tab (hence localStorage, not sessionStorage). + */ +export const INTENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export function encodePlanIntent(sel: PlanSelection, now: number = Date.now()): string { + return JSON.stringify({ plan: sel.plan, period: sel.period, savedAt: now }); +} + +/** A stored intent, or null when missing, malformed or older than the TTL. */ +export function decodePlanIntent(raw: string | null, now: number = Date.now()): PlanSelection | null { + if (!raw) return null; + try { + const data = JSON.parse(raw); + if (!data || typeof data !== 'object') return null; + const savedAt = Number(data.savedAt); + if (!Number.isFinite(savedAt) || savedAt > now || now - savedAt > INTENT_TTL_MS) return null; + return parsePlanIntent(String(data.plan ?? ''), String(data.period ?? '')); + } catch { + return null; + } +} + +export function savePlanIntent(sel: PlanSelection): void { + storage.set(INTENT_KEY, encodePlanIntent(sel)); +} + +export function readPlanIntent(): PlanSelection | null { + const raw = storage.get(INTENT_KEY); + const sel = decodePlanIntent(raw); + if (raw && !sel) storage.remove(INTENT_KEY); + return sel; +} + +// ── Eligibility and the one-time prompt ───────────────────────────────────── + +export interface TrialStatusLike { + plan: string | null; + status: string; + expiresAt?: string | null; + trialDaysLeft?: number; +} + +/** + * Whether the card trial is on offer: Cloud, an admin, and a free trial that + * is still running. Other roles cannot start a subscription, and after the + * trial there is nothing left to trial (the licence wall sells the plan). + */ +export function cardTrialEligible(input: { + isCloud: boolean; + role: string | null | undefined; + license: TrialStatusLike | null | undefined; + now?: number; +}): boolean { + const { isCloud, role, license } = input; + if (!isCloud || role !== 'ADMIN' || !license) return false; + if (license.plan !== 'trial' || license.status !== 'active') return false; + if (license.expiresAt) { + const end = new Date(license.expiresAt).getTime(); + return Number.isFinite(end) && end > (input.now ?? Date.now()); + } + return typeof license.trialDaysLeft === 'number' && license.trialDaysLeft > 0; +} + +/** 'shown': sent to /start-trial once; 'skipped' / 'checkout': chose. */ +export type CardTrialPrompt = 'shown' | 'skipped' | 'checkout'; + +export function cardTrialPromptKey(userId: string): string { + return `amcp_card_trial_prompt:${userId}`; +} + +export function readCardTrialPrompt(userId: string): CardTrialPrompt | null { + const v = storage.get(cardTrialPromptKey(userId)); + return v === 'shown' || v === 'skipped' || v === 'checkout' ? v : null; +} + +export function writeCardTrialPrompt(userId: string, value: CardTrialPrompt): void { + storage.set(cardTrialPromptKey(userId), value); +} + +/** + * Stripe wants a trial to end at least 48 hours out, so the licence site moves + * a sooner end to 49 hours from now. Mirrored here so the date we promise is + * the date Checkout shows. Null when there is no usable end. + */ +export const MIN_CARD_TRIAL_MS = 49 * 60 * 60 * 1000; + +export function cardTrialDisplayEnd( + expiresAt: string | null | undefined, + now: number = Date.now(), +): string | null { + if (!expiresAt) return null; + const end = new Date(expiresAt).getTime(); + if (!Number.isFinite(end)) return null; + return new Date(Math.max(end, now + MIN_CARD_TRIAL_MS)).toISOString(); +} + +/** + * The trial end as shown to the user: "7 October 2026" (long) or "7 Oct" + * (short), in their own time zone. English, like the rest of the UI. + */ +export function formatTrialEnd( + iso: string | null | undefined, + style: 'long' | 'short' = 'long', + timeZone?: string, +): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return d.toLocaleDateString('en-GB', { + day: 'numeric', + month: style === 'long' ? 'long' : 'short', + ...(style === 'long' && { year: 'numeric' }), + ...(timeZone && { timeZone }), + }); +} diff --git a/packages/frontend/src/lib/use-card-checkout.ts b/packages/frontend/src/lib/use-card-checkout.ts new file mode 100644 index 00000000..90772d11 --- /dev/null +++ b/packages/frontend/src/lib/use-card-checkout.ts @@ -0,0 +1,47 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { ApiError, license } from './api'; +import { useAuth } from './auth-context'; +import type { PlanSelection } from './card-trial'; + +const GENERIC_ERROR = 'We could not open the checkout. Please try again in a moment.'; + +/** + * Opens Stripe Checkout for a Cloud plan (POST /api/license/checkout-link), + * then leaves the page for it. `trial: true` is the card trial that ends with + * the free trial; `false` is a plain purchase. Resolves false on failure so a + * caller can fall back (e.g. to the pricing page); `error` holds a message + * fit to show. + */ +export function useCardCheckout() { + const { token } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const start = useCallback( + async (selection: PlanSelection, trial: boolean): Promise => { + if (!token) return false; + setLoading(true); + setError(null); + try { + const { url } = await license.checkoutLink(token, { + plan: selection.plan, + billingPeriod: selection.period, + trial, + }); + // Stays "loading" on purpose: the page is being left. + window.location.assign(url); + return true; + } catch (err) { + // A 409 says why (the workspace already pays); anything else is ours. + setError(err instanceof ApiError && err.status === 409 ? err.message : GENERIC_ERROR); + setLoading(false); + return false; + } + }, + [token], + ); + + return { start, loading, error }; +} diff --git a/packages/frontend/tests/e2e/card-trial.spec.ts b/packages/frontend/tests/e2e/card-trial.spec.ts new file mode 100644 index 00000000..b60894a8 --- /dev/null +++ b/packages/frontend/tests/e2e/card-trial.spec.ts @@ -0,0 +1,265 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + CLOUD_PLANS, + INTENT_TTL_MS, + cardTrialDisplayEnd, + cardTrialEligible, + decodePlanIntent, + encodePlanIntent, + formatPlanPrice, + formatTrialEnd, + parsePlanIntent, + yearlyPerMonth, + yearlySaving, +} from '../../src/lib/card-trial'; + +/** + * The card trial on AnythingMCP Cloud: right after sign-up an admin on a + * running free trial is offered Stripe Checkout with a trial (nothing charged + * today), once, with a quiet "Continue without payment details" link in the + * corner that keeps the no-card trial. + * + * The first block tests the pure helpers (no browser). The rest uses the same + * fake session as the other specs: seed the cookie and localStorage, stub + * /api/* and /health/*. + */ + +test.describe('card-trial helpers', () => { + test('reads the plan the pricing page sends', () => { + expect(parsePlanIntent('cloud_team', 'yearly')).toEqual({ plan: 'team', period: 'yearly' }); + expect(parsePlanIntent('cloud_starter', null)).toEqual({ plan: 'starter', period: 'monthly' }); + expect(parsePlanIntent('business', 'weekly')).toEqual({ plan: 'business', period: 'monthly' }); + expect(parsePlanIntent('cloud_enterprise', 'monthly')).toBeNull(); + expect(parsePlanIntent(null, 'yearly')).toBeNull(); + }); + + test('keeps a stored intent for seven days and no longer', () => { + const now = Date.UTC(2026, 9, 1); + const raw = encodePlanIntent({ plan: 'business', period: 'yearly' }, now); + expect(decodePlanIntent(raw, now + INTENT_TTL_MS - 1)).toEqual({ plan: 'business', period: 'yearly' }); + expect(decodePlanIntent(raw, now + INTENT_TTL_MS + 1)).toBeNull(); + expect(decodePlanIntent('not json', now)).toBeNull(); + expect(decodePlanIntent(JSON.stringify({ plan: 'team', period: 'monthly' }), now)).toBeNull(); + expect(decodePlanIntent(null, now)).toBeNull(); + }); + + test('offers the card trial to cloud admins on a running trial only', () => { + const now = Date.UTC(2026, 9, 1); + const trial = { plan: 'trial', status: 'active', expiresAt: new Date(now + 86_400_000).toISOString() }; + expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: trial, now })).toBe(true); + expect(cardTrialEligible({ isCloud: false, role: 'ADMIN', license: trial, now })).toBe(false); + expect(cardTrialEligible({ isCloud: true, role: 'EDITOR', license: trial, now })).toBe(false); + expect( + cardTrialEligible({ isCloud: true, role: 'ADMIN', license: { ...trial, expiresAt: new Date(now - 1).toISOString() }, now }), + ).toBe(false); + expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: { ...trial, plan: 'cloud_team' }, now })).toBe(false); + expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: null, now })).toBe(false); + }); + + test('shows the date Stripe will use, at least 49 hours out', () => { + const now = Date.UTC(2026, 9, 1, 12); + const inFiveDays = new Date(now + 5 * 86_400_000).toISOString(); + expect(cardTrialDisplayEnd(inFiveDays, now)).toBe(inFiveDays); + expect(cardTrialDisplayEnd(new Date(now + 3_600_000).toISOString(), now)).toBe( + new Date(now + 49 * 3_600_000).toISOString(), + ); + expect(cardTrialDisplayEnd(null, now)).toBeNull(); + }); + + test('formats the trial end and the prices', () => { + expect(formatTrialEnd('2026-10-07T09:00:00.000Z', 'long', 'UTC')).toBe('7 October 2026'); + expect(formatTrialEnd('2026-10-07T09:00:00.000Z', 'short', 'UTC')).toBe('7 Oct'); + expect(formatTrialEnd('nonsense')).toBeNull(); + + const [starter, team, business] = CLOUD_PLANS; + expect(formatPlanPrice(team, 'monthly')).toBe('49 €/month'); + expect(formatPlanPrice(starter, 'yearly')).toBe('190 €/year'); + expect(yearlyPerMonth(starter)).toBe(15.83); + expect(yearlyPerMonth(business)).toBe(82.5); + expect(CLOUD_PLANS.map(yearlySaving)).toEqual([38, 98, 198]); + }); +}); + +const ADMIN = { + id: 'u1', + email: 'admin@example.test', + name: 'Ada Admin', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +const TRIAL_END = new Date(Date.now() + 5 * 86_400_000).toISOString(); + +async function cloudSession( + page: Page, + opts: { + user?: typeof ADMIN; + seed?: Record; + license?: Record; + checkout?: (body: unknown) => { status: number; body: unknown }; + } = {}, +) { + const user = opts.user ?? ADMIN; + const posted: unknown[] = []; + const origin = test.info().project.use.baseURL ?? 'http://localhost:3100'; + await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]); + await page.addInitScript( + ({ user, seed }) => { + localStorage.setItem('amcp_token', 't'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + for (const [k, v] of Object.entries(seed)) localStorage.setItem(k, v); + }, + { user, seed: opts.seed ?? {} }, + ); + await page.route(/\/(api|health)\//, async (route) => { + const req = route.request(); + const p = new URL(req.url()).pathname; + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (p === '/health/server-info') { + return json({ deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] }); + } + if (p.endsWith('/license/checkout-link') && req.method() === 'POST') { + const body = req.postDataJSON(); + posted.push(body); + const reply = opts.checkout + ? opts.checkout(body) + : { status: 200, body: { url: 'https://checkout.example.test/start?intent=i1' } }; + return json(reply.body, reply.status); + } + if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: null }); + if (p.endsWith('/users/me')) return json(user); + if (p.endsWith('/license/status')) { + return json( + opts.license ?? { plan: 'trial', status: 'active', expiresAt: TRIAL_END, trialDaysLeft: 5, features: {} }, + ); + } + if (p.endsWith('/license/usage')) { + return json({ plan: 'trial', connectors: { current: 0, max: 2, isOver: false }, mcpServers: { current: 0, max: 2, isOver: false }, users: { current: 1, max: 1, isOver: false }, isOverAny: false }); + } + if (p.endsWith('/adapters/starter-pack')) return json([]); + if (p.endsWith('/connectors') || p.endsWith('/adapters')) return json([]); + return json({}); + }); + await page.route('https://checkout.example.test/**', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: '

Stripe Checkout stub

' }), + ); + return { posted }; +} + +test.describe('card-trial offer (cloud)', () => { + test('a new cloud admin is offered the card trial once, and can skip it quietly', async ({ page }) => { + await cloudSession(page); + await page.goto('/'); + await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Start your 7-day free trial' })).toBeVisible(); + await expect(page.getByText('€0 today.')).toBeVisible(); + // Team is preselected without an intent from the pricing page. + await expect(page.getByRole('radio', { name: /Team/ })).toHaveAttribute('aria-checked', 'true'); + + // The skip is a small link, not a button styled as one. + const skip = page.getByRole('button', { name: 'Continue without payment details' }); + await expect(skip).toHaveClass(/text-xs/); + await skip.click(); + + // On to the normal onboarding, and not offered again. + await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 }); + expect(await page.evaluate(() => localStorage.getItem('amcp_card_trial_prompt:u1'))).toBe('skipped'); + await page.goto('/'); + await expect(page).toHaveURL(/\/welcome$/, { timeout: 15_000 }); + }); + + test('Start free trial opens Checkout for the chosen plan with a trial', async ({ page }) => { + const { posted } = await cloudSession(page, { seed: { 'amcp_card_trial_prompt:u1': 'shown' } }); + await page.goto('/start-trial'); + await page.getByRole('radio', { name: 'Yearly' }).click(); + await page.getByRole('radio', { name: /Starter/ }).click(); + await expect(page.getByText('190 €/year')).toBeVisible(); + await page.getByRole('button', { name: 'Start free trial' }).click(); + + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i1'); + expect(posted).toEqual([{ plan: 'starter', billingPeriod: 'yearly', trial: true }]); + }); + + test('a failed checkout shows a friendly error and can be retried', async ({ page }) => { + let calls = 0; + await cloudSession(page, { + seed: { 'amcp_card_trial_prompt:u1': 'shown' }, + checkout: () => + ++calls === 1 + ? { status: 502, body: { message: 'upstream detail' } } + : { status: 200, body: { url: 'https://checkout.example.test/start?intent=i2' } }, + }); + await page.goto('/start-trial'); + await page.getByRole('button', { name: 'Start free trial' }).click(); + const alert = page.getByRole('alert').filter({ hasText: 'checkout' }); + await expect(alert).toContainText('could not open the checkout'); + await expect(alert).not.toContainText('upstream detail'); + await page.getByRole('button', { name: 'Try again' }).click(); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i2'); + }); + + test('the plan picked on the pricing page survives sign-up and is preselected', async ({ page }) => { + await cloudSession(page, { seed: { 'amcp_card_trial_prompt:u1': 'shown' } }); + // Signed out on the register page first (the cookie is irrelevant there). + await page.goto('/login?mode=register&plan=cloud_business&period=yearly'); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('amcp_plan_intent')), { timeout: 15_000 }) + .toContain('"plan":"business"'); + + await page.goto('/start-trial'); + await expect(page.getByRole('radio', { name: /Business/ })).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('radio', { name: 'Yearly' })).toHaveAttribute('aria-checked', 'true'); + }); + + test('a non-admin is never shown the offer', async ({ page }) => { + await cloudSession(page, { user: { ...ADMIN, role: 'EDITOR' } }); + await page.goto('/start-trial'); + await expect(page).not.toHaveURL(/\/start-trial$/, { timeout: 15_000 }); + }); +}); + +test.describe('card-trial entry points (cloud)', () => { + const SHOWN = { 'amcp_card_trial_prompt:u1': 'shown' }; + + test('the trial banner offers a card and, without a picked plan, opens the picker', async ({ page }) => { + const { posted } = await cloudSession(page, { seed: SHOWN }); + await page.goto('/connectors'); + const cta = page.getByRole('button', { name: /Add a payment method — no charge before/ }); + await expect(cta).toBeVisible({ timeout: 15_000 }); + await cta.click(); + await expect(page).toHaveURL(/\/start-trial$/); + expect(posted).toEqual([]); + }); + + test('the trial banner goes straight to Checkout for the plan picked on the pricing page', async ({ page }) => { + const intent = JSON.stringify({ plan: 'business', period: 'monthly', savedAt: Date.now() }); + const { posted } = await cloudSession(page, { seed: { ...SHOWN, amcp_plan_intent: intent } }); + await page.goto('/connectors'); + await page.getByRole('button', { name: /Add a payment method/ }).click({ timeout: 15_000 }); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i1'); + expect(posted).toEqual([{ plan: 'business', billingPeriod: 'monthly', trial: true }]); + }); + + test('after the trial, an admin buys the chosen plan from the licence wall, without a trial', async ({ page }) => { + const { posted } = await cloudSession(page, { + seed: SHOWN, + license: { plan: 'trial', status: 'active', expiresAt: new Date(Date.now() - 86_400_000).toISOString(), trialDaysLeft: 0 }, + }); + await page.goto('/connectors'); + await expect(page.getByRole('heading', { name: 'Your Trial Has Expired' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('link', { name: 'Compare plans' })).toBeVisible(); + await page.getByRole('radio', { name: /Starter/ }).click(); + await page.getByRole('button', { name: 'Subscribe to Starter' }).click(); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i1'); + expect(posted).toEqual([{ plan: 'starter', billingPeriod: 'monthly', trial: false }]); + }); + + test('a non-admin keeps the plain pricing link in the banner', async ({ page }) => { + await cloudSession(page, { user: { ...ADMIN, role: 'EDITOR' }, seed: SHOWN }); + await page.goto('/connectors'); + await expect(page.getByRole('link', { name: 'Upgrade now' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('button', { name: /Add a payment method/ })).toHaveCount(0); + }); +}); From 6c78112582879d726d34ba0920df409222432c92 Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 15:36:46 +0200 Subject: [PATCH 2/7] Checkout link: a revoked or expired paid licence does not block a new purchase --- .../backend/src/license/license-checkout.spec.ts | 15 +++++++++++++++ .../backend/src/license/license.controller.ts | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts index 28cd28e6..c29b9b08 100644 --- a/packages/backend/src/license/license-checkout.spec.ts +++ b/packages/backend/src/license/license-checkout.spec.ts @@ -322,6 +322,21 @@ describe('LicenseController.checkoutLink', () => { expect(licenseService.createCheckoutIntent).not.toHaveBeenCalled(); }); + it('lets a customer whose paid licence ended subscribe again', async () => { + for (const status of ['revoked', 'expired']) { + const { controller, licenseService } = makeController({ + license: trialLicense({ plan: 'starter', status, expiresAt: null }), + }); + await expect( + controller.checkoutLink(adminReq, { plan: 'starter', billingPeriod: 'monthly', trial: true }), + ).resolves.toEqual({ url: expect.any(String) }); + // No live trial, so no trial end: a returning customer pays now. + expect(licenseService.createCheckoutIntent).toHaveBeenCalledWith( + expect.not.objectContaining({ trialEnd: expect.anything() }), + ); + } + }); + it('maps any licence-site failure to a generic 502', async () => { const { controller } = makeController({ fail: true }); const err = await controller diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index c94ba423..2662d304 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -218,8 +218,10 @@ export class LicenseController { const current = await this.licenseService.getCurrentLicense(organizationId); // A paying workspace changes plan in the billing portal. A second checkout - // would add a second subscription beside the first and bill both. - if (current && current.plan !== 'trial') { + // would add a second subscription beside the first and bill both. A paid + // licence that was revoked or expired (a customer coming back) does not + // block a new purchase. + if (current && current.plan !== 'trial' && current.status === 'active') { throw new ConflictException( 'This workspace already has a subscription. Change your plan in the billing portal.', ); From 4dcaeb62d76ff24f20058c1ec8ba8012c2d0538b Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 15:54:41 +0200 Subject: [PATCH 3/7] Licence status reports an ended trial; banner and activation follow-ups /api/license/status answered plan null for a Cloud workspace whose trial had run out, the same as for one that never had a licence, so the licence wall offered 'Start 7-Day Free Trial' (which the licence site does not grant twice) instead of 'your trial has ended' and the plans. It now reports the latest inactive licence's plan and status, without features; gating is unchanged. The trial banner hides for an ended trial, the activation page reloads fully so the shell drops the trial banner, and LICENSE_API_URL (set by the cloud compose file) overrides the licence site URL. --- .../src/license/license.controller.spec.ts | 51 +++++++++++++++++++ .../backend/src/license/license.controller.ts | 20 ++++++++ .../backend/src/license/license.service.ts | 23 +++++++-- .../backend/src/settings/email.service.ts | 7 +-- .../app/settings/license/activate/page.tsx | 5 +- .../frontend/src/components/trial-banner.tsx | 4 +- 6 files changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/license/license.controller.spec.ts b/packages/backend/src/license/license.controller.spec.ts index 56f3bb95..d215df0c 100644 --- a/packages/backend/src/license/license.controller.spec.ts +++ b/packages/backend/src/license/license.controller.spec.ts @@ -76,3 +76,54 @@ describe('LicenseController — activate-trial is idempotent', () => { expect(result.trialDaysLeft).toBe(0); }); }); + +/** + * A Cloud workspace whose trial ran out used to get `plan: null` from + * /status, the same answer as a workspace that never had a licence. The app + * then offered "Start 7-Day Free Trial" (which the licence site will not grant + * twice) instead of "your trial has ended, choose a plan". + */ +describe('LicenseController — status reports an ended licence', () => { + const req = { headers: { authorization: 'Bearer t' } }; + + function makeController(opts: { active?: any; inactive?: any; cloud?: boolean }) { + const licenseService = { + getCurrentLicense: jest.fn(async () => opts.active ?? null), + getLatestInactiveLicense: jest.fn(async () => opts.inactive ?? null), + }; + const controller = new LicenseController( + licenseService as any, + {} as any, + { verifyToken: () => ({ organizationId: 'org-1' }) } as any, + {} as any, + { isCloud: () => opts.cloud ?? true } as any, + {} as any, + ); + return { controller, licenseService }; + } + + it('reports an expired trial as a trial with no days left, without features', async () => { + const expiresAt = new Date(Date.now() - 3600_000); + const { controller } = makeController({ inactive: { plan: 'trial', status: 'expired', expiresAt } }); + const status: any = await controller.getStatus(req); + expect(status).toMatchObject({ plan: 'trial', status: 'expired', trialDaysLeft: 0, features: null }); + }); + + it('reports a lapsed paid licence with its status and no trial countdown', async () => { + const { controller } = makeController({ inactive: { plan: 'starter', status: 'expired', expiresAt: null } }); + const status: any = await controller.getStatus(req); + expect(status).toMatchObject({ plan: 'starter', status: 'expired', features: null }); + expect(status.trialDaysLeft).toBeUndefined(); + }); + + it('still answers "none" for a workspace that never had a licence', async () => { + const { controller } = makeController({}); + expect(await controller.getStatus(req)).toMatchObject({ plan: null, status: 'none' }); + }); + + it('does not look up inactive licences on self-hosted', async () => { + const { controller, licenseService } = makeController({ cloud: false }); + await controller.getStatus(req); + expect(licenseService.getLatestInactiveLicense).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index 2662d304..27f5bd03 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -94,6 +94,26 @@ export class LicenseController { const license = await this.licenseService.getCurrentLicense(organizationId); if (!license) { + // Cloud: a workspace whose trial ran out (or whose paid licence lapsed) + // has no active licence, but it is not a workspace that never had one. + // Report the latest licence's plan and status so the app can say "your + // trial has ended" and offer the plans, instead of "start a free trial" + // (a trial the licence site will not grant a second time). Reporting + // only: no features, and gating still uses the active licence alone. + const last = + this.deployment.isCloud() && organizationId + ? await this.licenseService.getLatestInactiveLicense(organizationId) + : null; + if (last) { + return { + plan: last.plan, + status: last.status, + features: null, + expiresAt: last.expiresAt, + instanceId: null, + ...(last.plan === 'trial' && { trialDaysLeft: 0 }), + }; + } return { plan: null, status: 'none', features: null, expiresAt: null, instanceId: null }; } diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index da65964c..ea756b9f 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -6,10 +6,11 @@ import { DeploymentService } from '../common/deployment.service'; import { SiteSettingsService } from '../settings/site-settings.service'; import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout'; +// LICENSE_API_URL overrides both defaults (the cloud compose file sets it; +// local end-to-end runs point it at a local licence site). const LICENSE_API_URL = - process.env.NODE_ENV === 'production' - ? 'https://anythingmcp.com' - : 'http://localhost:3100'; + process.env.LICENSE_API_URL?.replace(/\/+$/, '') || + (process.env.NODE_ENV === 'production' ? 'https://anythingmcp.com' : 'http://localhost:3100'); /** * How hard we chase a trial licence before giving up. The licence API is a @@ -692,6 +693,22 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { // ── Get Current License ──────────────────────────────────────────────────── + /** + * The workspace's most recent licence that is no longer active (expired, + * revoked, invalid), for status reporting only — never for gating. Null + * when the workspace has none. + */ + async getLatestInactiveLicense( + organizationId: string, + ): Promise<{ plan: string; status: string; expiresAt: Date | null } | null> { + const license = await this.prisma.license.findFirst({ + where: { organizationId, status: { not: 'active' } }, + orderBy: { createdAt: 'desc' }, + select: { plan: true, status: true, expiresAt: true }, + }); + return license ?? null; + } + async getCurrentLicense(organizationId?: string): Promise { // 1. Per-org: find license directly assigned to this organization if (organizationId) { diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index 7b09c3cc..2a0ffba8 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -6,10 +6,11 @@ import { OrgSettingsService } from './org-settings.service'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; +// LICENSE_API_URL overrides both defaults (the cloud compose file sets it; +// local end-to-end runs point it at a local licence site). const LICENSE_API_URL = - process.env.NODE_ENV === 'production' - ? 'https://anythingmcp.com' - : 'http://localhost:3100'; + process.env.LICENSE_API_URL?.replace(/\/+$/, '') || + (process.env.NODE_ENV === 'production' ? 'https://anythingmcp.com' : 'http://localhost:3100'); @Injectable() export class EmailService { diff --git a/packages/frontend/src/app/settings/license/activate/page.tsx b/packages/frontend/src/app/settings/license/activate/page.tsx index bec9568d..6f616eee 100644 --- a/packages/frontend/src/app/settings/license/activate/page.tsx +++ b/packages/frontend/src/app/settings/license/activate/page.tsx @@ -85,7 +85,10 @@ function LicenseActivateInner() { sessionStore.remove(PENDING_KEY); setPhase('success'); setMessage(res.message || 'License activated successfully.'); - setTimeout(() => router.replace('/settings/license'), 1500); + // A full load rather than a client-side route change: the app shell + // (trial banner, licence wall) read the licence before activation and + // would keep showing the trial until the next reload. + setTimeout(() => window.location.replace('/settings/license'), 1500); }) .catch((err: any) => { setPhase('error'); diff --git a/packages/frontend/src/components/trial-banner.tsx b/packages/frontend/src/components/trial-banner.tsx index 14866e43..701e17f4 100644 --- a/packages/frontend/src/components/trial-banner.tsx +++ b/packages/frontend/src/components/trial-banner.tsx @@ -41,7 +41,9 @@ export function TrialBanner() { .catch(() => {}); }, [token]); - if (plan !== 'trial' || daysLeft === null) return null; + // An ended trial is reported too (status 'expired'); the licence wall + // covers that case, so the countdown banner stays out of the way. + if (plan !== 'trial' || daysLeft === null || status !== 'active') return null; const isUrgent = daysLeft <= 1; const isWarning = daysLeft <= 3; From 7bdeb1ef27b424142360d3908c9f338a30a8b0ed Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 15:58:23 +0200 Subject: [PATCH 4/7] Connect page: live check for the first request; dashboard nudge to connect a client Most workspaces that build a connector never send an MCP request, and the connect page never told them whether what they did in Claude or ChatGPT worked. It now polls the server's activity (new GET /api/mcp-servers/:id/ activity, org-scoped) and turns green the moment the first request lands (product event first_call_seen). The dashboard sends workspaces with connectors but no calls to that page. --- .../src/audit/product-event.service.ts | 6 + .../mcp-servers/mcp-servers.activity.spec.ts | 42 ++++++ .../src/mcp-servers/mcp-servers.controller.ts | 13 ++ .../frontend/src/app/mcp-server/[id]/page.tsx | 2 + packages/frontend/src/app/page.tsx | 29 +++- .../src/components/connection-check.tsx | 133 ++++++++++++++++++ packages/frontend/src/lib/api.ts | 3 + 7 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/mcp-servers/mcp-servers.activity.spec.ts create mode 100644 packages/frontend/src/components/connection-check.tsx diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index 727f3e24..0c628b95 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -24,6 +24,12 @@ export const ProductEvents = { API_KEY_GENERATED: 'api_key_generated', /** Left the post-attach page having copied nothing at all. */ LEFT_WITHOUT_COPY: 'left_page_without_copy', + /** + * Watched the first MCP request arrive live on the connect page (the + * connection check turned green). Answers: does the live check help people + * finish connecting, read against post_attach_viewed. + */ + FIRST_CALL_SEEN: 'first_call_seen', /** Saw the starter pack on /welcome. Read against the next one: how many take it. */ STARTER_PACK_VIEWED: 'starter_pack_viewed', /** Installed connectors from the starter pack. metadata.adapterSlug = comma list. */ diff --git a/packages/backend/src/mcp-servers/mcp-servers.activity.spec.ts b/packages/backend/src/mcp-servers/mcp-servers.activity.spec.ts new file mode 100644 index 00000000..6cedeef2 --- /dev/null +++ b/packages/backend/src/mcp-servers/mcp-servers.activity.spec.ts @@ -0,0 +1,42 @@ +import { NotFoundException } from '@nestjs/common'; +import { McpServersController } from './mcp-servers.controller'; + +/** + * The connect page polls this to see the first request from an AI client + * arrive. It must answer for the caller's own servers only. + */ +describe('McpServersController.activity', () => { + const last = new Date('2026-09-30T10:00:00Z'); + + function makeController(server: any) { + const service = { + findById: jest.fn(async () => server), + usageByServer: jest.fn(async (ids: string[]) => + new Map(ids.map((id) => [id, { calls30d: 3, lastCallAt: last }])), + ), + }; + const controller = new McpServersController(service as any, {} as any, {} as any); + return { controller, service }; + } + + it("returns the server's calls and last call time", async () => { + const { controller } = makeController({ id: 's1', organizationId: 'org-1' }); + const res = await controller.activity({ user: { organizationId: 'org-1' } }, 's1'); + expect(res).toEqual({ calls30d: 3, lastCallAt: last }); + }); + + it("answers 404 for another workspace's server without reading its usage", async () => { + const { controller, service } = makeController({ id: 's2', organizationId: 'org-2' }); + await expect( + controller.activity({ user: { organizationId: 'org-1' } }, 's2'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(service.usageByServer).not.toHaveBeenCalled(); + }); + + it('answers 404 for a server that does not exist', async () => { + const { controller } = makeController(null); + await expect( + controller.activity({ user: { organizationId: 'org-1' } }, 'nope'), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/packages/backend/src/mcp-servers/mcp-servers.controller.ts b/packages/backend/src/mcp-servers/mcp-servers.controller.ts index 6db0c4be..5a6712f2 100644 --- a/packages/backend/src/mcp-servers/mcp-servers.controller.ts +++ b/packages/backend/src/mcp-servers/mcp-servers.controller.ts @@ -149,6 +149,19 @@ export class McpServersController { return server; } + @Get(':id/activity') + @ApiOperation({ + summary: + 'Calls in the last 30 days and the time of the last one. Polled by the connect page to show when the first request from an AI client arrives.', + }) + async activity(@Req() req: any, @Param('id') id: string) { + const server = await this.mcpServersService.findById(id); + if (!server) throw new NotFoundException('MCP server not found'); + this.assertOrgMatch(server, req); + const usage = await this.mcpServersService.usageByServer([id]); + return usage.get(id) ?? { calls30d: 0, lastCallAt: null }; + } + @Put(':id') @ApiOperation({ summary: 'Update MCP server' }) async update(@Req() req: any, @Param('id') id: string, @Body() dto: UpdateMcpServerDto) { diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index f8a01b38..afb5a874 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -9,6 +9,7 @@ import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Badge, StatusPill, type Tone } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import { ConnectionCheck } from '@/components/connection-check'; // Opens claude.ai straight on its "Add custom connector" dialog. Connectors // moved from Settings to Customize → Connectors; the old settings URL now only @@ -640,6 +641,7 @@ export default function McpServerDetailPage() {
{/* Connect a client — sticky right column on desktop, shown first on mobile */}
)} + {/* Connectors built, but no AI client has called them yet: the step + most workspaces never take. Send them to the connect page, which + shows live when the first request arrives. */} + {!dataLoading && stats.connectors > 0 && analytics && !analytics.totalInvocations && !stats.invocations24h && ( +
+
+
Next step: connect your AI client
+
+ Your connectors are ready. Add your MCP server to Claude, ChatGPT or Cursor and make the first call. +
+
+ + + +
+ )} + {/* Stat cards */}
({ kind: 'loading' }); + const sawWaiting = useRef(false); + + useEffect(() => { + let live = true; + let timer: ReturnType | undefined; + const startedAt = Date.now(); + + const check = async () => { + if (!live) return; + // A hidden tab does not need an answer; ask again when it is visible. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + timer = setTimeout(check, POLL_MS); + return; + } + try { + const activity = await mcpServers.activity(serverId, token); + if (!live) return; + if (activity.lastCallAt) { + if (sawWaiting.current) { + setState({ kind: 'arrived', at: activity.lastCallAt }); + productEvents.track('first_call_seen', token, { serverId }); + } else { + setState({ kind: 'active', at: activity.lastCallAt, calls: activity.calls30d }); + } + return; + } + sawWaiting.current = true; + setState({ kind: 'waiting' }); + } catch { + // A failed poll is not news; keep the current state and try again. + } + if (Date.now() - startedAt < POLL_FOR_MS) timer = setTimeout(check, POLL_MS); + }; + + void check(); + return () => { + live = false; + if (timer) clearTimeout(timer); + }; + }, [serverId, token]); + + if (state.kind === 'loading') return null; + + if (state.kind === 'active') { + return ( +
+ + Connected · last request {timeAgo(state.at)} · {state.calls.toLocaleString('en-US')} in 30 days +
+ ); + } + + if (state.kind === 'arrived') { + return ( + +
+ + ✓ + +
+
Connected — your AI client just called this server
+
+ Everything works. Ask it anything your connectors can answer. +
+
+
+
+ ); + } + + return ( + +
+ + + + +
+
Waiting for the first request
+
    +
  1. Pick your AI client under Quick Connect and add this server.
  2. +
  3. Ask it to use one of your tools, e.g. “What can you do with my connectors?”
  4. +
  5. This box turns green as soon as the request arrives.
  6. +
+
+
+
+ ); +} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index ccbf125d..150fefac 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1253,6 +1253,9 @@ export const mcpServers = { request('/api/mcp-servers', { token }), get: (id: string, token: string) => request(`/api/mcp-servers/${id}`, { token }), + /** Calls in the last 30 days and the last one's time (the connect page's live check). */ + activity: (id: string, token: string) => + request<{ calls30d: number; lastCallAt: string | null }>(`/api/mcp-servers/${id}/activity`, { token }), create: (data: { name: string; slug?: string; description?: string; instructions?: string }, token: string) => request('/api/mcp-servers', { method: 'POST', body: data, token }), update: (id: string, data: { name?: string; slug?: string; description?: string; instructions?: string; isActive?: boolean }, token: string) => From dd41f303a1838df1f54059fb90960e886b1c181a Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 16:02:52 +0200 Subject: [PATCH 5/7] Connection check: a labelled live region, not a second role=status on the page --- packages/frontend/src/components/connection-check.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/connection-check.tsx b/packages/frontend/src/components/connection-check.tsx index 8e52579f..56c8efea 100644 --- a/packages/frontend/src/components/connection-check.tsx +++ b/packages/frontend/src/components/connection-check.tsx @@ -92,7 +92,7 @@ export function ConnectionCheck({ serverId, token }: { serverId: string; token: if (state.kind === 'arrived') { return ( - +
+
From b8e3e08e526412bf2a0a8f5516a9cbbcda7e7a24 Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 19:09:24 +0200 Subject: [PATCH 6/7] Sign-in fixes: no 'Trial Activated' for licensed workspaces, clear 'already registered' notice, trial without card made explicit - Cloud login flagged every admin as needing licence setup (it read an instance-wide key cloud never writes), so the app showed 'Trial Activated!' at each sign-in, '0 days' to paying workspaces. Only a workspace with no licence at all needs setup; the client shows the card only for a trial started just now. - Signing up with an address that already has an account (and its password) signs in as before, now with a notice saying so. Only the account's owner can see it, so it reveals nothing. - The trial offer says plainly that the trial also works without a card. - LICENSE_API_URL applies outside production only: in production a URL from the environment would let a self-hosted operator point licence checks at a server of their own. --- .../backend/src/auth/auth.controller.spec.ts | 41 ++++++++++++++++++- packages/backend/src/auth/auth.controller.ts | 22 +++++++--- .../backend/src/license/license.service.ts | 12 ++++-- .../backend/src/settings/email.service.ts | 12 ++++-- packages/frontend/src/app/login/page.tsx | 21 ++++++++++ .../frontend/src/app/start-trial/page.tsx | 29 +++++++------ 6 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/backend/src/auth/auth.controller.spec.ts b/packages/backend/src/auth/auth.controller.spec.ts index 5c78a361..83f2f9fa 100644 --- a/packages/backend/src/auth/auth.controller.spec.ts +++ b/packages/backend/src/auth/auth.controller.spec.ts @@ -23,10 +23,16 @@ function makeController({ mode, accounts = [], openRegistration = true, + orgLicense = null, + orgEndedLicense = null, }: { mode: 'cloud' | 'self-hosted'; accounts?: Account[]; openRegistration?: boolean; + /** The workspace's active licence (cloud), if any. */ + orgLicense?: { plan: string } | null; + /** Its latest ended licence (cloud), if any. */ + orgEndedLicense?: { plan: string; status: string } | null; }) { const users = [...accounts]; const sent: string[] = []; @@ -118,7 +124,10 @@ function makeController({ configService as any, siteSettings as any, organizationsService as any, - {} as any, // licenseService + { + getCurrentLicense: jest.fn(async () => orgLicense), + getLatestInactiveLicense: jest.fn(async () => orgEndedLicense), + } as any, // licenseService {} as any, // securityEvents {} as any, // rolesService {} as any, // recoveryCodes @@ -311,3 +320,33 @@ describe('AuthController — answers that do not reveal accounts', () => { }); }); }); + +/** + * Every cloud admin used to be told they "need licence setup" at each sign-in + * (the check read an instance-wide key that cloud never writes), and the app + * showed "Trial Activated!" to paying workspaces. Only a workspace without any + * licence needs setup now. + */ +describe('AuthController.login — cloud licence setup', () => { + const login = (controller: any) => + controller.login({}, { email: 'taken@example.com', password: 'Correct#Horse1' }); + + it('does not ask a workspace with an active licence to set one up', async () => { + const { controller } = makeController({ mode: 'cloud', accounts: [EXISTING], orgLicense: { plan: 'team' } }); + expect((await login(controller)).needsLicenseSetup).toBeUndefined(); + }); + + it('does not ask a workspace whose trial ended (the licence wall offers the plans)', async () => { + const { controller } = makeController({ + mode: 'cloud', + accounts: [EXISTING], + orgEndedLicense: { plan: 'trial', status: 'expired' }, + }); + expect((await login(controller)).needsLicenseSetup).toBeUndefined(); + }); + + it('asks a workspace that never had a licence', async () => { + const { controller } = makeController({ mode: 'cloud', accounts: [EXISTING] }); + expect((await login(controller)).needsLicenseSetup).toBe(true); + }); +}); diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 0c082584..84f4436a 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -399,12 +399,24 @@ export class AuthController { // Check if ADMIN needs to complete license setup let needsLicenseSetup = false; if (user.role === 'ADMIN') { - const licenseKey = await this.siteSettings.get('license_key'); - // Self-hosted: once the Business trial has been started the choice has - // been made, and the chooser would only offer a trial that cannot start. const isCloud = this.configService.get('DEPLOYMENT_MODE') === 'cloud'; - if (!licenseKey && (isCloud || (await this.edition.getState()).trialAvailable)) { - needsLicenseSetup = true; + if (isCloud) { + // Cloud licences belong to the workspace; the instance-wide + // `license_key` setting is never written there. Reading it made every + // cloud admin "need licence setup" at every sign-in, and the app then + // showed "Trial Activated!" to paying customers ("0 days") and again + // to users mid-trial. Only a workspace with no licence at all needs it. + const orgId = user.organizationId ?? undefined; + const current = orgId ? await this.licenseService.getCurrentLicense(orgId) : null; + const ended = !current && orgId ? await this.licenseService.getLatestInactiveLicense(orgId) : null; + needsLicenseSetup = !current && !ended; + } else { + const licenseKey = await this.siteSettings.get('license_key'); + // Self-hosted: once the Business trial has been started the choice has + // been made, and the chooser would only offer a trial that cannot start. + if (!licenseKey && (await this.edition.getState()).trialAvailable) { + needsLicenseSetup = true; + } } } diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index ea756b9f..473ad54c 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -6,11 +6,15 @@ import { DeploymentService } from '../common/deployment.service'; import { SiteSettingsService } from '../settings/site-settings.service'; import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout'; -// LICENSE_API_URL overrides both defaults (the cloud compose file sets it; -// local end-to-end runs point it at a local licence site). +// Production always talks to anythingmcp.com. The licence site decides +// which plan an installation runs; a URL taken from the environment would let +// any self-hosted operator point verification at a server of their own and +// unlock paid features. LICENSE_API_URL applies outside production only (local +// end-to-end runs against a local licence site). const LICENSE_API_URL = - process.env.LICENSE_API_URL?.replace(/\/+$/, '') || - (process.env.NODE_ENV === 'production' ? 'https://anythingmcp.com' : 'http://localhost:3100'); + process.env.NODE_ENV === 'production' + ? 'https://anythingmcp.com' + : process.env.LICENSE_API_URL?.replace(/\/+$/, '') || 'http://localhost:3100'; /** * How hard we chase a trial licence before giving up. The licence API is a diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index 2a0ffba8..c1f113bd 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -6,11 +6,15 @@ import { OrgSettingsService } from './org-settings.service'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; -// LICENSE_API_URL overrides both defaults (the cloud compose file sets it; -// local end-to-end runs point it at a local licence site). +// Production always talks to anythingmcp.com. The licence site decides +// which plan an installation runs; a URL taken from the environment would let +// any self-hosted operator point verification at a server of their own and +// unlock paid features. LICENSE_API_URL applies outside production only (local +// end-to-end runs against a local licence site). const LICENSE_API_URL = - process.env.LICENSE_API_URL?.replace(/\/+$/, '') || - (process.env.NODE_ENV === 'production' ? 'https://anythingmcp.com' : 'http://localhost:3100'); + process.env.NODE_ENV === 'production' + ? 'https://anythingmcp.com' + : process.env.LICENSE_API_URL?.replace(/\/+$/, '') || 'http://localhost:3100'; @Injectable() export class EmailService { diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx index 35ca1c21..baa371c3 100644 --- a/packages/frontend/src/app/login/page.tsx +++ b/packages/frontend/src/app/login/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { ApiError, auth, license, server, sso, type SsoProviderButton, recoveryCodes as recoveryApi } from '@/lib/api'; import { useAuth } from '@/lib/auth-context'; +import { useToast } from '@/components/toast'; import { buildPricingUrl } from '@/lib/marketing'; import { LogoIcon } from '@/components/logo-icon'; import { ProviderMark } from '@/components/provider-mark'; @@ -74,6 +75,7 @@ function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const { login } = useAuth(); + const toast = useToast(); const redirectTo = safeRedirect(searchParams.get('redirect')); const emailVerifiedParam = searchParams.get('emailVerified'); @@ -234,6 +236,18 @@ function LoginForm() { const loginResult = await auth.login(email, password); result = loginResult; needsLicenseSetup = !!loginResult.needsLicenseSetup; + // A verified account can only be one that existed before this + // sign-up: say so, rather than silently landing in a workspace + // the person thought they were creating. Only someone who typed + // the account's password sees this, so it reveals nothing. + if (loginResult.user?.emailVerified) { + toast.show({ + title: 'You already have an account', + description: 'This address was already registered, so we signed you in to your existing workspace.', + tone: 'info', + durationMs: 9000, + }); + } } catch { setUserEmail(email); setSetupStep('check-inbox'); @@ -264,6 +278,13 @@ function LoginForm() { try { const trialResult = await license.activateTrial(result.accessToken); if (offerCardTrial(result.user, trialResult)) return; + // "Trial Activated!" only for a trial started just now. A workspace + // that already holds a licence (a running trial, a paid plan) goes + // straight in, instead of being told it has "0 days" left. + if (!trialResult.trialStarted) { + router.push(redirectTo); + return; + } setTrialDaysLeft(trialResult.trialDaysLeft); setSetupStep('trial-activated'); } catch { diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx index 6b8c0c52..a507bd7e 100644 --- a/packages/frontend/src/app/start-trial/page.tsx +++ b/packages/frontend/src/app/start-trial/page.tsx @@ -119,18 +119,13 @@ export default function StartTrialPage() { 7-day free trial

- Start your 7-day free trial + Your 7-day free trial

- €0 today.{' '} - {endLong ? ( - <> - Your plan starts on {endLong} unless you cancel. - - ) : ( - 'Your plan starts when the trial ends unless you cancel.' - )}{' '} - Cancel anytime. + Every feature, free for 7 days. Add a card now and your plan simply carries on after the + trial, with nothing charged before{' '} + {endLong ? {endLong} : 'the trial ends'}. Or + try it without payment details and decide later.

@@ -144,7 +139,7 @@ export default function StartTrialPage() { onClick={handleStart} disabled={checkout.loading} > - {checkout.loading ? 'Opening checkout…' : checkout.error ? 'Try again' : 'Start free trial'} + {checkout.loading ? 'Opening checkout…' : checkout.error ? 'Try again' : 'Start free trial with card'} {checkout.error && (

@@ -152,10 +147,20 @@ export default function StartTrialPage() {

)}

- {plan.name}, billed {selection.period === 'yearly' ? 'yearly' : 'monthly'} from{' '} + €0 today. {plan.name}, billed {selection.period === 'yearly' ? 'yearly' : 'monthly'} from{' '} {endLong ?? 'the end of your trial'}. Secure checkout by Stripe. Cancel before then and you pay nothing.

+

+ Not ready to add a card?{' '} + +

From eb8c7192e0f752ca87c8eca707f582a5a09022f6 Mon Sep 17 00:00:00 2001 From: keysersoft Date: Wed, 30 Sep 2026 19:19:28 +0200 Subject: [PATCH 7/7] Security fixes from review: email verification enforced, no email change in cloud, card trial never lengthened - EmailVerifiedGuard ran before AuthGuard('jwt'), found no req.user and let every request through: an unverified cloud account could call the whole API (and open a checkout). It now reads the session token itself. Audited on cloud first: none of the 427 unverified accounts has a connector, a call or a subscription. - Cloud: PUT /api/users/me no longer changes the email address (it neither re-verified nor asked for the password, so a stolen session could move an account to another address). The app never did it; self-hosted unchanged. - A card trial needs 48 hours of the free trial left; with less the plan is sold without a trial instead of stretching it to Stripe's minimum. --- .../src/auth/email-verified.guard.spec.ts | 62 +++++++++++++++++++ .../backend/src/auth/email-verified.guard.ts | 27 +++++++- .../src/license/license-checkout.spec.ts | 5 ++ .../backend/src/license/license-checkout.ts | 7 ++- .../backend/src/users/users.controller.ts | 19 +++++- packages/frontend/src/lib/card-trial.ts | 20 +++--- .../frontend/tests/e2e/card-trial.spec.ts | 14 +++-- 7 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 packages/backend/src/auth/email-verified.guard.spec.ts diff --git a/packages/backend/src/auth/email-verified.guard.spec.ts b/packages/backend/src/auth/email-verified.guard.spec.ts new file mode 100644 index 00000000..7c9206fa --- /dev/null +++ b/packages/backend/src/auth/email-verified.guard.spec.ts @@ -0,0 +1,62 @@ +import { ForbiddenException } from '@nestjs/common'; +import { EmailVerifiedGuard } from './email-verified.guard'; + +/** + * Global guards run before the route's AuthGuard('jwt'), so req.user is unset + * here. The guard used to return early on that and checked nothing. + */ +describe('EmailVerifiedGuard', () => { + function makeGuard(opts: { cloud?: boolean; verified?: boolean }) { + const prisma = { + user: { findUnique: jest.fn(async () => ({ emailVerified: opts.verified ?? false })) }, + }; + const auth = { + verifyToken: jest.fn((t: string) => { + if (t === 'good') return { sub: 'u1' }; + throw new Error('invalid'); + }), + }; + const guard = new EmailVerifiedGuard( + { get: () => (opts.cloud === false ? 'self-hosted' : 'cloud') } as any, + prisma as any, + auth as any, + ); + return { guard, prisma }; + } + const ctx = (req: any) => ({ switchToHttp: () => ({ getRequest: () => req }) }) as any; + + it('refuses an unverified cloud user identified only by the bearer token', async () => { + const { guard } = makeGuard({ verified: false }); + await expect( + guard.canActivate(ctx({ path: '/api/mcp-servers', headers: { authorization: 'Bearer good' } })), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('lets a verified user through', async () => { + const { guard } = makeGuard({ verified: true }); + await expect( + guard.canActivate(ctx({ path: '/api/mcp-servers', headers: { authorization: 'Bearer good' } })), + ).resolves.toBe(true); + }); + + it('keeps the verification endpoints open to the unverified user', async () => { + const { guard } = makeGuard({ verified: false }); + await expect( + guard.canActivate(ctx({ path: '/api/auth/verify-email', headers: { authorization: 'Bearer good' } })), + ).resolves.toBe(true); + }); + + it('leaves tokens it cannot read, and anonymous requests, to the route', async () => { + const { guard, prisma } = makeGuard({ verified: false }); + await expect(guard.canActivate(ctx({ path: '/mcp/x', headers: { authorization: 'Bearer other' } }))).resolves.toBe(true); + await expect(guard.canActivate(ctx({ path: '/api/adapters', headers: {} }))).resolves.toBe(true); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it('does nothing on self-hosted', async () => { + const { guard } = makeGuard({ cloud: false, verified: false }); + await expect( + guard.canActivate(ctx({ path: '/api/mcp-servers', headers: { authorization: 'Bearer good' } })), + ).resolves.toBe(true); + }); +}); diff --git a/packages/backend/src/auth/email-verified.guard.ts b/packages/backend/src/auth/email-verified.guard.ts index 26027980..e8887e9f 100644 --- a/packages/backend/src/auth/email-verified.guard.ts +++ b/packages/backend/src/auth/email-verified.guard.ts @@ -1,6 +1,7 @@ import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../common/prisma.service'; +import { AuthService } from './auth.service'; /** * In cloud mode, users must verify their email before they can access any @@ -8,6 +9,11 @@ import { PrismaService } from '../common/prisma.service'; * * Applied globally via APP_GUARD. The allowlist below covers the endpoints * needed to *complete* the verification flow (and to log out). + * + * Global guards run before a route's own AuthGuard('jwt'), so `req.user` is + * not set yet when this one runs. It used to return early on that and never + * checked anything: an unverified cloud account could call every endpoint. + * It now reads the session token itself. */ @Injectable() export class EmailVerifiedGuard implements CanActivate { @@ -27,15 +33,30 @@ export class EmailVerifiedGuard implements CanActivate { constructor( private readonly configService: ConfigService, private readonly prisma: PrismaService, + private readonly authService: AuthService, ) {} + /** The session's user id: from req.user when set, else from the bearer JWT. */ + private userIdOf(req: any): string | undefined { + if (req?.user?.sub) return req.user.sub; + const header = req?.headers?.authorization; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return undefined; + try { + return this.authService.verifyToken(header.slice(7)).sub || undefined; + } catch { + // Not a session token of ours (an MCP OAuth token, an expired JWT): + // the route's own authentication deals with it. + return undefined; + } + } + async canActivate(context: ExecutionContext): Promise { const isCloud = this.configService.get('DEPLOYMENT_MODE') === 'cloud'; if (!isCloud) return true; const req = context.switchToHttp().getRequest(); - const user = req?.user; - if (!user?.sub) return true; + const userId = this.userIdOf(req); + if (!userId) return true; const path: string = req.path || req.url || ''; if (EmailVerifiedGuard.PATH_ALLOWLIST.some((p) => path.startsWith(p))) { @@ -43,7 +64,7 @@ export class EmailVerifiedGuard implements CanActivate { } const dbUser = await this.prisma.user.findUnique({ - where: { id: user.sub }, + where: { id: userId }, select: { emailVerified: true }, }); if (!dbUser?.emailVerified) { diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts index c29b9b08..4a6f257c 100644 --- a/packages/backend/src/license/license-checkout.spec.ts +++ b/packages/backend/src/license/license-checkout.spec.ts @@ -57,6 +57,11 @@ describe('cardTrialEnd', () => { expect(cardTrialEnd(trialLicense({ expiresAt: NOW }), NOW)).toBeNull(); }); + it('answers null (pay now) with under 48 hours of the free trial left, rather than lengthening it', () => { + expect(cardTrialEnd(trialLicense({ expiresAt: new Date(NOW.getTime() + 47 * 3_600_000) }), NOW)).toBeNull(); + expect(cardTrialEnd(trialLicense({ expiresAt: new Date(NOW.getTime() + 49 * 3_600_000) }), NOW)).not.toBeNull(); + }); + it('answers null for anything that is not a live trial', () => { expect(cardTrialEnd(null, NOW)).toBeNull(); expect(cardTrialEnd(trialLicense({ plan: 'cloud_team' }) as any, NOW)).toBeNull(); diff --git a/packages/backend/src/license/license-checkout.ts b/packages/backend/src/license/license-checkout.ts index 0f7cd724..1d530148 100644 --- a/packages/backend/src/license/license-checkout.ts +++ b/packages/backend/src/license/license-checkout.ts @@ -12,6 +12,9 @@ import type { AttributionClickId } from '../audit/signup-attribution'; */ export const CHECKOUT_PLANS = ['starter', 'team', 'business'] as const; + +/** A card trial needs at least this much of the free trial left (Stripe: 48 h). */ +export const CARD_TRIAL_MIN_LEAD_MS = 48 * 60 * 60 * 1000; export type CheckoutPlan = (typeof CHECKOUT_PLANS)[number]; export const CHECKOUT_BILLING_PERIODS = ['monthly', 'yearly'] as const; @@ -44,7 +47,9 @@ export function cardTrialEnd(license: LicenseInfo | null, now: Date = new Date() if (!license || license.plan !== 'trial' || license.status !== 'active') return null; if (!license.expiresAt) return null; const end = new Date(license.expiresAt); - if (Number.isNaN(end.getTime()) || end.getTime() <= now.getTime()) return null; + // Stripe needs a trial end at least 48 hours out; the licence site sells + // without a trial when it is nearer, rather than lengthening the trial. + if (Number.isNaN(end.getTime()) || end.getTime() < now.getTime() + CARD_TRIAL_MIN_LEAD_MS) return null; return end; } diff --git a/packages/backend/src/users/users.controller.ts b/packages/backend/src/users/users.controller.ts index 7addbb79..8d1bccb6 100644 --- a/packages/backend/src/users/users.controller.ts +++ b/packages/backend/src/users/users.controller.ts @@ -155,7 +155,24 @@ export class UsersController { async updateProfile(@Req() req: any, @Body() dto: UpdateProfileDto) { const data: any = {}; if (dto.name) data.name = dto.name; - if (dto.email) data.email = dto.email; + if (dto.email) { + // Cloud: the address is the verified identity (sign-in, password reset, + // billing), and this endpoint neither re-verifies nor asks for the + // password. A stolen session could otherwise move the account to + // another address and take it over through a reset. The app never + // changes it from here; support does, on request. Self-hosted operators + // keep the old behaviour. + if (process.env.DEPLOYMENT_MODE === 'cloud') { + const current = await this.usersService.findById(req.user.sub); + if (dto.email.trim().toLowerCase() !== current?.email?.trim().toLowerCase()) { + throw new BadRequestException( + 'Your email address cannot be changed here. Contact support@anythingmcp.com to change it.', + ); + } + } else { + data.email = dto.email; + } + } const user = await this.usersService.update(req.user.sub, data); const { passwordHash, ...profile } = user; diff --git a/packages/frontend/src/lib/card-trial.ts b/packages/frontend/src/lib/card-trial.ts index 7100f968..ca079af6 100644 --- a/packages/frontend/src/lib/card-trial.ts +++ b/packages/frontend/src/lib/card-trial.ts @@ -163,11 +163,13 @@ export function cardTrialEligible(input: { const { isCloud, role, license } = input; if (!isCloud || role !== 'ADMIN' || !license) return false; if (license.plan !== 'trial' || license.status !== 'active') return false; + // Stripe needs the trial end at least 48 hours out. With less left, the + // trial is not stretched to fit: the regular upgrade (pay now) applies. if (license.expiresAt) { const end = new Date(license.expiresAt).getTime(); - return Number.isFinite(end) && end > (input.now ?? Date.now()); + return Number.isFinite(end) && end >= (input.now ?? Date.now()) + MIN_CARD_TRIAL_MS; } - return typeof license.trialDaysLeft === 'number' && license.trialDaysLeft > 0; + return typeof license.trialDaysLeft === 'number' && license.trialDaysLeft > 2; } /** 'shown': sent to /start-trial once; 'skipped' / 'checkout': chose. */ @@ -187,20 +189,22 @@ export function writeCardTrialPrompt(userId: string, value: CardTrialPrompt): vo } /** - * Stripe wants a trial to end at least 48 hours out, so the licence site moves - * a sooner end to 49 hours from now. Mirrored here so the date we promise is - * the date Checkout shows. Null when there is no usable end. + * Stripe wants a trial to end at least 48 hours out. The card trial ends when + * the free trial does, and is only offered while that is at least this far + * away (the licence site sells without a trial otherwise, rather than + * lengthening it). Mirrors the licence site's rule. */ -export const MIN_CARD_TRIAL_MS = 49 * 60 * 60 * 1000; +export const MIN_CARD_TRIAL_MS = 48 * 60 * 60 * 1000; +/** The date the card trial would end (the free trial's end), or null if none is possible. */ export function cardTrialDisplayEnd( expiresAt: string | null | undefined, now: number = Date.now(), ): string | null { if (!expiresAt) return null; const end = new Date(expiresAt).getTime(); - if (!Number.isFinite(end)) return null; - return new Date(Math.max(end, now + MIN_CARD_TRIAL_MS)).toISOString(); + if (!Number.isFinite(end) || end < now + MIN_CARD_TRIAL_MS) return null; + return new Date(end).toISOString(); } /** diff --git a/packages/frontend/tests/e2e/card-trial.spec.ts b/packages/frontend/tests/e2e/card-trial.spec.ts index b60894a8..ec7a3e21 100644 --- a/packages/frontend/tests/e2e/card-trial.spec.ts +++ b/packages/frontend/tests/e2e/card-trial.spec.ts @@ -45,7 +45,7 @@ test.describe('card-trial helpers', () => { test('offers the card trial to cloud admins on a running trial only', () => { const now = Date.UTC(2026, 9, 1); - const trial = { plan: 'trial', status: 'active', expiresAt: new Date(now + 86_400_000).toISOString() }; + const trial = { plan: 'trial', status: 'active', expiresAt: new Date(now + 3 * 86_400_000).toISOString() }; expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: trial, now })).toBe(true); expect(cardTrialEligible({ isCloud: false, role: 'ADMIN', license: trial, now })).toBe(false); expect(cardTrialEligible({ isCloud: true, role: 'EDITOR', license: trial, now })).toBe(false); @@ -54,15 +54,17 @@ test.describe('card-trial helpers', () => { ).toBe(false); expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: { ...trial, plan: 'cloud_team' }, now })).toBe(false); expect(cardTrialEligible({ isCloud: true, role: 'ADMIN', license: null, now })).toBe(false); + // Under 48 hours left: no card trial (it would have to be lengthened). + expect( + cardTrialEligible({ isCloud: true, role: 'ADMIN', license: { ...trial, expiresAt: new Date(now + 86_400_000).toISOString() }, now }), + ).toBe(false); }); - test('shows the date Stripe will use, at least 49 hours out', () => { + test('the card trial ends with the free trial, and needs 48 hours of it left', () => { const now = Date.UTC(2026, 9, 1, 12); const inFiveDays = new Date(now + 5 * 86_400_000).toISOString(); expect(cardTrialDisplayEnd(inFiveDays, now)).toBe(inFiveDays); - expect(cardTrialDisplayEnd(new Date(now + 3_600_000).toISOString(), now)).toBe( - new Date(now + 49 * 3_600_000).toISOString(), - ); + expect(cardTrialDisplayEnd(new Date(now + 3_600_000).toISOString(), now)).toBeNull(); expect(cardTrialDisplayEnd(null, now)).toBeNull(); }); @@ -153,7 +155,7 @@ test.describe('card-trial offer (cloud)', () => { await cloudSession(page); await page.goto('/'); await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 }); - await expect(page.getByRole('heading', { name: 'Start your 7-day free trial' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Your 7-day free trial' })).toBeVisible(); await expect(page.getByText('€0 today.')).toBeVisible(); // Team is preselected without an intent from the pricing page. await expect(page.getByRole('radio', { name: /Team/ })).toHaveAttribute('aria-checked', 'true');