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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 28 additions & 9 deletions apps/dashboard/autumn.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -105,7 +104,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,
Expand Down Expand Up @@ -133,7 +136,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,
Expand Down Expand Up @@ -178,7 +185,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,
Expand Down Expand Up @@ -261,7 +272,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,
Expand Down Expand Up @@ -296,7 +311,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,
Expand Down
90 changes: 90 additions & 0 deletions apps/dashboard/lib/event-pricing.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
izadoesdev marked this conversation as resolved.
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"
);
});
});
6 changes: 4 additions & 2 deletions apps/dashboard/lib/investigation-purchase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 8 additions & 9 deletions apps/docs/app/(home)/pricing/_pricing/ai-pricing-summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/app/(home)/pricing/_pricing/best-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/app/(home)/pricing/_pricing/estimator-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
5 changes: 3 additions & 2 deletions apps/docs/app/(home)/pricing/_pricing/estimator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
formatCompact,
formatInteger,
formatMoney,
formatTierRate,
} from "./estimator-utils";
import { trackPricingPlanClick } from "./track-pricing";
import type { NormalizedPlan } from "./types";
Expand Down Expand Up @@ -131,7 +132,7 @@ export function Estimator({ plans }: Props) {
<p className="text-muted-foreground text-xs">
{bestPlanDisplayName === "Enterprise"
? "Custom pricing for high-volume usage"
: `Cheapest option for ${formatInteger(monthlyEvents)} events/month`}
: `Estimate for ${formatInteger(monthlyEvents)} events/month`}
</p>
</div>
{bestPlanDisplayName === "Enterprise" ? (
Expand Down Expand Up @@ -274,7 +275,7 @@ export function Estimator({ plans }: Props) {
{to}
</td>
<td className="px-3 py-2 text-foreground text-xs">
${(tier.amount * 1000).toFixed(2)}
{formatTierRate(tier.amount)}
</td>
</tr>
);
Expand Down
12 changes: 11 additions & 1 deletion apps/docs/app/(home)/pricing/_pricing/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 10 additions & 3 deletions apps/docs/app/(home)/pricing/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
{
Expand Down Expand Up @@ -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",
},
{
Expand Down Expand Up @@ -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",
},
{
Expand Down
12 changes: 6 additions & 6 deletions apps/docs/app/(home)/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ export default function PricingPage() {
<div className="px-4 pt-20 sm:px-6 sm:pt-24 lg:px-8 lg:pt-32">
<div className="mx-auto w-full max-w-7xl">
<header className="mb-8 text-center sm:mb-10">
<h1 className="mb-2 font-bold text-3xl tracking-tight sm:text-4xl">
Every feature, every plan.
<h1 className="mb-2 text-balance font-bold text-3xl tracking-tight sm:text-4xl">
Find the plan that fits your product.
</h1>
<p className="mx-auto max-w-2xl text-muted-foreground text-sm sm:text-base">
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.
<p className="mx-auto max-w-2xl text-pretty text-muted-foreground text-sm sm:text-base">
Compare analytics plans by usage and capabilities. Investigations
remain invite only and are purchased separately at $
{INVESTIGATION_USAGE.priceUsd} per completed investigation.
</p>
</header>

Expand Down
3 changes: 1 addition & 2 deletions apps/docs/app/(home)/pricing/pricing-faq.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/app/api/pricing/build-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions apps/docs/components/landing/pricing-preview.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,8 +24,9 @@ export function PricingPreview() {
</span>
</h2>
<p className="mt-3 max-w-2xl text-pretty text-muted-foreground text-sm sm:px-0 sm:text-base lg:text-lg">
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.
</p>
</div>

Expand Down
Loading
Loading