Skip to content

Commit cb010d2

Browse files
mxkaskethibaultleouayautofix-ci[bot]
authored
chore: yearly billing (#1996)
* chore: yearly billing * fix: product price * chore: add billing faqs * add stripe id * ci: apply automated fixes --------- Co-authored-by: Thibault Le Ouay Ducasse <thibaultleouay@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 4bc9249 commit cb010d2

File tree

11 files changed

+403
-198
lines changed

11 files changed

+403
-198
lines changed

apps/dashboard/src/components/data-table/billing/data-table.tsx

Lines changed: 199 additions & 173 deletions
Large diffs are not rendered by default.

apps/web/src/content/mdx-components/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from "react";
12
import { LatencyChartTable } from "../latency-chart-table";
23
import { ButtonLink } from "./button-link";
34
import { Code } from "./code";
@@ -7,6 +8,7 @@ import { Details } from "./details";
78
import { Grid } from "./grid";
89
import { createHeading } from "./heading";
910
import { Pre } from "./pre";
11+
import { PricingTabs } from "./pricing-tabs";
1012
import { MDXStatusPageExample } from "./status-page-example";
1113
import { Table } from "./table";
1214
import { MDXTweet } from "./tweet";
@@ -32,4 +34,6 @@ export const components = {
3234
SimpleChart: LatencyChartTable,
3335
Tweet: MDXTweet,
3436
StatusPageExample: MDXStatusPageExample,
37+
PricingTabs,
38+
Suspense: Suspense,
3539
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"use client";
2+
3+
import { allPlans } from "@openstatus/db/src/schema/plan/config";
4+
import type { BillingInterval } from "@openstatus/db/src/schema/plan/schema";
5+
import { getPriceConfig } from "@openstatus/db/src/schema/plan/utils";
6+
import { Badge } from "@openstatus/ui/components/ui/badge";
7+
import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs";
8+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
9+
import { useCallback } from "react";
10+
11+
const planColumns = [
12+
{ plan: "free" as const, header: "Hobby" },
13+
{ plan: "starter" as const, header: "Starter" },
14+
{ plan: "team" as const, header: "Pro" },
15+
] as const;
16+
17+
export function PricingTabs() {
18+
const searchParams = useSearchParams();
19+
const router = useRouter();
20+
const pathname = usePathname();
21+
22+
const interval = (
23+
searchParams.get("interval") === "yearly" ? "yearly" : "monthly"
24+
) as BillingInterval;
25+
26+
const setInterval = useCallback(
27+
(value: string) => {
28+
const params = new URLSearchParams(searchParams.toString());
29+
if (value === "monthly") {
30+
params.delete("interval");
31+
} else {
32+
params.set("interval", value);
33+
}
34+
const qs = params.toString();
35+
router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false });
36+
},
37+
[searchParams, router, pathname],
38+
);
39+
40+
return (
41+
<div className="not-prose mb-4">
42+
<Tabs value={interval} onValueChange={setInterval}>
43+
<TabsList className="h-auto w-full rounded-none">
44+
<TabsTrigger
45+
value="monthly"
46+
className="h-auto w-full truncate rounded-none p-3.5"
47+
>
48+
Monthly
49+
</TabsTrigger>
50+
<TabsTrigger
51+
value="yearly"
52+
className="h-auto w-full truncate rounded-none p-3.5"
53+
>
54+
Yearly{" "}
55+
<Badge variant="secondary" className="ml-1.5 h-auto rounded-none">
56+
2 months free
57+
</Badge>
58+
</TabsTrigger>
59+
</TabsList>
60+
</Tabs>
61+
<PricingUpdater interval={interval} />
62+
</div>
63+
);
64+
}
65+
66+
function PricingUpdater({ interval }: { interval: BillingInterval }) {
67+
const formatPrice = useCallback(
68+
(plan: (typeof planColumns)[number]["plan"]) => {
69+
const config = allPlans[plan];
70+
if (plan === "free") return "Free - Hobby";
71+
const price = getPriceConfig(plan, "USD", interval);
72+
const formatted = new Intl.NumberFormat(price.locale, {
73+
style: "currency",
74+
currency: price.currency,
75+
maximumFractionDigits: 0,
76+
}).format(price.value);
77+
const suffix = interval === "yearly" ? "/year" : "/month";
78+
return `${formatted}${suffix} - ${config.title}`;
79+
},
80+
[interval],
81+
);
82+
83+
// Walk the DOM to update prices in the GFM table headers
84+
if (typeof window !== "undefined") {
85+
// Use requestAnimationFrame to run after render
86+
requestAnimationFrame(() => {
87+
const tables = document.querySelectorAll(".table-wrapper table");
88+
for (const table of tables) {
89+
const headers = table.querySelectorAll("thead th");
90+
// The pricing table has 4 columns: Features | Hobby | Starter | Pro
91+
if (headers.length !== 4) continue;
92+
93+
for (let i = 0; i < planColumns.length; i++) {
94+
const th = headers[i + 1]; // skip first "Features comparison" column
95+
if (!th) continue;
96+
const { plan } = planColumns[i];
97+
th.textContent = formatPrice(plan);
98+
}
99+
}
100+
});
101+
}
102+
103+
return null;
104+
}

apps/web/src/content/pages/home.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ faq:
1717
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."
1818
- question: "What regions does openstatus monitor from?"
1919
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)."
20+
- question: "Do you offer annual billing?"
21+
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."
2022
- question: "Can I self-host openstatus?"
2123
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."
2224
---
@@ -193,6 +195,14 @@ Sydney 🇦🇺
193195

194196
</Details>
195197

198+
<Details summary="Do you offer annual billing?">
199+
200+
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).
201+
202+
You can switch between billing cycles at any time. Check the [pricing page](/pricing) for a full comparison.
203+
204+
</Details>
205+
196206
<Details summary="Can I self-host openstatus?">
197207

198208
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.

apps/web/src/content/pages/unrelated/pricing.mdx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,26 @@ publishedAt: "2025-11-10"
44
author: "Maximilian Kaske"
55
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."
66
category: "company"
7+
faq:
8+
- question: "What billing options are available?"
9+
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)."
10+
- question: "Can I switch between monthly and annual billing?"
11+
answer: "Yes. You can switch between monthly and annual billing at any time from your dashboard."
12+
- question: "What does the free plan include?"
13+
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."
14+
- question: "What are add-ons and how are they billed?"
15+
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."
16+
- question: "Can I upgrade or downgrade my plan?"
17+
answer: "Yes. You can upgrade or downgrade at any time from your dashboard. Changes take effect immediately, and billing is prorated."
18+
- question: "What currencies do you support?"
19+
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."
720
---
821

9-
| Features comparison | $0/month - Hobby |$30/month - Starter | $100/month - Pro |
22+
<Suspense>
23+
<PricingTabs />
24+
</Suspense>
25+
26+
| Features comparison | Free - Hobby |$30/month - Starter | $100/month - Pro |
1027
| --- | --- | --- | --- |
1128
| [Status Pages](/status-page) | | | |
1229
| Number of status pages | 1 | 1 +$20/mo./each | 5 +$20/mo./each |
@@ -42,9 +59,11 @@ category: "company"
4259
| **Collaboration** | | | |
4360
| Team members | 1 | Unlimited | Unlimited |
4461

62+
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).
63+
4564
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.
4665

47-
**Add-ons** are workspace settings. Once enabled, you can use these configurations for every status page, monitor, etc. in your workspace.
66+
**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.
4867

4968
---
5069

apps/web/src/lib/metadata/structured-data.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export const getJsonLDProduct = (): WithContext<Product> => {
9494
},
9595
offers: Object.entries(allPlans).map(([_, value]) => ({
9696
"@type": "Offer",
97-
price: value.price.USD,
97+
price: value.price.monthly.USD,
9898
name: value.title,
9999
priceCurrency: "USD",
100100
availability: "https://schema.org/InStock",
@@ -116,7 +116,7 @@ export const getJsonLDSoftwareApplication =
116116
operatingSystem: "Web, Self-hosted",
117117
offers: Object.entries(allPlans).map(([_, value]) => ({
118118
"@type": "Offer",
119-
price: value.price.USD,
119+
price: value.price.monthly.USD,
120120
name: value.title,
121121
priceCurrency: "USD",
122122
availability: "https://schema.org/InStock",

packages/api/src/router/stripe/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111

1212
import { Events } from "@openstatus/analytics";
1313
import { allPlans } from "@openstatus/db/src/schema/plan/config";
14-
import { addons } from "@openstatus/db/src/schema/plan/schema";
14+
import {
15+
addons,
16+
billingIntervals,
17+
} from "@openstatus/db/src/schema/plan/schema";
1518
import { updateAddonInLimits } from "@openstatus/db/src/schema/plan/utils";
1619
import { TRPCError } from "@trpc/server";
1720
import type { Stripe } from "stripe";
@@ -90,9 +93,9 @@ export const stripeRouter = createTRPCRouter({
9093
z.object({
9194
workspaceSlug: z.string(),
9295
plan: z.enum(workspacePlans),
96+
interval: z.enum(billingIntervals).default("monthly"),
9397
successUrl: z.string().optional(),
9498
cancelUrl: z.string().optional(),
95-
// TODO: plan: workspacePlanSchema
9699
}),
97100
)
98101
.mutation(async (opts) => {
@@ -145,7 +148,7 @@ export const stripeRouter = createTRPCRouter({
145148
.run();
146149
}
147150

148-
const priceId = getPriceIdForPlan(opts.input.plan);
151+
const priceId = getPriceIdForPlan(opts.input.plan, opts.input.interval);
149152
const session = await stripe.checkout.sessions.create({
150153
payment_method_types: ["card"],
151154
customer: stripeId,

packages/api/src/router/stripe/utils.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
// Shamelessly stolen from dub.co
22

33
import type { WorkspacePlan } from "@openstatus/db/src/schema";
4-
import type { Addons } from "@openstatus/db/src/schema/plan/schema";
4+
import type {
5+
Addons,
6+
BillingInterval,
7+
} from "@openstatus/db/src/schema/plan/schema";
58

69
export const getPlanFromPriceId = (priceId: string) => {
710
const env =
811
process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test";
9-
return PLANS.find((plan) => plan.price.monthly.priceIds[env] === priceId);
12+
return PLANS.find(
13+
(plan) =>
14+
plan.price.monthly.priceIds[env] === priceId ||
15+
plan.price.yearly.priceIds[env] === priceId,
16+
);
1017
};
1118

1219
export const getFeatureFromPriceId = (priceId: string) => {
@@ -17,10 +24,13 @@ export const getFeatureFromPriceId = (priceId: string) => {
1724
);
1825
};
1926

20-
export const getPriceIdForPlan = (plan: WorkspacePlan) => {
27+
export const getPriceIdForPlan = (
28+
plan: WorkspacePlan,
29+
interval: BillingInterval = "monthly",
30+
) => {
2131
const env =
2232
process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test";
23-
return PLANS.find((p) => p.plan === plan)?.price.monthly.priceIds[env];
33+
return PLANS.find((p) => p.plan === plan)?.price[interval].priceIds[env];
2434
};
2535

2636
export const getPriceIdForFeature = (feature: keyof Addons) => {
@@ -41,6 +51,12 @@ export const PLANS = [
4151
production: "price_1RxsLNBXJcTfzsyJ7La5Jn5y",
4252
},
4353
},
54+
yearly: {
55+
priceIds: {
56+
test: "XXX",
57+
production: "price_1TDlHxBXJcTfzsyJygJw92nU",
58+
},
59+
},
4460
},
4561
},
4662
{
@@ -52,12 +68,19 @@ export const PLANS = [
5268
production: "price_1RxsJzBXJcTfzsyJBOztaKlR",
5369
},
5470
},
71+
yearly: {
72+
priceIds: {
73+
test: "XXX",
74+
production: "price_1TDlGSBXJcTfzsyJMsDV4DRQ",
75+
},
76+
},
5577
},
5678
},
5779
] satisfies Array<{
5880
plan: WorkspacePlan;
5981
price: {
6082
monthly: { priceIds: { test: string; production: string } };
83+
yearly: { priceIds: { test: string; production: string } };
6184
};
6285
}>;
6386

packages/db/src/schema/plan/config.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { AVAILABLE_REGIONS, FREE_FLY_REGIONS } from "@openstatus/regions";
22
import type { WorkspacePlan } from "../workspaces/validation";
3-
import type { Addons, PlanLimits, Price } from "./schema";
3+
import type { Addons, IntervalPrice, PlanLimits, Price } from "./schema";
44

55
type PlanConfig = {
66
title: "Hobby" | "Starter" | "Pro";
77
id: WorkspacePlan;
88
description: string;
9-
price: Price;
9+
price: IntervalPrice;
1010
addons: Partial<{
1111
[K in keyof Addons]: {
1212
title: string;
@@ -24,9 +24,8 @@ export const allPlans: Record<WorkspacePlan, PlanConfig> = {
2424
id: "free",
2525
description: "Perfect for personal projects",
2626
price: {
27-
USD: 0,
28-
EUR: 0,
29-
INR: 0,
27+
monthly: { USD: 0, EUR: 0, INR: 0 },
28+
yearly: { USD: 0, EUR: 0, INR: 0 },
3029
},
3130
addons: {},
3231
limits: {
@@ -69,9 +68,8 @@ export const allPlans: Record<WorkspacePlan, PlanConfig> = {
6968
id: "starter",
7069
description: "Perfect for uptime monitoring",
7170
price: {
72-
USD: 30,
73-
EUR: 30,
74-
INR: 3000,
71+
monthly: { USD: 30, EUR: 30, INR: 3_000 },
72+
yearly: { USD: 300, EUR: 300, INR: 30_000 },
7573
},
7674
addons: {
7775
"email-domain-protection": {
@@ -144,9 +142,8 @@ export const allPlans: Record<WorkspacePlan, PlanConfig> = {
144142
id: "team",
145143
description: "Perfect for global synthetic monitoring",
146144
price: {
147-
USD: 100,
148-
EUR: 100,
149-
INR: 10_000,
145+
monthly: { USD: 100, EUR: 100, INR: 10_000 },
146+
yearly: { USD: 1_000, EUR: 1_000, INR: 100_000 },
150147
},
151148
addons: {
152149
"email-domain-protection": {

packages/db/src/schema/plan/schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ const priceSchema = z.object({
7272

7373
export type Price = z.infer<typeof priceSchema>;
7474

75+
export const billingIntervals = ["monthly", "yearly"] as const;
76+
export type BillingInterval = (typeof billingIntervals)[number];
77+
78+
const intervalPriceSchema = z.object({
79+
monthly: priceSchema,
80+
yearly: priceSchema,
81+
});
82+
83+
export type IntervalPrice = z.infer<typeof intervalPriceSchema>;
84+
7585
export const addons = [
7686
"email-domain-protection",
7787
"white-label",

0 commit comments

Comments
 (0)