From 389111022cc7962ea9ec4a88fa175873ccc14498 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 12 Sep 2026 21:03:57 +0300
Subject: [PATCH 1/4] fix(docs): disclose investigation pricing across entry
points
---
apps/dashboard/autumn.config.ts | 30 +++++--
.../lib/investigation-purchase.test.ts | 6 +-
apps/docs/app/(home)/pricing/page.tsx | 8 +-
apps/docs/app/(home)/pricing/pricing-faq.tsx | 3 +-
.../components/landing/pricing-preview.tsx | 6 +-
apps/docs/content/docs/dashboard.mdx | 2 +-
apps/docs/lib/comparison-config.ts | 18 ++--
apps/docs/lib/pricing-copy.test.ts | 18 ++++
.../ai/src/ai/tools/investigations.test.ts | 83 +++++++++++++++++++
packages/ai/src/ai/tools/investigations.ts | 22 ++++-
packages/ai/src/mcp/guide.ts | 8 +-
11 files changed, 176 insertions(+), 28 deletions(-)
diff --git a/apps/dashboard/autumn.config.ts b/apps/dashboard/autumn.config.ts
index 8461c9b7de..62f1d97b01 100644
--- a/apps/dashboard/autumn.config.ts
+++ b/apps/dashboard/autumn.config.ts
@@ -105,7 +105,11 @@ export const free = plan({
addOn: false,
autoEnable: true,
items: [
- item({ featureId: investigation_runs.id, included: 0 }),
+ item({
+ featureId: investigation_runs.id,
+ included: 0,
+ reset: { interval: "one_off" },
+ }),
item({
featureId: events.id,
included: 10_000,
@@ -133,7 +137,11 @@ export const hobby = plan({
interval: "month",
},
items: [
- item({ featureId: investigation_runs.id, included: 0 }),
+ item({
+ featureId: investigation_runs.id,
+ included: 0,
+ reset: { interval: "one_off" },
+ }),
item({
featureId: events.id,
included: 30_000,
@@ -178,7 +186,11 @@ export const pro = plan({
interval: "month",
},
items: [
- item({ featureId: investigation_runs.id, included: 0 }),
+ item({
+ featureId: investigation_runs.id,
+ included: 0,
+ reset: { interval: "one_off" },
+ }),
eventsOverageItem(1_000_000),
item({
featureId: agent_credits.id,
@@ -261,7 +273,11 @@ export const intelligence = plan({
interval: "month",
},
items: [
- item({ featureId: investigation_runs.id, included: 0 }),
+ item({
+ featureId: investigation_runs.id,
+ included: 0,
+ reset: { interval: "one_off" },
+ }),
eventsOverageItem(2_000_000),
item({
featureId: agent_credits.id,
@@ -296,7 +312,11 @@ export const intelligence_scale = plan({
interval: "month",
},
items: [
- item({ featureId: investigation_runs.id, included: 0 }),
+ item({
+ featureId: investigation_runs.id,
+ included: 0,
+ reset: { interval: "one_off" },
+ }),
eventsOverageItem(10_000_000),
item({
featureId: agent_credits.id,
diff --git a/apps/dashboard/lib/investigation-purchase.test.ts b/apps/dashboard/lib/investigation-purchase.test.ts
index 85719ef09d..43b042583c 100644
--- a/apps/dashboard/lib/investigation-purchase.test.ts
+++ b/apps/dashboard/lib/investigation-purchase.test.ts
@@ -28,12 +28,14 @@ describe("fixed investigation purchases", () => {
}]);
});
- test("active plan versions opt in with zero units and preserve every old credit grant", () => {
+ test("active plan versions declare one-off zero units and preserve every old credit grant", () => {
for (const [plan, monthly, daily] of [
[free, 10, undefined], [hobby, 20, 1], [pro, 350, 5],
[intelligence, 1500, undefined], [intelligence_scale, 5000, undefined],
] as const) {
- expect(plan.items?.filter((item) => item.featureId === "investigation_runs")).toEqual([{ featureId: "investigation_runs", included: 0 }]);
+ expect(plan.items?.filter((item) => item.featureId === "investigation_runs")).toEqual([
+ { featureId: "investigation_runs", included: 0, reset: { interval: "one_off" } },
+ ]);
const credits = plan.items?.filter((item) => item.featureId === "agent_credits");
expect(credits?.find((item) => item.reset?.interval === "month")?.included).toBe(monthly);
expect(credits?.find((item) => item.reset?.interval === "day")?.included).toBe(daily);
diff --git a/apps/docs/app/(home)/pricing/page.tsx b/apps/docs/app/(home)/pricing/page.tsx
index 1f65d6f074..35912f4d4f 100644
--- a/apps/docs/app/(home)/pricing/page.tsx
+++ b/apps/docs/app/(home)/pricing/page.tsx
@@ -20,12 +20,12 @@ export default function PricingPage() {
- Every feature, every plan.
+ Find the plan that fits your product.
- Analytics, uptime monitoring, link management, error tracking, web
- vitals, feature flags, and more included at every tier. Pick a plan
- based on volume, not features.
+ Compare analytics plans by usage and capabilities. Investigations
+ remain invite only and are purchased separately at $
+ {INVESTIGATION_USAGE.priceUsd} per completed investigation.
diff --git a/apps/docs/app/(home)/pricing/pricing-faq.tsx b/apps/docs/app/(home)/pricing/pricing-faq.tsx
index 7dd8db0256..584ceaba23 100644
--- a/apps/docs/app/(home)/pricing/pricing-faq.tsx
+++ b/apps/docs/app/(home)/pricing/pricing-faq.tsx
@@ -18,8 +18,7 @@ export const pricingFaqItems = [
},
{
question: "Is there a free trial?",
- answer:
- "The Free plan has no trial period and requires no credit card. It includes 10,000 events and 10 AI credits per month for ordinary Databunny chat. Investigations are purchased separately at $1 each; scheduled investigations remain invite only.",
+ answer: `The Free plan has no trial period and requires no credit card. It includes 10,000 events and 10 AI credits per month for ordinary Databunny chat. Investigations remain invite only and are purchased separately at $${INVESTIGATION_USAGE.priceUsd} per completed investigation.`,
},
{
question: "Can I switch plans?",
diff --git a/apps/docs/components/landing/pricing-preview.tsx b/apps/docs/components/landing/pricing-preview.tsx
index 9654b1bbab..5d632a93bc 100644
--- a/apps/docs/components/landing/pricing-preview.tsx
+++ b/apps/docs/components/landing/pricing-preview.tsx
@@ -1,5 +1,6 @@
"use client";
+import { INVESTIGATION_USAGE } from "@databuddy/shared/billing";
import { ArrowRightIcon } from "@databuddy/ui/icons";
import Link from "next/link";
import { Estimator } from "@/app/(home)/pricing/_pricing/estimator";
@@ -23,8 +24,9 @@ export function PricingPreview() {
- Every feature on every plan. Slide to your event volume and see the
- number. No sales call, no feature gates.
+ Estimate your analytics plan from your event volume, then compare plan
+ capabilities. Invite-only investigations are separate: $
+ {INVESTIGATION_USAGE.priceUsd} per completed investigation.
diff --git a/apps/docs/content/docs/dashboard.mdx b/apps/docs/content/docs/dashboard.mdx
index 8943c50a95..9c6cb52f52 100644
--- a/apps/docs/content/docs/dashboard.mdx
+++ b/apps/docs/content/docs/dashboard.mdx
@@ -681,7 +681,7 @@ Get your data where you need it:
- Send actionable investigations to the Slack channels configured for your organization after each daily or weekly run. Replies continue the same investigation.
+ Send actionable investigations to the Slack channels configured for your organization after each daily or weekly run. Replies are included clarifications using the same investigation's saved evidence. Start a new question or fresh analysis explicitly in the dashboard.
diff --git a/apps/docs/lib/comparison-config.ts b/apps/docs/lib/comparison-config.ts
index 3f26084b3d..9b5ee79783 100644
--- a/apps/docs/lib/comparison-config.ts
+++ b/apps/docs/lib/comparison-config.ts
@@ -1,3 +1,7 @@
+import { INVESTIGATION_USAGE } from "@databuddy/shared/billing";
+
+const investigationPrice = `Invite only · $${INVESTIGATION_USAGE.priceUsd} per completed investigation`;
+
export interface ComparisonFeature {
benefit: string;
category: "privacy" | "performance" | "features" | "pricing" | "technical";
@@ -435,7 +439,7 @@ export const competitors: Record = {
{
pageviews: "Automatic investigations",
competitor: "Not available",
- databuddy: "Free",
+ databuddy: investigationPrice,
},
{
pageviews: "Product analytics",
@@ -1008,7 +1012,7 @@ export const competitors: Record = {
{
pageviews: "Automatic investigations",
competitor: "Not available",
- databuddy: "Included",
+ databuddy: investigationPrice,
},
{
pageviews: "Product analytics",
@@ -1181,7 +1185,7 @@ export const competitors: Record = {
{
pageviews: "Automatic investigations",
competitor: "Not available",
- databuddy: "Included",
+ databuddy: investigationPrice,
},
{
pageviews: "Web analytics",
@@ -1358,7 +1362,7 @@ export const competitors: Record = {
{
pageviews: "Automatic investigations",
competitor: "Not available",
- databuddy: "Included",
+ databuddy: investigationPrice,
},
{
pageviews: "Web analytics",
@@ -1521,7 +1525,7 @@ export const competitors: Record = {
{
pageviews: "Automatic investigations",
competitor: "Not available",
- databuddy: "Included",
+ databuddy: investigationPrice,
},
{
pageviews: "Product analytics",
@@ -1722,7 +1726,7 @@ export const competitors: Record = {
{
pageviews: "Automatic investigations",
competitor: "Not available",
- databuddy: "Included",
+ databuddy: investigationPrice,
},
],
},
@@ -1911,7 +1915,7 @@ export const competitors: Record = {
{
pageviews: "Automatic investigations",
competitor: "Not available",
- databuddy: "Included",
+ databuddy: investigationPrice,
},
],
},
diff --git a/apps/docs/lib/pricing-copy.test.ts b/apps/docs/lib/pricing-copy.test.ts
index 0a2722977a..c56057d072 100644
--- a/apps/docs/lib/pricing-copy.test.ts
+++ b/apps/docs/lib/pricing-copy.test.ts
@@ -1,8 +1,10 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "bun:test";
+import { INVESTIGATION_USAGE } from "@databuddy/shared/billing";
import { buildPricingApiPayload } from "@/app/api/pricing/build-response";
import { RAW_PLANS } from "@/app/(home)/pricing/data";
+import { competitors } from "./comparison-config";
function included(
planId: string,
@@ -22,6 +24,22 @@ function included(
}
describe("public pricing copy", () => {
+ it("shows the completed-unit price and access gate on every investigation comparison row", () => {
+ const rows = Object.values(competitors).flatMap((competitor) =>
+ competitor.pricingTiers.filter(
+ (row) => row.pageviews === "Automatic investigations"
+ )
+ );
+ expect(rows.length).toBeGreaterThan(0);
+ for (const row of rows) {
+ expect(row.databuddy).toContain(
+ `$${INVESTIGATION_USAGE.priceUsd} per completed investigation`
+ );
+ expect(row.databuddy).toContain("Invite only");
+ expect(["Free", "Included"]).not.toContain(row.databuddy);
+ }
+ });
+
it("stays aligned with the pricing data used by the page and API", async () => {
const markdown = await readFile(
join(import.meta.dir, "..", "public", "pricing.md"),
diff --git a/packages/ai/src/ai/tools/investigations.test.ts b/packages/ai/src/ai/tools/investigations.test.ts
index 622708b915..605b96f721 100644
--- a/packages/ai/src/ai/tools/investigations.test.ts
+++ b/packages/ai/src/ai/tools/investigations.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "bun:test";
+import { INVESTIGATION_USAGE } from "@databuddy/shared/billing";
import { z } from "zod";
import type { AppContext } from "../config/context";
import {
@@ -113,6 +114,88 @@ describe("configure_investigations input", () => {
});
});
+describe("configure_investigations confirmation preview", () => {
+ const previewSchema = z.object({
+ confirmationRequired: z.literal(true),
+ scope: z.string(),
+ billing: z.string().optional(),
+ });
+ const options = {
+ toolCallId: "preview-1",
+ messages: [],
+ experimental_context: { ...context, mutationMode: "dry-run" },
+ };
+
+ it.each([
+ { action: "run" },
+ { action: "configure", frequency: "daily" },
+ { action: "configure", frequency: "weekly" },
+ { action: "configure", channelAction: "add", channelId: "C012345678" },
+ ] as const)("discloses completed-unit pricing before %j", async (input) => {
+ const execute = tools.configure_investigations.execute;
+ if (!execute) throw new Error("Missing native tool executor");
+ const preview = previewSchema.parse(
+ await execute(schema.parse(input), options)
+ );
+ expect(preview.billing).toContain(INVESTIGATION_USAGE.description);
+ expect(preview.billing).toContain("fixed-price investigation billing");
+ expect(preview.billing).toContain("several signals");
+ expect(preview.billing).toContain("multiple prepaid investigations");
+ expect(preview.billing).toContain("Existing legacy credit terms remain");
+ expect(preview.billing).toContain(
+ "Changing settings does not itself charge"
+ );
+ expect(preview.scope).toBe(
+ input.action === "run"
+ ? "Website website-1"
+ : "All websites in this organization"
+ );
+ });
+
+ it("discloses organization-wide scope when a run has no selected website", async () => {
+ const execute = tools.configure_investigations.execute;
+ if (!execute) throw new Error("Missing native tool executor");
+ const preview = previewSchema.parse(
+ await execute(schema.parse({ action: "run" }), {
+ ...options,
+ experimental_context: {
+ ...options.experimental_context,
+ defaultWebsiteId: null,
+ },
+ })
+ );
+ expect(preview.scope).toBe("All websites in this organization");
+ expect(preview.billing).toContain("multiple prepaid investigations");
+ });
+
+ it.each([
+ { action: "configure", frequency: "off" },
+ { action: "configure", timezone: "Europe/Berlin" },
+ { action: "configure", channelAction: "remove", channelId: "C012345678" },
+ ] as const)("does not present %j as starting paid analysis", async (input) => {
+ const execute = tools.configure_investigations.execute;
+ if (!execute) throw new Error("Missing native tool executor");
+ const preview = previewSchema.parse(
+ await execute(schema.parse(input), options)
+ );
+ expect(preview.billing).toBeUndefined();
+ });
+
+ it("still delegates confirmed work to the canonical RPC mutation boundary", async () => {
+ const execute = tools.configure_investigations.execute;
+ if (!execute) throw new Error("Missing native tool executor");
+ const result = await execute(
+ schema.parse({ action: "run", confirmed: true }),
+ options
+ );
+ expect(result).toMatchObject({
+ mutationBlocked: true,
+ message:
+ "Dry-run mode blocked insightGeneration.triggerRun; no data was changed.",
+ });
+ });
+});
+
describe("investigations", () => {
it("delegates brief, list, get, reply permissions, and idempotency to canonical RPC", async () => {
const calls: Array<{ input: unknown; method: string; router: string }> = [];
diff --git a/packages/ai/src/ai/tools/investigations.ts b/packages/ai/src/ai/tools/investigations.ts
index cdf53175a5..f3085ef03f 100644
--- a/packages/ai/src/ai/tools/investigations.ts
+++ b/packages/ai/src/ai/tools/investigations.ts
@@ -1,4 +1,5 @@
import { isValidTimezone } from "@databuddy/rpc/insight-schedule";
+import { INVESTIGATION_USAGE } from "@databuddy/shared/billing";
import {
historyInsightSchema,
insightBriefItemSchema,
@@ -250,7 +251,7 @@ export async function runInvestigationAction(
.parse(response);
return {
action: "reply" as const,
- message: `Reply accepted with status ${result.reply.status}. This included clarification uses saved evidence, without new measurements. Use investigations with action=get to read its answer in the updated timeline. Start a new question or fresh $1 analysis explicitly in the dashboard.`,
+ message: `Reply accepted with status ${result.reply.status}. This included clarification uses saved evidence, without new measurements. Use investigations with action=get to read its answer in the updated timeline. Start a new question or fresh $${INVESTIGATION_USAGE.priceUsd} analysis explicitly in the dashboard.`,
reply: result.reply,
};
}
@@ -291,7 +292,7 @@ export function createInvestigationTools() {
}),
configure_investigations: tool({
description:
- "Read or change automatic investigations. status returns the organization config; configure sets Off/Daily/Weekly, timezone, or Slack delivery; run investigates the selected website now, or every website when none is selected. Configure and run require a separate confirmation turn.",
+ "Read or change automatic investigations. status returns the organization config; configure sets Off/Daily/Weekly, timezone, or Slack delivery; run investigates the selected website now, or every website when none is selected. Configure and run require a separate confirmation turn. Show the preview's scope and billing disclosure before asking for confirmation. A run may investigate several signals; its price is not a single investigation's price.",
inputSchema,
execute: (input, options) => {
const context = getAppContext(options);
@@ -313,7 +314,22 @@ export function createInvestigationTools() {
validateConfiguration(input);
}
if (!input.confirmed) {
- return { confirmationRequired: true };
+ const websiteId = context.defaultWebsiteId ?? context.websiteId;
+ const startsAnalysis =
+ input.action === "run" ||
+ input.frequency === "daily" ||
+ input.frequency === "weekly" ||
+ input.channelAction === "add";
+ return {
+ confirmationRequired: true,
+ scope:
+ input.action === "run" && websiteId
+ ? `Website ${websiteId}`
+ : "All websites in this organization",
+ billing: startsAnalysis
+ ? `For organizations on fixed-price investigation billing: ${INVESTIGATION_USAGE.description} Each manual or scheduled run may investigate several signals and use multiple prepaid investigations. Changing settings does not itself charge for an investigation. Existing legacy credit terms remain in effect until the organization buys investigations or switches to a new plan version.`
+ : undefined,
+ };
}
if (input.action === "run") {
diff --git a/packages/ai/src/mcp/guide.ts b/packages/ai/src/mcp/guide.ts
index 6702d91339..f742b9c079 100644
--- a/packages/ai/src/mcp/guide.ts
+++ b/packages/ai/src/mcp/guide.ts
@@ -1,3 +1,5 @@
+import { INVESTIGATION_USAGE } from "@databuddy/shared/billing";
+
export const GUIDE_URI = "databuddy://guide";
export const MCP_INSTRUCTIONS = `Databuddy gives agents product analytics and durable investigations.
@@ -5,7 +7,7 @@ export const MCP_INSTRUCTIONS = `Databuddy gives agents product analytics and du
- Use get_data for current analytics. Batch related queries.
- Use list_insights for published findings. Preserve each returned recommendation exactly; if it is null, do not add advice.
- Use list_investigations to find cases, then get_investigation for evidence and history.
-- Use reply_to_investigation when a user answers a case's question or adds missing context. This resumes the same investigation.
+- Use reply_to_investigation for an included clarification of the same question using saved evidence. It does not fetch new measurements or change actions. A new question or fresh analysis must be started explicitly in the dashboard at $${INVESTIGATION_USAGE.priceUsd} per completed investigation.
- After a queued reply, poll get_investigation and reuse the same replyId on retries.
- Use capabilities only when you need to discover query types, and get_schema only when a field is uncertain.
- Most website-scoped tools accept websiteId, websiteName, or websiteDomain; tools that operate by a returned ID may not.
@@ -35,7 +37,9 @@ Investigations are durable cases, not generated summaries.
1. \`list_investigations\` returns the latest case for each subject.
2. \`get_investigation\` returns its evidence, observations, status, and human replies.
-3. \`reply_to_investigation\` adds human context and resumes that same case.
+3. \`reply_to_investigation\` adds human context for an included clarification using that case's saved evidence. It does not fetch new measurements or change actions.
+
+Start a new question or separate fresh analysis explicitly in the dashboard at $${INVESTIGATION_USAGE.priceUsd} per completed investigation. Clarifications of the same question and verification after applying a proposed repair are included.
Replies are asynchronous. When a reply is queued or running, poll \`get_investigation\` until its durable status succeeds or fails; do not submit the same context under a new reply ID.
From fe5b3599039ab59940e600d58c8f4afd36e08ed5 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 12 Sep 2026 21:09:00 +0300
Subject: [PATCH 2/4] fix(dashboard): validate event tiers above included
allowances
---
.agents/skills/databuddy-internal/SKILL.md | 1 +
apps/dashboard/autumn.config.ts | 7 +-
apps/dashboard/lib/event-pricing.test.ts | 90 +++++++++++++++++++
.../app/(home)/pricing/_pricing/best-plan.ts | 4 +-
.../app/(home)/pricing/_pricing/estimator.tsx | 2 +-
.../app/(home)/pricing/_pricing/normalize.ts | 12 ++-
apps/docs/app/(home)/pricing/data.ts | 13 ++-
7 files changed, 119 insertions(+), 10 deletions(-)
create mode 100644 apps/dashboard/lib/event-pricing.test.ts
diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md
index 813e03f070..22cf13766b 100644
--- a/.agents/skills/databuddy-internal/SKILL.md
+++ b/.agents/skills/databuddy-internal/SKILL.md
@@ -180,6 +180,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
## Billing (Autumn)
+- Autumn catalog updates must send complete mutable plan fields, including explicit `addOn`, `autoEnable`, price, and description: omitted provider fields can reset flags, erase descriptions, or create a free new version. Preserve live legacy economics and verify exact before/after provider readback; passing SDK/CLI validation does not establish provider defaults. Do not overwrite unrelated live credit-schema drift during a pricing sync.
- Retried insight jobs must persist immutable external delivery effects (currently Slack) before calling providers and reuse the effect ID as the provider idempotency key. An insight observation is product memory, not a delivery checkpoint.
- Investigations cost $1 per completed result through the separate Autumn `investigation_runs` meter. Clarifications and verification after applying a proposed repair are included. Persist the accepted price with an explicit queued analysis and bind its reservation to that price. Reserve one unit before new analysis and settle only after a readable complete result is persisted; retries reuse durable operation identity. Internal token costs are telemetry. Existing customers without the new entitlement retain legacy `agent_credits` terms; do not convert balances or point legacy credit refills at the new meter.
- Transactional billing email identity has three separate concepts: Autumn customer/billing owner, organization, and actual `to` recipient. Only personalize from the actual recipient record; if it is unavailable, omit the greeting rather than using the owner name. Distinguish fixed-price investigations from legacy credits in billing copy.
diff --git a/apps/dashboard/autumn.config.ts b/apps/dashboard/autumn.config.ts
index 62f1d97b01..b6de45bd3c 100644
--- a/apps/dashboard/autumn.config.ts
+++ b/apps/dashboard/autumn.config.ts
@@ -87,10 +87,9 @@ function eventsOverageItem(included: number) {
featureId: events.id,
included,
price: {
- tiers: EVENT_OVERAGE_TIERS.map((tier) => ({
- to: tier.to,
- amount: tier.amount,
- })),
+ tiers: EVENT_OVERAGE_TIERS.filter(
+ (tier) => tier.to === "inf" || tier.to > included
+ ),
tierBehaviour: "graduated",
billingUnits: 1,
billingMethod: "usage_based",
diff --git a/apps/dashboard/lib/event-pricing.test.ts b/apps/dashboard/lib/event-pricing.test.ts
new file mode 100644
index 0000000000..2b29831443
--- /dev/null
+++ b/apps/dashboard/lib/event-pricing.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, test } from "bun:test";
+import { displayNameForPlan } from "../../docs/app/(home)/pricing/_pricing/best-plan";
+import { estimateTieredOverageCostFromTiers } from "../../docs/app/(home)/pricing/_pricing/estimator-utils";
+import { normalizePlans } from "../../docs/app/(home)/pricing/_pricing/normalize";
+import { RAW_PLANS } from "../../docs/app/(home)/pricing/data";
+import { hobby, intelligence, intelligence_scale, pro } from "../autumn.config";
+
+const plans = [hobby, pro, intelligence, intelligence_scale];
+const normalized = normalizePlans(RAW_PLANS);
+
+describe("event pricing catalog and estimates", () => {
+ test("paid tiers start above each allowance and public absolute thresholds match the native catalog", () => {
+ for (const plan of plans) {
+ const native = plan.items?.find((item) => item.featureId === "events");
+ const published = RAW_PLANS.find(
+ (item) => item.id === plan.id
+ )?.items.find(
+ (item) => item.type === "priced_feature" && item.feature_id === "events"
+ );
+ if (
+ published?.type !== "priced_feature" ||
+ typeof native?.included !== "number"
+ ) {
+ throw new Error(`Missing event pricing for ${plan.id}`);
+ }
+ expect(published.included_usage).toBe(native.included);
+ expect(published.tiers).toEqual(native.price?.tiers);
+ for (const tier of native.price?.tiers ?? []) {
+ if (tier.to !== "inf") expect(tier.to).toBeGreaterThan(native.included);
+ }
+ }
+ });
+
+ test("Hobby and Pro retain their original thresholds and rates", () => {
+ expect(
+ hobby.items?.find((item) => item.featureId === "events")?.price?.tiers
+ ).toEqual([
+ { to: 2_030_000, amount: 0.000035 },
+ { to: 10_030_000, amount: 0.00003 },
+ { to: 50_030_000, amount: 0.00002 },
+ { to: 250_030_000, amount: 0.000015 },
+ { to: "inf", amount: 0.00001 },
+ ]);
+ expect(
+ pro.items?.find((item) => item.featureId === "events")?.price?.tiers
+ ).toEqual([
+ { to: 2_000_000, amount: 0.000035 },
+ { to: 10_000_000, amount: 0.00003 },
+ { to: 50_000_000, amount: 0.00002 },
+ { to: 250_000_000, amount: 0.000015 },
+ { to: "inf", amount: 0.00001 },
+ ]);
+ });
+
+ test.each([
+ ["hobby", 30_000, 0],
+ ["hobby", 2_030_000, 70],
+ ["hobby", 2_030_001, 70.00003],
+ ["pro", 1_000_000, 0],
+ ["pro", 2_000_000, 35],
+ ["pro", 2_000_001, 35.00003],
+ ["intelligence", 2_000_000, 0],
+ ["intelligence", 2_000_001, 0.00003],
+ ["intelligence", 10_000_000, 240],
+ ["intelligence", 10_000_001, 240.00002],
+ ["intelligence_scale", 10_000_000, 0],
+ ["intelligence_scale", 10_000_001, 0.00002],
+ ["intelligence_scale", 50_000_000, 800],
+ ["intelligence_scale", 50_000_001, 800.000015],
+ ] as const)("estimates %s at %i monthly events as $%f overage", (id, events, expected) => {
+ const plan = normalized.find((item) => item.id === id);
+ if (!plan?.eventTiers)
+ throw new Error(`Missing normalized pricing for ${id}`);
+ expect(
+ estimateTieredOverageCostFromTiers(
+ Math.max(events - plan.includedEventsMonthly, 0),
+ plan.eventTiers
+ )
+ ).toBeCloseTo(expected, 8);
+ });
+
+ test("keeps the enterprise label threshold in total monthly events", () => {
+ const scale = normalized.find((plan) => plan.id === "intelligence_scale");
+ if (!scale) throw new Error("Missing Scale plan");
+ expect(displayNameForPlan(250_000_000, normalized, scale)).toBe(scale.name);
+ expect(displayNameForPlan(250_000_001, normalized, scale)).toBe(
+ "Enterprise"
+ );
+ });
+});
diff --git a/apps/docs/app/(home)/pricing/_pricing/best-plan.ts b/apps/docs/app/(home)/pricing/_pricing/best-plan.ts
index ac0cd4f5d1..6ea71b3076 100644
--- a/apps/docs/app/(home)/pricing/_pricing/best-plan.ts
+++ b/apps/docs/app/(home)/pricing/_pricing/best-plan.ts
@@ -82,7 +82,9 @@ function computeEnterpriseThreshold(plans: NormalizedPlan[]): number {
highest = Math.max(highest, toNum);
}
}
- return highest > 0 ? highest : Number.POSITIVE_INFINITY;
+ return highest > 0
+ ? highest + maxPlan.includedEventsMonthly
+ : Number.POSITIVE_INFINITY;
}
export function displayNameForPlan(
diff --git a/apps/docs/app/(home)/pricing/_pricing/estimator.tsx b/apps/docs/app/(home)/pricing/_pricing/estimator.tsx
index 6ac268dda3..1277ad5a77 100644
--- a/apps/docs/app/(home)/pricing/_pricing/estimator.tsx
+++ b/apps/docs/app/(home)/pricing/_pricing/estimator.tsx
@@ -131,7 +131,7 @@ export function Estimator({ plans }: Props) {
{bestPlanDisplayName === "Enterprise"
? "Custom pricing for high-volume usage"
- : `Cheapest option for ${formatInteger(monthlyEvents)} events/month`}
+ : `Estimate for ${formatInteger(monthlyEvents)} events/month`}
{bestPlanDisplayName === "Enterprise" ? (
diff --git a/apps/docs/app/(home)/pricing/_pricing/normalize.ts b/apps/docs/app/(home)/pricing/_pricing/normalize.ts
index 2027977729..8985972842 100644
--- a/apps/docs/app/(home)/pricing/_pricing/normalize.ts
+++ b/apps/docs/app/(home)/pricing/_pricing/normalize.ts
@@ -30,7 +30,17 @@ function getEventsInfo(items: RawItem[]): {
tiers = item.tiers;
}
}
- return { included, tiers };
+ return {
+ included,
+ // Catalog ceilings are total monthly events; the estimator takes overage events.
+ tiers:
+ tiers
+ ?.filter((tier) => tier.to === "inf" || tier.to > included)
+ .map((tier) => ({
+ ...tier,
+ to: tier.to === "inf" ? "inf" : tier.to - included,
+ })) ?? null,
+ };
}
function getAgentCreditsByInterval(
diff --git a/apps/docs/app/(home)/pricing/data.ts b/apps/docs/app/(home)/pricing/data.ts
index b1250d516f..8ae03ac0aa 100644
--- a/apps/docs/app/(home)/pricing/data.ts
+++ b/apps/docs/app/(home)/pricing/data.ts
@@ -132,7 +132,10 @@ export const RAW_PLANS: RawPlan[] = [
feature: EVENTS_FEATURE,
included_usage: 30_000,
interval: "month",
- tiers: EVENT_TIERS,
+ tiers: EVENT_TIERS.map((tier) => ({
+ ...tier,
+ to: tier.to === "inf" ? "inf" : tier.to + 30_000,
+ })),
usage_model: "pay_per_use",
},
{
@@ -212,7 +215,9 @@ export const RAW_PLANS: RawPlan[] = [
feature: EVENTS_FEATURE,
included_usage: 2_000_000,
interval: "month",
- tiers: EVENT_TIERS,
+ tiers: EVENT_TIERS.filter(
+ (tier) => tier.to === "inf" || tier.to > 2_000_000
+ ),
usage_model: "pay_per_use",
},
{
@@ -244,7 +249,9 @@ export const RAW_PLANS: RawPlan[] = [
feature: EVENTS_FEATURE,
included_usage: 10_000_000,
interval: "month",
- tiers: EVENT_TIERS,
+ tiers: EVENT_TIERS.filter(
+ (tier) => tier.to === "inf" || tier.to > 10_000_000
+ ),
usage_model: "pay_per_use",
},
{
From e0c84d12f50918f16b0addfc99367ad5704ff889 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 12 Sep 2026 21:18:23 +0300
Subject: [PATCH 3/4] fix(docs): render event tier ranges and rates accurately
---
.../pricing/_pricing/ai-pricing-summary.tsx | 17 ++---
.../pricing/_pricing/estimator-utils.ts | 7 ++
.../app/(home)/pricing/_pricing/estimator.tsx | 3 +-
apps/docs/app/api/pricing/build-response.ts | 1 +
apps/docs/components/structured-data.tsx | 4 +-
apps/docs/lib/pricing-copy.test.ts | 73 +++++++++++++++++++
apps/docs/public/pricing.md | 8 +-
7 files changed, 98 insertions(+), 15 deletions(-)
diff --git a/apps/docs/app/(home)/pricing/_pricing/ai-pricing-summary.tsx b/apps/docs/app/(home)/pricing/_pricing/ai-pricing-summary.tsx
index 0e5e268832..defe267427 100644
--- a/apps/docs/app/(home)/pricing/_pricing/ai-pricing-summary.tsx
+++ b/apps/docs/app/(home)/pricing/_pricing/ai-pricing-summary.tsx
@@ -3,10 +3,7 @@ import {
INVESTIGATION_USAGE,
} from "@databuddy/shared/billing";
import type { RawPlan } from "../data";
-
-function formatTierRate(amount: number): string {
- return `$${(amount * 1000).toFixed(2)} per 1,000 events`;
-}
+import { formatTierRate } from "./estimator-utils";
function buildPlanSummary(plan: RawPlan): string {
const lines: string[] = [];
@@ -38,13 +35,15 @@ function buildPlanSummary(plan: RawPlan): string {
const per = item.interval ? ` per ${item.interval}` : "";
lines.push(`${item.feature.name}: ${qty} included${per}`);
- if (item.tiers?.length) {
- lines.push("Overage tiers:");
- let prevTo = 0;
+ if (item.tiers?.length && item.included_usage !== "inf") {
+ lines.push("Overage tiers (total monthly event counts):");
+ let prevTo = item.included_usage;
for (const tier of item.tiers) {
- const from = prevTo.toLocaleString();
+ const from = (prevTo + 1).toLocaleString();
const to = tier.to === "inf" ? "unlimited" : tier.to.toLocaleString();
- lines.push(` ${from}–${to}: ${formatTierRate(tier.amount)}`);
+ lines.push(
+ ` ${from}–${to}: ${formatTierRate(tier.amount)} per 1,000 events`
+ );
if (tier.to !== "inf") {
prevTo = tier.to;
}
diff --git a/apps/docs/app/(home)/pricing/_pricing/estimator-utils.ts b/apps/docs/app/(home)/pricing/_pricing/estimator-utils.ts
index fd2fcdad5d..9408f7e833 100644
--- a/apps/docs/app/(home)/pricing/_pricing/estimator-utils.ts
+++ b/apps/docs/app/(home)/pricing/_pricing/estimator-utils.ts
@@ -2,6 +2,13 @@ export function formatMoney(value: number): string {
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
+export function formatTierRate(amount: number): string {
+ return `$${(amount * 1000).toLocaleString("en-US", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 3,
+ })}`;
+}
+
export function formatInteger(value: number): string {
return value.toLocaleString();
}
diff --git a/apps/docs/app/(home)/pricing/_pricing/estimator.tsx b/apps/docs/app/(home)/pricing/_pricing/estimator.tsx
index 1277ad5a77..97c2aa4af4 100644
--- a/apps/docs/app/(home)/pricing/_pricing/estimator.tsx
+++ b/apps/docs/app/(home)/pricing/_pricing/estimator.tsx
@@ -13,6 +13,7 @@ import {
formatCompact,
formatInteger,
formatMoney,
+ formatTierRate,
} from "./estimator-utils";
import { trackPricingPlanClick } from "./track-pricing";
import type { NormalizedPlan } from "./types";
@@ -274,7 +275,7 @@ export function Estimator({ plans }: Props) {
{to}
- ${(tier.amount * 1000).toFixed(2)}
+ {formatTierRate(tier.amount)}
|
);
diff --git a/apps/docs/app/api/pricing/build-response.ts b/apps/docs/app/api/pricing/build-response.ts
index 307a0cf3a8..05a87bfb2d 100644
--- a/apps/docs/app/api/pricing/build-response.ts
+++ b/apps/docs/app/api/pricing/build-response.ts
@@ -55,6 +55,7 @@ function mapRawPlans() {
interval: f.interval,
...(f.type === "priced_feature" && f.tiers
? {
+ overageTierBasis: "total_monthly_events" as const,
overageTiers: f.tiers.map((t) => ({
upTo: t.to === "inf" ? ("unlimited" as const) : t.to,
pricePerUnit: t.amount,
diff --git a/apps/docs/components/structured-data.tsx b/apps/docs/components/structured-data.tsx
index 4ed20d34af..4beca1b90e 100644
--- a/apps/docs/components/structured-data.tsx
+++ b/apps/docs/components/structured-data.tsx
@@ -114,7 +114,7 @@ function planToOffer(plan: RawPlan, baseUrl: string) {
priceSpecs.push({
"@type": "UnitPriceSpecification",
- price: priceStr(t.amount * BLOCK_UNITS_FOR_EVENTS, 2), // e.g. "0.03" per 1,000 events
+ price: priceStr(t.amount * BLOCK_UNITS_FOR_EVENTS, 3),
priceCurrency: "USD",
referenceQuantity: {
"@type": "QuantitativeValue",
@@ -125,7 +125,7 @@ function planToOffer(plan: RawPlan, baseUrl: string) {
"@type": "QuantitativeValue",
minValue,
maxValue,
- unitText: "events",
+ unitText: "total monthly events",
},
unitText: "per 1,000 events (overage)",
});
diff --git a/apps/docs/lib/pricing-copy.test.ts b/apps/docs/lib/pricing-copy.test.ts
index c56057d072..db1c899ba6 100644
--- a/apps/docs/lib/pricing-copy.test.ts
+++ b/apps/docs/lib/pricing-copy.test.ts
@@ -2,8 +2,14 @@ import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "bun:test";
import { INVESTIGATION_USAGE } from "@databuddy/shared/billing";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
import { buildPricingApiPayload } from "@/app/api/pricing/build-response";
+import { AiPricingSummary } from "@/app/(home)/pricing/_pricing/ai-pricing-summary";
+import { Estimator } from "@/app/(home)/pricing/_pricing/estimator";
+import { normalizePlans } from "@/app/(home)/pricing/_pricing/normalize";
import { RAW_PLANS } from "@/app/(home)/pricing/data";
+import { StructuredData } from "@/components/structured-data";
import { competitors } from "./comparison-config";
function included(
@@ -24,6 +30,73 @@ function included(
}
describe("public pricing copy", () => {
+ it("renders the visible estimator's fractional-cent rates accurately", () => {
+ const plans = normalizePlans(RAW_PLANS.filter((plan) => plan.id === "hobby"));
+ const markup = renderToStaticMarkup(createElement(Estimator, { plans }));
+ expect(markup).toContain("Rate / 1,000 events");
+ expect(markup).toContain(">$0.035");
+ expect(markup).toContain(">$0.015");
+ });
+
+ it("renders JSON-LD event rates without rounding away half-cent increments", () => {
+ const plans = RAW_PLANS.filter((plan) => plan.id === "hobby");
+ const markup = renderToStaticMarkup(
+ createElement(StructuredData, {
+ page: { title: "Pricing", url: "/pricing" },
+ elements: [{ type: "softwareOffers", plans }],
+ })
+ );
+ expect(markup).toContain('"price":"0.035"');
+ expect(markup).toContain('"price":"0.015"');
+ expect(markup).toContain('"minValue":30001');
+ expect(markup).toContain('"maxValue":2030000');
+ expect(markup).toContain('"unitText":"total monthly events"');
+ });
+
+ it("declares the API's absolute tier basis beside its included allowance", () => {
+ const response = buildPricingApiPayload(
+ new Request("https://www.databuddy.cc/api/pricing")
+ );
+ for (const plan of response.plans) {
+ for (const feature of plan.features) {
+ if (feature.overageTiers) {
+ expect(feature.overageTierBasis).toBe("total_monthly_events");
+ }
+ }
+ }
+ const business = response.plans.find((plan) => plan.id === "intelligence");
+ expect(
+ business?.features.find((feature) => feature.id === "events")
+ ).toMatchObject({
+ included: 2_000_000,
+ overageTierBasis: "total_monthly_events",
+ overageTiers: [
+ { upTo: 10_000_000, pricePerUnit: 0.00003 },
+ { upTo: 50_000_000, pricePerUnit: 0.00002 },
+ { upTo: 250_000_000, pricePerUnit: 0.000015 },
+ { upTo: "unlimited", pricePerUnit: 0.00001 },
+ ],
+ });
+ });
+
+ it.each([
+ ["hobby", "30,001–2,030,000: $0.035 per 1,000 events"],
+ ["pro", "1,000,001–2,000,000: $0.035 per 1,000 events"],
+ ["intelligence", "2,000,001–10,000,000: $0.03 per 1,000 events"],
+ ["intelligence_scale", "10,000,001–50,000,000: $0.02 per 1,000 events"],
+ ] as const)("renders %s paid ranges as total monthly event counts with exact rates", (id, firstRange) => {
+ const plan = RAW_PLANS.find((entry) => entry.id === id);
+ if (!plan) throw new Error(`Missing pricing plan ${id}`);
+ const markup = renderToStaticMarkup(
+ createElement(AiPricingSummary, { plans: [plan] })
+ );
+ expect(markup).toContain("Overage tiers (total monthly event counts):");
+ expect(markup).toContain(`\n ${firstRange}\n`);
+ expect(markup).not.toContain("\n 0–");
+ expect(markup).toContain("$0.015 per 1,000 events");
+ expect(markup).toContain("$0.01 per 1,000 events");
+ });
+
it("shows the completed-unit price and access gate on every investigation comparison row", () => {
const rows = Object.values(competitors).flatMap((competitor) =>
competitor.pricingTiers.filter(
diff --git a/apps/docs/public/pricing.md b/apps/docs/public/pricing.md
index 5a389f0be3..575b9b9069 100644
--- a/apps/docs/public/pricing.md
+++ b/apps/docs/public/pricing.md
@@ -17,9 +17,9 @@ Machine-readable: [JSON](https://www.databuddy.cc/api/pricing) · static [Markdo
## Events (overage on paid plans)
-Overage = events **above** the monthly included amount. Cumulative overage is charged in bands (first band fills, then the next). Hobby and Pro use the same per-event rates, but the exact band boundaries differ slightly between plans.
+Overage = events **above** the monthly included amount. Usage is charged in bands (first band fills, then the next). The table below is the **Hobby example**, after its 30,000 included events; other plans have different paid band widths.
-| Cumulative overage (events) | $ / event | $ / 1,000 events |
+| Hobby cumulative overage (events) | $ / event | $ / 1,000 events |
| --- | --- | --- |
| 1st – 2,000,000 | $0.000035 | $0.035 |
| 2,000,001 – 10,000,000 | $0.00003 | $0.03 |
@@ -27,7 +27,9 @@ Overage = events **above** the monthly included amount. Cumulative overage is ch
| 50,000,001 – 250,000,000 | $0.000015 | $0.015 |
| 250,000,001+ | $0.00001 | $0.01 |
-For exact monthly totals at your event volume, use the calculator on the [pricing page](https://www.databuddy.cc/pricing).
+Pro includes 1,000,000 events, then charges $0.035 per 1,000 for its first 1,000,000 paid events (up to 2,000,000 total monthly events). Business includes 2,000,000 events and starts overage at $0.03 per 1,000, up to 10,000,000 total monthly events. Scale includes 10,000,000 events and starts overage at $0.02 per 1,000, up to 50,000,000 total monthly events.
+
+The [JSON API](https://www.databuddy.cc/api/pricing) declares `overageTierBasis: "total_monthly_events"`: its `overageTiers.upTo` values are total monthly event ceilings, including the allowance, not cumulative paid overage. For exact monthly totals at your event volume, use the calculator on the [pricing page](https://www.databuddy.cc/pricing).
## Product limits
From b9a6422014b482468271cbe8b07c9a7334af00c2 Mon Sep 17 00:00:00 2001
From: iza <59828082+izadoesdev@users.noreply.github.com>
Date: Sat, 12 Sep 2026 21:26:04 +0300
Subject: [PATCH 4/4] style(docs): balance pricing introduction text
---
apps/docs/app/(home)/pricing/page.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/docs/app/(home)/pricing/page.tsx b/apps/docs/app/(home)/pricing/page.tsx
index 35912f4d4f..a912948dd9 100644
--- a/apps/docs/app/(home)/pricing/page.tsx
+++ b/apps/docs/app/(home)/pricing/page.tsx
@@ -19,10 +19,10 @@ export default function PricingPage() {