Add per-feature usage breakdown to billing page (#232)

This commit is contained in:
Ben Senescu 2026-05-31 18:42:41 -04:00 committed by GitHub
parent e93e30e19e
commit 06e15dc70f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 515 additions and 58 deletions

View File

@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/serverFunctions/billing", () => ({
getBillingUsageEvents: vi.fn(),
}));
import { getBillingFeatureBreakdownRows } from "./BillingFeatureBreakdown";
describe("getBillingFeatureBreakdownRows", () => {
it("uses explicit creditFeature when present", () => {
const rows = getBillingFeatureBreakdownRows([
{
value: 250,
properties: {
creditFeature: "rank_tracking",
paths: ["v3/serp/google/organic/live/regular"],
},
},
]);
expect(rows).toEqual([{ label: "Rank Tracking", usd: 0.25 }]);
});
it("supports raw Autumn property aliases", () => {
const rows = getBillingFeatureBreakdownRows([
{
value: 200,
properties: {
credit_feature: "local_seo",
path: "v3/backlinks/summary/live",
},
},
]);
expect(rows).toEqual([{ label: "Local SEO", usd: 0.2 }]);
});
it("infers legacy events from DataForSEO paths", () => {
const rows = getBillingFeatureBreakdownRows([
{
value: 500,
properties: { paths: ["v3/backlinks/summary/live"] },
},
{
value: 250,
properties: {
paths: ["v3/dataforseo_labs/google/domain_rank_overview/live"],
},
},
{
value: 125,
properties: {
paths: ["v3/ai_optimization/llm_mentions/search/live"],
},
},
{
value: 100,
properties: { paths: ["backlinks/summary"] },
},
]);
expect(rows).toEqual([
{ label: "Backlinks", usd: 0.6 },
{ label: "Domain Overview", usd: 0.25 },
{ label: "AI Citations", usd: 0.125 },
]);
});
it("supports legacy JSON-encoded path groups", () => {
const rows = getBillingFeatureBreakdownRows([
{
value: 300,
properties: {
paths: '["v3/ai_optimization/chat_gpt/llm_responses/live"]',
},
},
{
value: 200,
properties: {
paths: '["v3","ai_optimization","perplexity","llm_responses","live"]',
},
},
]);
expect(rows).toEqual([{ label: "AI Prompt Responses", usd: 0.5 }]);
});
it("falls back to Other when neither feature nor path is available", () => {
const rows = getBillingFeatureBreakdownRows([
{
value: 100,
properties: {},
},
]);
expect(rows).toEqual([{ label: "Other", usd: 0.1 }]);
});
});

View File

@ -0,0 +1,185 @@
import { useQuery } from "@tanstack/react-query";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
autumnSeoDataCreditsToUsd,
} from "@/shared/billing";
import {
creditFeatureLabel,
mapDataforseoPathToCreditFeature,
} from "@/shared/billing-credit-features";
import {
getBillingUsageEvents,
type BillingUsageEvent,
} from "@/serverFunctions/billing";
const BILLING_USAGE_FEATURE_IDS: string[] = [
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
];
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
type BillingUsageEventProperties = {
creditFeature?: unknown;
credit_feature?: unknown;
path?: unknown;
paths?: unknown;
};
type BillingFeatureBreakdownRow = {
label: string;
usd: number;
};
type BillingUsageRange = {
start: number;
end: number;
};
function getLast30DayUsageRange(): BillingUsageRange {
const end = Date.now();
return {
start: end - THIRTY_DAYS_MS,
end,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getPathSegmentsFromProperties(
properties: BillingUsageEventProperties,
): string[] | null {
const paths = properties.paths ?? properties.path;
if (Array.isArray(paths)) {
const stringPaths = paths.filter(
(value): value is string => typeof value === "string",
);
if (
stringPaths.length > 1 &&
stringPaths.every((segment) => !segment.includes("/"))
) {
return stringPaths;
}
const path = stringPaths[0];
if (!path) return null;
const parsedPath = parseJsonEncodedPath(path);
return parsedPath ?? path.split("/").filter(Boolean);
}
if (typeof paths !== "string") return null;
const parsedPath = parseJsonEncodedPath(paths);
return parsedPath ?? paths.split("/").filter(Boolean);
}
function parseJsonEncodedPath(path: string): string[] | null {
if (!path.startsWith("[")) return null;
try {
const parsed: unknown = JSON.parse(path);
if (!Array.isArray(parsed)) return null;
const stringPaths = parsed.filter(
(value): value is string => typeof value === "string",
);
if (
stringPaths.length > 1 &&
stringPaths.every((segment) => !segment.includes("/"))
) {
return stringPaths;
}
const firstPath = stringPaths[0];
return firstPath ? firstPath.split("/").filter(Boolean) : null;
} catch {
return null;
}
}
function getCreditFeatureFromUsageEvent(
event: BillingUsageEvent,
): string | null {
const properties = isRecord(event.properties) ? event.properties : {};
const explicitFeature = properties.creditFeature ?? properties.credit_feature;
if (typeof explicitFeature === "string" && explicitFeature.length > 0) {
return explicitFeature;
}
const path = getPathSegmentsFromProperties(properties);
return path ? mapDataforseoPathToCreditFeature(path) : null;
}
export function getBillingFeatureBreakdownRows(
events: BillingUsageEvent[],
): BillingFeatureBreakdownRow[] {
const creditsByLabel = new Map<string, number>();
for (const event of events) {
const feature = getCreditFeatureFromUsageEvent(event);
const label = feature ? creditFeatureLabel(feature) : "Other";
creditsByLabel.set(label, (creditsByLabel.get(label) ?? 0) + event.value);
}
return [...creditsByLabel.entries()]
.map(([label, credits]) => ({
label,
usd: autumnSeoDataCreditsToUsd(credits),
}))
.filter((row) => row.usd > 0)
.toSorted((a, b) => b.usd - a.usd);
}
export function BillingFeatureBreakdown() {
const eventsQuery = useQuery({
queryKey: ["billing", "usage-events", BILLING_USAGE_FEATURE_IDS, "30d"],
queryFn: () => getBillingUsageEvents({ data: getLast30DayUsageRange() }),
staleTime: 60_000,
});
const rows = getBillingFeatureBreakdownRows(eventsQuery.data ?? []);
const total = rows.reduce((sum, row) => sum + row.usd, 0);
return (
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
<div className="flex items-baseline justify-between gap-4">
<span className="font-semibold">Usage by feature</span>
<span className="text-xs text-base-content/50">Last 30 days</span>
</div>
{eventsQuery.isLoading ? (
<div className="space-y-3">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="skeleton h-4 w-full" />
))}
</div>
) : rows.length === 0 ? (
<div className="text-sm text-base-content/40">
No usage recorded yet
</div>
) : (
<ul className="space-y-2.5">
{rows.map((row) => (
<li key={row.label} className="space-y-1">
<div className="flex items-baseline justify-between gap-4 text-sm">
<span>{row.label}</span>
<span className="tabular-nums text-base-content/70">
${row.usd.toFixed(2)}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-base-200">
<div
className="h-full rounded-full bg-[#7c3aed]"
style={{ width: `${(row.usd / total) * 100}%` }}
/>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -6,6 +6,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution"; import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart"; import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
import { BillingFeatureBreakdown } from "@/client/features/billing/BillingFeatureBreakdown";
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils"; import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
import { getBillingRouteState } from "@/client/features/billing/route-state"; import { getBillingRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
@ -310,6 +311,9 @@ function BillingPageContent() {
{/* Usage chart */} {/* Usage chart */}
<BillingUsageChart /> <BillingUsageChart />
{/* Per-feature usage breakdown */}
<BillingFeatureBreakdown />
{error ? <p className="text-sm text-error">{error}</p> : null} {error ? <p className="text-sm text-error">{error}</p> : null}
<p className="text-xs text-base-content/40"> <p className="text-xs text-base-content/40">

View File

@ -354,6 +354,9 @@ describe("mapDataforseoPathToCreditFeature", () => {
expect( expect(
mapDataforseoPathToCreditFeature(["v3", "backlinks", "summary", "live"]), mapDataforseoPathToCreditFeature(["v3", "backlinks", "summary", "live"]),
).toBe("backlinks"); ).toBe("backlinks");
expect(mapDataforseoPathToCreditFeature(["backlinks", "summary"])).toBe(
"backlinks",
);
expect( expect(
mapDataforseoPathToCreditFeature([ mapDataforseoPathToCreditFeature([
"v3", "v3",
@ -376,7 +379,7 @@ describe("mapDataforseoPathToCreditFeature", () => {
).toBe("site_audit"); ).toBe("site_audit");
}); });
it("maps real ai_optimization paths to ai_search", () => { it("maps ai_optimization llm_mentions paths to ai_citations", () => {
expect( expect(
mapDataforseoPathToCreditFeature([ mapDataforseoPathToCreditFeature([
"v3", "v3",
@ -385,7 +388,7 @@ describe("mapDataforseoPathToCreditFeature", () => {
"search", "search",
"live", "live",
]), ]),
).toBe("ai_search"); ).toBe("ai_citations");
expect( expect(
mapDataforseoPathToCreditFeature([ mapDataforseoPathToCreditFeature([
"v3", "v3",
@ -394,16 +397,30 @@ describe("mapDataforseoPathToCreditFeature", () => {
"aggregated_metrics", "aggregated_metrics",
"live", "live",
]), ]),
).toBe("ai_search"); ).toBe("ai_citations");
expect( expect(
mapDataforseoPathToCreditFeature([ mapDataforseoPathToCreditFeature([
"v3", "v3",
"ai_optimization", "ai_optimization",
"claude", "llm_mentions",
"top_pages",
"live",
]),
).toBe("ai_citations");
});
it("maps ai_optimization provider llm_responses paths to ai_prompt_responses", () => {
for (const provider of ["chat_gpt", "claude", "gemini", "perplexity"]) {
expect(
mapDataforseoPathToCreditFeature([
"v3",
"ai_optimization",
provider,
"llm_responses", "llm_responses",
"live", "live",
]), ]),
).toBe("ai_search"); ).toBe("ai_prompt_responses");
}
}); });
it("maps local and supporting paths to the intended credit features", () => { it("maps local and supporting paths to the intended credit features", () => {

View File

@ -6,6 +6,10 @@ import {
SEO_DATA_COST_MARKUP, SEO_DATA_COST_MARKUP,
roundUsdForBilling, roundUsdForBilling,
} from "@/shared/billing"; } from "@/shared/billing";
import {
type CreditFeature,
mapDataforseoPathToCreditFeature,
} from "@/shared/billing-credit-features";
import { autumn } from "@/server/billing/autumn"; import { autumn } from "@/server/billing/autumn";
import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription"; import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
@ -60,55 +64,7 @@ import { AppError } from "@/server/lib/errors";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
type CreditFeature = export { mapDataforseoPathToCreditFeature };
| "keyword_research"
| "domain_overview"
| "backlinks"
| "site_audit"
| "rank_tracking"
| "ai_search"
| "local_seo";
/**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
* to a product feature for analytics. path[1] is the API module; for dataforseo_labs,
* path[3] distinguishes keyword vs domain endpoints.
*/
export function mapDataforseoPathToCreditFeature(
path: string[],
): CreditFeature {
const module = path[1];
switch (module) {
case "on_page":
return "site_audit";
case "backlinks":
return "backlinks";
case "serp":
return path[2] === "google" && ["maps", "local_finder"].includes(path[3])
? "local_seo"
: "keyword_research";
case "ai_optimization":
return "ai_search";
case "business_data":
return "local_seo";
case "keywords_data":
return "keyword_research";
case "dataforseo_labs": {
const endpoint = path[3] ?? "";
if (
endpoint.startsWith("domain_") ||
endpoint === "ranked_keywords" ||
endpoint === "relevant_pages"
) {
return "domain_overview";
}
return "keyword_research";
}
default:
return "site_audit";
}
}
export function createDataforseoClient(customer: BillingCustomerContext) { export function createDataforseoClient(customer: BillingCustomerContext) {
return { return {
@ -457,10 +413,14 @@ async function trackDataforseoCost(args: {
const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits); const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits);
const topupDeduct = totalCostCredits - monthlyDeduct; const topupDeduct = totalCostCredits - monthlyDeduct;
const creditFeature =
args.creditFeature ?? mapDataforseoPathToCreditFeature(args.billing.path);
const properties = { const properties = {
provider: "dataforseo", provider: "dataforseo",
currency: "USD", currency: "USD",
paths: [args.billing.path.join("/")], paths: [args.billing.path.join("/")],
creditFeature,
totalCostUsd, totalCostUsd,
totalCostCredits, totalCostCredits,
fromCache: false, fromCache: false,
@ -497,9 +457,7 @@ async function trackDataforseoCost(args: {
organizationId: args.customer.organizationId, organizationId: args.customer.organizationId,
properties: { properties: {
project_id: args.customer.projectId, project_id: args.customer.projectId,
credit_feature: credit_feature: creditFeature,
args.creditFeature ??
mapDataforseoPathToCreditFeature(args.billing.path),
monthly_credits: monthlyDeduct, monthly_credits: monthlyDeduct,
topup_credits: topupDeduct, topup_credits: topupDeduct,
total_credits: totalCostCredits, total_credits: totalCostCredits,

View File

@ -0,0 +1,122 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing";
import { AppError } from "@/server/lib/errors";
import {
getRequiredEnvValue,
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
const EVENT_PAGE_LIMIT = 1000;
const BILLING_USAGE_FEATURE_IDS = [
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
] as const;
const billingUsageRangeSchema = z.object({
start: z.number(),
end: z.number(),
});
const billingUsagePropertySchema = z.json();
const autumnEventSchema = z
.object({
value: z.number(),
properties: z
.record(z.string(), billingUsagePropertySchema)
.optional()
.default({}),
})
.passthrough();
const autumnEventsListResponseSchema = z
.object({
list: z.array(autumnEventSchema),
has_more: z.boolean().optional(),
hasMore: z.boolean().optional(),
})
.passthrough();
export type BillingUsageEvent = {
value: number;
properties: Record<string, z.infer<typeof billingUsagePropertySchema>>;
};
export const getBillingUsageEvents = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => billingUsageRangeSchema.parse(data))
.handler(async ({ data, context }) => {
if (!(await isHostedServerAuthMode())) {
return [];
}
const events: BillingUsageEvent[] = [];
let offset = 0;
for (;;) {
const page = await fetchAutumnEventsPage({
customerId: context.organizationId,
end: data.end,
offset,
start: data.start,
});
events.push(...page.list);
if (!page.hasMore || page.list.length === 0) {
return events;
}
offset += page.list.length;
}
});
async function fetchAutumnEventsPage(args: {
customerId: string;
end: number;
offset: number;
start: number;
}): Promise<{ list: BillingUsageEvent[]; hasMore: boolean }> {
const secretKey = await getRequiredEnvValue("AUTUMN_SECRET_KEY");
const response = await fetch(AUTUMN_EVENTS_LIST_URL, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${secretKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
customer_id: args.customerId,
custom_range: {
end: args.end,
start: args.start,
},
feature_id: BILLING_USAGE_FEATURE_IDS,
limit: EVENT_PAGE_LIMIT,
offset: args.offset,
}),
});
if (!response.ok) {
throw new AppError(
"INTERNAL_ERROR",
`Autumn events.list failed with status ${response.status}`,
);
}
const parsed = autumnEventsListResponseSchema.parse(await response.json());
return {
hasMore: parsed.has_more ?? parsed.hasMore ?? false,
list: parsed.list.map((event) => ({
value: event.value,
properties: event.properties,
})),
};
}

View File

@ -0,0 +1,73 @@
export type CreditFeature =
| "keyword_research"
| "domain_overview"
| "backlinks"
| "site_audit"
| "rank_tracking"
| "ai_citations"
| "ai_prompt_responses"
| "local_seo";
const CREDIT_FEATURE_LABELS: Record<string, string> = {
keyword_research: "Keyword Research",
domain_overview: "Domain Overview",
backlinks: "Backlinks",
site_audit: "Site Audit",
rank_tracking: "Rank Tracking",
ai_citations: "AI Citations",
ai_prompt_responses: "AI Prompt Responses",
ai_search: "AI Search",
local_seo: "Local SEO",
};
/**
* Maps a DataForSEO API response path (e.g. ["v3", "dataforseo_labs", "google", "related_keywords", "live"])
* to a product feature for analytics. path[1] is the API module; for dataforseo_labs,
* path[3] distinguishes keyword vs domain endpoints.
*/
export function mapDataforseoPathToCreditFeature(
path: readonly string[],
): CreditFeature {
const normalizedPath = path[0] === "v3" ? path : ["v3", ...path];
const module = normalizedPath[1];
switch (module) {
case "on_page":
return "site_audit";
case "backlinks":
return "backlinks";
case "serp":
return normalizedPath[2] === "google" &&
["maps", "local_finder"].includes(normalizedPath[3])
? "local_seo"
: "keyword_research";
case "ai_optimization":
// llm_mentions/* are brand-citation lookups; every other ai_optimization
// endpoint is a provider /llm_responses prompt response (chat_gpt, claude,
// gemini, perplexity).
return normalizedPath[2] === "llm_mentions"
? "ai_citations"
: "ai_prompt_responses";
case "business_data":
return "local_seo";
case "keywords_data":
return "keyword_research";
case "dataforseo_labs": {
const endpoint = normalizedPath[3] ?? "";
if (
endpoint.startsWith("domain_") ||
endpoint === "ranked_keywords" ||
endpoint === "relevant_pages"
) {
return "domain_overview";
}
return "keyword_research";
}
default:
return "site_audit";
}
}
export function creditFeatureLabel(key: string) {
return CREDIT_FEATURE_LABELS[key] ?? "Other";
}