diff --git a/src/client/features/billing/BillingFeatureBreakdown.test.ts b/src/client/features/billing/BillingFeatureBreakdown.test.ts new file mode 100644 index 0000000..297d55f --- /dev/null +++ b/src/client/features/billing/BillingFeatureBreakdown.test.ts @@ -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 }]); + }); +}); diff --git a/src/client/features/billing/BillingFeatureBreakdown.tsx b/src/client/features/billing/BillingFeatureBreakdown.tsx new file mode 100644 index 0000000..5f7cdfc --- /dev/null +++ b/src/client/features/billing/BillingFeatureBreakdown.tsx @@ -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 { + 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(); + + 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 ( +
+
+ Usage by feature + Last 30 days +
+ + {eventsQuery.isLoading ? ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ) : rows.length === 0 ? ( +
+ No usage recorded yet +
+ ) : ( +
    + {rows.map((row) => ( +
  • +
    + {row.label} + + ${row.usd.toFixed(2)} + +
    +
    +
    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/routes/_app/billing.tsx b/src/routes/_app/billing.tsx index dfb87c2..704e4aa 100644 --- a/src/routes/_app/billing.tsx +++ b/src/routes/_app/billing.tsx @@ -6,6 +6,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution"; import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart"; +import { BillingFeatureBreakdown } from "@/client/features/billing/BillingFeatureBreakdown"; import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils"; import { getBillingRouteState } from "@/client/features/billing/route-state"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; @@ -310,6 +311,9 @@ function BillingPageContent() { {/* Usage chart */} + {/* Per-feature usage breakdown */} + + {error ?

{error}

: null}

diff --git a/src/server/lib/dataforseoClient.test.ts b/src/server/lib/dataforseoClient.test.ts index 11e9d31..5062168 100644 --- a/src/server/lib/dataforseoClient.test.ts +++ b/src/server/lib/dataforseoClient.test.ts @@ -354,6 +354,9 @@ describe("mapDataforseoPathToCreditFeature", () => { expect( mapDataforseoPathToCreditFeature(["v3", "backlinks", "summary", "live"]), ).toBe("backlinks"); + expect(mapDataforseoPathToCreditFeature(["backlinks", "summary"])).toBe( + "backlinks", + ); expect( mapDataforseoPathToCreditFeature([ "v3", @@ -376,7 +379,7 @@ describe("mapDataforseoPathToCreditFeature", () => { ).toBe("site_audit"); }); - it("maps real ai_optimization paths to ai_search", () => { + it("maps ai_optimization llm_mentions paths to ai_citations", () => { expect( mapDataforseoPathToCreditFeature([ "v3", @@ -385,7 +388,7 @@ describe("mapDataforseoPathToCreditFeature", () => { "search", "live", ]), - ).toBe("ai_search"); + ).toBe("ai_citations"); expect( mapDataforseoPathToCreditFeature([ "v3", @@ -394,16 +397,30 @@ describe("mapDataforseoPathToCreditFeature", () => { "aggregated_metrics", "live", ]), - ).toBe("ai_search"); + ).toBe("ai_citations"); expect( mapDataforseoPathToCreditFeature([ "v3", "ai_optimization", - "claude", - "llm_responses", + "llm_mentions", + "top_pages", "live", ]), - ).toBe("ai_search"); + ).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", + "live", + ]), + ).toBe("ai_prompt_responses"); + } }); it("maps local and supporting paths to the intended credit features", () => { diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts index 1d1c522..a1ac929 100644 --- a/src/server/lib/dataforseoClient.ts +++ b/src/server/lib/dataforseoClient.ts @@ -6,6 +6,10 @@ import { SEO_DATA_COST_MARKUP, roundUsdForBilling, } from "@/shared/billing"; +import { + type CreditFeature, + mapDataforseoPathToCreditFeature, +} from "@/shared/billing-credit-features"; import { autumn } from "@/server/billing/autumn"; import { getOrCreateOrganizationCustomer } 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 { isHostedServerAuthMode } from "@/server/lib/runtime-env"; -type CreditFeature = - | "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 { mapDataforseoPathToCreditFeature }; export function createDataforseoClient(customer: BillingCustomerContext) { return { @@ -457,10 +413,14 @@ async function trackDataforseoCost(args: { const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits); const topupDeduct = totalCostCredits - monthlyDeduct; + const creditFeature = + args.creditFeature ?? mapDataforseoPathToCreditFeature(args.billing.path); + const properties = { provider: "dataforseo", currency: "USD", paths: [args.billing.path.join("/")], + creditFeature, totalCostUsd, totalCostCredits, fromCache: false, @@ -497,9 +457,7 @@ async function trackDataforseoCost(args: { organizationId: args.customer.organizationId, properties: { project_id: args.customer.projectId, - credit_feature: - args.creditFeature ?? - mapDataforseoPathToCreditFeature(args.billing.path), + credit_feature: creditFeature, monthly_credits: monthlyDeduct, topup_credits: topupDeduct, total_credits: totalCostCredits, diff --git a/src/serverFunctions/billing.ts b/src/serverFunctions/billing.ts new file mode 100644 index 0000000..329dc22 --- /dev/null +++ b/src/serverFunctions/billing.ts @@ -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>; +}; + +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, + })), + }; +} diff --git a/src/shared/billing-credit-features.ts b/src/shared/billing-credit-features.ts new file mode 100644 index 0000000..e4bf93c --- /dev/null +++ b/src/shared/billing-credit-features.ts @@ -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 = { + 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"; +}