From 4d1b3917ba50cc4664494a413666c3f20d71f0c7 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:56:18 +0200 Subject: [PATCH 1/2] Offer the card trial right after a workspace's first successful call A Cloud workspace on the no-card trial that makes its first successful tool call becomes eligible for the card-trial offer: its admins see a dismissible card on the dashboard, the connector list and the connect page, and get one value-only email 30 to 90 minutes later. The checkout carries source=first_success to the licence site for Stripe metadata (trial_source). Nothing reaches MCP tool responses; self-hosted is unaffected. --- docs/operations/README.md | 1 + docs/operations/card-trial-offer.md | 102 +++++ packages/backend/src/audit/audit.module.ts | 3 +- packages/backend/src/audit/audit.service.ts | 22 +- .../src/audit/first-success.service.spec.ts | 230 +++++++++++ .../src/audit/first-success.service.ts | 139 +++++++ .../src/audit/product-event.service.ts | 32 ++ .../ee/cloud/onboarding-cron.service.spec.ts | 24 ++ .../src/ee/cloud/onboarding-cron.service.ts | 10 +- .../src/license/card-trial-offer.service.ts | 275 +++++++++++++ .../src/license/card-trial-offer.spec.ts | 362 ++++++++++++++++++ .../backend/src/license/card-trial-offer.ts | 164 ++++++++ .../src/license/license-checkout.spec.ts | 56 ++- .../backend/src/license/license-checkout.ts | 11 + .../src/license/license.controller.spec.ts | 2 + .../backend/src/license/license.controller.ts | 43 ++- .../backend/src/license/license.module.ts | 5 +- .../backend/src/settings/email-samples.ts | 41 ++ .../src/settings/email-templates.spec.ts | 40 ++ .../backend/src/settings/email-templates.ts | 62 +++ .../backend/src/settings/email.service.ts | 31 ++ packages/frontend/src/app/connectors/page.tsx | 4 + .../frontend/src/app/mcp-server/[id]/page.tsx | 13 +- packages/frontend/src/app/page.tsx | 4 + .../frontend/src/app/start-trial/page.tsx | 9 +- .../src/components/connection-check.tsx | 17 +- .../src/components/first-success-offer.tsx | 117 ++++++ packages/frontend/src/lib/api.ts | 12 + .../frontend/src/lib/first-success-offer.ts | 74 ++++ .../frontend/src/lib/use-card-checkout.ts | 7 +- .../tests/e2e/first-success-offer.spec.ts | 217 +++++++++++ 31 files changed, 2109 insertions(+), 20 deletions(-) create mode 100644 docs/operations/card-trial-offer.md create mode 100644 packages/backend/src/audit/first-success.service.spec.ts create mode 100644 packages/backend/src/audit/first-success.service.ts create mode 100644 packages/backend/src/license/card-trial-offer.service.ts create mode 100644 packages/backend/src/license/card-trial-offer.spec.ts create mode 100644 packages/backend/src/license/card-trial-offer.ts create mode 100644 packages/frontend/src/components/first-success-offer.tsx create mode 100644 packages/frontend/src/lib/first-success-offer.ts create mode 100644 packages/frontend/tests/e2e/first-success-offer.spec.ts diff --git a/docs/operations/README.md b/docs/operations/README.md index ced55443a..bada50ed7 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -9,5 +9,6 @@ Documents that exist for the people running AnythingMCP in production rather tha | [`slo.md`](./slo.md) | Service level objectives + how we measure them. | | [`observability.md`](./observability.md) | Logs (always on), Sentry (opt-in), OpenTelemetry (opt-in), and the cloud host's uptime probe and weekly stuck-users report. | | [`signup-attribution.md`](./signup-attribution.md) | Cloud: which channel brings sign-ups, verified sign-ups and paying customers (SQL), and the `sign_up_verified` GTM event. | +| [`card-trial-offer.md`](./card-trial-offer.md) | Cloud: the card-trial offer after a workspace's first successful tool call (in-app and one email), its Stripe attribution (`metadata.trial_source`) and the weekly funnel (SQL + Stripe). | If you just installed AnythingMCP, read these in order. diff --git a/docs/operations/card-trial-offer.md b/docs/operations/card-trial-offer.md new file mode 100644 index 000000000..3df112dbe --- /dev/null +++ b/docs/operations/card-trial-offer.md @@ -0,0 +1,102 @@ +# Card-trial offer after the first success + +AnythingMCP Cloud offers the card trial at the moment a workspace sees the product work: right after its first successful tool call. Cloud only (`DEPLOYMENT_MODE=cloud`); a self-hosted instance records and shows nothing. + +## How it works + +**Trigger.** When a tool call succeeds and is stored in `tool_invocations`, `FirstSuccessService` (`packages/backend/src/audit/first-success.service.ts`) checks the workspace once per process, in the background: + +1. It stores the workspace's first success time in `org_settings` (`first_successful_call_at`), unless it is already there. The check stops at this indexed read for every later call, and after a restart. +2. If that first success is less than 24 hours old and the workspace is on the **no-card trial**, it marks the workspace eligible: `org_settings.card_trial_offer_eligible_at` plus one `card_trial_offer_eligible` product event (`metadata.connectorId`, `adapterSlug`, `toolName`). The settings row is created with `ON CONFLICT DO NOTHING`, so two processes never record it twice. + +No-card trial means: a running `trial` licence, and no other licence in any state. Paying workspaces, card trials (a `starter`/`team` licence while Stripe says trialing), former customers and ended trials are not eligible, and keep the existing banners and licence wall. Workspaces that activated before this shipped get their first success stored, not the offer. + +**In the app.** `GET /api/license/card-trial-offer` answers `{ offer: { connectorName, trialEndsAt } }` for an admin of an eligible workspace while a card trial can still start (48 hours of free trial left), and `{ offer: null }` for everyone else, members who are not admins included. The dashboard, the connector list and the connect page (`/mcp-server/`, which re-asks a few seconds after the live check sees the first request) show a dismissible card: + +> **Your AI just ran its first My Shopify store action.** +> Keep it running: start your 7-day trial, and My Shopify store keeps working after your free trial ends on 18 October 2026. +> [Not now] [Start your 7-day trial] + +The button opens `/start-trial?source=first_success`. "Not now" hides the card for three days on every page (localStorage, per user). Events: `card_trial_offer_shown` (once a day per user and browser), `card_trial_offer_clicked`, `card_trial_offer_dismissed`, each with `metadata.via` = `dashboard`, `connectors` or `connect`. + +**Email.** The hourly Cloud onboarding cron runs `CardTrialOfferService.runEmailPass` after its trial-lifecycle and win-back passes. One email per workspace, to its active, verified admins who did not unsubscribe, between 30 minutes and 48 hours after it became eligible (30 to 90 minutes in practice). It is checked again at send time and is not sent once the workspace started a card trial, pays, or its trial ended. It waits while another email went out in the last 12 hours: a trial-lifecycle or win-back email to the workspace (`org_settings.trial_email_*`), or an onboarding or activation nudge to one of its admins (`users.onboarding_last_reminder_at`, `users.activation_reminder_at`). `org_settings.card_trial_offer_email` settles it: the send time, or `skipped:` (`customer`, `trial_ended`, `no_trial`, `no_recipient`). A failed send is retried by the next run inside the window. Sent emails record `card_trial_offer_emailed` (`metadata.kind` = `trial`, or `plan` when under 48 hours were left and the email linked to the plans instead). + +The email talks about value only (the connection works, up to three things to ask next, taken from the connector's own tools, the trial's end date and a link back to the app), never about cards, charges or prices, and it carries the unsubscribe line. Nothing of the offer is ever part of an MCP tool response. + +## Attribution to Stripe + +1. `/start-trial?source=first_success` sends `source: "first_success"` with `POST /api/license/checkout-link`. The backend accepts only known sources and records `checkout_link_created` (`metadata.via` = the source, or `app` for every other entry point; `metadata.kind` = `trial` or `pay`). +2. The backend passes `source` to the licence site's `POST /api/stripe/checkout-intent`, which stores it on the intent. +3. The site writes it to the Stripe Checkout Session and to the subscription as `metadata.trial_source = "first_success"`. + +A licence site without step 2 ignores the field, so the backend can be deployed first. + +## Weekly report + +**Cloud database:** eligible workspaces by the week they became eligible, and how far each got. + +```sql +WITH eligible AS ( + SELECT organization_id, min(created_at) AS eligible_at + FROM product_events + WHERE event = 'card_trial_offer_eligible' + GROUP BY 1 +), +steps AS ( + SELECT organization_id, + bool_or(event = 'card_trial_offer_shown') AS shown, + bool_or(event = 'card_trial_offer_clicked') AS clicked, + bool_or(event = 'card_trial_offer_dismissed') AS dismissed, + bool_or(event = 'card_trial_offer_emailed') AS emailed, + bool_or(event = 'checkout_link_created' AND metadata->>'via' = 'first_success') AS checkout_opened + FROM product_events + WHERE event IN ('card_trial_offer_shown', 'card_trial_offer_clicked', 'card_trial_offer_dismissed', + 'card_trial_offer_emailed', 'checkout_link_created') + GROUP BY 1 +), +subscribed AS ( + -- Any Stripe licence after the offer, from any entry point (card trial or paid). + SELECT e.organization_id + FROM eligible e + JOIN licenses l ON l.organization_id = e.organization_id + WHERE l.plan <> 'trial' AND l.created_at >= e.eligible_at + GROUP BY 1 +) +SELECT date_trunc('week', e.eligible_at)::date AS week, + count(*) AS eligible, + count(*) FILTER (WHERE s.shown) AS shown, + count(*) FILTER (WHERE s.clicked) AS clicked, + count(*) FILTER (WHERE s.dismissed) AS dismissed, + count(*) FILTER (WHERE s.emailed) AS emailed, + count(*) FILTER (WHERE s.checkout_opened) AS checkout_opened, + count(sub.organization_id) AS subscribed_any_source +FROM eligible e +LEFT JOIN steps s USING (organization_id) +LEFT JOIN subscribed sub USING (organization_id) +GROUP BY 1 +ORDER BY 1 DESC; +``` + +**Stripe:** card trials started from the offer, and how many of them paid after the trial, by the week the subscription was created. Run with the licence site's Stripe secret key in `STRIPE_KEY` (the Search API returns at most 100 rows per page; follow `next_page` when `has_more` is true). + +```bash +curl -sG https://api.stripe.com/v1/subscriptions/search -u "$STRIPE_KEY:" \ + --data-urlencode "query=metadata['trial_source']:'first_success'" \ + -d limit=100 -d 'expand[]=data.latest_invoice' | +jq -r --argjson now "$(date +%s)" ' + [.data[] | { + week: (.created | strftime("%G-W%V")), + card_trial: (.trial_end != null), + trial_over: ((.trial_end // 0) < $now), + paid: ((.latest_invoice.amount_paid // 0) > 0) + }] + | group_by(.week)[] + | [ .[0].week, + (map(select(.card_trial)) | length), + (map(select(.card_trial and .trial_over)) | length), + (map(select(.card_trial and .trial_over and .paid)) | length) ] + | @tsv' | +(echo -e "week\tcard_trials_started\ttrials_ended\tpaid_after_trial"; cat) +``` + +`paid_after_trial` counts subscriptions whose latest invoice, the first one after the trial, was paid with a non-zero amount. `metadata.organizationId` on each subscription joins it back to the workspace and its `card_trial_offer_eligible` week. diff --git a/packages/backend/src/audit/audit.module.ts b/packages/backend/src/audit/audit.module.ts index 0f5565e26..3c3d59f2c 100644 --- a/packages/backend/src/audit/audit.module.ts +++ b/packages/backend/src/audit/audit.module.ts @@ -1,6 +1,7 @@ import { Global, Module } from '@nestjs/common'; import { AuditService } from './audit.service'; import { AdsActivationService } from './ads-activation.service'; +import { FirstSuccessService } from './first-success.service'; import { AuditController } from './audit.controller'; import { SecurityEventService } from './security-event.service'; import { ProductEventService } from './product-event.service'; @@ -12,7 +13,7 @@ import { AlertsModule } from '../alerts/alerts.module'; @Module({ imports: [AlertsModule], controllers: [AuditController, ProductEventController, SignupAttributionController], - providers: [AuditService, SecurityEventService, ProductEventService, AdsActivationService], + providers: [AuditService, SecurityEventService, ProductEventService, AdsActivationService, FirstSuccessService], exports: [AuditService, SecurityEventService, ProductEventService], }) export class AuditModule {} diff --git a/packages/backend/src/audit/audit.service.ts b/packages/backend/src/audit/audit.service.ts index fdf479a27..5ea2a8188 100644 --- a/packages/backend/src/audit/audit.service.ts +++ b/packages/backend/src/audit/audit.service.ts @@ -28,6 +28,7 @@ const REPEAT_KEYS_MAX = 10_000; import { PrismaService } from '../common/prisma.service'; import { InvocationStatus, Prisma } from '../generated/prisma/client'; import { AdsActivationService } from './ads-activation.service'; +import { FirstSuccessService } from './first-success.service'; import { AlertsService } from '../alerts/alerts.service'; @Injectable() @@ -43,6 +44,7 @@ export class AuditService implements OnModuleDestroy { private readonly prisma: PrismaService, @Optional() private readonly adsActivation?: AdsActivationService, @Optional() private readonly alerts?: AlertsService, + @Optional() private readonly firstSuccess?: FirstSuccessService, ) { this.flushTimer = setInterval(() => void this.flushRepeats(false), 30_000); this.flushTimer.unref?.(); @@ -140,7 +142,7 @@ export class AuditService implements OnModuleDestroy { if (data.status === 'SUCCESS' && resolvedUserId) { await this.stampFirstSuccess(resolvedUserId); } - if (data.status === 'SUCCESS') this.reportActivation(data.organizationId); + if (data.status === 'SUCCESS') this.reportActivation(data); } catch (error: any) { // FK violation should be impossible after resolveUserId, but // keep the safety net: if it still trips, retry without user_id @@ -162,7 +164,7 @@ export class AuditService implements OnModuleDestroy { clientInfo: data.clientInfo, }, }); - if (data.status === 'SUCCESS') this.reportActivation(data.organizationId); + if (data.status === 'SUCCESS') this.reportActivation(data); return; } catch (retryError: any) { this.logger.warn( @@ -182,14 +184,24 @@ export class AuditService implements OnModuleDestroy { /** * Cloud: a workspace's first success may be a Google Ads activation (see - * ads-activation.service.ts). Runs in the background and never throws here. + * ads-activation.service.ts), and on the no-card trial it makes the + * workspace eligible for the card-trial offer (first-success.service.ts). + * Both run in the background and never throw here. */ - private reportActivation(organizationId?: string): void { + private reportActivation(data: { organizationId?: string; connectorId?: string; toolId: string }): void { try { - void this.adsActivation?.onSuccessfulInvocation(organizationId); + void this.adsActivation?.onSuccessfulInvocation(data.organizationId); } catch (err: any) { this.logger.debug(`Ads activation hook failed: ${err?.message ?? err}`); } + try { + void this.firstSuccess?.onSuccessfulInvocation(data.organizationId, { + connectorId: data.connectorId, + toolId: data.toolId, + }); + } catch (err: any) { + this.logger.debug(`First-success hook failed: ${err?.message ?? err}`); + } } /** diff --git a/packages/backend/src/audit/first-success.service.spec.ts b/packages/backend/src/audit/first-success.service.spec.ts new file mode 100644 index 000000000..db0a1f120 --- /dev/null +++ b/packages/backend/src/audit/first-success.service.spec.ts @@ -0,0 +1,230 @@ +/** + * The first successful tool call of a Cloud workspace: stored once, and on + * the no-card trial it makes the workspace eligible for the card-trial offer, + * once. Cheap on the call path: one indexed read per workspace and process. + */ +import { FirstSuccessService } from './first-success.service'; +import { ProductEventService } from './product-event.service'; +import { AuditService } from './audit.service'; +import { + FIRST_SUCCESS_KEY, + OFFER_ELIGIBLE_KEY, + offerLicenceState, + type OfferLicence, +} from '../license/card-trial-offer'; + +const NOW = new Date('2026-10-13T10:00:00.000Z'); +const DAY = 24 * 60 * 60 * 1000; + +const trial = (overrides: Partial = {}): OfferLicence => ({ + plan: 'trial', + status: 'active', + expiresAt: new Date(NOW.getTime() + 5 * DAY), + createdAt: new Date(NOW.getTime() - 2 * DAY), + ...overrides, +}); + +function makeService( + opts: { + isCloud?: boolean; + licences?: OfferLicence[]; + firstSuccessAt?: Date | null; + settings?: Array<{ organizationId: string; key: string; value: string }>; + } = {}, +) { + const settings = [...(opts.settings ?? [])]; + const events: any[] = []; + const firstAt = opts.firstSuccessAt === undefined ? new Date(NOW.getTime() - 60_000) : opts.firstSuccessAt; + const prisma = { + orgSettings: { + findUnique: jest.fn(async ({ where }: any) => { + const { organizationId, key } = where.organizationId_key; + return settings.find((s) => s.organizationId === organizationId && s.key === key) ?? null; + }), + createMany: jest.fn(async ({ data }: any) => { + let count = 0; + for (const row of data) { + if (settings.some((s) => s.organizationId === row.organizationId && s.key === row.key)) continue; + settings.push(row); + count++; + } + return { count }; + }), + }, + toolInvocation: { + findFirst: jest.fn(async () => + firstAt ? { createdAt: firstAt, connectorId: 'con-1', toolId: 'tool-1' } : null, + ), + }, + license: { findMany: jest.fn(async () => opts.licences ?? [trial()]) }, + connector: { findFirst: jest.fn(async () => ({ config: { adapterSlug: 'shopify' } })) }, + mcpTool: { findUnique: jest.fn(async () => ({ name: 'shopify_list_orders' })) }, + productEvent: { create: jest.fn(async ({ data }: any) => events.push(data)) }, + }; + const deployment = { isCloud: () => opts.isCloud ?? true }; + const svc = new FirstSuccessService(prisma as any, deployment as any, new ProductEventService(prisma as any)); + return { svc, prisma, settings, events }; +} + +describe('offerLicenceState: who is on the no-card trial', () => { + it('is eligible on a running free trial', () => { + expect(offerLicenceState([trial()], NOW)).toBe('eligible'); + }); + + it('is not eligible once the workspace is or was a customer, in any state', () => { + // A card trial is a paid-plan licence while Stripe says trialing. + for (const status of ['active', 'revoked', 'expired']) { + expect(offerLicenceState([trial(), trial({ plan: 'starter', status, expiresAt: null })], NOW)).toBe('customer'); + } + expect(offerLicenceState([trial({ plan: 'team', expiresAt: new Date(NOW.getTime() + 30 * DAY) })], NOW)).toBe( + 'customer', + ); + }); + + it('is not eligible once the trial has ended, whatever its status says', () => { + expect(offerLicenceState([trial({ expiresAt: new Date(NOW.getTime() - 1) })], NOW)).toBe('trial_ended'); + expect(offerLicenceState([trial({ status: 'expired' })], NOW)).toBe('trial_ended'); + expect(offerLicenceState([trial({ status: 'revoked' })], NOW)).toBe('trial_ended'); + expect(offerLicenceState([trial({ expiresAt: null })], NOW)).toBe('trial_ended'); + }); + + it('reads the newest active licence, like the licence guard', () => { + const old = trial({ createdAt: new Date(NOW.getTime() - 9 * DAY), expiresAt: new Date(NOW.getTime() - 2 * DAY) }); + expect(offerLicenceState([old, trial()], NOW)).toBe('eligible'); + }); + + it('has nothing to offer without a licence', () => { + expect(offerLicenceState([], NOW)).toBe('no_trial'); + }); +}); + +describe('FirstSuccessService', () => { + beforeEach(() => { + jest.useFakeTimers({ now: NOW, doNotFake: ['nextTick', 'setImmediate'] }); + }); + afterEach(() => jest.useRealTimers()); + + it('does nothing on a self-hosted instance', () => { + const { svc, prisma } = makeService({ isCloud: false }); + expect(svc.onSuccessfulInvocation('org-1')).toBeUndefined(); + expect(prisma.orgSettings.findUnique).not.toHaveBeenCalled(); + }); + + it('does nothing without a workspace', () => { + const { svc, prisma } = makeService(); + expect(svc.onSuccessfulInvocation(null)).toBeUndefined(); + expect(prisma.orgSettings.findUnique).not.toHaveBeenCalled(); + }); + + it('stores the first success and marks a no-card trial eligible, with the call that did it', async () => { + const { svc, settings, events } = makeService(); + await svc.onSuccessfulInvocation('org-1', { connectorId: 'con-1', toolId: 'tool-1' }); + + expect(settings.find((s) => s.key === FIRST_SUCCESS_KEY)?.value).toBe(new Date(NOW.getTime() - 60_000).toISOString()); + expect(settings.find((s) => s.key === OFFER_ELIGIBLE_KEY)?.value).toBe(NOW.toISOString()); + expect(events).toEqual([ + { + event: 'card_trial_offer_eligible', + userId: null, + organizationId: 'org-1', + metadata: { connectorId: 'con-1', adapterSlug: 'shopify', toolName: 'shopify_list_orders' }, + }, + ]); + }); + + it('checks a workspace once per process: later calls cost nothing', async () => { + const { svc, prisma, events } = makeService(); + await svc.onSuccessfulInvocation('org-1'); + expect(svc.onSuccessfulInvocation('org-1')).toBeUndefined(); + expect(svc.onSuccessfulInvocation('org-1')).toBeUndefined(); + expect(prisma.orgSettings.findUnique).toHaveBeenCalledTimes(1); + expect(events).toHaveLength(1); + }); + + it('stops at one indexed read when the first success is already stored (another process, a restart)', async () => { + const { svc, prisma, events } = makeService({ + settings: [{ organizationId: 'org-1', key: FIRST_SUCCESS_KEY, value: '2026-10-12T09:00:00.000Z' }], + }); + await svc.onSuccessfulInvocation('org-1'); + expect(prisma.toolInvocation.findFirst).not.toHaveBeenCalled(); + expect(prisma.license.findMany).not.toHaveBeenCalled(); + expect(events).toHaveLength(0); + }); + + it('records the eligible event once even when two processes race', async () => { + const { svc, prisma, events } = makeService(); + // The other process created the eligibility row between our two writes. + prisma.orgSettings.createMany + .mockImplementationOnce(async () => ({ count: 1 })) + .mockImplementationOnce(async () => ({ count: 0 })); + await svc.onSuccessfulInvocation('org-1'); + expect(events).toHaveLength(0); + }); + + it.each([ + ['paying', [trial(), trial({ plan: 'starter', expiresAt: null })]], + ['on a card trial', [trial({ plan: 'team', expiresAt: new Date(NOW.getTime() + 5 * DAY) })]], + ['a former customer', [trial(), trial({ plan: 'starter', status: 'revoked', expiresAt: null })]], + ['past the trial', [trial({ expiresAt: new Date(NOW.getTime() - 60_000) })]], + ['without a licence', []], + ])('stores the first success but makes a workspace %s not eligible', async (_label, licences) => { + const { svc, settings, events } = makeService({ licences: licences as OfferLicence[] }); + await svc.onSuccessfulInvocation('org-1'); + expect(settings.map((s) => s.key)).toEqual([FIRST_SUCCESS_KEY]); + expect(events).toHaveLength(0); + }); + + it('does not make a workspace eligible whose first success is more than a day old', async () => { + const old = new Date(NOW.getTime() - 3 * DAY); + const { svc, prisma, settings, events } = makeService({ firstSuccessAt: old }); + await svc.onSuccessfulInvocation('org-1'); + // Its real first success is stored, for the funnel; no offer. + expect(settings).toEqual([{ organizationId: 'org-1', key: FIRST_SUCCESS_KEY, value: old.toISOString() }]); + expect(prisma.license.findMany).not.toHaveBeenCalled(); + expect(events).toHaveLength(0); + }); + + it('never rejects, and tries again on the next call after a failure', async () => { + const { svc, prisma, events } = makeService(); + prisma.orgSettings.findUnique.mockRejectedValueOnce(new Error('db down')); + await expect(svc.onSuccessfulInvocation('org-1')).resolves.toBeUndefined(); + expect(events).toHaveLength(0); + await svc.onSuccessfulInvocation('org-1'); + expect(events).toHaveLength(1); + }); +}); + +describe('AuditService → first success', () => { + it('hands every stored success to the first-success check, never a failure', async () => { + const prisma = { + toolInvocation: { create: jest.fn(async () => ({ id: 'inv-1' })) }, + user: { findUnique: jest.fn(async () => null), updateMany: jest.fn() }, + }; + const firstSuccess = { onSuccessfulInvocation: jest.fn() }; + const audit = new AuditService(prisma as any, undefined, undefined, firstSuccess as any); + try { + await audit.logInvocation({ + toolId: 'tool-1', + organizationId: 'org-1', + connectorId: 'con-1', + input: {}, + status: 'SUCCESS', + }); + await audit.logInvocation({ + toolId: 'tool-1', + organizationId: 'org-1', + connectorId: 'con-1', + input: {}, + status: 'ERROR', + error: 'boom', + }); + expect(firstSuccess.onSuccessfulInvocation).toHaveBeenCalledTimes(1); + expect(firstSuccess.onSuccessfulInvocation).toHaveBeenCalledWith('org-1', { + connectorId: 'con-1', + toolId: 'tool-1', + }); + } finally { + await audit.onModuleDestroy(); + } + }); +}); diff --git a/packages/backend/src/audit/first-success.service.ts b/packages/backend/src/audit/first-success.service.ts new file mode 100644 index 000000000..e44562b2a --- /dev/null +++ b/packages/backend/src/audit/first-success.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../common/prisma.service'; +import { DeploymentService } from '../common/deployment.service'; +import { ProductEvents, ProductEventService } from './product-event.service'; +import { + FIRST_SUCCESS_FRESH_MS, + FIRST_SUCCESS_KEY, + OFFER_ELIGIBLE_KEY, + offerLicenceState, +} from '../license/card-trial-offer'; + +/** Workspaces remembered as settled; forgetting one only costs one indexed read. */ +const SETTLED_MAX = 50_000; + +/** + * Cloud: a workspace's first successful tool call. + * + * AuditService calls onSuccessfulInvocation after it stored a SUCCESS row, + * like AdsActivationService. The first time a process sees a workspace it + * reads two OrgSettings rows by their unique key; after that the workspace is + * settled in memory and later calls cost nothing. The work runs in the + * background: the tool call never waits for it and nothing here throws into + * it. + * + * - Stores the workspace's first success time (OrgSettings + * `first_successful_call_at`) if it is not stored yet. A workspace active + * before this shipped gets its real first success, read from + * tool_invocations once. + * - If that first success is fresh (FIRST_SUCCESS_FRESH_MS) and the workspace + * is on the no-card trial, marks it eligible for the card-trial offer + * (OrgSettings `card_trial_offer_eligible_at` + the + * `card_trial_offer_eligible` product event, once per workspace). + * + * Paying workspaces, card trials, former customers and ended trials are not + * eligible and keep the existing licence wall and banners. Inert on + * self-hosted. Nothing here touches the tool's response. + */ +@Injectable() +export class FirstSuccessService { + private readonly logger = new Logger(FirstSuccessService.name); + private readonly settled = new Set(); + private readonly inFlight = new Set(); + + constructor( + private readonly prisma: PrismaService, + private readonly deployment: DeploymentService, + private readonly productEvents: ProductEventService, + ) {} + + /** + * A SUCCESS invocation of this workspace was stored. Returns at once; the + * promise is for tests and never rejects. + */ + onSuccessfulInvocation( + organizationId: string | null | undefined, + call: { connectorId?: string | null; toolId?: string | null } = {}, + ): Promise | undefined { + if (!organizationId || !this.deployment.isCloud()) return undefined; + if (this.settled.has(organizationId) || this.inFlight.has(organizationId)) return undefined; + this.inFlight.add(organizationId); + return this.check(organizationId, call) + .then(() => this.settle(organizationId)) + .catch((err) => + // Not settled: the next successful call of this workspace tries again. + this.logger.warn(`First-success check failed for ${organizationId}: ${err?.message ?? err}`), + ) + .finally(() => this.inFlight.delete(organizationId)); + } + + private async check( + organizationId: string, + call: { connectorId?: string | null; toolId?: string | null }, + ): Promise { + const stored = await this.prisma.orgSettings.findUnique({ + where: { organizationId_key: { organizationId, key: FIRST_SUCCESS_KEY } }, + select: { id: true }, + }); + if (stored) return; + + // The earliest success, not necessarily this one: a workspace that was + // active before this shipped keeps the time it really activated. + const first = await this.prisma.toolInvocation.findFirst({ + where: { organizationId, status: 'SUCCESS' }, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true, connectorId: true, toolId: true }, + }); + const firstAt = first?.createdAt ?? new Date(); + await this.prisma.orgSettings.createMany({ + data: [{ organizationId, key: FIRST_SUCCESS_KEY, value: firstAt.toISOString() }], + skipDuplicates: true, + }); + + const now = new Date(); + if (now.getTime() - firstAt.getTime() > FIRST_SUCCESS_FRESH_MS) return; + + const licences = await this.prisma.license.findMany({ + where: { organizationId }, + select: { plan: true, status: true, expiresAt: true, createdAt: true }, + }); + if (offerLicenceState(licences, now) !== 'eligible') return; + + // The unique (organizationId, key) row is the lock: of two processes + // that get here together, only the one that creates it logs the event. + const created = await this.prisma.orgSettings.createMany({ + data: [{ organizationId, key: OFFER_ELIGIBLE_KEY, value: now.toISOString() }], + skipDuplicates: true, + }); + if (created.count !== 1) return; + + const connectorId = first?.connectorId ?? call.connectorId ?? null; + const toolId = first?.toolId ?? call.toolId ?? null; + const [connector, tool] = await Promise.all([ + connectorId + ? this.prisma.connector.findFirst({ + where: { id: connectorId, organizationId }, + select: { config: true }, + }) + : null, + toolId ? this.prisma.mcpTool.findUnique({ where: { id: toolId }, select: { name: true } }) : null, + ]); + const adapterSlug = (connector?.config as { adapterSlug?: unknown } | null)?.adapterSlug; + await this.productEvents.log({ + event: ProductEvents.CARD_TRIAL_OFFER_ELIGIBLE, + organizationId, + metadata: { + ...(connectorId && { connectorId }), + ...(typeof adapterSlug === 'string' && { adapterSlug }), + ...(tool?.name && { toolName: tool.name }), + }, + }); + this.logger.log(`Workspace ${organizationId} made its first successful call on the no-card trial.`); + } + + private settle(organizationId: string): void { + this.settled.add(organizationId); + // A Set iterates in insertion order: drop the oldest when it is full. + if (this.settled.size > SETTLED_MAX) this.settled.delete(this.settled.values().next().value!); + } +} diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index 046c0dbc4..d7043b159 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -95,6 +95,35 @@ export const ProductEvents = { * good). Server-only. Its presence is what stops a second report. */ ADS_ACTIVATION_REPORTED: 'ads_activation_reported', + /** + * Cloud: a workspace on the no-card trial made its first successful tool + * call and is offered the card trial in the app and by email (see + * card-trial-offer.ts). Once per workspace; metadata.connectorId, + * adapterSlug and toolName = the call that did it. Server-only. + */ + CARD_TRIAL_OFFER_ELIGIBLE: 'card_trial_offer_eligible', + /** + * The in-app offer, as an admin saw it (at most once a day per user and + * browser), clicked its button, or dismissed it. metadata.via = the page: + * 'dashboard', 'connectors' or 'connect'. Read against the eligible event. + */ + CARD_TRIAL_OFFER_SHOWN: 'card_trial_offer_shown', + CARD_TRIAL_OFFER_CLICKED: 'card_trial_offer_clicked', + CARD_TRIAL_OFFER_DISMISSED: 'card_trial_offer_dismissed', + /** + * The offer email went out to the workspace's admins. metadata.connectorId, + * metadata.kind = 'trial' (linked to the card trial) or 'plan' (under 48 h + * of trial left, linked to the plans). Server-only. + */ + CARD_TRIAL_OFFER_EMAILED: 'card_trial_offer_emailed', + /** + * The app minted a Stripe Checkout link (POST /api/license/checkout-link). + * metadata.via = where it came from ('first_success', or 'app' for every + * other entry point), metadata.kind = 'trial' (card trial) or 'pay'. + * Server-only. Answers how many clicks reach Checkout; Stripe says how + * many finish (metadata.trial_source). + */ + CHECKOUT_LINK_CREATED: 'checkout_link_created', } as const; export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents]; @@ -109,6 +138,9 @@ const SERVER_ONLY = new Set([ ProductEvents.AI_CLIENT_CONNECTED, ProductEvents.EMPTY_WORKSPACE_PROMPT, ProductEvents.ADS_ACTIVATION_REPORTED, + ProductEvents.CARD_TRIAL_OFFER_ELIGIBLE, + ProductEvents.CARD_TRIAL_OFFER_EMAILED, + ProductEvents.CHECKOUT_LINK_CREATED, ]); const CLIENT_REPORTABLE = new Set( Object.values(ProductEvents).filter((e) => !SERVER_ONLY.has(e)), diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts index 4259904cb..834c33286 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts @@ -93,6 +93,30 @@ describe('OnboardingCronService — activation pass', () => { ); }); + it('runs the card-trial offer email pass after the trial and win-back passes', async () => { + const { service } = makeService({}); + const order: string[] = []; + const prisma = (service as any).prisma; + prisma.license.findMany.mockImplementation(async () => { + order.push('license'); + return []; + }); + const offer = { + runEmailPass: jest.fn(async () => { + order.push('offer'); + return { examined: 2, sent: 1, skipped: 0, postponed: 1, failed: 0 }; + }), + }; + (service as any).cardTrialOffer = offer; + + const out = await service.run(); + + expect(offer.runEmailPass).toHaveBeenCalledTimes(1); + expect(out.cardTrialOffers).toBe(1); + // Lifecycle + win-back read the licences first, so their emails hold this one back. + expect(order).toEqual(['license', 'license', 'offer']); + }); + it('does not stamp when the email fails to send', async () => { const { service, update } = makeService({ stuckUsers: [ diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.ts index 045af8b39..591d86c4d 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { createHash, randomUUID } from 'crypto'; import { PrismaService } from '../../common/prisma.service'; import { isConsumerEmail } from '../../common/consumer-email-domains'; @@ -7,6 +7,7 @@ import { EmailService } from '../../settings/email.service'; import type { WinbackOffer } from '../../settings/email-templates'; import { LicenseService } from '../../license/license.service'; import { LicenseReleaseService } from '../../license/license-release.service'; +import { CardTrialOfferService } from '../../license/card-trial-offer.service'; const HOURS = (n: number) => n * 60 * 60 * 1000; @@ -175,6 +176,7 @@ export class OnboardingCronService { private readonly email: EmailService, private readonly license: LicenseService, private readonly licenseRelease: LicenseReleaseService, + @Optional() private readonly cardTrialOffer?: CardTrialOfferService, ) {} /** run() unless another run holds the lease; null when skipped. */ @@ -230,6 +232,7 @@ export class OnboardingCronService { winbackFirstMonth: number; winbackControl: number; winbackHelp: number; + cardTrialOffers: number; skipped: number; }> { const now = Date.now(); @@ -250,6 +253,7 @@ export class OnboardingCronService { winbackFirstMonth: 0, winbackControl: 0, winbackHelp: 0, + cardTrialOffers: 0, skipped: 0, }; @@ -395,6 +399,9 @@ export class OnboardingCronService { await this.runActivationPass(now, out); await this.runTrialLifecyclePass(now, out); await this.runWinbackPass(now, out); + // After the lifecycle and win-back passes, so an email they just sent + // holds this one back (12 hours; see card-trial-offer.service.ts). + out.cardTrialOffers = (await this.cardTrialOffer?.runEmailPass(new Date(now)))?.sent ?? 0; out.trialsMarkedExpired = await this.markExpiredTrials(now); // Before nudging anyone about their trial, make sure they actually got one. @@ -421,6 +428,7 @@ export class OnboardingCronService { `licensesReverified=${out.licensesReverified} licensesDeactivated=${out.licensesDeactivated} ` + `licensesReleased=${out.licensesReleased} winbackOffers=${out.winbackOffers} ` + `winbackFirstMonth=${out.winbackFirstMonth} winbackControl=${out.winbackControl} winbackHelp=${out.winbackHelp} ` + + `cardTrialOffers=${out.cardTrialOffers} ` + `skipped=${out.skipped}`, ); return out; diff --git a/packages/backend/src/license/card-trial-offer.service.ts b/packages/backend/src/license/card-trial-offer.service.ts new file mode 100644 index 000000000..e8fdc452a --- /dev/null +++ b/packages/backend/src/license/card-trial-offer.service.ts @@ -0,0 +1,275 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../common/prisma.service'; +import { DeploymentService } from '../common/deployment.service'; +import { EmailService } from '../settings/email.service'; +import { ProductEvents, ProductEventService } from '../audit/product-event.service'; +import { + FIRST_SUCCESS_SOURCE, + OFFER_ELIGIBLE_KEY, + OFFER_EMAIL_AFTER_MS, + OFFER_EMAIL_KEY, + OFFER_EMAIL_QUIET_MS, + OFFER_EMAIL_UNTIL_MS, + OTHER_TRIAL_EMAIL_KEYS, + OfferLicence, + cardTrialStillAvailable, + offerLicenceState, + offerTrialEnd, + pickNextSteps, +} from './card-trial-offer'; + +/** What the app shows an admin of an eligible workspace. */ +export interface CardTrialOffer { + /** The connector of the first successful call; null when it is gone. */ + connectorName: string | null; + /** When the free trial (and so the card trial) ends, ISO. */ + trialEndsAt: string; +} + +export interface CardTrialOfferEmailRun { + /** Eligible workspaces inside the email window. */ + examined: number; + sent: number; + /** Settled without an email: no longer eligible, or nobody to write to. */ + skipped: number; + /** Another email went out less than 12 hours ago; the next run asks again. */ + postponed: number; + /** Every send failed; the next run tries again inside the window. */ + failed: number; +} + +/** Tools read per connector to suggest next steps; plenty for three picks. */ +const MAX_TOOLS_READ = 60; + +/** + * The card-trial offer after the first success (card-trial-offer.ts), Cloud + * only: what the app shows (currentOffer) and the one email (runEmailPass, + * called hourly by the Cloud onboarding cron). + * + * Nothing here is ever part of an MCP tool response: the offer lives in the + * app and in one email, per the Claude directory policy (no promotion inside + * what the model receives). + */ +@Injectable() +export class CardTrialOfferService { + private readonly logger = new Logger(CardTrialOfferService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly deployment: DeploymentService, + private readonly email: EmailService, + private readonly productEvents: ProductEventService, + ) {} + + /** + * The offer for this signed-in user, or null. Only admins (the only role + * that can start a subscription), only on Cloud, only while the workspace + * is eligible and a card trial can still start (48 h of trial left). + */ + async currentOffer( + organizationId: string | null | undefined, + role: string | null | undefined, + now: Date = new Date(), + ): Promise { + if (!this.deployment.isCloud() || !organizationId || role !== 'ADMIN') return null; + const eligible = await this.prisma.orgSettings.findUnique({ + where: { organizationId_key: { organizationId, key: OFFER_ELIGIBLE_KEY } }, + select: { id: true }, + }); + if (!eligible) return null; + + const trialEndsAt = offerTrialEnd(await this.licences(organizationId), now); + if (!cardTrialStillAvailable(trialEndsAt, now)) return null; + + const { connector } = await this.firstSuccessConnector(organizationId); + return { connectorName: connector?.name ?? null, trialEndsAt: trialEndsAt!.toISOString() }; + } + + /** + * One email per eligible workspace, to its active, verified admins who did + * not opt out of these emails, between 30 minutes and 48 hours after it + * became eligible (the cron runs hourly, so 30 to 90 minutes in practice). + * + * - Sent once: OrgSettings `card_trial_offer_email` is set when it went + * out, and when it never will (no longer eligible, nobody to write to). + * - Re-checked at send time: a workspace that started a card trial, pays, + * or whose trial ended in the meantime gets nothing. + * - Quiet: not within 12 hours of a trial-lifecycle or win-back email to the + * workspace, or an onboarding or activation nudge to one of its admins. + * Postponed, not dropped; past 48 hours it is dropped. + * + * Deleted workspaces take their events and settings with them, so they are + * never selected. Never throws: a failure is logged and counted. + */ + async runEmailPass(now: Date = new Date()): Promise { + const out: CardTrialOfferEmailRun = { examined: 0, sent: 0, skipped: 0, postponed: 0, failed: 0 }; + if (!this.deployment.isCloud()) return out; + + let events: Array<{ organizationId: string | null }>; + try { + events = await this.prisma.productEvent.findMany({ + where: { + event: ProductEvents.CARD_TRIAL_OFFER_ELIGIBLE, + createdAt: { + gte: new Date(now.getTime() - OFFER_EMAIL_UNTIL_MS), + lte: new Date(now.getTime() - OFFER_EMAIL_AFTER_MS), + }, + }, + select: { organizationId: true }, + }); + } catch (err: any) { + this.logger.warn(`Card-trial offer emails: could not read eligible workspaces: ${err?.message ?? err}`); + return out; + } + + const orgIds = [...new Set(events.map((e) => e.organizationId).filter((id): id is string => !!id))]; + for (const organizationId of orgIds) { + try { + const result = await this.emailWorkspace(organizationId, now); + if (result === 'already') continue; + out.examined++; + out[result]++; + } catch (err: any) { + out.examined++; + out.failed++; + this.logger.warn(`Card-trial offer email for ${organizationId} failed: ${err?.message ?? err}`); + } + } + if (out.examined > 0) { + this.logger.log( + `Card-trial offer emails: examined=${out.examined} sent=${out.sent} skipped=${out.skipped} ` + + `postponed=${out.postponed} failed=${out.failed}`, + ); + } + return out; + } + + private async emailWorkspace( + organizationId: string, + now: Date, + ): Promise<'already' | 'sent' | 'skipped' | 'postponed' | 'failed'> { + const flags = await this.prisma.orgSettings.findMany({ + where: { organizationId, key: { in: [OFFER_EMAIL_KEY, ...OTHER_TRIAL_EMAIL_KEYS] } }, + select: { key: true, updatedAt: true }, + }); + if (flags.some((f) => f.key === OFFER_EMAIL_KEY)) return 'already'; + + const licences = await this.licences(organizationId); + const state = offerLicenceState(licences, now); + if (state !== 'eligible') { + await this.settle(organizationId, `skipped:${state}`); + return 'skipped'; + } + + const quietSince = now.getTime() - OFFER_EMAIL_QUIET_MS; + if (flags.some((f) => f.updatedAt.getTime() >= quietSince)) return 'postponed'; + + const admins = await this.prisma.organizationMember.findMany({ + where: { organizationId, role: 'ADMIN', deactivatedAt: null }, + select: { + user: { + select: { + email: true, + name: true, + emailVerified: true, + emailMarketingOptOut: true, + onboardingLastReminderAt: true, + activationReminderAt: true, + }, + }, + }, + }); + const recipients = admins.map((a) => a.user).filter((u) => u.emailVerified && !u.emailMarketingOptOut); + if (recipients.length === 0) { + await this.settle(organizationId, 'skipped:no_recipient'); + return 'skipped'; + } + const nudgedRecently = recipients.some((u) => + [u.onboardingLastReminderAt, u.activationReminderAt].some((at) => !!at && at.getTime() >= quietSince), + ); + if (nudgedRecently) return 'postponed'; + + const trialEndsAt = offerTrialEnd(licences, now)!; + const cardTrial = cardTrialStillAvailable(trialEndsAt, now); + const { connector, toolName } = await this.firstSuccessConnector(organizationId, true); + const nextSteps = pickNextSteps(connector?.tools ?? [], toolName); + const ctaPath = cardTrial + ? `/start-trial?source=${FIRST_SUCCESS_SOURCE}` + : `/settings/license?source=${FIRST_SUCCESS_SOURCE}`; + + let sentAny = false; + for (const r of recipients) { + const ok = await this.email.sendCardTrialOfferEmail(r.email, { + name: r.name || 'there', + connectorName: connector?.name ?? null, + nextSteps, + trialEndsAt, + cardTrial, + ctaPath, + }); + if (ok) sentAny = true; + } + // A failed send does not settle it: the next run retries inside the window. + if (!sentAny) return 'failed'; + + await this.settle(organizationId, now.toISOString()); + await this.productEvents.log({ + event: ProductEvents.CARD_TRIAL_OFFER_EMAILED, + organizationId, + metadata: { + ...(connector?.id && { connectorId: connector.id }), + kind: cardTrial ? 'trial' : 'plan', + }, + }); + return 'sent'; + } + + private async licences(organizationId: string): Promise { + return this.prisma.license.findMany({ + where: { organizationId }, + select: { plan: true, status: true, expiresAt: true, createdAt: true }, + }); + } + + /** The connector (scoped to the workspace) and tool of the call that made it eligible. */ + private async firstSuccessConnector( + organizationId: string, + withTools = false, + ): Promise<{ + connector: { id: string; name: string; tools?: Array<{ name: string; description: string }> } | null; + toolName: string | null; + }> { + const event = await this.prisma.productEvent.findFirst({ + where: { organizationId, event: ProductEvents.CARD_TRIAL_OFFER_ELIGIBLE }, + orderBy: { createdAt: 'asc' }, + select: { metadata: true }, + }); + const meta = (event?.metadata ?? {}) as { connectorId?: unknown; toolName?: unknown }; + const toolName = typeof meta.toolName === 'string' ? meta.toolName : null; + if (typeof meta.connectorId !== 'string') return { connector: null, toolName }; + const connector = await this.prisma.connector.findFirst({ + where: { id: meta.connectorId, organizationId }, + select: { + id: true, + name: true, + ...(withTools && { + tools: { + where: { isEnabled: true, deprecatedAt: null }, + select: { name: true, description: true }, + orderBy: { createdAt: 'asc' }, + take: MAX_TOOLS_READ, + }, + }), + }, + }); + return { connector: connector ?? null, toolName }; + } + + private async settle(organizationId: string, value: string): Promise { + await this.prisma.orgSettings.upsert({ + where: { organizationId_key: { organizationId, key: OFFER_EMAIL_KEY } }, + create: { organizationId, key: OFFER_EMAIL_KEY, value }, + update: { value }, + }); + } +} diff --git a/packages/backend/src/license/card-trial-offer.spec.ts b/packages/backend/src/license/card-trial-offer.spec.ts new file mode 100644 index 000000000..deef1a05d --- /dev/null +++ b/packages/backend/src/license/card-trial-offer.spec.ts @@ -0,0 +1,362 @@ +/** + * The card-trial offer after the first successful tool call (Cloud only): + * who sees it in the app, and the one email, sent once, held back near other + * emails, and only while the workspace is still on the no-card trial. + */ +import { CardTrialOfferService } from './card-trial-offer.service'; +import { ProductEventService } from '../audit/product-event.service'; +import { + OFFER_ELIGIBLE_KEY, + OFFER_EMAIL_KEY, + cardTrialStillAvailable, + firstSentence, + pickNextSteps, + type OfferLicence, +} from './card-trial-offer'; + +const NOW = new Date('2026-10-13T10:17:00.000Z'); +const MIN = 60 * 1000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +const trial = (overrides: Partial = {}): OfferLicence => ({ + plan: 'trial', + status: 'active', + expiresAt: new Date(NOW.getTime() + 5 * DAY), + createdAt: new Date(NOW.getTime() - 2 * DAY), + ...overrides, +}); + +interface Admin { + email: string; + name: string | null; + emailVerified?: boolean; + emailMarketingOptOut?: boolean; + onboardingLastReminderAt?: Date | null; + activationReminderAt?: Date | null; +} + +const TOOLS = [ + { name: 'shopify_list_orders', description: 'List the store’s recent orders, newest first. Supports filters.' }, + { name: 'shopify_get_product', description: 'Get one product with its variants and stock.' }, + { name: 'shopify_graphql_schema', description: 'Return the GraphQL schema of the Admin API.' }, + { name: 'shopify_create_discount', description: 'Create a discount code for a price rule.' }, + { name: 'shopify_search_customers', description: 'Search customers by name, email or tag.' }, +]; + +function makeService( + opts: { + isCloud?: boolean; + licences?: OfferLicence[]; + /** When the workspace became eligible; null = never. */ + eligibleAt?: Date | null; + flags?: Array<{ key: string; updatedAt: Date }>; + admins?: Admin[]; + connector?: { id: string; name: string; tools?: typeof TOOLS } | null; + sendOk?: boolean; + } = {}, +) { + const eligibleAt = opts.eligibleAt === undefined ? new Date(NOW.getTime() - 40 * MIN) : opts.eligibleAt; + const settings: Array<{ organizationId: string; key: string; value: string; updatedAt: Date }> = [ + ...(eligibleAt ? [{ organizationId: 'org-1', key: OFFER_ELIGIBLE_KEY, value: eligibleAt.toISOString(), updatedAt: eligibleAt }] : []), + ...(opts.flags ?? []).map((f) => ({ organizationId: 'org-1', key: f.key, value: f.updatedAt.toISOString(), updatedAt: f.updatedAt })), + ]; + const events: any[] = eligibleAt + ? [ + { + event: 'card_trial_offer_eligible', + organizationId: 'org-1', + createdAt: eligibleAt, + metadata: { connectorId: 'con-1', adapterSlug: 'shopify', toolName: 'shopify_list_orders' }, + }, + ] + : []; + const admins = (opts.admins ?? [{ email: 'ada@example.com', name: 'Ada' }]).map((a) => ({ + user: { + emailVerified: true, + emailMarketingOptOut: false, + onboardingLastReminderAt: null, + activationReminderAt: null, + ...a, + }, + })); + const connector = + opts.connector === undefined ? { id: 'con-1', name: 'My Shopify store', tools: TOOLS } : opts.connector; + + const inRange = (at: Date, r: any) => (!r?.gte || at >= r.gte) && (!r?.lte || at <= r.lte); + const prisma = { + orgSettings: { + findUnique: jest.fn(async ({ where }: any) => { + const { organizationId, key } = where.organizationId_key; + return settings.find((s) => s.organizationId === organizationId && s.key === key) ?? null; + }), + findMany: jest.fn(async ({ where }: any) => + settings.filter((s) => s.organizationId === where.organizationId && where.key.in.includes(s.key)), + ), + upsert: jest.fn(async ({ create, update, where }: any) => { + const { organizationId, key } = where.organizationId_key; + const row = settings.find((s) => s.organizationId === organizationId && s.key === key); + if (row) Object.assign(row, update, { updatedAt: NOW }); + else settings.push({ ...create, updatedAt: NOW }); + }), + }, + license: { findMany: jest.fn(async () => opts.licences ?? [trial()]) }, + productEvent: { + findMany: jest.fn(async ({ where }: any) => + events.filter((e) => e.event === where.event && inRange(e.createdAt, where.createdAt)), + ), + findFirst: jest.fn(async ({ where }: any) => + events.find((e) => e.event === where.event && e.organizationId === where.organizationId) ?? null, + ), + create: jest.fn(async ({ data }: any) => events.push({ ...data, createdAt: NOW })), + }, + organizationMember: { findMany: jest.fn(async () => admins) }, + connector: { + findFirst: jest.fn(async ({ where, select }: any) => { + if (!connector || where.id !== connector.id || where.organizationId !== 'org-1') return null; + return select.tools ? connector : { id: connector.id, name: connector.name }; + }), + }, + }; + const email = { sendCardTrialOfferEmail: jest.fn(async () => opts.sendOk ?? true) }; + const deployment = { isCloud: () => opts.isCloud ?? true }; + const svc = new CardTrialOfferService( + prisma as any, + deployment as any, + email as any, + new ProductEventService(prisma as any), + ); + const emailFlag = () => settings.find((s) => s.key === OFFER_EMAIL_KEY)?.value; + const emailedEvents = () => events.filter((e) => e.event === 'card_trial_offer_emailed'); + return { svc, prisma, email, settings, emailFlag, emailedEvents }; +} + +describe('next steps for the email', () => { + it('suggests reads first, skips the tool just used and the meta tools', () => { + const steps = pickNextSteps(TOOLS, 'shopify_list_orders'); + expect(steps.map((s) => s.name)).toEqual([ + 'shopify_get_product', + 'shopify_search_customers', + 'shopify_create_discount', + ]); + expect(steps[0].text).toBe('Get one product with its variants and stock.'); + }); + + it('caps at three and drops duplicate or empty descriptions', () => { + const tools = [ + { name: 'a_list_x', description: 'List all the invoices of the account.' }, + { name: 'b_list_x', description: 'List all the invoices of the account.' }, + { name: 'c_get_x', description: '' }, + { name: 'd_get_x', description: null }, + ]; + expect(pickNextSteps(tools).map((s) => s.name)).toEqual(['a_list_x']); + expect(pickNextSteps([...TOOLS, ...TOOLS])).toHaveLength(3); + expect(pickNextSteps([])).toEqual([]); + }); + + it('keeps the first sentence, in plain text, without long dashes, and capped', () => { + expect(firstSentence('Use `search` to find **orders**. Then more.')).toBe('Use search to find orders.'); + expect(firstSentence('list orders — newest first')).toBe('List orders, newest first'); + const long = firstSentence(`Returns ${'very '.repeat(60)}long text.`)!; + expect(long.length).toBeLessThanOrEqual(140); + expect(long.endsWith('…')).toBe(true); + expect(firstSentence(' ')).toBeNull(); + }); + + it('a card trial needs 48 hours of the free trial left', () => { + expect(cardTrialStillAvailable(new Date(NOW.getTime() + 49 * HOUR), NOW)).toBe(true); + expect(cardTrialStillAvailable(new Date(NOW.getTime() + 47 * HOUR), NOW)).toBe(false); + expect(cardTrialStillAvailable(null, NOW)).toBe(false); + }); +}); + +describe('CardTrialOfferService.currentOffer (in-app)', () => { + it('shows an admin of an eligible workspace the connector and the trial end', async () => { + const { svc } = makeService(); + await expect(svc.currentOffer('org-1', 'ADMIN', NOW)).resolves.toEqual({ + connectorName: 'My Shopify store', + trialEndsAt: new Date(NOW.getTime() + 5 * DAY).toISOString(), + }); + }); + + it('shows nothing to members who cannot start a trial', async () => { + const { svc, prisma } = makeService(); + for (const role of ['EDITOR', 'VIEWER', undefined]) { + await expect(svc.currentOffer('org-1', role, NOW)).resolves.toBeNull(); + } + expect(prisma.orgSettings.findUnique).not.toHaveBeenCalled(); + }); + + it('shows nothing on a self-hosted instance', async () => { + const { svc, prisma } = makeService({ isCloud: false }); + await expect(svc.currentOffer('org-1', 'ADMIN', NOW)).resolves.toBeNull(); + expect(prisma.orgSettings.findUnique).not.toHaveBeenCalled(); + }); + + it('shows nothing to a workspace that never became eligible', async () => { + const { svc } = makeService({ eligibleAt: null }); + await expect(svc.currentOffer('org-1', 'ADMIN', NOW)).resolves.toBeNull(); + }); + + it.each([ + ['started a card trial', [trial(), trial({ plan: 'starter', expiresAt: new Date(NOW.getTime() + 5 * DAY) })]], + ['pays', [trial({ plan: 'team', expiresAt: null })]], + ['is past its trial', [trial({ expiresAt: new Date(NOW.getTime() - MIN) })]], + ['has under 48 hours of trial left', [trial({ expiresAt: new Date(NOW.getTime() + 30 * HOUR) })]], + ])('hides the offer once the workspace %s', async (_label, licences) => { + const { svc } = makeService({ licences: licences as OfferLicence[] }); + await expect(svc.currentOffer('org-1', 'ADMIN', NOW)).resolves.toBeNull(); + }); + + it('still offers when the connector was deleted, without its name', async () => { + const { svc } = makeService({ connector: null }); + await expect(svc.currentOffer('org-1', 'ADMIN', NOW)).resolves.toMatchObject({ connectorName: null }); + }); +}); + +describe('CardTrialOfferService.runEmailPass', () => { + it('emails the admins about 30 minutes after, with the connector, next steps and the card-trial link', async () => { + const { svc, email, emailFlag, emailedEvents } = makeService(); + const run = await svc.runEmailPass(NOW); + + expect(run).toEqual({ examined: 1, sent: 1, skipped: 0, postponed: 0, failed: 0 }); + expect(email.sendCardTrialOfferEmail).toHaveBeenCalledWith('ada@example.com', { + name: 'Ada', + connectorName: 'My Shopify store', + nextSteps: [ + { name: 'shopify_get_product', text: 'Get one product with its variants and stock.' }, + { name: 'shopify_search_customers', text: 'Search customers by name, email or tag.' }, + { name: 'shopify_create_discount', text: 'Create a discount code for a price rule.' }, + ], + trialEndsAt: new Date(NOW.getTime() + 5 * DAY), + cardTrial: true, + ctaPath: '/start-trial?source=first_success', + }); + expect(emailFlag()).toBe(NOW.toISOString()); + expect(emailedEvents()).toEqual([ + expect.objectContaining({ organizationId: 'org-1', metadata: { connectorId: 'con-1', kind: 'trial' } }), + ]); + }); + + it('never sends twice', async () => { + const { svc, email } = makeService(); + await svc.runEmailPass(NOW); + const again = await svc.runEmailPass(new Date(NOW.getTime() + HOUR)); + expect(email.sendCardTrialOfferEmail).toHaveBeenCalledTimes(1); + expect(again).toEqual({ examined: 0, sent: 0, skipped: 0, postponed: 0, failed: 0 }); + }); + + it('waits 30 minutes after the first success, and gives up after 48 hours', async () => { + const early = makeService({ eligibleAt: new Date(NOW.getTime() - 10 * MIN) }); + await early.svc.runEmailPass(NOW); + expect(early.email.sendCardTrialOfferEmail).not.toHaveBeenCalled(); + + const late = makeService({ eligibleAt: new Date(NOW.getTime() - 49 * HOUR) }); + await late.svc.runEmailPass(NOW); + expect(late.email.sendCardTrialOfferEmail).not.toHaveBeenCalled(); + }); + + it.each([ + ['started a card trial', [trial(), trial({ plan: 'starter', expiresAt: new Date(NOW.getTime() + 5 * DAY) })], 'skipped:customer'], + ['pays', [trial({ plan: 'team', expiresAt: null })], 'skipped:customer'], + ['trial ended', [trial({ expiresAt: new Date(NOW.getTime() - MIN) })], 'skipped:trial_ended'], + ])('sends nothing once the workspace %s, and settles it', async (_label, licences, flag) => { + const { svc, email, emailFlag } = makeService({ licences: licences as OfferLicence[] }); + const run = await svc.runEmailPass(NOW); + expect(email.sendCardTrialOfferEmail).not.toHaveBeenCalled(); + expect(run.skipped).toBe(1); + expect(emailFlag()).toBe(flag); + }); + + it('links to the plans when a card trial can no longer start', async () => { + const { svc, email, emailedEvents } = makeService({ + licences: [trial({ expiresAt: new Date(NOW.getTime() + 20 * HOUR) })], + }); + await svc.runEmailPass(NOW); + expect(email.sendCardTrialOfferEmail).toHaveBeenCalledWith( + 'ada@example.com', + expect.objectContaining({ cardTrial: false, ctaPath: '/settings/license?source=first_success' }), + ); + expect(emailedEvents()[0].metadata.kind).toBe('plan'); + }); + + it('holds back within 12 hours of a trial or win-back email, then sends', async () => { + for (const key of ['trial_email_warn3', 'trial_email_warn1', 'trial_email_winback7']) { + const held = makeService({ flags: [{ key, updatedAt: new Date(NOW.getTime() - 2 * HOUR) }] }); + const run = await held.svc.runEmailPass(NOW); + expect(run.postponed).toBe(1); + expect(held.email.sendCardTrialOfferEmail).not.toHaveBeenCalled(); + expect(held.emailFlag()).toBeUndefined(); + + // The flag stays where it is; 12 hours later the email goes. + const later = await held.svc.runEmailPass(new Date(NOW.getTime() + 11 * HOUR)); + expect(later.sent).toBe(1); + } + }); + + it('holds back within 12 hours of an onboarding or activation nudge to an admin', async () => { + for (const nudge of ['onboardingLastReminderAt', 'activationReminderAt'] as const) { + const held = makeService({ + admins: [{ email: 'ada@example.com', name: 'Ada', [nudge]: new Date(NOW.getTime() - HOUR) }], + }); + expect((await held.svc.runEmailPass(NOW)).postponed).toBe(1); + expect(held.email.sendCardTrialOfferEmail).not.toHaveBeenCalled(); + } + const old = makeService({ + admins: [{ email: 'ada@example.com', name: 'Ada', onboardingLastReminderAt: new Date(NOW.getTime() - 13 * HOUR) }], + }); + expect((await old.svc.runEmailPass(NOW)).sent).toBe(1); + }); + + it('writes only to verified admins who did not unsubscribe', async () => { + const { svc, email } = makeService({ + admins: [ + { email: 'ada@example.com', name: 'Ada' }, + { email: 'out@example.com', name: 'Out', emailMarketingOptOut: true }, + { email: 'new@example.com', name: null, emailVerified: false }, + ], + }); + await svc.runEmailPass(NOW); + expect(email.sendCardTrialOfferEmail.mock.calls.map((c: any[]) => c[0])).toEqual(['ada@example.com']); + }); + + it('settles a workspace with nobody to write to', async () => { + const { svc, email, emailFlag } = makeService({ + admins: [{ email: 'out@example.com', name: 'Out', emailMarketingOptOut: true }], + }); + expect((await svc.runEmailPass(NOW)).skipped).toBe(1); + expect(email.sendCardTrialOfferEmail).not.toHaveBeenCalled(); + expect(emailFlag()).toBe('skipped:no_recipient'); + }); + + it('retries a failed send on the next run instead of settling it', async () => { + const { svc, email, emailFlag, emailedEvents } = makeService({ sendOk: false }); + expect((await svc.runEmailPass(NOW)).failed).toBe(1); + expect(emailFlag()).toBeUndefined(); + expect(emailedEvents()).toHaveLength(0); + email.sendCardTrialOfferEmail.mockResolvedValue(true); + expect((await svc.runEmailPass(new Date(NOW.getTime() + HOUR))).sent).toBe(1); + }); + + it('still sends, without steps, when the connector is gone', async () => { + const { svc, email } = makeService({ connector: null }); + await svc.runEmailPass(NOW); + expect(email.sendCardTrialOfferEmail).toHaveBeenCalledWith( + 'ada@example.com', + expect.objectContaining({ connectorName: null, nextSteps: [] }), + ); + }); + + it('does nothing on a self-hosted instance', async () => { + const { svc, prisma, email } = makeService({ isCloud: false }); + expect(await svc.runEmailPass(NOW)).toEqual({ examined: 0, sent: 0, skipped: 0, postponed: 0, failed: 0 }); + expect(prisma.productEvent.findMany).not.toHaveBeenCalled(); + expect(email.sendCardTrialOfferEmail).not.toHaveBeenCalled(); + }); + + it('never throws: one broken workspace is counted and the run goes on', async () => { + const { svc, prisma } = makeService(); + prisma.license.findMany.mockRejectedValueOnce(new Error('db down')); + await expect(svc.runEmailPass(NOW)).resolves.toMatchObject({ examined: 1, failed: 1 }); + }); +}); diff --git a/packages/backend/src/license/card-trial-offer.ts b/packages/backend/src/license/card-trial-offer.ts new file mode 100644 index 000000000..8f0eea54c --- /dev/null +++ b/packages/backend/src/license/card-trial-offer.ts @@ -0,0 +1,164 @@ +import { CARD_TRIAL_MIN_LEAD_MS } from './license-checkout'; + +/** + * The card-trial offer after a workspace's first successful tool call + * (AnythingMCP Cloud only). + * + * The card trial used to be offered only at sign-up, before anyone had seen + * the product work. A workspace on the no-card trial that makes its first + * successful call is now "eligible for the post-success offer": its admins see + * a dismissible card on the dashboard and the connect pages, and get one email + * 30 to 90 minutes later. Nothing of it reaches an MCP tool response. + * + * Pure helpers shared by FirstSuccessService (marks eligibility on the call + * path), CardTrialOfferService (the in-app offer and the email) and the tests. + */ + +/** OrgSettings: when the workspace made its first successful tool call (ISO). */ +export const FIRST_SUCCESS_KEY = 'first_successful_call_at'; +/** + * OrgSettings: when the workspace became eligible (ISO). Created with ON + * CONFLICT DO NOTHING, so of two processes seeing the first success at once + * only one records the product event. + */ +export const OFFER_ELIGIBLE_KEY = 'card_trial_offer_eligible_at'; +/** + * OrgSettings: the offer email is settled. An ISO time when it was sent, or + * `skipped:` when it never will be. Its presence alone stops a send. + */ +export const OFFER_EMAIL_KEY = 'card_trial_offer_email'; + +/** `?source=` on /start-trial, carried into the checkout and Stripe metadata. */ +export const FIRST_SUCCESS_SOURCE = 'first_success'; + +/** + * A first success older than this when we first see it does not make the + * workspace eligible: it activated before this feature shipped (or long ago), + * and its "aha" moment has passed. + */ +export const FIRST_SUCCESS_FRESH_MS = 24 * 60 * 60 * 1000; + +/** The email goes out no sooner than this after the workspace became eligible. */ +export const OFFER_EMAIL_AFTER_MS = 30 * 60 * 1000; +/** And not at all later than this: a late "your connection works" is noise. */ +export const OFFER_EMAIL_UNTIL_MS = 48 * 60 * 60 * 1000; +/** No offer email within this long of another lifecycle or onboarding email. */ +export const OFFER_EMAIL_QUIET_MS = 12 * 60 * 60 * 1000; + +/** + * OrgSettings flags the other Cloud emails leave behind (onboarding cron): + * trial lifecycle (3 days, 1 day, ended) and win-back. Their `updatedAt` is + * when they were sent or settled. + */ +export const OTHER_TRIAL_EMAIL_KEYS = [ + 'trial_email_warn3', + 'trial_email_warn1', + 'trial_email_expired', + 'trial_email_winback7', + 'trial_email_winback30', +] as const; + +export interface OfferLicence { + plan: string; + status: string; + expiresAt: Date | null; + createdAt: Date; +} + +/** + * Where a workspace stands for the offer, from all its licence rows: + * - `customer`: any licence other than the free trial, in any state. A paid + * plan, a card trial (a 'starter'/'team' licence while Stripe says + * trialing), or a former customer whose subscription lapsed. The same rule + * the win-back uses: none of them is offered a trial. + * - `no_trial`: no licence at all (the trial was never mirrored). + * - `trial_ended`: the free trial is over or no longer active. + * - `eligible`: a running free trial, without a card. + */ +export type OfferLicenceState = 'eligible' | 'customer' | 'no_trial' | 'trial_ended'; + +export function offerLicenceState(licences: OfferLicence[], now: Date = new Date()): OfferLicenceState { + if (licences.some((l) => l.plan !== 'trial')) return 'customer'; + if (licences.length === 0) return 'no_trial'; + // The current licence is the newest active one (LicenseService.getCurrentLicense). + const current = licences + .filter((l) => l.status === 'active') + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; + if (!current?.expiresAt) return 'trial_ended'; + return current.expiresAt.getTime() > now.getTime() ? 'eligible' : 'trial_ended'; +} + +/** When the running free trial ends, for an eligible workspace; null otherwise. */ +export function offerTrialEnd(licences: OfferLicence[], now: Date = new Date()): Date | null { + if (offerLicenceState(licences, now) !== 'eligible') return null; + const current = licences + .filter((l) => l.status === 'active') + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; + return current?.expiresAt ?? null; +} + +/** + * Whether a card trial can still start: Stripe needs its end at least 48 hours + * out, and the card trial ends when the free trial does (license-checkout.ts). + * With less left, /start-trial sends the user away, so the in-app offer is + * hidden and the email links to the plans instead. + */ +export function cardTrialStillAvailable(trialEndsAt: Date | null, now: Date = new Date()): boolean { + return !!trialEndsAt && trialEndsAt.getTime() >= now.getTime() + CARD_TRIAL_MIN_LEAD_MS; +} + +/** + * Tools that are about the API rather than about the user's data: playbooks, + * schema helpers, account checks. Not what anyone wants to ask next. + */ +const META_TOOL = /(playbook|_guide$|_schema(_url)?$|_graphql_|how_to_obtain|whoami|account_info|_ping$|health)/i; +/** Reads come first: a safe, useful thing to try is to look something up. */ +const READ_TOOL = /(^|_)(list|search|get|find|query|lookup|report|summary|top|count|recent)(_|$)/i; +const MAX_STEP_CHARS = 140; + +/** The first sentence of a tool description, without markdown, capped. */ +export function firstSentence(description: string | null | undefined): string | null { + if (!description) return null; + const plain = description + .replace(/`([^`]*)`/g, '$1') + .replace(/\*\*([^*]*)\*\*/g, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\s+/g, ' ') + .trim(); + if (!plain) return null; + const match = plain.match(/^(.+?[.!?])(\s|$)/); + let sentence = (match ? match[1] : plain).trim(); + if (sentence.length > MAX_STEP_CHARS) { + sentence = `${sentence.slice(0, MAX_STEP_CHARS - 1).replace(/[\s,;:.-]+\S*$/, '')}…`; + } + // Long dashes read as machine-written; the copy uses none. + sentence = sentence.replace(/\s*[—–]\s*/g, ', '); + return sentence.charAt(0).toUpperCase() + sentence.slice(1); +} + +/** + * Up to `max` concrete next things for the email, from the connector's own + * enabled tools: not the tool that was just used, not meta tools, reads first, + * one per distinct description. + */ +export function pickNextSteps( + tools: Array<{ name: string; description: string | null }>, + usedToolName?: string | null, + max = 3, +): Array<{ name: string; text: string }> { + const candidates = tools + .filter((t) => t.name !== usedToolName && !META_TOOL.test(t.name)) + .map((t, index) => ({ name: t.name, text: firstSentence(t.description), index, read: READ_TOOL.test(t.name) })) + .filter((t): t is { name: string; text: string; index: number; read: boolean } => !!t.text && t.text.length >= 12); + candidates.sort((a, b) => Number(b.read) - Number(a.read) || a.index - b.index); + const seen = new Set(); + const out: Array<{ name: string; text: string }> = []; + for (const c of candidates) { + const key = c.text.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push({ name: c.name, text: c.text }); + if (out.length >= max) break; + } + return out; +} diff --git a/packages/backend/src/license/license-checkout.spec.ts b/packages/backend/src/license/license-checkout.spec.ts index a55c4ef1b..a9cb8043b 100644 --- a/packages/backend/src/license/license-checkout.spec.ts +++ b/packages/backend/src/license/license-checkout.spec.ts @@ -18,8 +18,10 @@ import { NotFoundException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; import { RolesGuard } from '../auth/roles.guard'; -import { LicenseController } from './license.controller'; +import { CheckoutLinkDto, LicenseController } from './license.controller'; import { LicenseService } from './license.service'; import { CHECKOUT_PLANS, @@ -221,7 +223,10 @@ describe('LicenseController.checkoutLink', () => { return { url: 'https://anythingmcp.com/api/stripe/checkout/start?intent=i1' }; }), }; - const productEvents = { clickIdForUser: jest.fn(async () => opts.click ?? null) }; + const productEvents = { + clickIdForUser: jest.fn(async () => opts.click ?? null), + log: jest.fn(async () => undefined), + }; const eligibility = { hadSubscription: jest.fn(async () => (opts.hadSubscription === undefined ? false : opts.hadSubscription)), }; @@ -233,6 +238,7 @@ describe('LicenseController.checkoutLink', () => { { isCloud: () => opts.isCloud ?? true } as any, productEvents as any, eligibility as any, + {} as any, ); return { controller, licenseService, productEvents, eligibility }; } @@ -410,6 +416,52 @@ describe('LicenseController.checkoutLink', () => { expect(eligibility.hadSubscription).not.toHaveBeenCalled(); }); + it('passes the in-app source through, and records the checkout link with where it came from', async () => { + const { controller, licenseService, productEvents } = makeController(); + await controller.checkoutLink(adminReq, { + plan: 'starter', + billingPeriod: 'monthly', + trial: true, + source: 'first_success', + }); + const [payload] = licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(payload.source).toBe('first_success'); + expect(productEvents.log).toHaveBeenCalledWith({ + event: 'checkout_link_created', + userId: 'u1', + organizationId: 'org-1', + metadata: { via: 'first_success', kind: 'trial' }, + }); + + const plain = makeController(); + await plain.controller.checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: false }); + const [plainPayload] = plain.licenseService.createCheckoutIntent.mock.calls[0] as any[]; + expect(plainPayload).not.toHaveProperty('source'); + expect(plain.productEvents.log).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { via: 'app', kind: 'pay' } }), + ); + }); + + it('records no checkout link when the licence site fails', async () => { + const { controller, productEvents } = makeController({ fail: true }); + await controller + .checkoutLink(adminReq, { plan: 'team', billingPeriod: 'monthly', trial: true, source: 'first_success' }) + .catch(() => undefined); + expect(productEvents.log).not.toHaveBeenCalled(); + }); + + it('accepts only known sources: the value ends up in Stripe metadata', async () => { + const base = { plan: 'starter', billingPeriod: 'monthly', trial: true }; + const ok = await validate(plainToInstance(CheckoutLinkDto, { ...base, source: 'first_success' })); + expect(ok).toHaveLength(0); + const none = await validate(plainToInstance(CheckoutLinkDto, base)); + expect(none).toHaveLength(0); + for (const source of ['newsletter', 'first_success; drop', '', 42]) { + const errors = await validate(plainToInstance(CheckoutLinkDto, { ...base, source })); + expect(errors.map((e) => e.property)).toContain('source'); + } + }); + 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-checkout.ts b/packages/backend/src/license/license-checkout.ts index dda606eb3..fd7e11eab 100644 --- a/packages/backend/src/license/license-checkout.ts +++ b/packages/backend/src/license/license-checkout.ts @@ -23,6 +23,15 @@ 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; + +/** + * Where in the app a checkout was started, when it is worth measuring. Sent + * to the licence site as `source`, which stores it on the Stripe Checkout + * Session and the subscription as `metadata.trial_source`. Allow-listed: the + * value ends up in Stripe. + */ +export const CHECKOUT_SOURCES = ['first_success'] as const; +export type CheckoutSource = (typeof CHECKOUT_SOURCES)[number]; export type CheckoutBillingPeriod = (typeof CHECKOUT_BILLING_PERIODS)[number]; /** Body of POST {licence site}/api/stripe/checkout-intent (the agreed contract). */ @@ -36,6 +45,8 @@ export interface CheckoutIntentPayload { organizationId?: string; /** A Stripe promotion code the user arrived with; the site pre-applies it. */ promoCode?: string; + /** The app entry point (CHECKOUT_SOURCES); Stripe metadata `trial_source`. */ + source?: CheckoutSource; adMetadata?: { ad_consent: 'granted'; gclid?: string; diff --git a/packages/backend/src/license/license.controller.spec.ts b/packages/backend/src/license/license.controller.spec.ts index 96a8b0a1e..6a02001f4 100644 --- a/packages/backend/src/license/license.controller.spec.ts +++ b/packages/backend/src/license/license.controller.spec.ts @@ -30,6 +30,7 @@ describe('LicenseController — activate-trial is idempotent', () => { { isCloud: () => true } as any, {} as any, {} as any, + {} as any, ); return { controller, licenseService }; } @@ -100,6 +101,7 @@ describe('LicenseController — status reports an ended licence', () => { { isCloud: () => opts.cloud ?? true } as any, {} 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 3a63de44f..51bd772df 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -24,18 +24,23 @@ 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 { ProductEventService, ProductEvents } from '../audit/product-event.service'; import { CardTrialEligibilityService } from './card-trial-eligibility.service'; +import { CardTrialOfferService } from './card-trial-offer.service'; import { CHECKOUT_BILLING_PERIODS, CHECKOUT_PLANS, + CHECKOUT_SOURCES, CheckoutBillingPeriod, CheckoutPlan, + CheckoutSource, cardTrialEnd, checkoutAdMetadata, cloudFrontendOrigin, } from './license-checkout'; +type CardTrialOfferView = { connectorName: string | null; trialEndsAt: string }; + class SetLicenseKeyDto { @IsString() @Matches(/^AMCP-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}$/, { @@ -44,7 +49,7 @@ class SetLicenseKeyDto { licenseKey: string; } -class CheckoutLinkDto { +export class CheckoutLinkDto { @IsIn(CHECKOUT_PLANS as unknown as string[]) plan: CheckoutPlan; @@ -60,6 +65,12 @@ class CheckoutLinkDto { @IsString() @Matches(/^[A-Za-z0-9_-]{1,64}$/) promo?: string; + + // Which in-app offer led here (e.g. 'first_success', the card-trial offer + // after the first successful tool call). Lands in Stripe metadata. + @IsOptional() + @IsIn(CHECKOUT_SOURCES as unknown as string[]) + source?: CheckoutSource; } class BillingPortalDto { @@ -86,6 +97,7 @@ export class LicenseController { private readonly deployment: DeploymentService, private readonly productEvents: ProductEventService, private readonly cardTrialEligibility: CardTrialEligibilityService, + private readonly cardTrialOffer: CardTrialOfferService, ) {} @Get('status') @@ -296,7 +308,7 @@ export class LicenseController { ); try { - return await this.licenseService.createCheckoutIntent({ + const link = await this.licenseService.createCheckoutIntent({ email, plan: `cloud_${dto.plan}`, billingPeriod: dto.billingPeriod, @@ -305,7 +317,15 @@ export class LicenseController { organizationId, ...(dto.promo && { promoCode: dto.promo.toUpperCase() }), ...(adMetadata && { adMetadata }), + ...(dto.source && { source: dto.source }), }); + void this.productEvents.log({ + event: ProductEvents.CHECKOUT_LINK_CREATED, + userId: req.user.sub, + organizationId, + metadata: { via: dto.source ?? 'app', kind: trialEnd ? 'trial' : 'pay' }, + }); + return link; } catch (err: any) { this.logger.warn(`Checkout link for org ${organizationId} failed: ${err?.message ?? err}`); throw new BadGatewayException( @@ -335,6 +355,23 @@ export class LicenseController { }; } + @Get('card-trial-offer') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @ApiOperation({ + summary: + 'Cloud: the card-trial offer after the first successful tool call, for admins of an eligible workspace (null otherwise)', + }) + async cardTrialOfferForUser(@Req() req: any): Promise<{ offer: CardTrialOfferView | null }> { + // Null for everyone it does not apply to: self-hosted, members who are + // not admins (they cannot start a subscription), workspaces that pay or + // already run a card trial, and trials with under 48 hours left. + const offer = await this.cardTrialOffer + .currentOffer(req.user?.organizationId, req.user?.role) + .catch(() => null); + return { offer }; + } + @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 1da27cbc3..10d56a764 100644 --- a/packages/backend/src/license/license.module.ts +++ b/packages/backend/src/license/license.module.ts @@ -3,13 +3,14 @@ import { LicenseService } from './license.service'; import { LicenseGuardService } from './license-guard.service'; import { LicenseController } from './license.controller'; import { CardTrialEligibilityService } from './card-trial-eligibility.service'; +import { CardTrialOfferService } from './card-trial-offer.service'; import { SettingsModule } from '../settings/settings.module'; import { UsersModule } from '../users/users.module'; @Module({ imports: [SettingsModule, UsersModule], controllers: [LicenseController], - providers: [LicenseService, LicenseGuardService, CardTrialEligibilityService], - exports: [LicenseService, LicenseGuardService, CardTrialEligibilityService], + providers: [LicenseService, LicenseGuardService, CardTrialEligibilityService, CardTrialOfferService], + exports: [LicenseService, LicenseGuardService, CardTrialEligibilityService, CardTrialOfferService], }) export class LicenseModule {} diff --git a/packages/backend/src/settings/email-samples.ts b/packages/backend/src/settings/email-samples.ts index 522f3a3ba..99c91ca0b 100644 --- a/packages/backend/src/settings/email-samples.ts +++ b/packages/backend/src/settings/email-samples.ts @@ -3,6 +3,7 @@ import type { TrustStats } from '../public-stats/trust-stats.format'; import { RenderedEmail, activationReminderEmail, + cardTrialOfferEmail, existingAccountEmail, invitationEmail, licenseKeyEmail, @@ -22,6 +23,8 @@ export const SAMPLE_CLOUD_URL = 'https://cloud.anythingmcp.com'; export const SAMPLE_MARKETING_URL = 'https://anythingmcp.com'; /** When the sample trials ended: the first win-back goes out a day later. */ const SAMPLE_TRIAL_ENDED_AT = new Date('2026-10-07T09:30:00Z'); +/** When the sample trial of the card-trial offer ends. */ +const SAMPLE_TRIAL_ENDS_AT = new Date('2026-10-18T09:30:00Z'); /** Placeholder numbers for previews only; real emails use TrustStatsService. */ export const SAMPLE_TRUST_STATS: TrustStats = { @@ -201,6 +204,42 @@ export const EMAIL_SAMPLES: EmailSample[] = [ unsub(ctx), ), }, + { + // A day after sign-up the sample workspace made its first successful call. + id: 'card-trial-offer', + render: (ctx, n) => + cardTrialOfferEmail( + { + name: n.name, + connectorName: n.other, + nextSteps: [ + { name: 'shopify_get_product', text: 'Get one product with its variants and stock.' }, + { name: 'shopify_search_customers', text: 'Search customers by name, email or tag.' }, + { name: n.other, text: n.name }, + ], + trialEndsAt: SAMPLE_TRIAL_ENDS_AT, + ctaUrl: `${SAMPLE_CLOUD_URL}/start-trial?source=first_success`, + cardTrial: true, + }, + unsub(ctx), + ), + }, + { + // Under 48 hours of trial left: no card trial, the plans instead. Connector gone. + id: 'card-trial-offer-plans', + render: (ctx, n) => + cardTrialOfferEmail( + { + name: n.name, + connectorName: null, + nextSteps: [], + trialEndsAt: SAMPLE_TRIAL_ENDS_AT, + ctaUrl: `${SAMPLE_CLOUD_URL}/settings/license?source=first_success`, + cardTrial: false, + }, + unsub(ctx), + ), + }, ]; /** Ids of the samples that must carry an unsubscribe line. */ @@ -214,4 +253,6 @@ export const MARKETING_SAMPLE_IDS = new Set([ 'winback-discount-first-month', 'winback-discount-final', 'winback-help', + 'card-trial-offer', + 'card-trial-offer-plans', ]); diff --git a/packages/backend/src/settings/email-templates.spec.ts b/packages/backend/src/settings/email-templates.spec.ts index bfae4e430..27bab97bd 100644 --- a/packages/backend/src/settings/email-templates.spec.ts +++ b/packages/backend/src/settings/email-templates.spec.ts @@ -195,3 +195,43 @@ describe('trial win-back', () => { } }); }); + +describe('card-trial offer after the first success', () => { + const render = (id: string, other = 'My Shopify store') => + EMAIL_SAMPLES.find((s) => s.id === id)!.render(cloud, { name: 'Anna', other }); + + it('leads with the connection that works and what to ask next', () => { + const email = render('card-trial-offer'); + expect(email.subject).toBe('Your My Shopify store connection works'); + for (const part of [email.html, email.text]) { + expect(part).toContain('first successful call to My Shopify store'); + expect(part).toContain('Get one product with its variants and stock.'); + expect(part).toContain('Search customers by name, email or tag.'); + } + }); + + it('dates the end of the trial and links to the card trial with its source', () => { + const email = render('card-trial-offer'); + for (const part of [email.html, email.text]) expect(part).toContain('18 October'); + expect(email.text).toContain('Keep it running: https://cloud.anythingmcp.com/start-trial?source=first_success'); + expect(email.html).toContain('href="https://cloud.anythingmcp.com/start-trial?source=first_success"'); + expect(email.marketing).toBe(true); + }); + + it('links to the plans when the card trial can no longer start, and copes without a connector', () => { + const email = render('card-trial-offer-plans'); + expect(email.subject).toBe('Your first connection works'); + expect(email.text).toContain('Choose a plan: https://cloud.anythingmcp.com/settings/license?source=first_success'); + expect(email.text).toContain('Ask your AI what it can do with your app'); + }); + + it('talks value only: no card, charge, price or trial-pressure words, and no em dashes', () => { + for (const id of ['card-trial-offer', 'card-trial-offer-plans']) { + const email = render(id); + // The text part carries the same copy as the HTML, without its markup. + const all = `${email.subject}\n${email.text}`; + expect(all).not.toMatch(/\bcard\b|charg|payment|billed|invoice|€|\$\d|price|cancel/i); + expect(`${email.subject}\n${email.text}`).not.toContain('—'); + } + }); +}); diff --git a/packages/backend/src/settings/email-templates.ts b/packages/backend/src/settings/email-templates.ts index 15234d842..f6f7fef50 100644 --- a/packages/backend/src/settings/email-templates.ts +++ b/packages/backend/src/settings/email-templates.ts @@ -529,3 +529,65 @@ export function trialWinbackEmail( unsubscribeUrl: ctx.unsubscribeUrl, }); } + +/** + * The card-trial offer after a workspace's first successful tool call + * (card-trial-offer.service.ts), 30 to 90 minutes after it. Value first: + * the connection works, here is what to ask next. Then the trial's end date + * and the way to keep it running. No card, charge or price talk. + */ +export interface CardTrialOfferEmailInput { + name: string; + /** The connector's name in the workspace; null when it is gone. */ + connectorName: string | null; + /** Up to three things to ask next, from the connector's tools. */ + nextSteps: Array<{ name: string; text: string }>; + trialEndsAt: Date; + /** Where the button leads: /start-trial (card trial) or the plans. */ + ctaUrl: string; + /** True when the card trial can still start; false links to the plans. */ + cardTrial: boolean; +} + +export function cardTrialOfferEmail(input: CardTrialOfferEmailInput, ctx: MarketingContext): RenderedEmail { + const hi = greeting(input.name); + const named = input.connectorName?.trim() || null; + const app = named ?? 'your app'; + const endsOn = dayAndMonth(input.trialEndsAt); + const replyLine = 'Something did not work the way you expected? Just reply: a person reads every email.'; + const keep = input.cardTrial + ? 'To keep it running after that, start your trial on the plan that fits you. It takes a minute, in the app.' + : 'To keep it running after that, choose the plan that fits you. It takes a minute, in the app.'; + const cta = input.cardTrial ? 'Keep it running' : 'Choose a plan'; + + const stepsHtml = input.nextSteps.length + ? p('A few things to ask your AI next, in the same chat:') + + steps(input.nextSteps.map((s) => ({ title: esc(s.text), detail: esc(s.name) }))) + : p(`Ask your AI what it can do with ${esc(app)}: it knows every tool the connection offers and picks the right one.`); + const stepsText = input.nextSteps.length + ? `A few things to ask your AI next, in the same chat:\n${input.nextSteps.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}\n\n` + : `Ask your AI what it can do with ${app}: it knows every tool the connection offers and picks the right one.\n\n`; + + return build({ + subject: named ? `Your ${named} connection works` : 'Your first connection works', + preheader: 'Your AI just used it for real. Here is what to try next.', + bodyHtml: + h1(`Your ${esc(app)} connection works.`, 'Here is what to try next.') + + p( + `${esc(hi)} your AI just made its first successful call to ${esc(app)} through AnythingMCP. ` + + 'The setup is done: from now on you ask in plain language and it does the work.', + ) + + stepsHtml + + p(`Your trial ends on ${esc(endsOn)}. ${keep}`) + + button(input.ctaUrl, cta) + + small(replyLine), + bodyText: + `${hi}\n\nYour AI just made its first successful call to ${app} through AnythingMCP. ` + + 'The setup is done: from now on you ask in plain language and it does the work.\n\n' + + stepsText + + `Your trial ends on ${endsOn}. ${keep}\n\n${cta}: ${input.ctaUrl}\n\n` + + replyLine, + ctx, + unsubscribeUrl: ctx.unsubscribeUrl, + }); +} diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index 8f475e675..c9df1aae9 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -9,10 +9,12 @@ import { TrustStatsService } from '../public-stats/trust-stats.service'; import { EMPTY_TRUST_STATS, TrustStats, formatTrustStats } from '../public-stats/trust-stats.format'; import type { EmailBrandContext } from './email-layout'; import { + CardTrialOfferEmailInput, MarketingContext, RenderedEmail, WinbackOffer, activationReminderEmail, + cardTrialOfferEmail, existingAccountEmail, invitationEmail, licenseKeyEmail, @@ -641,6 +643,35 @@ export class EmailService { } } + /** + * The card-trial offer after a workspace's first successful tool call + * (Cloud, SMTP only; see card-trial-offer.service.ts). Marketing: the + * caller honours the opt-out, and the email carries the unsubscribe line. + */ + async sendCardTrialOfferEmail( + to: string, + input: Omit & { ctaPath: string }, + ): Promise { + const transport = await this.createTransporter(); + if (!transport) { + this.logger.warn(`Skipping card-trial offer email to ${to}: no SMTP configured`); + return false; + } + + const { ctaPath, ...rest } = input; + const { ctx, headers } = await this.marketing(to); + const email = cardTrialOfferEmail({ ...rest, ctaUrl: `${this.cloudUrl()}${ctaPath}` }, ctx); + + try { + await transport.transporter.sendMail({ from: transport.from, to, ...this.mail(email, headers) }); + this.logger.log(`Card-trial offer email sent to ${to}`); + return true; + } catch (err) { + this.logger.error(`Failed to send card-trial offer email to ${to}: ${err}`); + return false; + } + } + // ── External API Fallback ───────────────────────────────────────────────── private async sendViaExternalApi( diff --git a/packages/frontend/src/app/connectors/page.tsx b/packages/frontend/src/app/connectors/page.tsx index 39f2ef532..206e05df4 100644 --- a/packages/frontend/src/app/connectors/page.tsx +++ b/packages/frontend/src/app/connectors/page.tsx @@ -7,6 +7,7 @@ import { connectors, withSecretsNotice } from '@/lib/api'; import * as Dialog from '@radix-ui/react-dialog'; import { AppSelect } from '@/components/ui/select'; import { AppShell } from '@/components/app-shell'; +import { FirstSuccessOffer } from '@/components/first-success-offer'; import { Card } from '@/components/ui/card'; import { Badge, StatusPill, type Tone } from '@/components/ui/badge'; import { SetupStatusPill } from '@/components/setup-status'; @@ -265,6 +266,9 @@ export default function ConnectorsPage() { return ( +
+ +
{msg && (
{msg} diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index 44151b786..ebd5e38af 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -10,6 +10,7 @@ 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'; +import { FirstSuccessOffer } from '@/components/first-success-offer'; import { copyText } from '@/lib/clipboard'; import { getApiOrigin } from '@/lib/origin'; import { CLAUDE_DIRECTORY_URL } from '@/lib/marketing'; @@ -28,6 +29,9 @@ export default function McpServerDetailPage() { const router = useRouter(); const [server, setServer] = useState(null); + // Bumped a moment after the first request arrives, so the card-trial offer + // (which the backend unlocks on the first successful call) can appear. + const [offerKey, setOfferKey] = useState(0); const [allConnectors, setAllConnectors] = useState([]); const [loading, setLoading] = useState(true); @@ -700,7 +704,14 @@ Auth: OAuth`;
{/* Connect a client — sticky right column on desktop, shown first on mobile */}