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 013b8bfc1..e5b378d89 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -1,7 +1,7 @@ "use client"; import { Check } from "lucide-react"; -import { Fragment, useTransition } from "react"; +import { Fragment, useState, useTransition } from "react"; import { Button } from "@openstatus/ui/components/ui/button"; import { @@ -13,12 +13,14 @@ import { TableHeader, TableRow, } from "@openstatus/ui/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { config as featureGroups, plans } from "@/data/plans"; import { getStripe } from "@/lib/stripe"; import { useTRPC } from "@/lib/trpc/client"; import { cn } from "@/lib/utils"; import type { WorkspacePlan } from "@openstatus/db/src/schema"; +import type { BillingInterval } from "@openstatus/db/src/schema/plan/schema"; import { getAddonPriceConfig, getPriceConfig, @@ -34,6 +36,7 @@ const BASE_URL = : "http://localhost:3000"; export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { + const [interval, setInterval] = useState("monthly"); const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); const router = useRouter(); @@ -67,191 +70,214 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { ); return ( - - - A list to compare the different features by plan. - - - - - Features comparison - - {filteredPlans.map(({ id, ...plan }) => { - const isCurrentPlan = workspace.plan === id; - const price = getPriceConfig(id, currency); - return ( - -
-
-

{plan.title}

-

- {plan.description} -

-
-

- - {new Intl.NumberFormat(price.locale, { - style: "currency", - currency: price.currency, - }).format(price.value)} - - - /month - -

-
+ + A list to compare the different features by plan. + + + + + Features comparison + + {filteredPlans.map(({ id, ...plan }) => { + const isCurrentPlan = workspace.plan === id; + const price = getPriceConfig(id, currency, interval); + const isFreePlan = id === "free"; + return ( + +
+
+

{plan.title}

+

+ {plan.description} +

+
+
+ {isFreePlan ? ( +

Free

+ ) : ( +

+ + {new Intl.NumberFormat(price.locale, { + style: "currency", + currency: price.currency, + }).format(price.value)} + + + {interval === "yearly" ? "/year" : "/month"} + +

+ )} +
+ +
+
+ ); + })} +
+
+ + {Object.entries(featureGroups).map( + ([groupKey, { label, features }]) => ( + + + - {isCurrentPlan - ? "Current Plan" - : isPending - ? "Choosing..." - : "Choose"} - - - - ); - })} - - - - {Object.entries(featureGroups).map( - ([groupKey, { label, features }]) => ( - - - - {label} - - - {features.map( - ({ value, label: featureLabel, monthly, badge }) => ( - - -
- {featureLabel}{" "} - {badge ? ( - {badge} - ) : null} -
-
- {filteredPlans.map((plan) => { - const limitValue = - plan.limits[value as keyof typeof plan.limits]; - const isAddon = value in plan.addons; + {label} + +
+ {features.map( + ({ value, label: featureLabel, monthly, badge }) => ( + + +
+ {featureLabel}{" "} + {badge ? ( + {badge} + ) : null} +
+
+ {filteredPlans.map((plan) => { + const limitValue = + plan.limits[value as keyof typeof plan.limits]; + const isAddon = value in plan.addons; - function renderContent() { - if (isAddon) { - const price = getAddonPriceConfig( - plan.id, - value as keyof typeof plan.addons, - currency, - ); - if (!price) return null; + function renderContent() { + if (isAddon) { + const price = getAddonPriceConfig( + plan.id, + value as keyof typeof plan.addons, + currency, + ); + if (!price) return null; - const isNumber = typeof limitValue === "number"; - return ( -
- - {isNumber - ? new Intl.NumberFormat("us") - .format(limitValue) - .toString() - : null} - - - - {isNumber ? " + " : ""} + const isNumber = typeof limitValue === "number"; + return ( +
+ + {isNumber + ? new Intl.NumberFormat("us") + .format(limitValue) + .toString() + : null} - {new Intl.NumberFormat(price.locale, { - style: "currency", - currency: price.currency, - }).format(price.value)} - {isNumber ? "/mo./each" : "/mo."} + + {isNumber ? " + " : ""} + + + {new Intl.NumberFormat(price.locale, { + style: "currency", + currency: price.currency, + }).format(price.value)} + {isNumber ? "/mo./each" : "/mo."} + +
+ ); + } + if (typeof limitValue === "boolean") { + return limitValue ? ( + + ) : ( + + ‐ -
- ); - } - if (typeof limitValue === "boolean") { - return limitValue ? ( - - ) : ( - - ‐ - - ); - } - if (typeof limitValue === "number") { - return new Intl.NumberFormat("us") - .format(limitValue) - .toString(); - } + ); + } + if (typeof limitValue === "number") { + return new Intl.NumberFormat("us") + .format(limitValue) + .toString(); + } - // TODO: create a format function for this in @data/plans - if (value === "regions" && Array.isArray(limitValue)) { - return limitValue?.length ?? 0; - } + // TODO: create a format function for this in @data/plans + if ( + value === "regions" && + Array.isArray(limitValue) + ) { + return limitValue?.length ?? 0; + } - if ( - Array.isArray(limitValue) && - limitValue.length > 0 - ) { - return limitValue[0]; + if ( + Array.isArray(limitValue) && + limitValue.length > 0 + ) { + return limitValue[0]; + } + return limitValue; } - return limitValue; - } - return ( - - {renderContent()} - {monthly ? "/mo." : ""} - - ); - })} -
- ), - )} -
- ), - )} -
-
+ return ( + + {renderContent()} + {monthly ? "/mo." : ""} + + ); + })} + + ), + )} + + ), + )} + + + ); } diff --git a/apps/web/src/content/mdx-components/index.tsx b/apps/web/src/content/mdx-components/index.tsx index 44cf1cf29..ef3e2998f 100644 --- a/apps/web/src/content/mdx-components/index.tsx +++ b/apps/web/src/content/mdx-components/index.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import { LatencyChartTable } from "../latency-chart-table"; import { ButtonLink } from "./button-link"; import { Code } from "./code"; @@ -7,6 +8,7 @@ import { Details } from "./details"; import { Grid } from "./grid"; import { createHeading } from "./heading"; import { Pre } from "./pre"; +import { PricingTabs } from "./pricing-tabs"; import { MDXStatusPageExample } from "./status-page-example"; import { Table } from "./table"; import { MDXTweet } from "./tweet"; @@ -32,4 +34,6 @@ export const components = { SimpleChart: LatencyChartTable, Tweet: MDXTweet, StatusPageExample: MDXStatusPageExample, + PricingTabs, + Suspense: Suspense, }; diff --git a/apps/web/src/content/mdx-components/pricing-tabs.tsx b/apps/web/src/content/mdx-components/pricing-tabs.tsx new file mode 100644 index 000000000..6e763b773 --- /dev/null +++ b/apps/web/src/content/mdx-components/pricing-tabs.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { allPlans } from "@openstatus/db/src/schema/plan/config"; +import type { BillingInterval } from "@openstatus/db/src/schema/plan/schema"; +import { getPriceConfig } from "@openstatus/db/src/schema/plan/utils"; +import { Badge } from "@openstatus/ui/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +const planColumns = [ + { plan: "free" as const, header: "Hobby" }, + { plan: "starter" as const, header: "Starter" }, + { plan: "team" as const, header: "Pro" }, +] as const; + +export function PricingTabs() { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const interval = ( + searchParams.get("interval") === "yearly" ? "yearly" : "monthly" + ) as BillingInterval; + + const setInterval = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === "monthly") { + params.delete("interval"); + } else { + params.set("interval", value); + } + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false }); + }, + [searchParams, router, pathname], + ); + + return ( +
+ + + + Monthly + + + Yearly{" "} + + 2 months free + + + + + +
+ ); +} + +function PricingUpdater({ interval }: { interval: BillingInterval }) { + const formatPrice = useCallback( + (plan: (typeof planColumns)[number]["plan"]) => { + const config = allPlans[plan]; + if (plan === "free") return "Free - Hobby"; + const price = getPriceConfig(plan, "USD", interval); + const formatted = new Intl.NumberFormat(price.locale, { + style: "currency", + currency: price.currency, + maximumFractionDigits: 0, + }).format(price.value); + const suffix = interval === "yearly" ? "/year" : "/month"; + return `${formatted}${suffix} - ${config.title}`; + }, + [interval], + ); + + // Walk the DOM to update prices in the GFM table headers + if (typeof window !== "undefined") { + // Use requestAnimationFrame to run after render + requestAnimationFrame(() => { + const tables = document.querySelectorAll(".table-wrapper table"); + for (const table of tables) { + const headers = table.querySelectorAll("thead th"); + // The pricing table has 4 columns: Features | Hobby | Starter | Pro + if (headers.length !== 4) continue; + + for (let i = 0; i < planColumns.length; i++) { + const th = headers[i + 1]; // skip first "Features comparison" column + if (!th) continue; + const { plan } = planColumns[i]; + th.textContent = formatPrice(plan); + } + } + }); + } + + return null; +} diff --git a/apps/web/src/content/pages/home.mdx b/apps/web/src/content/pages/home.mdx index 6daf929de..26ec30e39 100644 --- a/apps/web/src/content/pages/home.mdx +++ b/apps/web/src/content/pages/home.mdx @@ -17,6 +17,8 @@ faq: answer: "Openstatus is built by Thibault and Max, a bootstrapped two-person team. We're profitable and self-funded — we'll be here when your next audit comes around." - question: "What regions does openstatus monitor from?" answer: "Openstatus monitors from 28 regions worldwide: Europe (Amsterdam, Stockholm, Paris, Frankfurt, London), North America (Dallas, New Jersey, Los Angeles, San Jose, Chicago, Toronto), South America (São Paulo), Asia (Mumbai, Tokyo, Singapore), Africa (Johannesburg), and Oceania (Sydney)." + - question: "Do you offer annual billing?" + answer: "Yes. All paid plans are available with monthly or annual billing. Choose annual billing to get 2 months free — that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). You can switch between billing cycles at any time." - question: "Can I self-host openstatus?" answer: "Yes. Openstatus is fully open source and can be self-hosted using its 8.5MB Docker image. You can also deploy private monitoring locations behind your firewall for internal services. The source code is available on GitHub." --- @@ -193,6 +195,14 @@ Sydney 🇦🇺 +
+ +Yes. All paid plans are available with **monthly** or **annual** billing. Choose annual billing to get **2 months free** — that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). + +You can switch between billing cycles at any time. Check the [pricing page](/pricing) for a full comparison. + +
+
Yes. Openstatus is fully open source and can be self-hosted using its **8.5MB Docker image**. You can also deploy **private monitoring locations** behind your firewall to check internal services not exposed to the internet. diff --git a/apps/web/src/content/pages/unrelated/pricing.mdx b/apps/web/src/content/pages/unrelated/pricing.mdx index ccb70bc9d..ab8d52388 100644 --- a/apps/web/src/content/pages/unrelated/pricing.mdx +++ b/apps/web/src/content/pages/unrelated/pricing.mdx @@ -4,9 +4,26 @@ publishedAt: "2025-11-10" author: "Maximilian Kaske" description: "Start free with uptime monitoring and a status page. Upgrade to Starter ($30/mo) or Pro ($100/mo) for more monitors, team collaboration, and advanced features. No credit card required." category: "company" +faq: + - question: "What billing options are available?" + answer: "All paid plans are available with monthly or annual billing. Choose annual billing to get 2 months free — Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo)." + - question: "Can I switch between monthly and annual billing?" + answer: "Yes. You can switch between monthly and annual billing at any time from your dashboard." + - question: "What does the free plan include?" + answer: "The free plan includes one monitor, one status page with three components, and a minimum check interval of 10 minutes. No credit card required." + - question: "What are add-ons and how are they billed?" + answer: "Add-ons are workspace-level settings like White Label, Email Authentication, and extra status pages. Once enabled, they apply to your entire workspace. Add-ons are billed monthly regardless of your plan's billing cycle." + - question: "Can I upgrade or downgrade my plan?" + answer: "Yes. You can upgrade or downgrade at any time from your dashboard. Changes take effect immediately, and billing is prorated." + - question: "What currencies do you support?" + answer: "We support EUR, USD, and INR. Contact us at ping@openstatus.dev or book a call if you have questions about pricing in your currency." --- -| Features comparison | $0/month - Hobby |$30/month - Starter | $100/month - Pro | + + + + +| Features comparison | Free - Hobby |$30/month - Starter | $100/month - Pro | | --- | --- | --- | --- | | [Status Pages](/status-page) | | | | | Number of status pages | 1 | 1 +$20/mo./each | 5 +$20/mo./each | @@ -42,9 +59,11 @@ category: "company" | **Collaboration** | | | | | Team members | 1 | Unlimited | Unlimited | +All paid plans are available with **monthly** or **annual** billing. Choose annual billing to get **2 months free** — that's Starter at $300/year ($25/mo) and Pro at $1,000/year (~$83/mo). + We provide pricing support for **EUR**/**USD**/**INR** as currency. Contact us at [ping@openstatus.dev](mailto:ping@openstatus.dev) or [book a call](https://openstatus.dev/cal) if you have questions. -**Add-ons** are workspace settings. Once enabled, you can use these configurations for every status page, monitor, etc. in your workspace. +**Add-ons** are workspace settings. Once enabled, you can use these configurations for every status page, monitor, etc. in your workspace. Add-ons are billed monthly. --- diff --git a/apps/web/src/lib/metadata/structured-data.ts b/apps/web/src/lib/metadata/structured-data.ts index 51640ee76..7dc642eec 100644 --- a/apps/web/src/lib/metadata/structured-data.ts +++ b/apps/web/src/lib/metadata/structured-data.ts @@ -94,7 +94,7 @@ export const getJsonLDProduct = (): WithContext => { }, offers: Object.entries(allPlans).map(([_, value]) => ({ "@type": "Offer", - price: value.price.USD, + price: value.price.monthly.USD, name: value.title, priceCurrency: "USD", availability: "https://schema.org/InStock", @@ -116,7 +116,7 @@ export const getJsonLDSoftwareApplication = operatingSystem: "Web, Self-hosted", offers: Object.entries(allPlans).map(([_, value]) => ({ "@type": "Offer", - price: value.price.USD, + price: value.price.monthly.USD, name: value.title, priceCurrency: "USD", availability: "https://schema.org/InStock", diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index 67eeefb4e..2111ce3c4 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -11,7 +11,10 @@ import { import { Events } from "@openstatus/analytics"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; -import { addons } from "@openstatus/db/src/schema/plan/schema"; +import { + addons, + billingIntervals, +} from "@openstatus/db/src/schema/plan/schema"; import { updateAddonInLimits } from "@openstatus/db/src/schema/plan/utils"; import { TRPCError } from "@trpc/server"; import type { Stripe } from "stripe"; @@ -90,9 +93,9 @@ export const stripeRouter = createTRPCRouter({ z.object({ workspaceSlug: z.string(), plan: z.enum(workspacePlans), + interval: z.enum(billingIntervals).default("monthly"), successUrl: z.string().optional(), cancelUrl: z.string().optional(), - // TODO: plan: workspacePlanSchema }), ) .mutation(async (opts) => { @@ -145,7 +148,7 @@ export const stripeRouter = createTRPCRouter({ .run(); } - const priceId = getPriceIdForPlan(opts.input.plan); + const priceId = getPriceIdForPlan(opts.input.plan, opts.input.interval); const session = await stripe.checkout.sessions.create({ payment_method_types: ["card"], customer: stripeId, diff --git a/packages/api/src/router/stripe/utils.ts b/packages/api/src/router/stripe/utils.ts index aa3256691..5e36b5fc9 100644 --- a/packages/api/src/router/stripe/utils.ts +++ b/packages/api/src/router/stripe/utils.ts @@ -1,12 +1,19 @@ // Shamelessly stolen from dub.co import type { WorkspacePlan } from "@openstatus/db/src/schema"; -import type { Addons } from "@openstatus/db/src/schema/plan/schema"; +import type { + Addons, + BillingInterval, +} from "@openstatus/db/src/schema/plan/schema"; export const getPlanFromPriceId = (priceId: string) => { const env = process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; - return PLANS.find((plan) => plan.price.monthly.priceIds[env] === priceId); + return PLANS.find( + (plan) => + plan.price.monthly.priceIds[env] === priceId || + plan.price.yearly.priceIds[env] === priceId, + ); }; export const getFeatureFromPriceId = (priceId: string) => { @@ -17,10 +24,13 @@ export const getFeatureFromPriceId = (priceId: string) => { ); }; -export const getPriceIdForPlan = (plan: WorkspacePlan) => { +export const getPriceIdForPlan = ( + plan: WorkspacePlan, + interval: BillingInterval = "monthly", +) => { const env = process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test"; - return PLANS.find((p) => p.plan === plan)?.price.monthly.priceIds[env]; + return PLANS.find((p) => p.plan === plan)?.price[interval].priceIds[env]; }; export const getPriceIdForFeature = (feature: keyof Addons) => { @@ -41,6 +51,12 @@ export const PLANS = [ production: "price_1RxsLNBXJcTfzsyJ7La5Jn5y", }, }, + yearly: { + priceIds: { + test: "XXX", + production: "price_1TDlHxBXJcTfzsyJygJw92nU", + }, + }, }, }, { @@ -52,12 +68,19 @@ export const PLANS = [ production: "price_1RxsJzBXJcTfzsyJBOztaKlR", }, }, + yearly: { + priceIds: { + test: "XXX", + production: "price_1TDlGSBXJcTfzsyJMsDV4DRQ", + }, + }, }, }, ] satisfies Array<{ plan: WorkspacePlan; price: { monthly: { priceIds: { test: string; production: string } }; + yearly: { priceIds: { test: string; production: string } }; }; }>; diff --git a/packages/db/src/schema/plan/config.ts b/packages/db/src/schema/plan/config.ts index 12dd85ba7..07742c564 100644 --- a/packages/db/src/schema/plan/config.ts +++ b/packages/db/src/schema/plan/config.ts @@ -1,12 +1,12 @@ import { AVAILABLE_REGIONS, FREE_FLY_REGIONS } from "@openstatus/regions"; import type { WorkspacePlan } from "../workspaces/validation"; -import type { Addons, PlanLimits, Price } from "./schema"; +import type { Addons, IntervalPrice, PlanLimits, Price } from "./schema"; type PlanConfig = { title: "Hobby" | "Starter" | "Pro"; id: WorkspacePlan; description: string; - price: Price; + price: IntervalPrice; addons: Partial<{ [K in keyof Addons]: { title: string; @@ -24,9 +24,8 @@ export const allPlans: Record = { id: "free", description: "Perfect for personal projects", price: { - USD: 0, - EUR: 0, - INR: 0, + monthly: { USD: 0, EUR: 0, INR: 0 }, + yearly: { USD: 0, EUR: 0, INR: 0 }, }, addons: {}, limits: { @@ -69,9 +68,8 @@ export const allPlans: Record = { id: "starter", description: "Perfect for uptime monitoring", price: { - USD: 30, - EUR: 30, - INR: 3000, + monthly: { USD: 30, EUR: 30, INR: 3_000 }, + yearly: { USD: 300, EUR: 300, INR: 30_000 }, }, addons: { "email-domain-protection": { @@ -144,9 +142,8 @@ export const allPlans: Record = { id: "team", description: "Perfect for global synthetic monitoring", price: { - USD: 100, - EUR: 100, - INR: 10_000, + monthly: { USD: 100, EUR: 100, INR: 10_000 }, + yearly: { USD: 1_000, EUR: 1_000, INR: 100_000 }, }, addons: { "email-domain-protection": { diff --git a/packages/db/src/schema/plan/schema.ts b/packages/db/src/schema/plan/schema.ts index d0d2ec4ff..d7b23eafe 100644 --- a/packages/db/src/schema/plan/schema.ts +++ b/packages/db/src/schema/plan/schema.ts @@ -72,6 +72,16 @@ const priceSchema = z.object({ export type Price = z.infer; +export const billingIntervals = ["monthly", "yearly"] as const; +export type BillingInterval = (typeof billingIntervals)[number]; + +const intervalPriceSchema = z.object({ + monthly: priceSchema, + yearly: priceSchema, +}); + +export type IntervalPrice = z.infer; + export const addons = [ "email-domain-protection", "white-label", diff --git a/packages/db/src/schema/plan/utils.ts b/packages/db/src/schema/plan/utils.ts index 1f07ab735..6609dddd5 100644 --- a/packages/db/src/schema/plan/utils.ts +++ b/packages/db/src/schema/plan/utils.ts @@ -1,6 +1,11 @@ import type { WorkspacePlan } from "../workspaces/validation"; import { allPlans } from "./config"; -import { type Addons, type Limits, limitsSchema } from "./schema"; +import { + type Addons, + type BillingInterval, + type Limits, + limitsSchema, +} from "./schema"; export function getLimit(limits: Limits, limit: T) { return limits[limit] || allPlans.free.limits[limit]; @@ -57,9 +62,13 @@ function resolvePriceConfig( return { value, locale, currency: effectiveCurrency }; } -export function getPriceConfig(plan: WorkspacePlan, currency?: string) { +export function getPriceConfig( + plan: WorkspacePlan, + currency?: string, + interval: BillingInterval = "monthly", +) { const planConfig = allPlans[plan]; - return resolvePriceConfig(planConfig.price, currency); + return resolvePriceConfig(planConfig.price[interval], currency); } export function getAddonPriceConfig(