From 68ef66242dbed00a6cc9c5a18379a754733151a1 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:30:39 +0200 Subject: [PATCH] Say before checkout when a card trial is not available and the plan is billed today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An address that already had an AnythingMCP subscription gets no card trial from the licence site, so Checkout charged it today while /start-trial said "€0 today". The app now asks first (GET /api/license/card-trial-eligibility, backed by the site's subscription-history lookup) and shows a pay-today page with the plan price; checkout-link stops asking for a trial for those addresses and reports whether the site granted one, so the page also catches a late refusal. The trial banner, the licence settings and the post-Approve line no longer promise "no charge before" to them. The /start-trial page shows the real trial end date instead of "7-day". --- .../backend/src/auth/login.controller.spec.ts | 28 ++++- packages/backend/src/auth/login.controller.ts | 24 ++++- .../card-trial-eligibility.service.spec.ts | 89 +++++++++++++++ .../license/card-trial-eligibility.service.ts | 90 ++++++++++++++++ .../src/license/license-checkout.spec.ts | 50 ++++++++- .../src/license/license.controller.spec.ts | 2 + .../backend/src/license/license.controller.ts | 34 +++++- .../backend/src/license/license.module.ts | 5 +- .../backend/src/license/license.service.ts | 6 +- .../src/app/settings/license/page.tsx | 19 +++- .../frontend/src/app/start-trial/page.tsx | 102 ++++++++++++++---- .../frontend/src/components/trial-banner.tsx | 26 +++-- packages/frontend/src/lib/api.ts | 12 ++- .../frontend/src/lib/use-card-checkout.ts | 36 +++++-- .../src/lib/use-card-trial-eligibility.ts | 46 ++++++++ .../frontend/tests/e2e/card-trial.spec.ts | 58 +++++++++- 16 files changed, 566 insertions(+), 61 deletions(-) create mode 100644 packages/backend/src/license/card-trial-eligibility.service.spec.ts create mode 100644 packages/backend/src/license/card-trial-eligibility.service.ts create mode 100644 packages/frontend/src/lib/use-card-trial-eligibility.ts diff --git a/packages/backend/src/auth/login.controller.spec.ts b/packages/backend/src/auth/login.controller.spec.ts index 5ab031705..23c672cfe 100644 --- a/packages/backend/src/auth/login.controller.spec.ts +++ b/packages/backend/src/auth/login.controller.spec.ts @@ -520,6 +520,7 @@ describe('LoginController', () => { let cloud: LoginController; let productEvents: { log: jest.Mock }; let licenseGuard: { getTrialState: jest.Mock }; + let eligibility: { hadSubscription: jest.Mock }; let cloudPrisma: { user: { findUnique: jest.Mock }; organizationMember: { findFirst: jest.Mock }; @@ -541,7 +542,7 @@ describe('LoginController', () => { } cloudPrisma.user.findUnique.mockImplementation(({ select }: any) => select?.organizationId - ? { organizationId: 'org-1' } + ? { organizationId: 'org-1', email: 'a@b.com' } : { id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash' }, ); cloudPrisma.organizationMember.findFirst.mockResolvedValue({ role: opts.role ?? 'ADMIN' }); @@ -571,6 +572,7 @@ describe('LoginController', () => { }; productEvents = { log: jest.fn().mockResolvedValue(undefined) }; licenseGuard = { getTrialState: jest.fn().mockResolvedValue(null) }; + eligibility = { hadSubscription: jest.fn().mockResolvedValue(false) }; config.get.mockImplementation((key: string) => key === 'FRONTEND_URL' ? 'https://cloud.example/' : undefined, ); @@ -584,6 +586,7 @@ describe('LoginController', () => { grants as any, productEvents as any, licenseGuard as any, + eligibility as any, ); }); @@ -665,6 +668,29 @@ describe('LoginController', () => { ); }); + it('is left out for an address that had a subscription: it would pay today', async () => { + arrange(); + licenseGuard.getTrialState.mockResolvedValue(trial()); + eligibility.hadSubscription.mockResolvedValue(true); + const res = await login(); + + expect(eligibility.hadSubscription).toHaveBeenCalledWith('a@b.com'); + expect(res._sent).toContain('Claude is connected'); + expect(res._sent).not.toContain('start-trial'); + expect(res._sent).not.toContain('Nothing is charged'); + }); + + it('keeps the line when the history is unknown, and never waits long for it', async () => { + arrange(); + licenseGuard.getTrialState.mockResolvedValue(trial()); + eligibility.hadSubscription.mockReturnValue(new Promise(() => undefined)); + const started = Date.now(); + const res = await login(); + + expect(Date.now() - started).toBeLessThan(3000); + expect(res._sent).toContain('start-trial'); + }); + it('is never shown to an editor, whose licence is not looked up', async () => { arrange({ role: 'EDITOR' }); licenseGuard.getTrialState.mockResolvedValue(trial()); diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts index 70fcf64f8..8251da26d 100644 --- a/packages/backend/src/auth/login.controller.ts +++ b/packages/backend/src/auth/login.controller.ts @@ -28,6 +28,7 @@ import { providerMarkSvg } from './provider-marks'; import { McpConnectionGrantService } from '../mcp-servers/mcp-connection-grant.service'; import { ProductEvents, ProductEventService } from '../audit/product-event.service'; import { LicenseGuardService } from '../license/license-guard.service'; +import { CardTrialEligibilityService } from '../license/card-trial-eligibility.service'; import { AMCP_MARK_SVG as AMCP_MARK, claudeMark, @@ -94,6 +95,7 @@ export class LoginController { private readonly grants: McpConnectionGrantService, @Optional() private readonly productEvents?: ProductEventService, @Optional() private readonly licenseGuard?: LicenseGuardService, + @Optional() private readonly cardTrialEligibility?: CardTrialEligibilityService, ) {} /** The trust row under every page of the flow. */ @@ -386,7 +388,7 @@ export class LoginController { if (typeof requestedServerId === 'string' && requestedServerId) return false; const account = await this.prisma.user.findUnique({ where: { id: userId }, - select: { organizationId: true }, + select: { organizationId: true, email: true }, }); const organizationId = account?.organizationId; if (!organizationId) return false; @@ -422,7 +424,10 @@ export class LoginController { member?.role === 'ADMIN' ? await this.licenseGuard?.getTrialState(organizationId).catch(() => null) : null; - const trialEndsAt = trial?.active && trial.cardTrialAvailable ? trial.endsAt : null; + const trialEndsAt = + trial?.active && trial.cardTrialAvailable && !(await this.hadSubscriptionQuickly(account?.email)) + ? trial.endsAt + : null; await this.productEvents ?.log({ @@ -447,6 +452,21 @@ export class LoginController { return true; } + /** + * Whether this address had a subscription before (it would be charged + * today, so "nothing is charged before" would be false). The authorization + * waits for this page, so the licence site gets 1.5 s; no answer counts as + * no, as the page always said, and the site checks again at checkout. + */ + private async hadSubscriptionQuickly(email: string | null | undefined): Promise { + if (!email || !this.cardTrialEligibility) return false; + const answer = await Promise.race([ + this.cardTrialEligibility.hadSubscription(email).catch(() => null), + new Promise((resolve) => setTimeout(() => resolve(null), 1500).unref?.()), + ]); + return answer === true; + } + private renderFirstConnectorOffer(params: { clientName: string; redirectHost?: string; diff --git a/packages/backend/src/license/card-trial-eligibility.service.spec.ts b/packages/backend/src/license/card-trial-eligibility.service.spec.ts new file mode 100644 index 000000000..3b4ae0330 --- /dev/null +++ b/packages/backend/src/license/card-trial-eligibility.service.spec.ts @@ -0,0 +1,89 @@ +/** + * Whether an admin can start a card trial, known before Checkout: the licence + * site gives no trial to an address that had a subscription, so the app must + * not promise one (it used to say "€0 today" and Checkout charged today). + */ +import { CardTrialEligibilityService } from './card-trial-eligibility.service'; + +const NOW = new Date('2026-10-13T10:00:00.000Z'); +const DAY = 86_400_000; + +function makeService(opts: { isCloud?: boolean; license?: any; history?: Record | null } = {}) { + const licenseService = { + getCurrentLicense: jest.fn(async () => + opts.license === undefined + ? { plan: 'trial', status: 'active', expiresAt: new Date(NOW.getTime() + 5 * DAY) } + : opts.license, + ), + subscriptionHistory: jest.fn(async (emails: string[]) => + opts.history === null ? null : new Map(emails.map((e) => [e, opts.history?.[e] ?? false])), + ), + }; + const svc = new CardTrialEligibilityService(licenseService as any, { isCloud: () => opts.isCloud ?? true } as any); + return { svc, licenseService }; +} + +describe('CardTrialEligibilityService', () => { + beforeEach(() => jest.useFakeTimers({ now: NOW, doNotFake: ['nextTick', 'setImmediate'] })); + afterEach(() => jest.useRealTimers()); + + it('offers the card trial to an address that never had a subscription', async () => { + const { svc } = makeService(); + await expect(svc.forAdmin('org-1', 'new@example.com')).resolves.toEqual({ + kind: 'trial', + trialEndsAt: new Date(NOW.getTime() + 5 * DAY), + }); + }); + + it('says "pay today" to an address that had a subscription', async () => { + const { svc } = makeService({ history: { 'back@example.com': true } }); + await expect(svc.forAdmin('org-1', 'Back@Example.com ')).resolves.toEqual({ + kind: 'pay', + reason: 'had_subscription', + }); + }); + + it('keeps the trial when the licence site cannot answer: it checks again at checkout', async () => { + const { svc } = makeService({ history: null }); + await expect(svc.forAdmin('org-1', 'new@example.com')).resolves.toMatchObject({ kind: 'trial' }); + }); + + it.each([ + ['under 48 hours are left', { plan: 'trial', status: 'active', expiresAt: new Date(NOW.getTime() + DAY) }], + ['the trial has ended', null], + ['the workspace pays', { plan: 'starter', status: 'active', expiresAt: null }], + ])('offers nothing and asks nothing when %s', async (_label, license) => { + const { svc, licenseService } = makeService({ license }); + await expect(svc.forAdmin('org-1', 'new@example.com')).resolves.toEqual({ kind: 'none' }); + expect(licenseService.subscriptionHistory).not.toHaveBeenCalled(); + }); + + it('does nothing on self-hosted', async () => { + const { svc, licenseService } = makeService({ isCloud: false }); + await expect(svc.forAdmin('org-1', 'new@example.com')).resolves.toEqual({ kind: 'none' }); + expect(licenseService.getCurrentLicense).not.toHaveBeenCalled(); + }); + + it('reuses an answer for ten minutes, and never caches "unknown"', async () => { + const { svc, licenseService } = makeService({ history: { 'a@example.com': true } }); + await svc.hadSubscription('a@example.com'); + await svc.hadSubscription('A@example.com'); + expect(licenseService.subscriptionHistory).toHaveBeenCalledTimes(1); + jest.setSystemTime(NOW.getTime() + 11 * 60_000); + await svc.hadSubscription('a@example.com'); + expect(licenseService.subscriptionHistory).toHaveBeenCalledTimes(2); + + const unknown = makeService({ history: null }); + await expect(unknown.svc.hadSubscription('a@example.com')).resolves.toBeNull(); + await unknown.svc.hadSubscription('a@example.com'); + expect(unknown.licenseService.subscriptionHistory).toHaveBeenCalledTimes(2); + }); + + it('asks only for the addresses it does not know yet', async () => { + const { svc, licenseService } = makeService({ history: { 'a@example.com': true } }); + await svc.hadSubscription('a@example.com'); + const both = await svc.hadSubscriptions(['a@example.com', 'b@example.com']); + expect(both).toEqual(new Map([['a@example.com', true], ['b@example.com', false]])); + expect(licenseService.subscriptionHistory).toHaveBeenLastCalledWith(['b@example.com']); + }); +}); diff --git a/packages/backend/src/license/card-trial-eligibility.service.ts b/packages/backend/src/license/card-trial-eligibility.service.ts new file mode 100644 index 000000000..66d3e6bbe --- /dev/null +++ b/packages/backend/src/license/card-trial-eligibility.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { DeploymentService } from '../common/deployment.service'; +import { LicenseService } from './license.service'; +import { cardTrialEnd } from './license-checkout'; + +/** How long an answer of the licence site is reused for one address. */ +const HISTORY_TTL_MS = 10 * 60 * 1000; +const HISTORY_CACHE_MAX = 10_000; + +/** + * Whether the signed-in admin can start a card trial, before they reach + * Checkout (Cloud only). + * + * The licence site grants a card trial only to an address that never held an + * AnythingMCP subscription (`hasHadSubscription` in its checkout-intent); any + * other address is charged today. The app used to find that out only at + * Checkout, after a page that said "€0 today". The same lookup is exposed + * to this server as POST /api/license/subscription-history (service token, + * LicenseService.subscriptionHistory), so the app can say the truth first. + * + * - `trial`: a running free trial with 48 h left, and an address that never + * had a subscription, or one the site could not answer for (the site checks + * again at checkout, and the app follows its answer: see checkoutLink). + * - `pay`: the address had a subscription. The plan starts and is billed + * today. + * - `none`: no card trial applies (no running trial, under 48 h left, a paid + * plan); the app keeps its existing pay-now paths. + */ +export type CardTrialEligibility = + | { kind: 'trial'; trialEndsAt: Date } + | { kind: 'pay'; reason: 'had_subscription' } + | { kind: 'none' }; + +@Injectable() +export class CardTrialEligibilityService { + /** Address (lowercased) → had a subscription, and when the site said so. */ + private readonly history = new Map(); + + constructor( + private readonly licenseService: LicenseService, + private readonly deployment: DeploymentService, + ) {} + + /** + * Whether each address ever held a subscription, from the licence site, + * cached for 10 minutes. Null when the site could not answer for all of + * them; unknown answers are not cached. + */ + async hadSubscriptions(emails: string[], now: number = Date.now()): Promise | null> { + const keys = [...new Set(emails.map((e) => e.trim().toLowerCase()).filter(Boolean))]; + const out = new Map(); + const missing: string[] = []; + for (const key of keys) { + const hit = this.history.get(key); + if (hit && now - hit.at < HISTORY_TTL_MS) out.set(key, hit.had); + else missing.push(key); + } + if (missing.length === 0) return out; + const fresh = await this.licenseService.subscriptionHistory(missing).catch(() => null); + if (!fresh) return null; + for (const [key, had] of fresh) { + out.set(key, had); + this.history.delete(key); + this.history.set(key, { had, at: now }); + } + while (this.history.size > HISTORY_CACHE_MAX) this.history.delete(this.history.keys().next().value!); + return out; + } + + /** One address: true / false, or null when unknown. */ + async hadSubscription(email: string): Promise { + const key = email.trim().toLowerCase(); + if (!key) return null; + const answer = await this.hadSubscriptions([key]); + return answer?.get(key) ?? null; + } + + /** What the app may offer this admin of this workspace. */ + async forAdmin(organizationId: string | null | undefined, email: string | null | undefined): Promise { + if (!this.deployment.isCloud() || !organizationId || !email) return { kind: 'none' }; + const current = await this.licenseService.getCurrentLicense(organizationId); + // A workspace with a live paid plan changes plan in the billing portal. + if (current && current.plan !== 'trial' && current.status === 'active') return { kind: 'none' }; + // Only where a card trial would otherwise be offered is the site asked. + const trialEndsAt = cardTrialEnd(current); + if (!trialEndsAt) return { kind: 'none' }; + const had = await this.hadSubscription(email); + return had === true ? { kind: 'pay', reason: 'had_subscription' } : { kind: 'trial', trialEndsAt }; + } +} diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts index b2c6e62bb..a55c4ef1b 100644 --- a/packages/backend/src/license/license-checkout.spec.ts +++ b/packages/backend/src/license/license-checkout.spec.ts @@ -139,6 +139,7 @@ describe('LicenseService.createCheckoutIntent', () => { await expect(makeService().createCheckoutIntent(payload)).resolves.toEqual({ url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1', + trial: false, }); const [url, body, config] = mockedAxios.post.mock.calls[0] as any[]; expect(url).toMatch(/\/api\/stripe\/checkout-intent$/); @@ -147,6 +148,23 @@ describe('LicenseService.createCheckoutIntent', () => { expect(config.timeout).toBeGreaterThan(0); }); + it('reports whether the licence site granted the trial', async () => { + const trialPayload = { ...payload, trialEnd: '2026-10-06T10:00:00.000Z' }; + mockedAxios.post.mockResolvedValueOnce({ data: { url: 'https://anythingmcp.com/c', trial: false } } as any); + await expect(makeService().createCheckoutIntent(trialPayload)).resolves.toEqual({ + url: 'https://anythingmcp.com/c', + trial: false, + }); + mockedAxios.post.mockResolvedValueOnce({ data: { url: 'https://anythingmcp.com/c', trial: true } } as any); + await expect(makeService().createCheckoutIntent(trialPayload)).resolves.toEqual({ + url: 'https://anythingmcp.com/c', + trial: true, + }); + // No trial asked, none granted, whatever the answer says. + mockedAxios.post.mockResolvedValueOnce({ data: { url: 'https://anythingmcp.com/c', trial: true } } as any); + await expect(makeService().createCheckoutIntent(payload)).resolves.toMatchObject({ trial: false }); + }); + it('retries once on an upstream fault', async () => { mockedAxios.post .mockRejectedValueOnce(httpError(503)) @@ -154,6 +172,7 @@ describe('LicenseService.createCheckoutIntent', () => { await expect(makeService().createCheckoutIntent(payload)).resolves.toEqual({ url: 'https://anythingmcp.com/c', + trial: false, }); expect(mockedAxios.post).toHaveBeenCalledTimes(2); }); @@ -192,7 +211,9 @@ describe('LicenseController.checkoutLink', () => { user: { sub: 'u1', email: 'admin@example.com', role: 'ADMIN', organizationId: 'org-1' }, }; - function makeController(opts: { isCloud?: boolean; license?: any; click?: any; fail?: boolean } = {}) { + function makeController( + opts: { isCloud?: boolean; license?: any; click?: any; fail?: boolean; hadSubscription?: boolean | null } = {}, + ) { const licenseService = { getCurrentLicense: jest.fn(async () => (opts.license === undefined ? trialLicense() : opts.license)), createCheckoutIntent: jest.fn(async () => { @@ -201,6 +222,9 @@ describe('LicenseController.checkoutLink', () => { }), }; const productEvents = { clickIdForUser: jest.fn(async () => opts.click ?? null) }; + const eligibility = { + hadSubscription: jest.fn(async () => (opts.hadSubscription === undefined ? false : opts.hadSubscription)), + }; const controller = new LicenseController( licenseService as any, {} as any, @@ -208,8 +232,9 @@ describe('LicenseController.checkoutLink', () => { {} as any, { isCloud: () => opts.isCloud ?? true } as any, productEvents as any, + eligibility as any, ); - return { controller, licenseService, productEvents }; + return { controller, licenseService, productEvents, eligibility }; } beforeEach(() => { @@ -364,6 +389,27 @@ describe('LicenseController.checkoutLink', () => { } }); + it('asks for no trial for an address that had a subscription: it pays today', async () => { + const { controller, licenseService, eligibility } = makeController({ hadSubscription: true }); + await controller.checkoutLink(adminReq, { plan: 'starter', billingPeriod: 'monthly', trial: true }); + expect(eligibility.hadSubscription).toHaveBeenCalledWith('admin@example.com'); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload).not.toHaveProperty('trialEnd'); + }); + + it('still asks for the trial when the history is unknown: the licence site decides', async () => { + const { controller, licenseService } = makeController({ hadSubscription: null }); + await controller.checkoutLink(adminReq, { plan: 'starter', billingPeriod: 'monthly', trial: true }); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload.trialEnd).toBe(IN_FIVE_DAYS.toISOString()); + }); + + it('does not look up the history for a plain purchase', async () => { + const { controller, eligibility } = makeController(); + await controller.checkoutLink(adminReq, { plan: 'starter', billingPeriod: 'monthly', trial: false }); + expect(eligibility.hadSubscription).not.toHaveBeenCalled(); + }); + 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.spec.ts b/packages/backend/src/license/license.controller.spec.ts index d215df0ca..96a8b0a1e 100644 --- a/packages/backend/src/license/license.controller.spec.ts +++ b/packages/backend/src/license/license.controller.spec.ts @@ -29,6 +29,7 @@ describe('LicenseController — activate-trial is idempotent', () => { { findById: jest.fn(async () => user) } as any, { isCloud: () => true } as any, {} as any, + {} as any, ); return { controller, licenseService }; } @@ -98,6 +99,7 @@ describe('LicenseController — status reports an ended licence', () => { {} as any, { isCloud: () => opts.cloud ?? true } as any, {} 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 0497a9717..3a63de44f 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -25,6 +25,7 @@ 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 { CardTrialEligibilityService } from './card-trial-eligibility.service'; import { CHECKOUT_BILLING_PERIODS, CHECKOUT_PLANS, @@ -84,6 +85,7 @@ export class LicenseController { private readonly usersService: UsersService, private readonly deployment: DeploymentService, private readonly productEvents: ProductEventService, + private readonly cardTrialEligibility: CardTrialEligibilityService, ) {} @Get('status') @@ -250,7 +252,7 @@ export class LicenseController { 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 }> { + async checkoutLink(@Req() req: any, @Body() dto: CheckoutLinkDto): Promise<{ url: string; trial: boolean }> { // 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()) { @@ -282,8 +284,13 @@ export class LicenseController { } // 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; + // trial is over, it is a plain purchase: no trialEnd means pay now. An + // address that had a subscription before gets no trial from the licence + // site either; asking for one would only hide that it pays today. + const hadSubscription = dto.trial + ? await this.cardTrialEligibility.hadSubscription(email).catch(() => null) + : null; + const trialEnd = dto.trial && hadSubscription !== true ? cardTrialEnd(current) : null; const adMetadata = checkoutAdMetadata( await this.productEvents.clickIdForUser(req.user.sub).catch(() => null), ); @@ -307,6 +314,27 @@ export class LicenseController { } } + @Get('card-trial-eligibility') + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Roles('ADMIN') + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Cloud, ADMIN: whether this address can start a card trial (kind trial), must pay today because it had a subscription (pay), or neither applies (none)', + }) + async cardTrialEligibilityForUser( + @Req() req: any, + ): Promise<{ kind: 'trial' | 'pay' | 'none'; trialEndsAt: string | null }> { + if (!this.deployment.isCloud()) throw new NotFoundException(); + const result = await this.cardTrialEligibility + .forAdmin(req.user?.organizationId, req.user?.email) + .catch(() => ({ kind: 'none' as const })); + return { + kind: result.kind, + trialEndsAt: result.kind === 'trial' ? result.trialEndsAt.toISOString() : null, + }; + } + @Post('verify') @UseGuards(AuthGuard('jwt'), RolesGuard) @Roles('ADMIN') diff --git a/packages/backend/src/license/license.module.ts b/packages/backend/src/license/license.module.ts index 9bfb09d4a..1da27cbc3 100644 --- a/packages/backend/src/license/license.module.ts +++ b/packages/backend/src/license/license.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { LicenseService } from './license.service'; import { LicenseGuardService } from './license-guard.service'; import { LicenseController } from './license.controller'; +import { CardTrialEligibilityService } from './card-trial-eligibility.service'; import { SettingsModule } from '../settings/settings.module'; import { UsersModule } from '../users/users.module'; @Module({ imports: [SettingsModule, UsersModule], controllers: [LicenseController], - providers: [LicenseService, LicenseGuardService], - exports: [LicenseService, LicenseGuardService], + providers: [LicenseService, LicenseGuardService, CardTrialEligibilityService], + exports: [LicenseService, LicenseGuardService, CardTrialEligibilityService], }) export class LicenseModule {} diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index 242a099cb..5bc3bd5d5 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -206,7 +206,7 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { * 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 }> { + async createCheckoutIntent(payload: CheckoutIntentPayload): Promise<{ url: string; trial: boolean }> { const headers = this.serviceHeaders(); if (!headers['x-amcp-service-token']) { this.logger.error('Checkout link requested but LICENSE_SERVICE_TOKEN is not set.'); @@ -224,7 +224,9 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { if (!/^https?:\/\//i.test(url)) { throw new CheckoutUnavailableError('Checkout intent answered without a URL'); } - return { url }; + // The site says whether it granted the card trial: an address that had + // a subscription before is charged today even when a trial was asked. + return { url, trial: !!payload.trialEnd && data?.trial === true }; } catch (err: any) { lastErr = err; if ( diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx index 63e6764a7..8ceacd8d1 100644 --- a/packages/frontend/src/app/settings/license/page.tsx +++ b/packages/frontend/src/app/settings/license/page.tsx @@ -12,6 +12,7 @@ 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'; +import { useCardTrialKind } from '@/lib/use-card-trial-eligibility'; import { describeBilling } from '@/lib/billing-summary'; interface LicenseStatus { @@ -48,6 +49,7 @@ export default function LicenseSettingsPage() { 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 cardTrialKind = useCardTrialKind(token, cardTrialOffer); const billingSummary = isCloud ? describeBilling(status?.billing) : null; const cardTrialDate = cardTrialOffer ? formatTrialEnd(cardTrialDisplayEnd(status?.expiresAt), 'long') @@ -417,13 +419,20 @@ export default function LicenseSettingsPage() {

Upgrade Plan

{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. -

+ {cardTrialKind === 'pay' ? ( +

+ Choose a plan to keep AnythingMCP Cloud running after your trial. This address has had + a subscription before, so the plan starts and is billed when you check out. +

+ ) : ( +

+ 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 + {cardTrialKind === 'pay' ? 'Choose a plan' : 'Add payment method'} (DEFAULT_SELECTION); const [promo, setPromo] = useState(null); + // 'pay': this address had a subscription, so the plan starts and is billed today. + const [kind, setKind] = useState<'trial' | 'pay'>('trial'); // Set when the user came from "Connect" in an AI client: every way out of // this page goes back to that authorization. const [authorizationReturn, setAuthorizationReturn] = useState(null); @@ -66,14 +77,14 @@ export default function StartTrialPage() { return; } let live = true; - license - .getStatus(token) - .then((lic) => { + Promise.all([license.getStatus(token), fetchCardTrialKind(token)]) + .then(([lic, trialKind]) => { if (!live) return; - if (!cardTrialEligible({ isCloud: true, role: user.role, license: lic })) { + if (!cardTrialEligible({ isCloud: true, role: user.role, license: lic }) || trialKind === 'none') { leave('/'); return; } + setKind(trialKind); setAuthorizationReturn(readAuthorizationReturn()); setTrialEnd(cardTrialDisplayEnd(lic.expiresAt)); setSelection(readPlanIntent() ?? DEFAULT_SELECTION); @@ -88,10 +99,18 @@ export default function StartTrialPage() { }; }, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router, leave]); + // The licence site refused the trial at checkout: say so before sending anyone on. + const paysToday = kind === 'pay' || !!checkout.chargesTodayUrl; + const handleStart = async () => { if (!user) return; - const ok = await checkout.start(selection, true); - if (ok) writeCardTrialPrompt(user.id, 'checkout'); + if (checkout.chargesTodayUrl) { + writeCardTrialPrompt(user.id, 'checkout'); + window.location.assign(checkout.chargesTodayUrl); + return; + } + const outcome = await checkout.start(selection, !paysToday); + if (outcome === 'redirected') writeCardTrialPrompt(user.id, 'checkout'); }; const handleSkip = () => { @@ -111,6 +130,15 @@ export default function StartTrialPage() { const endLong = formatTrialEnd(trialEnd, 'long'); const plan = planById(selection.plan); + const price = formatPlanPrice(plan, selection.period); + const dueToday = formatEur(selection.period === 'yearly' ? plan.yearly : plan.monthly); + const startLabel = checkout.loading + ? 'Opening checkout…' + : checkout.error + ? 'Try again' + : paysToday + ? 'Continue to checkout' + : 'Start free trial with card'; return (
@@ -136,19 +164,29 @@ export default function StartTrialPage() {

- 7-day free trial + {paysToday ? 'Choose your plan' : endLong ? `Free until ${endLong}` : 'Free trial'}

- Your 7-day free trial + {paysToday ? 'Keep your workspace running' : 'Your free trial'}

-

- 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 — the no-card trial has a{' '} - lower connector limit, and you can add a card - any time to unlock your full plan. -

+ {paysToday ? ( +

+ This email address has had an AnythingMCP subscription before, so a new free trial is + not available with a card. Your plan starts today + and is billed today. Your workspace, connectors and settings stay exactly as they + are{endLong ? <>, and your current trial keeps running until{' '} + {endLong} if you decide later : ''}. +

+ ) : ( +

+ Every feature, free until{' '} + {endLong ? {endLong} : 'your trial ends'}. Add + a card now and your plan simply carries on after the trial, with nothing charged before + then. Or keep trying without payment details: the no-card trial has a{' '} + lower connector limit, and you can add a card + any time to unlock your full plan. +

+ )} {authorizationReturn && (

Either way, you go straight back to finish connecting your AI client. @@ -157,16 +195,25 @@ export default function StartTrialPage() {

- +
+ {checkout.chargesTodayUrl && ( +

+ This address already had a subscription, so the checkout will charge {dueToday} today. +

+ )} {promo && (

@@ -179,18 +226,27 @@ export default function StartTrialPage() {

)}

- €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. + {paysToday ? ( + <> + {plan.name}, {price}, billed today and then {selection.period === 'yearly' ? 'every year' : 'every month'}. + Secure checkout by Stripe. Cancel anytime from the billing portal. + + ) : ( + <> + €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?{' '} + {paysToday ? 'Not ready yet?' : 'Not ready to add a card?'}{' '}

diff --git a/packages/frontend/src/components/trial-banner.tsx b/packages/frontend/src/components/trial-banner.tsx index 701e17f40..940f6c395 100644 --- a/packages/frontend/src/components/trial-banner.tsx +++ b/packages/frontend/src/components/trial-banner.tsx @@ -6,6 +6,7 @@ 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 { useCardTrialKind } from '@/lib/use-card-trial-eligibility'; import { cardTrialDisplayEnd, cardTrialEligible, @@ -41,6 +42,9 @@ export function TrialBanner() { .catch(() => {}); }, [token]); + const isTrialAdmin = deploymentMode === 'cloud' && user?.role === 'ADMIN' && plan === 'trial' && status === 'active'; + const cardTrialKind = useCardTrialKind(token, isTrialAdmin); + // 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; @@ -60,7 +64,7 @@ export function TrialBanner() { // Only show the value clause once they've actually built something. const value = connectors && connectors > 0 - ? ` Keep your ${connectors} connector${connectors === 1 ? '' : 's'} running —` + ? ` Keep your ${connectors} connector${connectors === 1 ? '' : 's'} running.` : ''; // Cloud admins on a running trial add a card instead (the card trial ends @@ -72,17 +76,21 @@ export function TrialBanner() { license: { plan, status: status ?? '', expiresAt, trialDaysLeft: daysLeft }, }); const chargeDate = formatTrialEnd(cardTrialDisplayEnd(expiresAt), 'short'); + // An address that had a subscription before gets no card trial: its plan + // starts and is billed today, which /start-trial says before checkout. + const paysToday = cardTrialKind === 'pay'; const addPaymentMethod = async () => { // With a plan picked on the pricing page, straight to Checkout; - // otherwise to the plan picker first. + // otherwise (or when it would charge today) to the plan picker first. const intent = readPlanIntent(); - if (!intent) { + if (!intent || paysToday) { router.push('/start-trial'); return; } - const ok = await checkout.start(intent, true); - if (!ok) window.location.assign(pricingUrl); + const outcome = await checkout.start(intent, true); + if (outcome === 'charges_today') router.push('/start-trial'); + else if (outcome === 'failed') window.location.assign(pricingUrl); }; return ( @@ -104,9 +112,11 @@ export function TrialBanner() { > {checkout.loading ? 'Opening checkout…' - : chargeDate - ? `Add a payment method — no charge before ${chargeDate}` - : 'Add a payment method'} + : paysToday + ? 'Choose a plan' + : chargeDate + ? `Add a payment method: no charge before ${chargeDate}` + : 'Add a payment method'} ) : (
- request<{ url: string }>('/api/license/checkout-link', { + /** `trial`: whether the licence site granted the card trial asked for. */ + request<{ url: string; trial?: boolean }>('/api/license/checkout-link', { method: 'POST', body, token, }), + /** + * Cloud (ADMIN): can this address start a card trial? 'pay' = it had a + * subscription before, so a plan starts and is billed today. + */ + cardTrialEligibility: (token: string) => + request<{ kind: 'trial' | 'pay' | 'none'; trialEndsAt: string | null }>( + '/api/license/card-trial-eligibility', + { token }, + ), getInstanceId: () => request<{ instanceId: string }>('/api/license/instance-id'), getUsage: (token?: string) => diff --git a/packages/frontend/src/lib/use-card-checkout.ts b/packages/frontend/src/lib/use-card-checkout.ts index 6bcd0c46b..7bcdb638f 100644 --- a/packages/frontend/src/lib/use-card-checkout.ts +++ b/packages/frontend/src/lib/use-card-checkout.ts @@ -7,43 +7,59 @@ import { readPromoCode, type PlanSelection } from './card-trial'; const GENERIC_ERROR = 'We could not open the checkout. Please try again in a moment.'; +/** + * What became of a checkout request: + * - 'redirected': the page is leaving for Stripe Checkout; + * - 'charges_today': a card trial was asked for, but the licence site will + * charge today (the address had a subscription before). Nothing was opened: + * `chargesTodayUrl` holds the checkout, for the page to say so first; + * - 'failed': `error` says why. + */ +export type CheckoutOutcome = 'redirected' | 'charges_today' | 'failed'; + /** * 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. + * the free trial; `false` is a plain purchase. */ export function useCardCheckout() { const { token } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [chargesTodayUrl, setChargesTodayUrl] = useState(null); const start = useCallback( - async (selection: PlanSelection, trial: boolean): Promise => { - if (!token) return false; + async (selection: PlanSelection, trial: boolean): Promise => { + if (!token) return 'failed'; setLoading(true); setError(null); try { const promo = readPromoCode(); - const { url } = await license.checkoutLink(token, { + const res = await license.checkoutLink(token, { plan: selection.plan, billingPeriod: selection.period, trial, ...(promo && { promo }), }); + // A trial was asked for and the licence site did not grant it: never + // send someone to a charge the page did not announce. + if (trial && res.trial === false) { + setChargesTodayUrl(res.url); + setLoading(false); + return 'charges_today'; + } // Stays "loading" on purpose: the page is being left. - window.location.assign(url); - return true; + window.location.assign(res.url); + return 'redirected'; } 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; + return 'failed'; } }, [token], ); - return { start, loading, error }; + return { start, loading, error, chargesTodayUrl }; } diff --git a/packages/frontend/src/lib/use-card-trial-eligibility.ts b/packages/frontend/src/lib/use-card-trial-eligibility.ts new file mode 100644 index 000000000..dfb52a8ee --- /dev/null +++ b/packages/frontend/src/lib/use-card-trial-eligibility.ts @@ -0,0 +1,46 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { license } from './api'; + +/** + * Whether this Cloud admin can start a card trial + * (GET /api/license/card-trial-eligibility): + * - 'trial': yes, ending when the free trial does; + * - 'pay': no, the address had a subscription before, so a plan starts and + * is billed today; + * - 'none': no card trial applies (no running trial, under 48 hours left); + * - 'loading' until the answer is in, and 'trial' if it cannot be had (the + * licence site checks again at checkout, and the app follows its answer). + */ +export type CardTrialKind = 'trial' | 'pay' | 'none'; + +/** One request per session token, shared by every component that asks. */ +const answers = new Map>(); + +export function fetchCardTrialKind(token: string): Promise { + let answer = answers.get(token); + if (!answer) { + answer = license + .cardTrialEligibility(token) + .then((r) => (r?.kind === 'pay' || r?.kind === 'none' ? r.kind : 'trial')) + .catch(() => 'trial' as const); + answers.set(token, answer); + } + return answer; +} + +export function useCardTrialKind(token: string | null | undefined, enabled: boolean): CardTrialKind | 'loading' { + const [kind, setKind] = useState('loading'); + useEffect(() => { + if (!enabled || !token) return; + let live = true; + void fetchCardTrialKind(token).then((k) => { + if (live) setKind(k); + }); + return () => { + live = false; + }; + }, [token, enabled]); + return kind; +} diff --git a/packages/frontend/tests/e2e/card-trial.spec.ts b/packages/frontend/tests/e2e/card-trial.spec.ts index 972ab0b6b..588657401 100644 --- a/packages/frontend/tests/e2e/card-trial.spec.ts +++ b/packages/frontend/tests/e2e/card-trial.spec.ts @@ -102,6 +102,8 @@ async function cloudSession( seed?: Record; license?: Record; checkout?: (body: unknown) => { status: number; body: unknown }; + /** GET /license/card-trial-eligibility: 'pay' = the address had a subscription. */ + eligibility?: 'trial' | 'pay' | 'none'; } = {}, ) { const user = opts.user ?? ADMIN; @@ -132,6 +134,9 @@ async function cloudSession( : { status: 200, body: { url: 'https://checkout.example.test/start?intent=i1' } }; return json(reply.body, reply.status); } + if (p.endsWith('/license/card-trial-eligibility')) { + return json({ kind: opts.eligibility ?? 'trial', trialEndsAt: opts.eligibility === 'trial' ? TRIAL_END : null }); + } if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: null }); if (p.endsWith('/users/me')) return json(user); if (p.endsWith('/license/status')) { @@ -157,7 +162,10 @@ 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: 'Your 7-day free trial' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Your free trial' })).toBeVisible(); + // The real end date, never a length the user may no longer have. + await expect(page.getByText(/^Free until \d{1,2} \w+ \d{4}$/)).toBeVisible(); + await expect(page.getByText(/7-day/)).toHaveCount(0); await expect(page.getByText('€0 today.')).toBeVisible(); // Starter is preselected without an intent from the pricing page. await expect(page.getByRole('radio', { name: /Starter/ })).toHaveAttribute('aria-checked', 'true'); @@ -243,7 +251,7 @@ test.describe('card-trial entry points (cloud)', () => { 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/ }); + 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$/); @@ -280,3 +288,49 @@ test.describe('card-trial entry points (cloud)', () => { await expect(page.getByRole('button', { name: /Add a payment method/ })).toHaveCount(0); }); }); + +test.describe('an address that had a subscription (cloud)', () => { + const SHOWN = { 'amcp_card_trial_prompt:u1': 'shown' }; + + test('is told before checkout that the plan starts and is billed today, with its price', async ({ page }) => { + const { posted } = await cloudSession(page, { seed: SHOWN, eligibility: 'pay' }); + await page.goto('/start-trial'); + await expect(page.getByRole('heading', { name: 'Keep your workspace running' })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('Your plan starts today and is billed today.')).toBeVisible(); + await expect(page.getByText(/Starter, 19\s€\/month, billed today and then every month\./)).toBeVisible(); + await expect(page.getByText('€0 today', { exact: false })).toHaveCount(0); + await expect(page.getByText(/free trial with card/i)).toHaveCount(0); + + await page.getByRole('button', { name: 'Continue to checkout' }).click(); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i1'); + expect(posted).toEqual([{ plan: 'starter', billingPeriod: 'monthly', trial: false }]); + }); + + test('if the licence site still refuses the trial at checkout, the page says so before sending anyone on', async ({ page }) => { + const { posted } = await cloudSession(page, { + seed: SHOWN, + checkout: () => ({ status: 200, body: { url: 'https://checkout.example.test/start?intent=i9', trial: false } }), + }); + await page.goto('/start-trial'); + await page.getByRole('button', { name: 'Start free trial with card' }).click({ timeout: 15_000 }); + await expect(page.getByRole('status')).toContainText('the checkout will charge 19 € today'); + await expect(page).toHaveURL(/\/start-trial$/); + await expect(page.getByText('Your plan starts today and is billed today.')).toBeVisible(); + + await page.getByRole('button', { name: 'Continue to checkout' }).click(); + await expect(page).toHaveURL('https://checkout.example.test/start?intent=i9'); + expect(posted).toHaveLength(1); + }); + + test('the trial banner offers "Choose a plan", not "no charge before"', async ({ page }) => { + const intent = JSON.stringify({ plan: 'team', period: 'monthly', savedAt: Date.now() }); + const { posted } = await cloudSession(page, { seed: { ...SHOWN, amcp_plan_intent: intent }, eligibility: 'pay' }); + await page.goto('/connectors'); + const cta = page.getByRole('button', { name: 'Choose a plan' }); + await expect(cta).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(/no charge before/)).toHaveCount(0); + await cta.click(); + await expect(page).toHaveURL(/\/start-trial$/); + expect(posted).toEqual([]); + }); +});