From 500dbdf09f316d284390de26f7fec5f3771657f3 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:28:27 +0200 Subject: [PATCH] feat: opt-in Starter trial through Stripe Checkout (#2904) * feat: opt-in Starter trial through Stripe Checkout No trial starts at signup anymore. The owner of a free workspace who signed in with GitHub or Google starts a 14-day Starter trial from a Stripe Checkout page (no card required), from the billing page, the plan table or the upgrade dialog, with an optional "what do you want to test?" note. - getCheckoutSession({ trial, surface, intent }) runs the eligibility guards before creating a Stripe customer - sessionCompleted marks the trial: audit reason trial_started, customer metadata trialed, user.trialed_at, trial_started event - new user.trialed_at column; the trialEligibility query reads only the database, the Checkout mutation adds the Stripe guards - welcome email and marketing copy no longer promise an automatic trial * feat: trial marker on the workspace, eligibility on workspace.get, upgrade intent dialog - move trialed_at from user to workspace, next to trial_ends_at; the webhook sets it through markWorkspaceTrialed - workspace.get returns `trial` (eligibility without Stripe), checked against the workspace row first; drop stripeRouter.trialEligibility and its prefetch - after ?success or ?trial on billing, ask "What are you building?" once per user, stored as upgrade-intent feedback; feedback.hasSubmitted * style(dashboard): taller textarea and ghost skip in the upgrade intent dialog * feat: workspace.trialed_at migration, drop intent field from billing trial card The post-checkout "What are you building?" dialog asks instead. * fix: trial detection from checkout metadata, dedupe trial_started, upgrade_dialog surface - classify opt-in trials by subscription.metadata.source, not live status - track trial_started only when the workspace marker is first set - show the plan-table trial caption only when Starter is listed - index account.user_id for the provider lookup - no credit card needed hint in the billing card and nav banner * style(dashboard): ask what openstatus is used for in the upgrade intent dialog * ci: apply automated fixes * fix: share trial config, toast only once the trial is live - TRIAL_DAYS and TrialSurface move to an import-free trial-config module - the billing page polls workspace.get before the trial-started toast - tailor wording in the upgrade intent dialog - oxfmt * ci: apply automated fixes * fix(db): regenerate 0093 with account_user_id_idx * chore(db): drop trialedAt comment --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../(dashboard)/settings/billing/client.tsx | 102 +- .../settings/billing/search-params.ts | 1 + .../data-table/billing/data-table.tsx | 47 +- .../src/components/dialogs/upgrade-intent.tsx | 88 + .../src/components/dialogs/upgrade.tsx | 17 +- .../src/components/nav/nav-banner-upgrade.tsx | 14 +- apps/dashboard/src/lib/auth/index.ts | 35 +- apps/web/src/content/pages/home.mdx | 4 +- .../pages/product/incident-management.mdx | 4 +- .../src/content/pages/product/status-page.mdx | 4 +- .../pages/product/uptime-monitoring.mdx | 4 +- .../src/content/pages/unrelated/pricing.mdx | 6 +- packages/api/src/router/feedback.test.ts | 44 + packages/api/src/router/feedback.ts | 18 + packages/api/src/router/stripe/index.ts | 68 +- .../api/src/router/stripe/trial-config.ts | 4 + .../src/router/stripe/trial-lifecycle.test.ts | 95 + packages/api/src/router/stripe/trial.test.ts | 497 +- packages/api/src/router/stripe/trial.ts | 223 +- packages/api/src/router/stripe/webhook.ts | 42 +- packages/api/src/router/workspace.ts | 10 +- .../db/drizzle/0093_wonderful_red_skull.sql | 2 + packages/db/drizzle/meta/0093_snapshot.json | 6907 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/feedbacks/constants.ts | 1 + packages/db/src/schema/users/user.ts | 1 + .../db/src/schema/workspaces/workspace.ts | 1 + packages/emails/emails/welcome.tsx | 17 +- packages/emails/src/templates.test.tsx | 14 +- .../src/user/__tests__/providers.test.ts | 29 + packages/services/src/user/index.ts | 7 +- packages/services/src/user/providers.ts | 22 + packages/services/src/user/schemas.ts | 3 + .../src/workspace/__tests__/trial.test.ts | 136 +- packages/services/src/workspace/index.ts | 3 +- packages/services/src/workspace/trial.ts | 52 +- 36 files changed, 8075 insertions(+), 454 deletions(-) create mode 100644 apps/dashboard/src/components/dialogs/upgrade-intent.tsx create mode 100644 packages/api/src/router/feedback.test.ts create mode 100644 packages/api/src/router/stripe/trial-config.ts create mode 100644 packages/db/drizzle/0093_wonderful_red_skull.sql create mode 100644 packages/db/drizzle/meta/0093_snapshot.json create mode 100644 packages/services/src/user/__tests__/providers.test.ts create mode 100644 packages/services/src/user/providers.ts diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx index e93b194f2..ea00748da 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx @@ -1,12 +1,14 @@ "use client"; +import { TRIAL_DAYS } from "@openstatus/api/src/router/stripe/trial-config"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import { Button } from "@openstatus/ui/components/ui/button"; +import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useQueryStates } from "nuqs"; -import { useEffect, useMemo, useTransition } from "react"; +import { useEffect, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { BillingAddons } from "@/components/content/billing-addons"; @@ -23,6 +25,7 @@ import { SectionTitle, } from "@/components/content/section"; import { DataTable } from "@/components/data-table/billing/data-table"; +import { UpgradeIntentDialog } from "@/components/dialogs/upgrade-intent"; import { FormCard, FormCardContent, @@ -76,9 +79,26 @@ export function Client() { const router = useRouter(); const [isPending, startTransition] = useTransition(); const queryClient = useQueryClient(); - const [{ success, setup }, setSearchParams] = + const [{ success, setup, trial }, setSearchParams] = useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + // Captured on mount: the toasts clear the params while the dialog is open. + const [askIntent, setAskIntent] = useState(Boolean(success || trial)); + const { data: hasIntent } = useQuery({ + ...trpc.feedback.hasSubmitted.queryOptions({ source: "upgrade-intent" }), + enabled: askIntent, + }); + const [currency] = useCookieState("x-currency", "USD"); + const trialCheckoutMutation = useMutation( + trpc.stripeRouter.getCheckoutSession.mutationOptions({ + onSuccess: (data) => { + if (data?.type === "checkout" && data.session.url) { + window.location.assign(data.session.url); + } + }, + onError: (error) => toast.error(error.message), + }), + ); const { data: usage } = useQuery(trpc.workspace.usage.queryOptions()); const paymentMethodSetupMutation = useMutation( trpc.stripeRouter.getPaymentMethodSetupSession.mutationOptions({ @@ -122,6 +142,38 @@ export function Client() { } }, [success, setSearchParams]); + useEffect(() => { + if (!trial) return; + let cancelled = false; + queryClient.invalidateQueries({ + queryKey: trpc.stripeRouter.getBillingInterval.queryKey(), + }); + // The webhook can land after the redirect, and anyone can open + // `?trial=true`, so only toast once the workspace is actually trialing. + (async () => { + for (let attempt = 0; attempt < 5 && !cancelled; attempt++) { + const ws = await queryClient.fetchQuery({ + ...trpc.workspace.get.queryOptions(), + staleTime: 0, + }); + if (cancelled) return; + if (ws.trialDaysLeft !== null) { + toast.success(`Your ${TRIAL_DAYS}-day Starter trial has started`, { + duration: 5_000, + onAutoClose: () => setSearchParams({ trial: null }), + onDismiss: () => setSearchParams({ trial: null }), + }); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + if (!cancelled) setSearchParams({ trial: null }); + })(); + return () => { + cancelled = true; + }; + }, [trial, setSearchParams, queryClient, trpc]); + useEffect(() => { if (setup) { queryClient.invalidateQueries({ @@ -157,6 +209,10 @@ export function Client() { return ( +
Billing @@ -165,6 +221,48 @@ export function Client() { + {workspace?.trial.eligible ? ( + + + + Try Starter free for {TRIAL_DAYS} days + + + 1 min checks, more monitors, custom domain, Slack and on-call + integrations. + + + + + + No credit card needed. + {" "} + The workspace returns to Hobby after {TRIAL_DAYS} days unless + you add a card. + + + + + ) : null} {workspace.trialEndsAt && trialDaysLeft ? ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts index 0e6b7e14c..84e66c386 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts @@ -3,6 +3,7 @@ import { createSearchParamsCache, parseAsBoolean } from "nuqs/server"; export const searchParamsParsers = { success: parseAsBoolean, setup: parseAsBoolean, + trial: parseAsBoolean, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx index 326b19966..26453ac01 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -1,5 +1,9 @@ "use client"; +import { + TRIAL_DAYS, + type TrialSurface, +} from "@openstatus/api/src/router/stripe/trial-config"; import type { WorkspacePlan } from "@openstatus/db/src/schema"; import type { Addons, @@ -49,7 +53,13 @@ function getQuantitySuffix(addon: keyof Addons, interval: BillingInterval) { return packSize > 1 ? `${period}/${packSize}` : `${period}/each`; } -export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { +export function DataTable({ + restrictTo, + surface = "plan_table", +}: { + restrictTo?: WorkspacePlan[]; + surface?: TrialSurface; +}) { const [interval, setInterval] = useState("monthly"); const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); @@ -61,6 +71,7 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { trpc.stripeRouter.getBillingInterval.queryOptions(), ); const currentInterval = intervalQuery.data; + const canTrial = workspace?.trial.eligible === true; const checkoutSessionMutation = useMutation( trpc.stripeRouter.getCheckoutSession.mutationOptions({ @@ -128,7 +139,11 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { {isTrialing ? "Upgrading ends your trial and charges your card today." - : "A list to compare the different features by plan."} + : canTrial && + interval === "monthly" && + filteredPlans.some(({ id }) => id === "starter") + ? `Starter can be tried free for ${TRIAL_DAYS} days, no credit card needed.` + : "A list to compare the different features by plan."} @@ -157,6 +172,8 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { currentInterval !== null && currentInterval !== interval; const price = getPriceConfig(id, currency, interval); + const isTrialOffer = + canTrial && id === "starter" && interval === "monthly"; return ( {plan.description}

+ {isTrialOffer ? ( +

+ Free for {TRIAL_DAYS} days. No credit card needed. +

+ ) : null}
{isFreePlan ? ( @@ -196,6 +218,19 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { onClick={() => { startTransition(async () => { try { + if (isTrialOffer) { + await checkoutSessionMutation.mutateAsync({ + trial: true, + plan: "starter", + interval: "monthly", + surface, + currency: currency || "USD", + workspaceSlug: workspace.slug, + successUrl: `${BASE_URL}/settings/billing?trial=true`, + cancelUrl: `${BASE_URL}/settings/billing`, + }); + return; + } if (id === "free") { await customerPortalMutation.mutateAsync({ workspaceSlug: workspace.slug, @@ -232,9 +267,11 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { ? "Choosing..." : isIntervalSwitch ? `Switch to ${interval}` - : isTrialing && !isFreePlan - ? "Upgrade now" - : "Choose"} + : isTrialOffer + ? `Start ${TRIAL_DAYS}-day trial` + : isTrialing && !isFreePlan + ? "Upgrade now" + : "Choose"}
diff --git a/apps/dashboard/src/components/dialogs/upgrade-intent.tsx b/apps/dashboard/src/components/dialogs/upgrade-intent.tsx new file mode 100644 index 000000000..b2b424c7b --- /dev/null +++ b/apps/dashboard/src/components/dialogs/upgrade-intent.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Button } from "@openstatus/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@openstatus/ui/components/ui/dialog"; +import { Label } from "@openstatus/ui/components/ui/label"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import type { DialogProps } from "@radix-ui/react-dialog"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; + +import { useTRPC } from "@/lib/trpc/client"; + +/** Asked after an upgrade or trial start; stored as `upgrade-intent` feedback. */ +export function UpgradeIntentDialog(props: DialogProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const id = useId(); + const [message, setMessage] = useState(""); + const feedbackMutation = useMutation( + trpc.feedback.submit.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.feedback.hasSubmitted.queryKey({ + source: "upgrade-intent", + }), + }); + toast.success("Thanks for sharing"); + props.onOpenChange?.(false); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + + + + What are you using openstatus for? + + A sentence or two helps us tailor openstatus to you. + + +
+ +