Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/backend/src/auth/login.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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' });
Expand Down Expand Up @@ -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,
);
Expand All @@ -584,6 +586,7 @@ describe('LoginController', () => {
grants as any,
productEvents as any,
licenseGuard as any,
eligibility as any,
);
});

Expand Down Expand Up @@ -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());
Expand Down
24 changes: 22 additions & 2 deletions packages/backend/src/auth/login.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -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<boolean> {
if (!email || !this.cardTrialEligibility) return false;
const answer = await Promise.race([
this.cardTrialEligibility.hadSubscription(email).catch(() => null),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1500).unref?.()),
]);
return answer === true;
}

private renderFirstConnectorOffer(params: {
clientName: string;
redirectHost?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, boolean> | 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']);
});
});
90 changes: 90 additions & 0 deletions packages/backend/src/license/card-trial-eligibility.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, { had: boolean; at: number }>();

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<Map<string, boolean> | null> {
const keys = [...new Set(emails.map((e) => e.trim().toLowerCase()).filter(Boolean))];
const out = new Map<string, boolean>();
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<boolean | null> {
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<CardTrialEligibility> {
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 };
}
}
Loading
Loading