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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/operations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +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). |
| [`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), the checkout source every Cloud checkout carries to Stripe (`metadata.checkout_source`, per entry point) and the weekly funnel (SQL + Stripe). |

If you just installed AnythingMCP, read these in order.
76 changes: 67 additions & 9 deletions docs/operations/card-trial-offer.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,38 @@ The licence site gives no card trial to an address that ever held a subscription
> Keep it running: start your trial, and My Shopify store keeps working after your free trial ends on 18 October 2026.
> [Not now] [Start your 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`.
The button opens `/start-trial?source=first_success_banner`. "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 and whose address never had a subscription (without an answer from the licence site the email waits for the next run), 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:<reason>` (`customer`, `trial_ended`, `no_trial`, `no_recipient`, `no_trial_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).
**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 and whose address never had a subscription (without an answer from the licence site the email waits for the next run), 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:<reason>` (`customer`, `trial_ended`, `no_trial`, `no_recipient`, `no_trial_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's button opens `/start-trial?source=first_success_email` (or `/settings/license?source=first_success_email`, which passes the source on to its card-trial button and its pricing link), so the email and the in-app card are counted apart. Emails sent before 16 October 2026 say `source=first_success`, which is still accepted.

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"`.
Every Cloud checkout names where it started, and the licence site writes it to the Stripe Checkout Session and to the subscription as `metadata.checkout_source`. Card trials also get the same value as `metadata.trial_source` (the key the first version used; it is left off pay-now checkouts, where the name would be wrong).

A licence site without step 2 ignores the field, so the backend can be deployed first.
**In-app checkouts** (`POST /api/license/checkout-link` → the site's `POST /api/stripe/checkout-intent`). The frontend sends `source`; the backend accepts only `CHECKOUT_SOURCES` (`packages/backend/src/license/license-checkout.ts`, mirrored in `packages/frontend/src/lib/checkout-source.ts`) and sends `app` when the page named none, so no intent goes without one. It also records `checkout_link_created` with `metadata.via` = the same value and `metadata.kind` = `trial` or `pay`.

| Source | Entry point |
|---|---|
| `first_success_banner` | the first-success card (dashboard, connectors, connect page) |
| `first_success_email` | the first-success email |
| `first_success` | the same offer before banner and email were told apart (old emails, open tabs) |
| `trial_banner` | the countdown banner's "Add a payment method" / "Choose a plan" |
| `license_page` | Settings → License |
| `trial_ended_wall` | the wall after the free trial ("Subscribe to …") |
| `post_approve` | "add a card now" on the page after approving an AI client |
| `signup_prompt` | the card-trial page shown once after sign-up |
| `trial_limit`, `starter_pack` | "add a card" next to the connector limit |
| `chat_setup` | the plan link the setup tools give the AI (`upgradeUrl`, `choosePlanUrl`) |
| `start_trial` | `/start-trial` opened without a known source |
| `app` | a checkout the page did not name |

**Pricing-page checkouts** (`anythingmcp.com/pricing` → `POST /api/stripe/checkout`). Links to the pricing page carry `?source=`: `app_pricing` (any app link, the default of `usePricingUrl`), `trial_banner`, `license_page`, `trial_ended_wall`, `first_success_email`, `chat_setup`, and from the Cloud's emails `email_trial_warn3`, `email_trial_warn1`, `email_trial_expired`, `email_winback`. The site keeps only the values in its `PRICING_CHECKOUT_SOURCES` and records everything else, a visitor who came on their own included, as `pricing_page`.

**`/start-trial` events.** `start_trial_viewed` (once per page load) and `start_trial_checkout_started` (the button), both with `metadata.source` and `metadata.kind` (`trial`, or `pay` for an address that had a subscription). Read them against `checkout_link_created` to see where each entry point's visitors stop.

**Deploy order.** Either order is safe. The site already stores any slug it gets on an intent (as `trial_source` before the site change, `checkout_source` after it) and ignores `?source=` on the pricing page until it knows it. Deploying the site first means every Cloud checkout lands in `checkout_source` from the first minute.

## Weekly report

Expand All @@ -50,7 +69,7 @@ steps AS (
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
bool_or(event = 'checkout_link_created' AND metadata->>'via' LIKE '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')
Expand Down Expand Up @@ -79,11 +98,50 @@ 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).
**Cloud database, every entry point:** how far each source's visitors got over the last week.

```sql
SELECT coalesce(metadata->>'source', metadata->>'via') AS source,
count(*) FILTER (WHERE event = 'start_trial_viewed') AS start_trial_views,
count(*) FILTER (WHERE event = 'start_trial_checkout_started') AS start_trial_clicks,
count(*) FILTER (WHERE event = 'checkout_link_created') AS checkout_links,
count(*) FILTER (WHERE event = 'checkout_link_created'
AND metadata->>'kind' = 'trial') AS card_trial_links
FROM product_events
WHERE event IN ('start_trial_viewed', 'start_trial_checkout_started', 'checkout_link_created')
AND created_at >= now() - interval '7 days'
GROUP BY 1
ORDER BY checkout_links DESC;
```

**Stripe, every entry point:** Checkout Sessions of the last week by `checkout_source`, and how many completed. Sessions have no Search API, so the script lists them (100 per page). Run with the licence site's Stripe secret key in `STRIPE_KEY`; `SINCE` is a Unix time (`date -v-7d +%s` on macOS, `date -d '7 days ago' +%s` on Linux).

```bash
after=""
while :; do
page=$(curl -sG https://api.stripe.com/v1/checkout/sessions -u "$STRIPE_KEY:" \
-d limit=100 -d "created[gte]=$SINCE" ${after:+-d starting_after=$after})
echo "$page" | jq -c '.data[] | select(.metadata.app == "anythingmcp")'
[ "$(echo "$page" | jq -r .has_more)" = true ] || break
after=$(echo "$page" | jq -r '.data[-1].id')
done |
jq -rs 'group_by(.metadata.checkout_source // .metadata.trial_source // "(none)")[]
| [ (.[0].metadata.checkout_source // .metadata.trial_source // "(none)"),
length,
(map(select(.status == "complete")) | length),
(map(select(.metadata.trial == "1")) | length),
(map(select(.metadata.trial == "1" and .status == "complete")) | length) ]
| @tsv' |
(echo -e "source\tsessions\tcompleted\ttrial_sessions\ttrials_completed"; cat)
```

`(none)` is a session from before the change. Subscriptions are searchable directly: `metadata['checkout_source']:'first_success_email'`.

**Stripe, the offer:** 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'" \
--data-urlencode "query=metadata['checkout_source']:'first_success_banner' OR metadata['checkout_source']:'first_success_email' OR metadata['trial_source']:'first_success'" \
-d limit=100 -d 'expand[]=data.latest_invoice' |
jq -r --argjson now "$(date +%s)" '
[.data[] | {
Expand Down
10 changes: 5 additions & 5 deletions packages/backend/src/adapters/connector-setup.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ describe('ConnectorSetupService — install', () => {
const out: any = await service.install(ctx, { adapter: 'openplz' });
expect(out.body).toMatchObject({
error: 'Trial limit reached (2 connectors).',
upgradeUrl: 'https://cloud.example.com/start-trial',
upgradeUrl: 'https://cloud.example.com/start-trial?source=chat_setup',
});
expect(out.body.whatTheUserCanDo).toMatch(/nothing is charged before the trial ends/);
});
Expand All @@ -266,7 +266,7 @@ describe('ConnectorSetupService — install', () => {
licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Connector limit reached.'));
licenseGuard.getUsage.mockResolvedValueOnce({ plan: 'cloud_starter', connectors: { current: 5, max: 5 } });
const out: any = await service.install(ctx, { adapter: 'openplz' });
expect(out.body.upgradeUrl).toBe('https://cloud.example.com/settings/license');
expect(out.body.upgradeUrl).toBe('https://cloud.example.com/settings/license?source=chat_setup');
});

it('at the limit, tells an editor who can lift it, without a billing link', async () => {
Expand All @@ -292,15 +292,15 @@ describe('ConnectorSetupService — status', () => {
expect(out.trial).toEqual({
daysLeft: 5,
endsAt: expect.any(String),
choosePlanUrl: 'https://cloud.example.com/start-trial',
choosePlanUrl: 'https://cloud.example.com/start-trial?source=chat_setup',
afterTheTrial: expect.stringMatching(/Nothing is charged before then/),
});
});

it('sends an admin to the licence page once a card trial no longer fits', async () => {
const { service, ctx } = build({ role: 'ADMIN', trial: running(1, false) });
const out: any = (await service.status(ctx)).body;
expect(out.trial).toMatchObject({ daysLeft: 1, choosePlanUrl: 'https://cloud.example.com/settings/license' });
expect(out.trial).toMatchObject({ daysLeft: 1, choosePlanUrl: 'https://cloud.example.com/settings/license?source=chat_setup' });
expect(out.trial.afterTheTrial).not.toMatch(/card/i);
});

Expand All @@ -313,7 +313,7 @@ describe('ConnectorSetupService — status', () => {
it('reports an ended trial, with the licence page for an admin', async () => {
const ended = { endsAt: new Date(Date.now() - DAY), active: false, cardTrialAvailable: false };
const admin: any = (await build({ role: 'ADMIN', trial: ended }).service.status(build().ctx)).body;
expect(admin.trial).toMatchObject({ ended: true, choosePlanUrl: 'https://cloud.example.com/settings/license' });
expect(admin.trial).toMatchObject({ ended: true, choosePlanUrl: 'https://cloud.example.com/settings/license?source=chat_setup' });
const editor: any = (await build({ role: 'EDITOR', trial: ended }).service.status(build().ctx)).body;
expect(editor.trial).toEqual({ ended: true, endedAt: expect.any(String), afterTheTrial: expect.stringMatching(/administrator/) });
});
Expand Down
9 changes: 5 additions & 4 deletions packages/backend/src/adapters/connector-setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { computeSetupState } from '../connectors/connector-setup-status.util';
import { decrypt } from '../common/crypto/encryption.util';
import { findSecretLiterals } from '../common/secret-literals.util';
import { searchCatalog } from './catalog-search';
import { CHAT_SETUP_SOURCE } from '../license/license-checkout';

/** Assistants a setup link can name (see assistantForRedirectUris). */
const RETURN_ASSISTANTS: readonly ReturnAssistant[] = ['claude', 'chatgpt', 'muse'];
Expand Down Expand Up @@ -350,7 +351,7 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
whatTheUserCanDo: onTrial
? 'Add a card to continue the trial on the full plan (nothing is charged before the trial ends), or remove a connector.'
: 'Choose a plan with more connectors, or remove a connector.',
upgradeUrl: `${ctx.dashboardBase}${onTrial ? '/start-trial' : '/settings/license'}`,
upgradeUrl: `${ctx.dashboardBase}${onTrial ? '/start-trial' : '/settings/license'}?source=${CHAT_SETUP_SOURCE}`,
};
}

Expand Down Expand Up @@ -417,7 +418,7 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
ended: true,
endedAt: endsAt,
...(admin
? { choosePlanUrl: `${ctx.dashboardBase}/settings/license`, afterTheTrial: 'The trial has ended. Choosing a plan turns the connectors back on.' }
? { choosePlanUrl: `${ctx.dashboardBase}/settings/license?source=${CHAT_SETUP_SOURCE}`, afterTheTrial: 'The trial has ended. Choosing a plan turns the connectors back on.' }
: { afterTheTrial: 'The trial has ended. A workspace administrator can choose a plan.' }),
};
}
Expand All @@ -429,13 +430,13 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
? {
daysLeft,
endsAt,
choosePlanUrl: `${ctx.dashboardBase}/start-trial`,
choosePlanUrl: `${ctx.dashboardBase}/start-trial?source=${CHAT_SETUP_SOURCE}`,
afterTheTrial: 'Adding a card keeps the workspace running when the trial ends. Nothing is charged before then.',
}
: {
daysLeft,
endsAt,
choosePlanUrl: `${ctx.dashboardBase}/settings/license`,
choosePlanUrl: `${ctx.dashboardBase}/settings/license?source=${CHAT_SETUP_SOURCE}`,
afterTheTrial: 'Choosing a plan keeps the workspace running when the trial ends.',
};
}
Expand Down
23 changes: 19 additions & 4 deletions packages/backend/src/audit/product-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,27 @@ export const ProductEvents = {
* of trial left, linked to the plans). Server-only.
*/
CARD_TRIAL_OFFER_EMAILED: 'card_trial_offer_emailed',
/**
* The card-trial page (/start-trial) was opened, once per page load.
* metadata.source = the entry point that led there (CHECKOUT_SOURCES in
* license-checkout.ts, e.g. 'first_success_banner', 'first_success_email',
* 'trial_banner'), metadata.kind = 'trial' (card trial on offer) or 'pay'
* (the address had a subscription, the plan is billed today). Answers how
* many clicks on each entry point, emails included, reach the page.
*/
START_TRIAL_VIEWED: 'start_trial_viewed',
/**
* Its checkout button was pressed: metadata.source and metadata.kind as
* above. Read against start_trial_viewed (who leaves without trying) and
* checkout_link_created (who reaches Stripe).
*/
START_TRIAL_CHECKOUT_STARTED: 'start_trial_checkout_started',
/**
* 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).
* metadata.via = the entry point it came from (CHECKOUT_SOURCES, 'app' when
* the page did not say), metadata.kind = 'trial' (card trial) or 'pay'.
* Server-only. Answers how many clicks reach Checkout; Stripe says how many
* finish (metadata.checkout_source, the same value).
*/
CHECKOUT_LINK_CREATED: 'checkout_link_created',
} as const;
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/auth/login.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ describe('LoginController', () => {
expect(res._sent).toContain('Your free trial runs until October&nbsp;13.');
expect(res._sent).toContain('Nothing is charged before October&nbsp;13.');
expect(res._sent).toMatch(
/<a href="https:\/\/cloud\.example\/start-trial" target="_blank" rel="noopener noreferrer">add a card now \(new tab\)<\/a>/,
/<a href="https:\/\/cloud\.example\/start-trial\?source=post_approve" target="_blank" rel="noopener noreferrer">add a card now \(new tab\)<\/a>/,
);
// Continue stays the first and only button.
const html = res._sent ?? '';
Expand Down
Loading
Loading