diff --git a/adr/0002-hosted-dataforseo-metering-with-autumn.md b/adr/0002-hosted-dataforseo-metering-with-autumn.md new file mode 100644 index 0000000..a4c0ada --- /dev/null +++ b/adr/0002-hosted-dataforseo-metering-with-autumn.md @@ -0,0 +1,49 @@ +# Hosted DataForSEO metering with Autumn + +## Status + +Accepted + +## Context + +In hosted mode, OpenSEO uses platform-managed DataForSEO credentials and bills each organization for actual provider usage. + +The low-level DataForSEO helpers can make live requests directly. If feature code imports them freely, it is easy to skip billing checks, forget usage tracking, or meter against estimated cost instead of the cost DataForSEO actually returned. + +Autumn's billing model fits this flow: check access before the call, then track usage after the call succeeds. + +## Decision + +Hosted DataForSEO access must go through `createDataforseoClient`. + +We model hosted SEO data billing in Autumn as a credit system: + +- `base-plan` grants recurring `usage_credits` +- `credit-top-up` sells more `usage_credits` +- `seo_data_usage` is the metered feature DataForSEO calls consume +- `1000` credits equals `$1` + +In hosted mode, the client: + +- accepts `BillingCustomerContext`, not an Autumn customer ID +- resolves the Autumn customer from `organizationId` +- checks `seo_data_usage` before calling DataForSEO, using a small minimum balance guardrail +- executes a raw `fetch*Raw` helper that returns parsed data plus provider billing metadata +- tracks the actual reported DataForSEO cost in Autumn after the call succeeds + +In non-hosted mode, the client skips Autumn and executes the DataForSEO call directly. + +Raw `fetch*Raw` helpers remain low-level transport and parsing functions. They are not the application entry point for hosted features. + +## Rationale + +This makes the metered path the easiest path. Feature code asks for DataForSEO data once and gets billing enforcement by default. + +It also keeps billing aligned with provider-reported cost. We do not know the exact charge until DataForSEO responds, so the client does a preflight balance check and records the exact usage event afterwards. + +## Consequences + +- New DataForSEO capabilities should be added to `src/server/lib/dataforseoClient.ts`, not called from feature code via raw helpers. +- Hosted feature services must pass billing customer context into the client. +- Subscription eligibility remains a separate concern handled by auth middleware; the client is responsible for usage metering. +- Direct raw DataForSEO imports in hosted application code should be treated as billing bypasses. diff --git a/package.json b/package.json index db36eda..ef71ffd 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@tanstack/react-router": "^1.136.3", "@tanstack/react-router-devtools": "^1.136.3", "@tanstack/react-start": "^1.136.3", + "autumn-js": "^1.1.2", "better-auth": "^1.5.5", "cheerio": "^1.2.0", "cloudflare": "^5.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a316fce..a0de271 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tanstack/react-start': specifier: ^1.136.3 version: 1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + autumn-js: + specifier: ^1.1.2 + version: 1.1.2(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) better-auth: specifier: ^1.5.5 version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) @@ -2041,6 +2044,29 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autumn-js@1.1.2: + resolution: {integrity: sha512-glSAvGJ6G8cJKLH6enonQ0WverZUMnGirv6hYzMBZViFqAiU6IDrEbbD0Xl7LGUN/pUjd9JgVwrmxeu3cnLO0g==} + peerDependencies: + better-auth: ^1.3.17 + better-call: ^1.0.12 + express: ^5.2.1 + hono: ^4.0.0 + next: ^14.0.0 || ^15.0.0 + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + better-auth: + optional: true + better-call: + optional: true + express: + optional: true + hono: + optional: true + next: + optional: true + react: + optional: true + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -2276,6 +2302,10 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2539,6 +2569,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} @@ -2982,6 +3016,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + query-string@9.3.1: + resolution: {integrity: sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==} + engines: {node: '>=18'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3059,6 +3097,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.6.3: + resolution: {integrity: sha512-1HSG1ENTj7Kkm5muMnXuzzfdDOf7CFnbSYFA+H3Fp/rB9lOCxCPgy1jlZxTKyFoC5jJay8Mmc+VbPLYRjzYLrA==} + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -3145,6 +3186,10 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + srvx@0.11.7: resolution: {integrity: sha512-p9qj9wkv/MqG1VoJpOsqXv1QcaVcYRk7ifsC6i3TEwDXFyugdhJN4J3KzQPZq2IJJ2ZCt7ASOB++85pEK38jRw==} engines: {node: '>=20.16.0'} @@ -5021,6 +5066,16 @@ snapshots: asynckit@0.4.0: {} + autumn-js@1.1.2(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4): + dependencies: + query-string: 9.3.1 + rou3: 0.6.3 + zod: 4.3.6 + optionalDependencies: + better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + better-call: 1.3.2(zod@4.3.6) + react: 19.2.4 + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 @@ -5236,6 +5291,8 @@ snapshots: decimal.js-light@2.5.1: {} + decode-uri-component@0.4.1: {} + deep-eql@5.0.2: {} defu@6.1.4: {} @@ -5468,6 +5525,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + filter-obj@5.1.0: {} + form-data-encoder@1.7.2: {} form-data@4.0.5: @@ -5874,6 +5933,12 @@ snapshots: punycode@2.3.1: {} + query-string@9.3.1: + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + queue-microtask@1.2.3: {} react-dom@19.2.4(react@19.2.4): @@ -5975,6 +6040,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rou3@0.6.3: {} + rou3@0.7.12: {} run-parallel@1.2.0: @@ -6066,6 +6133,8 @@ snapshots: dependencies: memory-pager: 1.5.0 + split-on-first@3.0.0: {} + srvx@0.11.7: {} srvx@0.11.9: {} diff --git a/scripts/backlinks-cost-profile.ts b/scripts/backlinks-cost-profile.ts index abc3c47..fa94ecd 100644 --- a/scripts/backlinks-cost-profile.ts +++ b/scripts/backlinks-cost-profile.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import process from "node:process"; import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; loadLocalEnv(); @@ -33,43 +34,37 @@ async function main() { } const input = buildInput(args); + const billingCustomer = buildBillingCustomer(args); const repeat = parsePositiveInteger(args.repeat, 1); const includeTabs = parseBoolean(args.includeTabs, true); const runs = []; for (let index = 0; index < repeat; index += 1) { - const overview = await service.profileOverview(input); + const overview = await service.profileOverview(input, billingCustomer); const domains = includeTabs - ? await service.profileReferringDomains(input) + ? await service.profileReferringDomains(input, billingCustomer) + : null; + const pages = includeTabs + ? await service.profileTopPages(input, billingCustomer) : null; - const pages = includeTabs ? await service.profileTopPages(input) : null; runs.push({ run: index + 1, overview: { - fromCache: overview.billing.fromCache, - totalCostUsd: overview.billing.totalCostUsd, - calls: overview.billing.calls, + backlinksRows: overview.overview.backlinks.length, + trendRows: overview.overview.trends.length, + newLostRows: overview.overview.newLostTrends.length, }, domainsTab: domains ? { - fromCache: domains.billing.fromCache, - totalCostUsd: domains.billing.totalCostUsd, - calls: domains.billing.calls, + rows: domains.rows.length, } : null, pagesTab: pages ? { - fromCache: pages.billing.fromCache, - totalCostUsd: pages.billing.totalCostUsd, - calls: pages.billing.calls, + rows: pages.rows.length, } : null, - fullyExploredCostUsd: roundUsd( - overview.billing.totalCostUsd + - (domains?.billing.totalCostUsd ?? 0) + - (pages?.billing.totalCostUsd ?? 0), - ), }); } @@ -105,6 +100,19 @@ function buildInput(cliArgs: Record): BacklinksLookupInput { }; } +function buildBillingCustomer( + cliArgs: Record, +): BillingCustomerContext { + return { + organizationId: + cliArgs.organizationId ?? process.env.BILLING_ORGANIZATION_ID ?? "local", + userEmail: + cliArgs.userEmail ?? + process.env.BILLING_USER_EMAIL ?? + "local@example.com", + }; +} + function parseArgs(argv: string[]) { const parsed: Record = {}; @@ -174,10 +182,6 @@ function loadLocalEnv() { } } -function roundUsd(value: number) { - return Math.round(value * 100000) / 100000; -} - function printUsageAndExit(message: string): never { console.error(message); console.error( diff --git a/src/client/features/backlinks/BacklinksPageCharts.tsx b/src/client/features/backlinks/BacklinksPageCharts.tsx index 892936e..388fbe7 100644 --- a/src/client/features/backlinks/BacklinksPageCharts.tsx +++ b/src/client/features/backlinks/BacklinksPageCharts.tsx @@ -1,9 +1,9 @@ +import { useEffect, useRef, useState } from "react"; import { CartesianGrid, Legend, Line, LineChart, - ResponsiveContainer, Tooltip, XAxis, YAxis, @@ -20,14 +20,22 @@ export function BacklinksTrendChart({ }: { data: BacklinksOverviewData["trends"]; }) { + const { containerRef, chartWidth } = useChartWidth(); + if (data.length === 0) { return ; } return ( -
- +
+ {chartWidth > 0 ? ( @@ -64,7 +72,7 @@ export function BacklinksTrendChart({ name="Referring domains" /> - + ) : null}
); } @@ -74,14 +82,22 @@ export function BacklinksNewLostChart({ }: { data: BacklinksOverviewData["newLostTrends"]; }) { + const { containerRef, chartWidth } = useChartWidth(); + if (data.length === 0) { return ; } return ( -
- +
+ {chartWidth > 0 ? ( @@ -118,11 +134,38 @@ export function BacklinksNewLostChart({ name="Lost backlinks" /> - + ) : null}
); } +function useChartWidth() { + const containerRef = useRef(null); + const [chartWidth, setChartWidth] = useState(0); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const updateWidth = () => { + setChartWidth(container.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver(updateWidth); + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, []); + + return { containerRef, chartWidth }; +} + function EmptyChartState() { return (
diff --git a/src/client/features/billing/BillingRouteParts.tsx b/src/client/features/billing/BillingRouteParts.tsx new file mode 100644 index 0000000..4e495b6 --- /dev/null +++ b/src/client/features/billing/BillingRouteParts.tsx @@ -0,0 +1,102 @@ +import { CreditCard } from "lucide-react"; +import type { ReactNode } from "react"; + +export function BillingHeader({ hasPaidPlan }: { hasPaidPlan: boolean }) { + return ( +
+
+ + Hosted billing +
+

+ {hasPaidPlan ? "Billing" : "Choose a plan"} +

+

+ {hasPaidPlan + ? "OpenSEO hosted usage is metered against your shared backlinks balance. Your monthly plan covers the first $5, and extra top-ups carry forward." + : "You need a paid plan to use OpenSEO's managed service. The base plan costs $5/month and includes $5 of usage credits. If you use all of those credits, you can buy more at any time."} +

+
+ ); +} + +export function SubscriptionIntro({ hasPaidPlan }: { hasPaidPlan: boolean }) { + return ( +
+

+ {hasPaidPlan ? "Subscription" : "Managed service access"} +

+

+ {hasPaidPlan + ? "Hosted workspaces need an active paid plan before project pages and DataForSEO-backed features are available." + : "Start the base plan to unlock OpenSEO's managed service and your included monthly usage credits."} +

+
+ ); +} + +export function SubscriptionStatusCard({ + hasPaidPlan, +}: { + hasPaidPlan: boolean; +}) { + return ( +
+
+ {hasPaidPlan ? "Current status" : "Base plan"} +
+
+ {hasPaidPlan ? "Active" : "$5/month"} +
+
+ {hasPaidPlan + ? "Your organization can use hosted OpenSEO features." + : "Includes $5 of usage credits every month."} +
+
+ ); +} + +export function BillingAlerts(args: { + actionError: string | null; + hasPaidPlan: boolean; +}) { + return ( + <> + {args.actionError ? ( +
+ {args.actionError} +
+ ) : null} + + {!args.hasPaidPlan ? ( +
+ + Subscribe to the base plan first. After that, you can manage your + plan and buy more credits here. + +
+ ) : null} + + ); +} + +export function CenteredCard(args: { + title: string; + body: string; + action?: ReactNode; +}) { + return ( +
+
+
+

{args.title}

+

{args.body}

+ {args.action ? ( +
{args.action}
+ ) : null} +
+
+
+ ); +} diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index 5e29d2e..672e9d0 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -1,15 +1,16 @@ import * as React from "react"; import { Link, useLocation } from "@tanstack/react-router"; -import { ChevronsUpDown, Menu, User } from "lucide-react"; -import { getProjectNavItems } from "@/client/navigation/items"; +import { ChevronsUpDown, CreditCard, Menu, User } from "lucide-react"; import { AppContent, MissingSeoSetupModal, SeoApiStatusBanners, } from "@/client/layout/AppShellParts"; +import { getProjectNavItems } from "@/client/navigation/items"; import { getSignInHrefForLocation } from "@/lib/auth-redirect"; import { authClient, useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { BILLING_ROUTE } from "@/shared/billing"; import { getSeoApiKeyStatus } from "@/serverFunctions/config"; const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key"; @@ -32,6 +33,13 @@ export function AuthenticatedAppLayout({ React.useState(false); React.useEffect(() => { + if (location.pathname === BILLING_ROUTE) { + setSeoApiKeyStatusError(false); + setIsSeoApiKeyConfigured(null); + setShowMissingSeoApiKeyModal(false); + return; + } + let cancelled = false; const checkSeoApiKeyStatus = async () => { @@ -57,7 +65,7 @@ export function AuthenticatedAppLayout({ return () => { cancelled = true; }; - }, []); + }, [location.pathname]); const shouldShowMissingSeoApiKeyModal = showMissingSeoApiKeyModal && location.pathname !== DATAFORSEO_HELP_PATH; @@ -95,6 +103,7 @@ export function AuthenticatedAppLayout({ setDrawerOpen(true)} /> @@ -123,18 +132,19 @@ export function AuthenticatedAppLayout({ function TopNav({ drawerOpen, projectId, + pathname, onOpenDrawer, }: { drawerOpen: boolean; projectId: string | null; + pathname: string; onOpenDrawer: () => void; }) { - const isHostedMode = isHostedClientAuthMode(); const projectNavItems = projectId ? getProjectNavItems(projectId) : []; return ( -
-
+
+
{projectId ? ( ) : null} - OpenSEO + OpenSEO
-
- +
+ OpenSEO {projectId ? projectNavItems.map((item) => { - const { icon: Icon, ...linkProps } = item; + const { icon: Icon, matchSegment, ...linkProps } = item; + const isActive = pathname.includes(matchSegment); + return ( {item.label} @@ -177,7 +188,7 @@ function TopNav({
-
+
- {isHostedMode ? ( - <> -
- - - ) : null} +
- {isHostedMode ? : null} +
); } @@ -215,8 +221,9 @@ function HostedSessionActions({ mobileOnly?: boolean; }) { const { data: session } = useSession(); + const isHostedMode = isHostedClientAuthMode(); - if (!session?.user?.email) { + if (!isHostedMode || !session?.user?.email) { return null; } @@ -231,8 +238,8 @@ function HostedSessionActions({ }); }; - return ( -
+ const menu = ( +
); + + if (mobileOnly) { + return menu; + } + + return ( + <> +
+ {menu} + + ); } diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index 0939a58..d1705d0 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -4,6 +4,8 @@ const STANDARD_MESSAGES: Record = { UNAUTHENTICATED: "Please sign in and try again.", AUTH_CONFIG_MISSING: "OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.", + PAYMENT_REQUIRED: + "An active hosted subscription is required before you can use OpenSEO.", FORBIDDEN: "You do not have access to this resource.", NOT_FOUND: "The requested resource was not found.", AUDIT_CAPACITY_REACHED: diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index fc7764c..cb32af5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' +import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' @@ -55,6 +56,11 @@ const AuthSignInRoute = AuthSignInRouteImport.update({ path: '/sign-in', getParentRoute: () => AuthRoute, } as any) +const AppBillingRoute = AppBillingRouteImport.update({ + id: '/billing', + path: '/billing', + getParentRoute: () => AppRouteRoute, +} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', @@ -122,6 +128,7 @@ const ProjectPProjectIdAuditIssuesResultIdRoute = export interface FileRoutesByFullPath { '/': typeof AppIndexRoute + '/billing': typeof AppBillingRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren @@ -139,6 +146,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof AppIndexRoute + '/billing': typeof AppBillingRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute @@ -157,6 +165,7 @@ export interface FileRoutesById { '/_app': typeof AppRouteRouteWithChildren '/_project': typeof ProjectRouteRouteWithChildren '/_auth': typeof AuthRouteWithChildren + '/_app/billing': typeof AppBillingRoute '/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-up': typeof AuthSignUpRoute '/_app/': typeof AppIndexRoute @@ -177,6 +186,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/billing' | '/sign-in' | '/sign-up' | '/p/$projectId' @@ -194,6 +204,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/billing' | '/sign-in' | '/sign-up' | '/help/dataforseo-api-key' @@ -211,6 +222,7 @@ export interface FileRouteTypes { | '/_app' | '/_project' | '/_auth' + | '/_app/billing' | '/_auth/sign-in' | '/_auth/sign-up' | '/_app/' @@ -279,6 +291,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthSignInRouteImport parentRoute: typeof AuthRoute } + '/_app/billing': { + id: '/_app/billing' + path: '/billing' + fullPath: '/billing' + preLoaderRoute: typeof AppBillingRouteImport + parentRoute: typeof AppRouteRoute + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -367,11 +386,13 @@ declare module '@tanstack/react-router' { } interface AppRouteRouteChildren { + AppBillingRoute: typeof AppBillingRoute AppIndexRoute: typeof AppIndexRoute AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute } const AppRouteRouteChildren: AppRouteRouteChildren = { + AppBillingRoute: AppBillingRoute, AppIndexRoute: AppIndexRoute, AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute, } diff --git a/src/routes/_app/billing.tsx b/src/routes/_app/billing.tsx new file mode 100644 index 0000000..7846766 --- /dev/null +++ b/src/routes/_app/billing.tsx @@ -0,0 +1,355 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ExternalLink, LoaderCircle } from "lucide-react"; +import { useState } from "react"; +import { + BillingAlerts, + BillingHeader, + CenteredCard, + SubscriptionIntro, + SubscriptionStatusCard, +} from "@/client/features/billing/BillingRouteParts"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { + createBacklinksTopUpCheckout, + createHostedSubscriptionCheckout, + getHostedBillingStatus, + openHostedBillingPortal, +} from "@/serverFunctions/billing"; + +export const Route = createFileRoute("/_app/billing")({ + beforeLoad: () => { + if (!isHostedClientAuthMode()) { + throw notFound(); + } + }, + component: BillingPage, +}); + +function BillingPage() { + const { data: session, isPending: isSessionPending } = useSession(); + const [actionError, setActionError] = useState(null); + const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false); + const [topUpAmount, setTopUpAmount] = useState("20"); + + const billingStatusQuery = useQuery({ + queryKey: ["billing", "status"], + queryFn: () => getHostedBillingStatus(), + enabled: Boolean(session?.user?.id), + }); + + const { billingPortalMutation, startSubscriptionMutation, topUpMutation } = + useBillingActions({ + setActionError, + setIsRedirectingToCheckout, + }); + + if (isSessionPending || (session?.user?.id && billingStatusQuery.isLoading)) { + return ( +
+ +
+ ); + } + + if (!session?.user?.id) { + return ( + + Go to sign in + + } + /> + ); + } + + if (billingStatusQuery.isError) { + return ( + + ); + } + + const billing = billingStatusQuery.data; + const hasPaidPlan = billing?.hasPaidPlan ?? false; + const balance = billing?.balance; + const isActionPending = + startSubscriptionMutation.isPending || + topUpMutation.isPending || + billingPortalMutation.isPending; + const { isValid: isValidTopUpAmount, parsed: parsedTopUpAmount } = + parseTopUpAmount(topUpAmount); + const topUpDisabled = !hasPaidPlan || isActionPending || !isValidTopUpAmount; + + if (isRedirectingToCheckout) { + return ( + + ); + } + + return ( +
+ + +
+
+
+ + + + + {!hasPaidPlan ? ( + + ) : null} + + {hasPaidPlan ? ( + + ) : null} +
+
+ +
+
+
+

+ SEO data credits +

+

+ Buy extra usage credits for DataForSEO-powered features like + backlinks. +

+
+ +
+
+ Remaining + + {formatUsd(balance?.remaining ?? 0)} + +
+
+
+ + Granted + + + {formatUsd(balance?.granted ?? 0)} + +
+
+ + Used + + + {formatUsd(balance?.usage ?? 0)} + +
+
+
+ + + + + +

+ Credit purchases use our hosted checkout flow and apply to your + organization balance. +

+
+
+
+ + + +
+ + Hosted billing is powered by Autumn. +
+
+ ); +} + +function useBillingActions(args: { + setActionError: (value: string | null) => void; + setIsRedirectingToCheckout: (value: boolean) => void; +}) { + const setBillingActionError = (error: unknown, fallback: string) => { + args.setActionError(getStandardErrorMessage(error, fallback)); + }; + + const billingPortalMutation = useMutation({ + mutationFn: async () => { + args.setActionError(null); + + try { + const result = await openHostedBillingPortal({ + data: { returnUrl: window.location.href }, + }); + redirectToHostedUrl(result.url, args.setActionError); + } catch (error) { + setBillingActionError( + error, + "We could not open the billing portal. Please try again.", + ); + } + }, + }); + + const startSubscriptionMutation = useMutation({ + mutationFn: async () => { + args.setActionError(null); + args.setIsRedirectingToCheckout(true); + + try { + const result = await createHostedSubscriptionCheckout({ + data: { returnUrl: window.location.href }, + }); + redirectToHostedUrl(result.url, args.setActionError); + } catch (error) { + setBillingActionError( + error, + "We could not open the hosted billing flow. Please try again.", + ); + } finally { + args.setIsRedirectingToCheckout(false); + } + }, + }); + + const topUpMutation = useMutation({ + mutationFn: async ({ amount }: { amount: number }) => { + args.setActionError(null); + args.setIsRedirectingToCheckout(true); + + try { + const result = await createBacklinksTopUpCheckout({ + data: { + returnUrl: window.location.href, + amount, + }, + }); + redirectToHostedUrl(result.url, args.setActionError); + } catch (error) { + setBillingActionError( + error, + "We could not open the credit purchase flow. Please try again.", + ); + } finally { + args.setIsRedirectingToCheckout(false); + } + }, + }); + + return { + billingPortalMutation, + startSubscriptionMutation, + topUpMutation, + }; +} + +function redirectToHostedUrl( + url: string | null | undefined, + setActionError: (value: string | null) => void, +) { + if (!url) { + setActionError( + "We could not open the hosted billing flow. Please try again.", + ); + return; + } + + window.location.assign(url); +} + +function parseTopUpAmount(value: string) { + const trimmed = value.trim(); + + if (!/^\d+$/.test(trimmed)) { + return { + isValid: false, + parsed: 20, + }; + } + + const parsed = Number(trimmed); + const isValid = Number.isInteger(parsed) && parsed >= 10 && parsed <= 99; + + return { + isValid, + parsed: isValid ? parsed : 20, + }; +} + +function formatUsd(value: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +} diff --git a/src/routes/_app/index.tsx b/src/routes/_app/index.tsx index 2d467d3..3abe662 100644 --- a/src/routes/_app/index.tsx +++ b/src/routes/_app/index.tsx @@ -8,6 +8,7 @@ import { } from "@/client/lib/error-messages"; import { AuthConfigErrorCard } from "@/client/components/AuthConfigErrorCard"; import { UnauthenticatedErrorCard } from "@/client/components/UnauthenticatedErrorCard"; +import { BILLING_ROUTE } from "@/shared/billing"; export const Route = createFileRoute("/_app/")({ component: IndexRedirect, @@ -30,6 +31,14 @@ function IndexRedirect() { mutate(); }, [mutate]); + useEffect(() => { + if (getErrorCode(error) !== "PAYMENT_REQUIRED") { + return; + } + + void navigate({ href: BILLING_ROUTE }); + }, [error, navigate]); + if (isError) { const errorCode = getErrorCode(error); @@ -62,6 +71,18 @@ function IndexRedirect() { ); } + if (errorCode === "PAYMENT_REQUIRED") { + return ( +
+
+

+ Redirecting you to billing so you can start a hosted subscription. +

+
+
+ ); + } + return (
diff --git a/src/server/billing/autumn.ts b/src/server/billing/autumn.ts new file mode 100644 index 0000000..ad79d4c --- /dev/null +++ b/src/server/billing/autumn.ts @@ -0,0 +1,6 @@ +import { Autumn } from "autumn-js"; +import { getRequiredEnvValue } from "@/server/lib/runtime-env"; + +export const autumn = new Autumn({ + secretKey: () => getRequiredEnvValue("AUTUMN_SECRET_KEY"), +}); diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts new file mode 100644 index 0000000..689d5d0 --- /dev/null +++ b/src/server/billing/subscription.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; + +const { getOrCreateMock } = vi.hoisted(() => ({ + getOrCreateMock: vi.fn(), +})); + +vi.mock("@/server/billing/autumn", () => ({ + autumn: { + customers: { + getOrCreate: getOrCreateMock, + }, + }, +})); + +import { + getOrCreateOrganizationCustomer, + hasActivePaidPlan, +} from "./subscription"; + +describe("subscription billing", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps access when an active plan is scheduled to cancel", () => { + expect( + hasActivePaidPlan({ + subscriptions: [ + { + planId: AUTUMN_PAID_PLAN_ID, + status: "active", + pastDue: false, + canceledAt: Date.now(), + }, + ], + }), + ).toBe(true); + }); + + it("rejects past-due paid plans", () => { + expect( + hasActivePaidPlan({ + subscriptions: [ + { + planId: AUTUMN_PAID_PLAN_ID, + status: "active", + pastDue: true, + canceledAt: null, + }, + ], + }), + ).toBe(false); + }); + + it("does not rewrite the org billing email on lookup", async () => { + getOrCreateMock.mockResolvedValue({ id: "cust_123" }); + + await getOrCreateOrganizationCustomer({ + organizationId: "org_123", + userEmail: "alice@example.com", + }); + + expect(getOrCreateMock).toHaveBeenCalledWith({ + customerId: "org_123", + name: "org_123", + }); + }); +}); diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts new file mode 100644 index 0000000..0b8cc91 --- /dev/null +++ b/src/server/billing/subscription.ts @@ -0,0 +1,57 @@ +import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; +import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; +import { autumn } from "@/server/billing/autumn"; +import { AppError } from "@/server/lib/errors"; +import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; + +export type BillingCustomerContext = Pick< + EnsuredUserContext, + "organizationId" | "userEmail" +>; + +export async function getOrCreateOrganizationCustomer( + context: BillingCustomerContext, +) { + const customer = await autumn.customers.getOrCreate({ + customerId: context.organizationId, + name: context.organizationId, + }); + + if (!customer.id) { + throw new AppError("INTERNAL_ERROR", "Failed to resolve billing customer"); + } + + return { + ...customer, + id: customer.id, + }; +} + +export function hasActivePaidPlan(customer: { + subscriptions: Array<{ + planId: string; + status: string; + pastDue: boolean; + canceledAt: number | null; + }>; +}) { + return customer.subscriptions.some( + (subscription) => + subscription.planId === AUTUMN_PAID_PLAN_ID && + subscription.status === "active" && + !subscription.pastDue, + ); +} + +export async function requireHostedPaidSubscription( + context: BillingCustomerContext, +) { + if (!(await isHostedServerAuthMode())) { + return; + } + + const customer = await getOrCreateOrganizationCustomer(context); + if (!hasActivePaidPlan(customer)) { + throw new AppError("PAYMENT_REQUIRED"); + } +} diff --git a/src/server/features/backlinks/services/BacklinksService.billing.test.ts b/src/server/features/backlinks/services/BacklinksService.billing.test.ts index ef09d6a..5f8899c 100644 --- a/src/server/features/backlinks/services/BacklinksService.billing.test.ts +++ b/src/server/features/backlinks/services/BacklinksService.billing.test.ts @@ -1,25 +1,36 @@ import { beforeEach, expect, it, vi } from "vitest"; +const backlinksSummaryMock = vi.fn(); +const backlinksRowsMock = vi.fn(); +const referringDomainsMock = vi.fn(); +const domainPagesMock = vi.fn(); +const timeseriesSummaryMock = vi.fn(); +const newLostTimeseriesMock = vi.fn(); + vi.mock("@/server/lib/dataforseoBacklinks", () => ({ normalizeBacklinksTarget: vi.fn(), - fetchBacklinksSummaryRaw: vi.fn(), - fetchBacklinksRowsRaw: vi.fn(), - fetchReferringDomainsRaw: vi.fn(), - fetchDomainPagesSummaryRaw: vi.fn(), - fetchTimeseriesSummaryRaw: vi.fn(), - fetchNewLostTimeseriesRaw: vi.fn(), })); +vi.mock("@/server/lib/dataforseoClient", () => ({ + createDataforseoClient: vi.fn(() => ({ + backlinks: { + summary: backlinksSummaryMock, + rows: backlinksRowsMock, + referringDomains: referringDomainsMock, + domainPages: domainPagesMock, + timeseriesSummary: timeseriesSummaryMock, + newLostTimeseries: newLostTimeseriesMock, + }, + })), +})); + +import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinks"; import { createBacklinksService } from "./BacklinksService"; -import { - fetchBacklinksRowsRaw, - fetchBacklinksSummaryRaw, - fetchDomainPagesSummaryRaw, - fetchNewLostTimeseriesRaw, - fetchReferringDomainsRaw, - fetchTimeseriesSummaryRaw, - normalizeBacklinksTarget, -} from "@/server/lib/dataforseoBacklinks"; + +const billingCustomer = { + organizationId: "org_123", + userEmail: "team@example.com", +}; const cache = new Map(); const service = createBacklinksService({ @@ -43,104 +54,86 @@ it("profiles only the initial overview calls and reuses cache on repeat", async displayTarget: "example.com", scope: "domain", }); - vi.mocked(fetchBacklinksSummaryRaw).mockResolvedValue({ - data: { - rank: 42, - backlinks: 1200, - referring_pages: 900, - referring_domains: 320, - broken_backlinks: 12, - broken_pages: 3, - backlinks_spam_score: 5, - info: { target_spam_score: 4 }, - new_backlinks: 25, - lost_backlinks: 10, - new_referring_domains: 8, - lost_referring_domains: 2, + backlinksSummaryMock.mockResolvedValue({ + rank: 42, + backlinks: 1200, + referring_pages: 900, + referring_domains: 320, + broken_backlinks: 12, + broken_pages: 3, + backlinks_spam_score: 5, + info: { target_spam_score: 4 }, + new_backlinks: 25, + lost_backlinks: 10, + new_referring_domains: 8, + lost_referring_domains: 2, + }); + backlinksRowsMock.mockResolvedValue([ + { + domain_from: "source.example", + url_from: "https://source.example/post", + url_to: "https://example.com/", + anchor: "Example", + item_type: "content", + dofollow: true, + rank: 77, + domain_from_rank: 65, + page_from_rank: 54, + backlink_spam_score: 3, + first_seen: "2026-01-01", + last_visited: "2026-03-01", + lost_date: null, + is_lost: false, + is_broken: false, + links_count: 1, + rel_attributes: ["noopener"], }, - billing: createBilling("/v3/backlinks/summary/live", 0.02003, 1), - }); - vi.mocked(fetchBacklinksRowsRaw).mockResolvedValue({ - data: [ - { - domain_from: "source.example", - url_from: "https://source.example/post", - url_to: "https://example.com/", - anchor: "Example", - item_type: "content", - dofollow: true, - rank: 77, - domain_from_rank: 65, - page_from_rank: 54, - backlink_spam_score: 3, - first_seen: "2026-01-01", - last_visited: "2026-03-01", - lost_date: null, - is_lost: false, - is_broken: false, - links_count: 1, - rel_attributes: ["noopener"], - }, - ], - billing: createBilling("/v3/backlinks/backlinks/live", 0.023, 1), - }); - vi.mocked(fetchTimeseriesSummaryRaw).mockResolvedValue({ - data: [ - { - date: "2026-02-01", - backlinks: 1100, - referring_domains: 300, - rank: 40, - }, - ], - billing: createBilling("/v3/backlinks/timeseries_summary/live", 0.02039, 1), - }); - vi.mocked(fetchNewLostTimeseriesRaw).mockResolvedValue({ - data: [ - { - date: "2026-02-01", - new_backlinks: 20, - lost_backlinks: 5, - new_referring_domains: 3, - lost_referring_domains: 1, - }, - ], - billing: createBilling( - "/v3/backlinks/timeseries_new_lost_summary/live", - 0.02039, - 1, - ), - }); - - const first = await service.profileOverview({ - target: "example.com", - includeSubdomains: true, - includeIndirectLinks: true, - excludeInternalBacklinks: true, - status: "live", - }); - const second = await service.profileOverview({ - target: "example.com", - includeSubdomains: true, - includeIndirectLinks: true, - excludeInternalBacklinks: true, - status: "live", - }); - - expect(first.billing.fromCache).toBe(false); - expect(first.billing.totalCostUsd).toBe(0.08381); - expect(first.billing.calls.map((call) => call.endpoint)).toEqual([ - "/v3/backlinks/summary/live", - "/v3/backlinks/backlinks/live", - "/v3/backlinks/timeseries_summary/live", - "/v3/backlinks/timeseries_new_lost_summary/live", ]); + timeseriesSummaryMock.mockResolvedValue([ + { + date: "2026-02-01", + backlinks: 1100, + referring_domains: 300, + rank: 40, + }, + ]); + newLostTimeseriesMock.mockResolvedValue([ + { + date: "2026-02-01", + new_backlinks: 20, + lost_backlinks: 5, + new_referring_domains: 3, + lost_referring_domains: 1, + }, + ]); + + const first = await service.profileOverview( + { + target: "example.com", + includeSubdomains: true, + includeIndirectLinks: true, + excludeInternalBacklinks: true, + status: "live", + }, + billingCustomer, + ); + const second = await service.profileOverview( + { + target: "example.com", + includeSubdomains: true, + includeIndirectLinks: true, + excludeInternalBacklinks: true, + status: "live", + }, + billingCustomer, + ); + expect(first.overview.referringDomains).toEqual([]); expect(first.overview.topPages).toEqual([]); - expect(second.billing.fromCache).toBe(true); - expect(fetchReferringDomainsRaw).not.toHaveBeenCalled(); - expect(fetchDomainPagesSummaryRaw).not.toHaveBeenCalled(); - expect(fetchBacklinksSummaryRaw).toHaveBeenCalledOnce(); + expect(referringDomainsMock).not.toHaveBeenCalled(); + expect(domainPagesMock).not.toHaveBeenCalled(); + expect(backlinksSummaryMock).toHaveBeenCalledOnce(); + expect(second).toEqual(first); }); it("profiles referring domains and top pages separately", async () => { @@ -149,58 +142,52 @@ it("profiles referring domains and top pages separately", async () => { displayTarget: "https://example.com/foo", scope: "page", }); - vi.mocked(fetchReferringDomainsRaw).mockResolvedValue({ - data: [ - { - domain: "source.example", - backlinks: 4, - referring_pages: 2, - rank: 65, - first_seen: "2026-01-01", - broken_backlinks: 0, - broken_pages: 0, - backlinks_spam_score: 2, - target_spam_score: 4, - }, - ], - billing: createBilling("/v3/backlinks/referring_domains/live", 0.023, 1), - }); - vi.mocked(fetchDomainPagesSummaryRaw).mockResolvedValue({ - data: [ - { - page: "https://example.com/foo", - backlinks: 100, - referring_domains: 20, - rank: 50, - broken_backlinks: 0, - }, - ], - billing: createBilling( - "/v3/backlinks/domain_pages_summary/live", - 0.02003, - 1, - ), - }); + referringDomainsMock.mockResolvedValue([ + { + domain: "source.example", + backlinks: 4, + referring_pages: 2, + rank: 65, + first_seen: "2026-01-01", + broken_backlinks: 0, + broken_pages: 0, + backlinks_spam_score: 2, + target_spam_score: 4, + }, + ]); + domainPagesMock.mockResolvedValue([ + { + page: "https://example.com/foo", + backlinks: 100, + referring_domains: 20, + rank: 50, + broken_backlinks: 0, + }, + ]); - const domains = await service.profileReferringDomains({ - target: "https://example.com/foo", - includeSubdomains: true, - includeIndirectLinks: true, - excludeInternalBacklinks: true, - status: "live", - }); - const pages = await service.profileTopPages({ - target: "https://example.com/foo", - includeSubdomains: true, - includeIndirectLinks: true, - excludeInternalBacklinks: true, - status: "live", - }); + const domains = await service.profileReferringDomains( + { + target: "https://example.com/foo", + includeSubdomains: true, + includeIndirectLinks: true, + excludeInternalBacklinks: true, + status: "live", + }, + billingCustomer, + ); + const pages = await service.profileTopPages( + { + target: "https://example.com/foo", + includeSubdomains: true, + includeIndirectLinks: true, + excludeInternalBacklinks: true, + status: "live", + }, + billingCustomer, + ); - expect(domains.billing.totalCostUsd).toBe(0.023); expect(domains.rows).toHaveLength(1); expect(domains.rows[0]?.spamScore).toBe(2); - expect(pages.billing.totalCostUsd).toBe(0.02003); expect(pages.rows).toHaveLength(1); }); @@ -210,48 +197,75 @@ it("does not fall back to target spam score for referring domains", async () => displayTarget: "example.com", scope: "domain", }); - vi.mocked(fetchReferringDomainsRaw).mockResolvedValue({ - data: [ - { - domain: "source.example", - backlinks: 4, - referring_pages: 2, - rank: 65, - first_seen: "2026-01-01", - broken_backlinks: 0, - broken_pages: 0, - backlinks_spam_score: null, - target_spam_score: 4, - }, - ], - billing: createBilling("/v3/backlinks/referring_domains/live", 0.023, 1), - }); + referringDomainsMock.mockResolvedValue([ + { + domain: "source.example", + backlinks: 4, + referring_pages: 2, + rank: 65, + first_seen: "2026-01-01", + broken_backlinks: 0, + broken_pages: 0, + backlinks_spam_score: null, + target_spam_score: 4, + }, + ]); - const domains = await service.profileReferringDomains({ - target: "example.com", - includeSubdomains: true, - includeIndirectLinks: true, - excludeInternalBacklinks: true, - status: "live", - }); + const domains = await service.profileReferringDomains( + { + target: "example.com", + includeSubdomains: true, + includeIndirectLinks: true, + excludeInternalBacklinks: true, + status: "live", + }, + billingCustomer, + ); expect(domains.rows).toHaveLength(1); expect(domains.rows[0]?.spamScore).toBeNull(); }); -function createBilling( - endpoint: string, - costUsd: number, - rowsReturned: number, -) { - return { - endpoint, - path: endpoint.split("/").filter(Boolean), - costUsd, - resultCount: 1, - rowsReturned, +it("keeps cache entries isolated per organization", async () => { + vi.mocked(normalizeBacklinksTarget).mockReturnValue({ + apiTarget: "example.com", + displayTarget: "example.com", + scope: "domain", + }); + backlinksSummaryMock.mockResolvedValue({ + rank: 42, + backlinks: 1200, + referring_pages: 900, + referring_domains: 320, + broken_backlinks: 12, + broken_pages: 3, + backlinks_spam_score: 5, + info: { target_spam_score: 4 }, + new_backlinks: 25, + lost_backlinks: 10, + new_referring_domains: 8, + lost_referring_domains: 2, + }); + backlinksRowsMock.mockResolvedValue([]); + timeseriesSummaryMock.mockResolvedValue([]); + newLostTimeseriesMock.mockResolvedValue([]); + + const input = { + target: "example.com", + includeSubdomains: true, + includeIndirectLinks: true, + excludeInternalBacklinks: true, + status: "live" as const, }; -} + + await service.profileOverview(input, billingCustomer); + await service.profileOverview(input, { + organizationId: "org_456", + userEmail: "other@example.com", + }); + + expect(backlinksSummaryMock).toHaveBeenCalledTimes(2); +}); function parseCachedValue(raw: string): unknown { try { diff --git a/src/server/features/backlinks/services/BacklinksService.ts b/src/server/features/backlinks/services/BacklinksService.ts index 8cc7eca..5d9cc78 100644 --- a/src/server/features/backlinks/services/BacklinksService.ts +++ b/src/server/features/backlinks/services/BacklinksService.ts @@ -6,7 +6,7 @@ import { profileTopPagesRows, type BacklinksCache, } from "@/server/features/backlinks/services/backlinksServiceData"; -import type { BacklinksOverviewResult } from "@/server/features/backlinks/services/backlinksOverviewSchema"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; const defaultCache: BacklinksCache = { @@ -16,61 +16,51 @@ const defaultCache: BacklinksCache = { function createBacklinksService(cache: BacklinksCache = defaultCache) { return { - async getOverview( + async profileOverview( input: BacklinksLookupInput, - ): Promise { - const profile = await profileBacklinksOverview( - cache, - buildOverviewCacheKey(input), - input, - ); - return profile.overview; - }, - async profileOverview(input: BacklinksLookupInput) { + billingCustomer: BillingCustomerContext, + ) { return profileBacklinksOverview( cache, - buildOverviewCacheKey(input), + buildOverviewCacheKey(input, billingCustomer), input, + billingCustomer, ); }, - async getReferringDomains(input: BacklinksLookupInput) { - const profile = await profileReferringDomainsRows( - cache, - buildTabCacheKey("backlinks:referring-domains", input), - input, - ); - return profile.rows; - }, - async profileReferringDomains(input: BacklinksLookupInput) { + async profileReferringDomains( + input: BacklinksLookupInput, + billingCustomer: BillingCustomerContext, + ) { return profileReferringDomainsRows( cache, - buildTabCacheKey("backlinks:referring-domains", input), + buildTabCacheKey("backlinks:referring-domains", input, billingCustomer), input, + billingCustomer, ); }, - async getTopPages(input: BacklinksLookupInput) { - const profile = await profileTopPagesRows( - cache, - buildTabCacheKey("backlinks:top-pages", input), - input, - ); - return profile.rows; - }, - async profileTopPages(input: BacklinksLookupInput) { + async profileTopPages( + input: BacklinksLookupInput, + billingCustomer: BillingCustomerContext, + ) { return profileTopPagesRows( cache, - buildTabCacheKey("backlinks:top-pages", input), + buildTabCacheKey("backlinks:top-pages", input, billingCustomer), input, + billingCustomer, ); }, } as const; } -function buildOverviewCacheKey(input: BacklinksLookupInput) { +function buildOverviewCacheKey( + input: BacklinksLookupInput, + billingCustomer: BillingCustomerContext, +) { const normalizedTarget = normalizeBacklinksTarget(input.target, { scope: input.scope, }); return buildCacheKey("backlinks:overview", { + organizationId: billingCustomer.organizationId, target: normalizedTarget.apiTarget, scope: normalizedTarget.scope, includeSubdomains: input.includeSubdomains, @@ -80,11 +70,16 @@ function buildOverviewCacheKey(input: BacklinksLookupInput) { }); } -function buildTabCacheKey(prefix: string, input: BacklinksLookupInput) { +function buildTabCacheKey( + prefix: string, + input: BacklinksLookupInput, + billingCustomer: BillingCustomerContext, +) { const normalizedTarget = normalizeBacklinksTarget(input.target, { scope: input.scope, }); return buildCacheKey(prefix, { + organizationId: billingCustomer.organizationId, target: normalizedTarget.apiTarget, scope: normalizedTarget.scope, includeSubdomains: input.includeSubdomains, diff --git a/src/server/features/backlinks/services/backlinksCost.ts b/src/server/features/backlinks/services/backlinksCost.ts deleted file mode 100644 index 670b702..0000000 --- a/src/server/features/backlinks/services/backlinksCost.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from "zod"; - -export type BacklinksApiCallCost = { - endpoint: string; - path: string[]; - costUsd: number; - resultCount: number | null; - rowsReturned: number; -}; - -export type BacklinksCostSummary = { - provider: "dataforseo"; - currency: "USD"; - fromCache: boolean; - totalCostUsd: number; - calls: BacklinksApiCallCost[]; -}; - -export const backlinksCostSummarySchema = z.object({ - provider: z.literal("dataforseo"), - currency: z.literal("USD"), - fromCache: z.boolean(), - totalCostUsd: z.number(), - calls: z.array( - z.object({ - endpoint: z.string(), - path: z.array(z.string()), - costUsd: z.number(), - resultCount: z.number().nullable(), - rowsReturned: z.number(), - }), - ), -}); - -export function summarizeBacklinksCosts( - calls: BacklinksApiCallCost[], - fromCache: boolean, -): BacklinksCostSummary { - return { - provider: "dataforseo", - currency: "USD", - fromCache, - totalCostUsd: roundUsd(calls.reduce((sum, call) => sum + call.costUsd, 0)), - calls, - }; -} - -function roundUsd(value: number) { - return Math.round(value * 100000) / 100000; -} diff --git a/src/server/features/backlinks/services/backlinksServiceData.ts b/src/server/features/backlinks/services/backlinksServiceData.ts index 775291d..2a206ba 100644 --- a/src/server/features/backlinks/services/backlinksServiceData.ts +++ b/src/server/features/backlinks/services/backlinksServiceData.ts @@ -1,27 +1,22 @@ import { z } from "zod"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; import { - type BacklinksApiResponse, type BacklinksRequest, - fetchBacklinksRowsRaw, - fetchBacklinksSummaryRaw, - fetchDomainPagesSummaryRaw, - fetchNewLostTimeseriesRaw, - fetchReferringDomainsRaw, - fetchTimeseriesSummaryRaw, + type fetchBacklinksRowsRaw, + type fetchBacklinksSummaryRaw, + type fetchDomainPagesSummaryRaw, + type fetchNewLostTimeseriesRaw, + type fetchReferringDomainsRaw, + type fetchTimeseriesSummaryRaw, normalizeBacklinksTarget, } from "@/server/lib/dataforseoBacklinks"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; import { backlinksOverviewSchema, referringDomainRowSchema, topPageRowSchema, type BacklinksOverviewResult, } from "@/server/features/backlinks/services/backlinksOverviewSchema"; -import { - backlinksCostSummarySchema, - summarizeBacklinksCosts, - type BacklinksApiCallCost, - type BacklinksCostSummary, -} from "@/server/features/backlinks/services/backlinksCost"; import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; const BACKLINKS_OVERVIEW_TTL_SECONDS = 6 * 60 * 60; @@ -34,33 +29,25 @@ export type BacklinksCache = { type BacklinksOverviewProfile = { overview: BacklinksOverviewResult; - billing: BacklinksCostSummary; }; type ReferringDomainsProfile = { rows: BacklinksOverviewResult["referringDomains"]; - billing: BacklinksCostSummary; }; type TopPagesProfile = { rows: BacklinksOverviewResult["topPages"]; - billing: BacklinksCostSummary; }; const backlinksOverviewCacheSchema = z.object({ overview: backlinksOverviewSchema, - billing: backlinksCostSummarySchema, }); const referringDomainsCacheSchema = z.object({ rows: z.array(referringDomainRowSchema), - billing: backlinksCostSummarySchema, }); -const topPagesCacheSchema = z.object({ - rows: z.array(topPageRowSchema), - billing: backlinksCostSummarySchema, -}); +const topPagesCacheSchema = z.object({ rows: z.array(topPageRowSchema) }); type BacklinksDateRange = { dateFrom: string; @@ -71,16 +58,18 @@ export async function profileBacklinksOverview( cache: BacklinksCache, cacheKey: string, input: BacklinksLookupInput, + billingCustomer: BillingCustomerContext, ): Promise { const cachedRaw = await cache.get(cacheKey); const cached = backlinksOverviewCacheSchema.safeParse(cachedRaw); if (cached.success) { return { overview: cached.data.overview, - billing: withCacheFlag(cached.data.billing), }; } + const dataforseo = createDataforseoClient(billingCustomer); + const now = new Date(); const normalizedTarget = normalizeBacklinksTarget(input.target, { scope: input.scope, @@ -89,14 +78,14 @@ export async function profileBacklinksOverview( const dateRange = buildBacklinksDateRange(now); const [summary, backlinks, trends, newLostTrends] = await Promise.all([ - fetchBacklinksSummaryRaw(request), - fetchBacklinksRowsRaw({ ...request, limit: 100 }), + dataforseo.backlinks.summary(request), + dataforseo.backlinks.rows({ ...request, limit: 100 }), normalizedTarget.scope === "domain" - ? fetchTimeseriesSummaryRaw({ ...request, ...dateRange }) - : Promise.resolve(emptyResponse([])), + ? dataforseo.backlinks.timeseriesSummary({ ...request, ...dateRange }) + : Promise.resolve([]), normalizedTarget.scope === "domain" - ? fetchNewLostTimeseriesRaw({ ...request, ...dateRange }) - : Promise.resolve(emptyResponse([])), + ? dataforseo.backlinks.newLostTimeseries({ ...request, ...dateRange }) + : Promise.resolve([]), ]); const overview = buildOverviewResult({ @@ -108,88 +97,76 @@ export async function profileBacklinksOverview( trends, newLostTrends, }); - const billing = summarizeBacklinksCosts( - collectCostCalls([ - summary.billing, - backlinks.billing, - trends.billing, - newLostTrends.billing, - ]), - false, - ); - await cacheValue( cache, cacheKey, - { overview, billing }, + { overview }, BACKLINKS_OVERVIEW_TTL_SECONDS, ); - return { overview, billing }; + return { overview }; } export async function profileReferringDomainsRows( cache: BacklinksCache, cacheKey: string, input: BacklinksLookupInput, + billingCustomer: BillingCustomerContext, ): Promise { const cachedRaw = await cache.get(cacheKey); const cached = referringDomainsCacheSchema.safeParse(cachedRaw); if (cached.success) { return { rows: cached.data.rows, - billing: withCacheFlag(cached.data.billing), }; } + const dataforseo = createDataforseoClient(billingCustomer); + const request = buildBacklinksRequest( input, normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget, ); - const response = await fetchReferringDomainsRaw({ ...request, limit: 100 }); - const rows = mapReferringDomainsRows(response.data); - const billing = summarizeBacklinksCosts([response.billing], false); + const response = await dataforseo.backlinks.referringDomains({ + ...request, + limit: 100, + }); + const rows = mapReferringDomainsRows(response); - await cacheValue( - cache, - cacheKey, - { rows, billing }, - BACKLINKS_TAB_TTL_SECONDS, - ); + await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS); - return { rows, billing }; + return { rows }; } export async function profileTopPagesRows( cache: BacklinksCache, cacheKey: string, input: BacklinksLookupInput, + billingCustomer: BillingCustomerContext, ): Promise { const cachedRaw = await cache.get(cacheKey); const cached = topPagesCacheSchema.safeParse(cachedRaw); if (cached.success) { return { rows: cached.data.rows, - billing: withCacheFlag(cached.data.billing), }; } + const dataforseo = createDataforseoClient(billingCustomer); + const request = buildBacklinksRequest( input, normalizeBacklinksTarget(input.target, { scope: input.scope }).apiTarget, ); - const response = await fetchDomainPagesSummaryRaw({ ...request, limit: 100 }); - const rows = mapTopPagesRows(response.data); - const billing = summarizeBacklinksCosts([response.billing], false); + const response = await dataforseo.backlinks.domainPages({ + ...request, + limit: 100, + }); + const rows = mapTopPagesRows(response); - await cacheValue( - cache, - cacheKey, - { rows, billing }, - BACKLINKS_TAB_TTL_SECONDS, - ); + await cacheValue(cache, cacheKey, { rows }, BACKLINKS_TAB_TTL_SECONDS); - return { rows, billing }; + return { rows }; } function buildBacklinksRequest( @@ -225,10 +202,10 @@ function buildOverviewResult(args: { input: BacklinksLookupInput; normalizedTarget: ReturnType; now: Date; - summary: Awaited>; - backlinks: Awaited>; - trends: Awaited>; - newLostTrends: Awaited>; + summary: Awaited>["data"]; + backlinks: Awaited>["data"]; + trends: Awaited>["data"]; + newLostTrends: Awaited>["data"]; }): BacklinksOverviewResult { return { target: args.normalizedTarget.apiTarget, @@ -239,29 +216,29 @@ function buildOverviewResult(args: { excludeInternalBacklinks: args.input.excludeInternalBacklinks, status: args.input.status, summary: { - rank: args.summary.data.rank ?? null, - backlinks: args.summary.data.backlinks ?? null, - referringPages: args.summary.data.referring_pages ?? null, - referringDomains: args.summary.data.referring_domains ?? null, - brokenBacklinks: args.summary.data.broken_backlinks ?? null, - brokenPages: args.summary.data.broken_pages ?? null, - backlinksSpamScore: args.summary.data.backlinks_spam_score ?? null, - targetSpamScore: args.summary.data.info?.target_spam_score ?? null, - newBacklinks: args.summary.data.new_backlinks ?? null, - lostBacklinks: args.summary.data.lost_backlinks ?? null, + rank: args.summary.rank ?? null, + backlinks: args.summary.backlinks ?? null, + referringPages: args.summary.referring_pages ?? null, + referringDomains: args.summary.referring_domains ?? null, + brokenBacklinks: args.summary.broken_backlinks ?? null, + brokenPages: args.summary.broken_pages ?? null, + backlinksSpamScore: args.summary.backlinks_spam_score ?? null, + targetSpamScore: args.summary.info?.target_spam_score ?? null, + newBacklinks: args.summary.new_backlinks ?? null, + lostBacklinks: args.summary.lost_backlinks ?? null, newReferringDomains: - args.summary.data.new_referring_domains ?? - args.summary.data.new_reffering_domains ?? + args.summary.new_referring_domains ?? + args.summary.new_reffering_domains ?? null, lostReferringDomains: - args.summary.data.lost_referring_domains ?? - args.summary.data.lost_reffering_domains ?? + args.summary.lost_referring_domains ?? + args.summary.lost_reffering_domains ?? null, }, - backlinks: mapBacklinksRows(args.backlinks.data), + backlinks: mapBacklinksRows(args.backlinks), referringDomains: [], topPages: [], - trends: args.trends.data + trends: args.trends .filter((item) => Boolean(item.date)) .map((item) => ({ date: item.date ?? "", @@ -269,7 +246,7 @@ function buildOverviewResult(args: { referringDomains: item.referring_domains ?? null, rank: item.rank ?? null, })), - newLostTrends: args.newLostTrends.data + newLostTrends: args.newLostTrends .filter((item) => Boolean(item.date)) .map((item) => ({ date: item.date ?? "", @@ -334,14 +311,6 @@ function mapTopPagesRows( })); } -function collectCostCalls(calls: BacklinksApiCallCost[]) { - return calls.filter((call) => call.costUsd > 0 || call.rowsReturned > 0); -} - -function withCacheFlag(summary: BacklinksCostSummary): BacklinksCostSummary { - return { ...summary, fromCache: true }; -} - async function cacheValue( cache: BacklinksCache, key: string, @@ -352,16 +321,3 @@ async function cacheValue( console.error("backlinks.cache-write failed:", error); }); } - -function emptyResponse(data: T): BacklinksApiResponse { - return { - data, - billing: { - endpoint: "", - path: [], - costUsd: 0, - resultCount: null, - rowsReturned: 0, - }, - }; -} diff --git a/src/server/features/domain/services/DomainService.ts b/src/server/features/domain/services/DomainService.ts index 4ec1911..b4e8747 100644 --- a/src/server/features/domain/services/DomainService.ts +++ b/src/server/features/domain/services/DomainService.ts @@ -1,13 +1,13 @@ import { normalizeDomainInput, toRelativePath, - fetchDomainRankOverviewRaw, - fetchRankedKeywordsRaw, type DomainRankedKeywordItem, } from "@/server/lib/dataforseo"; import { sortBy } from "remeda"; import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache"; import { z } from "zod"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; /** Domain overview data is refreshed every 12 hours. */ const DOMAIN_OVERVIEW_TTL_SECONDS = 12 * 60 * 60; @@ -70,16 +70,20 @@ const domainOverviewSchema = z.object({ fetchedAt: z.string(), }); -async function getOverview(input: { - domain: string; - includeSubdomains: boolean; - locationCode: number; - languageCode: string; -}): Promise { +async function getOverview( + input: { + domain: string; + includeSubdomains: boolean; + locationCode: number; + languageCode: string; + }, + billingCustomer: BillingCustomerContext, +): Promise { const domain = normalizeDomainInput(input.domain, input.includeSubdomains); // --- KV cache check --- const cacheKey = buildCacheKey("domain:overview", { + organizationId: billingCustomer.organizationId, domain, includeSubdomains: input.includeSubdomains, locationCode: input.locationCode, @@ -94,16 +98,21 @@ async function getOverview(input: { // --- Fetch fresh from DataForSEO --- const nowIso = new Date().toISOString(); + const dataforseo = createDataforseoClient(billingCustomer); const [metricsResponse, rankedKeywordsResponse] = await Promise.all([ - fetchDomainRankOverviewRaw(domain, input.locationCode, input.languageCode), - fetchRankedKeywordsRaw( - domain, - input.locationCode, - input.languageCode, - 200, - ["keyword_data.keyword_info.search_volume,desc"], - ), + dataforseo.domain.rankOverview({ + target: domain, + locationCode: input.locationCode, + languageCode: input.languageCode, + }), + dataforseo.domain.rankedKeywords({ + target: domain, + locationCode: input.locationCode, + languageCode: input.languageCode, + limit: 200, + orderBy: ["keyword_data.keyword_info.search_volume,desc"], + }), ]); const metrics = metricsResponse[0]; diff --git a/src/server/features/keywords/services/research/research-data.ts b/src/server/features/keywords/services/research/research-data.ts index 8168e9f..a0ae509 100644 --- a/src/server/features/keywords/services/research/research-data.ts +++ b/src/server/features/keywords/services/research/research-data.ts @@ -1,9 +1,6 @@ -import { - fetchKeywordIdeasRaw, - fetchKeywordSuggestionsRaw, - fetchRelatedKeywordsRaw, - type LabsKeywordDataItem, -} from "@/server/lib/dataforseo"; +import { type LabsKeywordDataItem } from "@/server/lib/dataforseoClient"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; import { normalizeIntent, normalizeKeyword, @@ -56,14 +53,15 @@ function mapKeywordDataItems(items: LabsKeywordDataItem[]): EnrichedKeyword[] { async function fetchRelatedRows( params: Omit, + dataforseo: ReturnType, ) { - const items = await fetchRelatedKeywordsRaw( - params.seedKeyword, - params.locationCode, - params.languageCode, - params.resultLimit, - 3, - ); + const items = await dataforseo.keywords.related({ + keyword: params.seedKeyword, + locationCode: params.locationCode, + languageCode: params.languageCode, + limit: params.resultLimit, + depth: 3, + }); const rows: EnrichedKeyword[] = []; const seen = new Set(); @@ -103,28 +101,31 @@ async function fetchRelatedRows( export async function fetchResearchRowsBySource( params: FetchResearchRowsParams, + billingCustomer: BillingCustomerContext, ): Promise { + const dataforseo = createDataforseoClient(billingCustomer); + if (params.source === "related") { - return fetchRelatedRows(params); + return fetchRelatedRows(params, dataforseo); } if (params.source === "suggestions") { return mapKeywordDataItems( - await fetchKeywordSuggestionsRaw( - params.seedKeyword, - params.locationCode, - params.languageCode, - params.resultLimit, - ), + await dataforseo.keywords.suggestions({ + keyword: params.seedKeyword, + locationCode: params.locationCode, + languageCode: params.languageCode, + limit: params.resultLimit, + }), ); } return mapKeywordDataItems( - await fetchKeywordIdeasRaw( - params.seedKeyword, - params.locationCode, - params.languageCode, - params.resultLimit, - ), + await dataforseo.keywords.ideas({ + keyword: params.seedKeyword, + locationCode: params.locationCode, + languageCode: params.languageCode, + limit: params.resultLimit, + }), ); } diff --git a/src/server/features/keywords/services/research/research.ts b/src/server/features/keywords/services/research/research.ts index b5b8a5d..e4d6a67 100644 --- a/src/server/features/keywords/services/research/research.ts +++ b/src/server/features/keywords/services/research/research.ts @@ -1,4 +1,5 @@ import { AppError } from "@/server/lib/errors"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; import { CACHE_TTL, buildCacheKey, @@ -90,19 +91,24 @@ async function fetchRowsFromSource( source: KeywordSource, input: ResearchKeywordsInput, seedKeyword: string, + billingCustomer: BillingCustomerContext, ): Promise { - return fetchResearchRowsBySource({ - source, - seedKeyword, - locationCode: input.locationCode, - languageCode: input.languageCode, - resultLimit: input.resultLimit, - }); + return fetchResearchRowsBySource( + { + source, + seedKeyword, + locationCode: input.locationCode, + languageCode: input.languageCode, + resultLimit: input.resultLimit, + }, + billingCustomer, + ); } async function fetchAutoRows( input: ResearchKeywordsInput, seedKeyword: string, + billingCustomer: BillingCustomerContext, ): Promise { const attempts: SourceAttempt[] = []; let lastSource: KeywordSource = "related"; @@ -110,7 +116,12 @@ async function fetchAutoRows( const seenKeywords = new Set(); for (const source of AUTO_KEYWORD_SOURCES) { - const rows = await fetchRowsFromSource(source, input, seedKeyword); + const rows = await fetchRowsFromSource( + source, + input, + seedKeyword, + billingCustomer, + ); for (const row of rows) { if (accumulatedRows.length >= input.resultLimit) break; if (seenKeywords.has(row.keyword)) continue; @@ -158,8 +169,14 @@ async function fetchManualRows( mode: Exclude, input: ResearchKeywordsInput, seedKeyword: string, + billingCustomer: BillingCustomerContext, ): Promise { - const rows = await fetchRowsFromSource(mode, input, seedKeyword); + const rows = await fetchRowsFromSource( + mode, + input, + seedKeyword, + billingCustomer, + ); const attempt: SourceAttempt = { source: mode, rowCount: rows.length, @@ -188,9 +205,11 @@ function buildResearchCacheKey( input: ResearchKeywordsInput, normalizedKeywords: string[], mode: KeywordMode, + billingCustomer: BillingCustomerContext, ): string { return buildCacheKey("kw:research", { cacheVersion: CACHE_VERSION, + organizationId: billingCustomer.organizationId, projectId: input.projectId, keywords: normalizedKeywords, locationCode: input.locationCode, @@ -224,6 +243,7 @@ function persistRows(input: ResearchKeywordsInput, rows: EnrichedKeyword[]) { export async function research( input: ResearchKeywordsInput, + billingCustomer: BillingCustomerContext, ): Promise { const uniqueKeywords = [ ...new Set(input.keywords.map(normalizeKeyword)), @@ -235,7 +255,12 @@ export async function research( const seedKeyword = uniqueKeywords[0]; const mode = getMode(input); - const cacheKey = buildResearchCacheKey(input, uniqueKeywords, mode); + const cacheKey = buildResearchCacheKey( + input, + uniqueKeywords, + mode, + billingCustomer, + ); const cachedRaw = await getCached(cacheKey); const cachedResult = cachedResultSchema.safeParse(cachedRaw); @@ -249,8 +274,8 @@ export async function research( const result = mode === "auto" - ? await fetchAutoRows(input, seedKeyword) - : await fetchManualRows(mode, input, seedKeyword); + ? await fetchAutoRows(input, seedKeyword, billingCustomer) + : await fetchManualRows(mode, input, seedKeyword, billingCustomer); await setCached(cacheKey, result, CACHE_TTL.researchResult); persistRows(input, result.rows); diff --git a/src/server/features/keywords/services/research/serp.ts b/src/server/features/keywords/services/research/serp.ts index 4df950f..fad4a0a 100644 --- a/src/server/features/keywords/services/research/serp.ts +++ b/src/server/features/keywords/services/research/serp.ts @@ -1,10 +1,9 @@ -import { - fetchLiveSerpItemsRaw, - type SerpLiveItem, -} from "@/server/lib/dataforseo"; +import { type SerpLiveItem } from "@/server/lib/dataforseoClient"; import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache"; import type { SerpResultItem } from "@/types/keywords"; import { z } from "zod"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; import { normalizeKeyword } from "./helpers"; const SERP_CACHE_TTL_SECONDS = 12 * 60 * 60; @@ -55,14 +54,18 @@ function mapOrganicSerpItems(items: SerpLiveItem[]): SerpResultItem[] { })); } -async function getSerpLiveAnalysis(input: { - keyword: string; - locationCode: number; - languageCode: string; -}): Promise { +async function getSerpLiveAnalysis( + input: { + keyword: string; + locationCode: number; + languageCode: string; + }, + billingCustomer: BillingCustomerContext, +): Promise { const keyword = normalizeKeyword(input.keyword); const cacheKey = buildCacheKey("serp:analysis", { + organizationId: billingCustomer.organizationId, keyword, locationCode: input.locationCode, languageCode: input.languageCode, @@ -74,11 +77,11 @@ async function getSerpLiveAnalysis(input: { return cached.data; } - const liveItems = await fetchLiveSerpItemsRaw( + const liveItems = await createDataforseoClient(billingCustomer).serp.live({ keyword, - input.locationCode, - input.languageCode, - ); + locationCode: input.locationCode, + languageCode: input.languageCode, + }); const items = mapOrganicSerpItems(liveItems); const result: SerpAnalysisResult = { requestedKeyword: keyword, items }; diff --git a/src/server/lib/dataforseo.ts b/src/server/lib/dataforseo.ts index 0c3e38b..de07bb1 100644 --- a/src/server/lib/dataforseo.ts +++ b/src/server/lib/dataforseo.ts @@ -8,6 +8,7 @@ import { } from "dataforseo-client"; import { env } from "cloudflare:workers"; import { getDomain } from "tldts"; +import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost"; import { AppError } from "@/server/lib/errors"; import { dataforseoResponseSchema, @@ -23,6 +24,7 @@ import { type LabsKeywordDataItem, type RelatedKeywordItem, type SerpLiveItem, + successfulDataforseoTaskSchema, } from "@/server/lib/dataforseoSchemas"; export type { DomainRankedKeywordItem, @@ -84,13 +86,22 @@ async function postDataforseo( * Validate that the top-level response and first task both succeeded. * Throws a descriptive error on failure. Returns the first task. */ -function assertOk( +type DataforseoTaskLike = { + status_code?: number; + status_message?: string; + path?: string[]; + cost?: number; + result_count?: number | null; + result?: DataforseoTask["result"]; +}; + +function assertOk( response: { status_code?: number; status_message?: string; tasks?: T[]; } | null, -): T { +): DataforseoTask { if (!response) { throw new AppError( "INTERNAL_ERROR", @@ -113,7 +124,24 @@ function assertOk( task.status_message || "DataForSEO task failed", ); } - return task; + + const parsedTask = successfulDataforseoTaskSchema.safeParse(task); + if (!parsedTask.success) { + throw new AppError( + "INTERNAL_ERROR", + "DataForSEO task missing billing metadata", + ); + } + + return parsedTask.data; +} + +function buildTaskBilling(task: DataforseoTask) { + return { + path: task.path, + costUsd: task.cost, + resultCount: task.result_count, + }; } // --------------------------------------------------------------------------- @@ -126,7 +154,7 @@ export async function fetchRelatedKeywordsRaw( languageCode: string, limit: number, depth: number = 3, -): Promise { +): Promise> { const api = getLabsApi(); const req = new DataforseoLabsGoogleRelatedKeywordsLiveRequestInfo({ keyword, @@ -138,13 +166,15 @@ export async function fetchRelatedKeywordsRaw( include_serp_info: false, }); + const endpoint = "google-related-keywords-live"; const response = await api.googleRelatedKeywordsLive([req]); - const task = assertOk(response); - return parseTaskItems( - "google-related-keywords-live", - task, - relatedKeywordItemSchema, - ); + const task = assertOk(response); + const data = parseTaskItems(endpoint, task, relatedKeywordItemSchema); + + return { + data, + billing: buildTaskBilling(task), + }; } export async function fetchKeywordSuggestionsRaw( @@ -152,7 +182,7 @@ export async function fetchKeywordSuggestionsRaw( locationCode: number, languageCode: string, limit: number, -): Promise { +): Promise> { const api = getLabsApi(); const req = new DataforseoLabsGoogleKeywordSuggestionsLiveRequestInfo({ keyword, @@ -166,13 +196,15 @@ export async function fetchKeywordSuggestionsRaw( exact_match: false, }); + const endpoint = "google-keyword-suggestions-live"; const response = await api.googleKeywordSuggestionsLive([req]); - const task = assertOk(response); - return parseTaskItems( - "google-keyword-suggestions-live", - task, - labsKeywordDataItemSchema, - ); + const task = assertOk(response); + const data = parseTaskItems(endpoint, task, labsKeywordDataItemSchema); + + return { + data, + billing: buildTaskBilling(task), + }; } export async function fetchKeywordIdeasRaw( @@ -180,7 +212,7 @@ export async function fetchKeywordIdeasRaw( locationCode: number, languageCode: string, limit: number, -): Promise { +): Promise> { const api = getLabsApi(); const req = new DataforseoLabsGoogleKeywordIdeasLiveRequestInfo({ keywords: [keyword], @@ -193,13 +225,15 @@ export async function fetchKeywordIdeasRaw( closely_variants: false, }); + const endpoint = "google-keyword-ideas-live"; const response = await api.googleKeywordIdeasLive([req]); - const task = assertOk(response); - return parseTaskItems( - "google-keyword-ideas-live", - task, - labsKeywordDataItemSchema, - ); + const task = assertOk(response); + const data = parseTaskItems(endpoint, task, labsKeywordDataItemSchema); + + return { + data, + billing: buildTaskBilling(task), + }; } // --------------------------------------------------------------------------- @@ -210,7 +244,7 @@ export async function fetchDomainRankOverviewRaw( target: string, locationCode: number, languageCode: string, -): Promise { +): Promise> { const api = getLabsApi(); const req = new DataforseoLabsGoogleDomainRankOverviewLiveRequestInfo({ target, @@ -219,13 +253,15 @@ export async function fetchDomainRankOverviewRaw( limit: 1, }); + const endpoint = "google-domain-rank-overview-live"; const response = await api.googleDomainRankOverviewLive([req]); - const task = assertOk(response); - return parseTaskItems( - "google-domain-rank-overview-live", - task, - domainMetricsItemSchema, - ); + const task = assertOk(response); + const data = parseTaskItems(endpoint, task, domainMetricsItemSchema); + + return { + data, + billing: buildTaskBilling(task), + }; } export async function fetchRankedKeywordsRaw( @@ -234,7 +270,7 @@ export async function fetchRankedKeywordsRaw( languageCode: string, limit: number, orderBy?: string[], -): Promise { +): Promise> { const api = getLabsApi(); const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({ target, @@ -244,13 +280,15 @@ export async function fetchRankedKeywordsRaw( order_by: orderBy, }); + const endpoint = "google-ranked-keywords-live"; const response = await api.googleRankedKeywordsLive([req]); - const task = assertOk(response); - return parseTaskItems( - "google-ranked-keywords-live", - task, - domainRankedKeywordItemSchema, - ); + const task = assertOk(response); + const data = parseTaskItems(endpoint, task, domainRankedKeywordItemSchema); + + return { + data, + billing: buildTaskBilling(task), + }; } // --------------------------------------------------------------------------- @@ -261,7 +299,7 @@ export async function fetchLiveSerpItemsRaw( keyword: string, locationCode: number, languageCode: string, -): Promise { +): Promise> { const responseRaw = await postDataforseo( "/v3/serp/google/organic/live/advanced", [ @@ -276,12 +314,14 @@ export async function fetchLiveSerpItemsRaw( ], ); const response = dataforseoResponseSchema.parse(responseRaw); - const task = assertOk(response); - return parseTaskItems( - "google-organic-live-advanced", - task, - serpSnapshotItemSchema, - ); + const endpoint = "google-organic-live-advanced"; + const task = assertOk(response); + const data = parseTaskItems(endpoint, task, serpSnapshotItemSchema); + + return { + data, + billing: buildTaskBilling(task), + }; } // --------------------------------------------------------------------------- diff --git a/src/server/lib/dataforseoBacklinks.ts b/src/server/lib/dataforseoBacklinks.ts index c8156b2..6696b40 100644 --- a/src/server/lib/dataforseoBacklinks.ts +++ b/src/server/lib/dataforseoBacklinks.ts @@ -1,5 +1,8 @@ import { AppError } from "@/server/lib/errors"; -import type { BacklinksApiCallCost } from "@/server/features/backlinks/services/backlinksCost"; +import type { + DataforseoApiCallCost, + DataforseoApiResponse, +} from "@/server/lib/dataforseoCost"; import { getRequiredEnvValue } from "@/server/lib/runtime-env"; import type { BacklinksLookupInput } from "@/types/schemas/backlinks"; import { @@ -22,23 +25,18 @@ export type BacklinksRequest = BacklinksLookupInput & { target: string; }; -type BacklinksListRequest = BacklinksRequest & { +export type BacklinksListRequest = BacklinksRequest & { limit?: number; }; -type BacklinksTimeseriesRequest = BacklinksRequest & { +export type BacklinksTimeseriesRequest = BacklinksRequest & { dateFrom: string; dateTo: string; }; type DataforseoTaskResponse = { results: unknown[]; - billing: Omit; -}; - -export type BacklinksApiResponse = { - data: T; - billing: BacklinksApiCallCost; + billing: DataforseoApiCallCost; }; async function createAuthenticatedFetch() { @@ -152,7 +150,6 @@ async function postBacklinks(path: string, payload: unknown) { return { results: task.result ?? [], billing: { - endpoint: path, path: task.path ?? [], costUsd: task.cost ?? responseData.cost ?? 0, resultCount: task.result_count ?? null, @@ -182,11 +179,8 @@ export async function fetchBacklinksSummaryRaw(input: BacklinksRequest) { ); return { data, - billing: { - ...response.billing, - rowsReturned: data ? 1 : 0, - }, - } satisfies BacklinksApiResponse; + billing: response.billing, + } satisfies DataforseoApiResponse; } export async function fetchBacklinksRowsRaw(input: BacklinksListRequest) { @@ -204,11 +198,8 @@ export async function fetchBacklinksRowsRaw(input: BacklinksListRequest) { ); return { data, - billing: { - ...response.billing, - rowsReturned: data.length, - }, - } satisfies BacklinksApiResponse; + billing: response.billing, + } satisfies DataforseoApiResponse; } export async function fetchReferringDomainsRaw(input: BacklinksListRequest) { @@ -226,11 +217,8 @@ export async function fetchReferringDomainsRaw(input: BacklinksListRequest) { ); return { data, - billing: { - ...response.billing, - rowsReturned: data.length, - }, - } satisfies BacklinksApiResponse; + billing: response.billing, + } satisfies DataforseoApiResponse; } export async function fetchDomainPagesSummaryRaw(input: BacklinksListRequest) { @@ -251,11 +239,8 @@ export async function fetchDomainPagesSummaryRaw(input: BacklinksListRequest) { ); return { data, - billing: { - ...response.billing, - rowsReturned: data.length, - }, - } satisfies BacklinksApiResponse; + billing: response.billing, + } satisfies DataforseoApiResponse; } export async function fetchTimeseriesSummaryRaw( @@ -279,11 +264,8 @@ export async function fetchTimeseriesSummaryRaw( ); return { data, - billing: { - ...response.billing, - rowsReturned: data.length, - }, - } satisfies BacklinksApiResponse; + billing: response.billing, + } satisfies DataforseoApiResponse; } export async function fetchNewLostTimeseriesRaw( @@ -307,9 +289,6 @@ export async function fetchNewLostTimeseriesRaw( ); return { data, - billing: { - ...response.billing, - rowsReturned: data.length, - }, - } satisfies BacklinksApiResponse; + billing: response.billing, + } satisfies DataforseoApiResponse; } diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts new file mode 100644 index 0000000..f6ef037 --- /dev/null +++ b/src/server/lib/dataforseoClient.ts @@ -0,0 +1,243 @@ +import { MINIMUM_SEO_DATA_BALANCE_USD } from "@/shared/billing"; +import { + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_CREDITS_PER_USD, + AUTUMN_SEO_DATA_USAGE_FEATURE_ID, + roundUsdForBilling, +} from "@/shared/billing"; +import { autumn } from "@/server/billing/autumn"; +import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { + fetchKeywordIdeasRaw, + fetchKeywordSuggestionsRaw, + fetchRelatedKeywordsRaw, + fetchDomainRankOverviewRaw, + fetchRankedKeywordsRaw, + fetchLiveSerpItemsRaw, + type LabsKeywordDataItem, + type SerpLiveItem, +} from "@/server/lib/dataforseo"; +import { + fetchBacklinksRowsRaw, + fetchBacklinksSummaryRaw, + fetchDomainPagesSummaryRaw, + fetchNewLostTimeseriesRaw, + fetchReferringDomainsRaw, + fetchTimeseriesSummaryRaw, + type BacklinksListRequest, + type BacklinksRequest, + type BacklinksTimeseriesRequest, +} from "@/server/lib/dataforseoBacklinks"; +import { + type DataforseoApiResponse, + type DataforseoApiCallCost, +} from "@/server/lib/dataforseoCost"; +import { AppError } from "@/server/lib/errors"; +import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; + +export function createDataforseoClient(customer: BillingCustomerContext) { + return { + backlinks: { + summary(input: BacklinksRequest) { + return meterDataforseoCall(customer, () => + fetchBacklinksSummaryRaw(input), + ); + }, + rows(input: BacklinksListRequest) { + return meterDataforseoCall(customer, () => + fetchBacklinksRowsRaw(input), + ); + }, + referringDomains(input: BacklinksListRequest) { + return meterDataforseoCall(customer, () => + fetchReferringDomainsRaw(input), + ); + }, + domainPages(input: BacklinksListRequest) { + return meterDataforseoCall(customer, () => + fetchDomainPagesSummaryRaw(input), + ); + }, + timeseriesSummary(input: BacklinksTimeseriesRequest) { + return meterDataforseoCall(customer, () => + fetchTimeseriesSummaryRaw(input), + ); + }, + newLostTimeseries(input: BacklinksTimeseriesRequest) { + return meterDataforseoCall(customer, () => + fetchNewLostTimeseriesRaw(input), + ); + }, + }, + keywords: { + related(input: { + keyword: string; + locationCode: number; + languageCode: string; + limit: number; + depth?: number; + }) { + return meterDataforseoCall(customer, () => + fetchRelatedKeywordsRaw( + input.keyword, + input.locationCode, + input.languageCode, + input.limit, + input.depth, + ), + ); + }, + suggestions(input: { + keyword: string; + locationCode: number; + languageCode: string; + limit: number; + }) { + return meterDataforseoCall(customer, () => + fetchKeywordSuggestionsRaw( + input.keyword, + input.locationCode, + input.languageCode, + input.limit, + ), + ); + }, + ideas(input: { + keyword: string; + locationCode: number; + languageCode: string; + limit: number; + }) { + return meterDataforseoCall(customer, () => + fetchKeywordIdeasRaw( + input.keyword, + input.locationCode, + input.languageCode, + input.limit, + ), + ); + }, + }, + domain: { + rankOverview(input: { + target: string; + locationCode: number; + languageCode: string; + }) { + return meterDataforseoCall(customer, () => + fetchDomainRankOverviewRaw( + input.target, + input.locationCode, + input.languageCode, + ), + ); + }, + rankedKeywords(input: { + target: string; + locationCode: number; + languageCode: string; + limit: number; + orderBy?: string[]; + }) { + return meterDataforseoCall(customer, () => + fetchRankedKeywordsRaw( + input.target, + input.locationCode, + input.languageCode, + input.limit, + input.orderBy, + ), + ); + }, + }, + serp: { + live(input: { + keyword: string; + locationCode: number; + languageCode: string; + }) { + return meterDataforseoCall(customer, () => + fetchLiveSerpItemsRaw( + input.keyword, + input.locationCode, + input.languageCode, + ), + ); + }, + }, + } as const; +} + +async function meterDataforseoCall( + customer: BillingCustomerContext, + execute: () => Promise>, +): Promise { + const isHostedMode = await isHostedServerAuthMode(); + + if (!isHostedMode) { + const result = await execute(); + return result.data; + } + + const billingCustomer = await getOrCreateOrganizationCustomer(customer); + + await assertSeoDataBalanceAvailable({ + customerId: billingCustomer.id, + minimumBalanceUsd: MINIMUM_SEO_DATA_BALANCE_USD, + }); + + const result = await execute(); + + await trackDataforseoCost({ + customerId: billingCustomer.id, + billing: result.billing, + }); + + return result.data; +} + +async function assertSeoDataBalanceAvailable(args: { + customerId: string; + minimumBalanceUsd: number; +}) { + const result = await autumn.check({ + customerId: args.customerId, + featureId: AUTUMN_SEO_DATA_USAGE_FEATURE_ID, + requiredBalance: Math.ceil( + roundUsdForBilling(args.minimumBalanceUsd) * + AUTUMN_SEO_DATA_CREDITS_PER_USD, + ), + }); + + if (!result.allowed) { + throw new AppError("PAYMENT_REQUIRED"); + } +} + +async function trackDataforseoCost(args: { + customerId: string; + billing: DataforseoApiCallCost; +}) { + const totalCostUsd = roundUsdForBilling(args.billing.costUsd); + const totalCostCredits = Math.ceil( + totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD, + ); + + await autumn.track({ + customerId: args.customerId, + featureId: AUTUMN_SEO_DATA_USAGE_FEATURE_ID, + value: totalCostCredits, + properties: { + provider: "dataforseo", + currency: "USD", + balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + paths: [args.billing.path.join("/")], + totalCostUsd, + totalCostCredits, + fromCache: false, + }, + }); +} + +export type { LabsKeywordDataItem, SerpLiveItem }; diff --git a/src/server/lib/dataforseoCost.ts b/src/server/lib/dataforseoCost.ts new file mode 100644 index 0000000..8c5357e --- /dev/null +++ b/src/server/lib/dataforseoCost.ts @@ -0,0 +1,10 @@ +export type DataforseoApiCallCost = { + path: string[]; + costUsd: number; + resultCount: number | null; +}; + +export type DataforseoApiResponse = { + data: T; + billing: DataforseoApiCallCost; +}; diff --git a/src/server/lib/dataforseoSchemas.ts b/src/server/lib/dataforseoSchemas.ts index 70941fa..0023201 100644 --- a/src/server/lib/dataforseoSchemas.ts +++ b/src/server/lib/dataforseoSchemas.ts @@ -1,18 +1,13 @@ import { z } from "zod"; import { AppError } from "@/server/lib/errors"; -type DataforseoTaskResult = { items?: unknown[] }; - -export type DataforseoTask = { - status_code?: number; - status_message?: string; - result?: DataforseoTaskResult[]; -}; - const dataforseoTaskSchema = z .object({ status_code: z.number().optional(), status_message: z.string().optional(), + path: z.array(z.string()), + cost: z.number(), + result_count: z.number().nullable(), result: z .array( z @@ -25,6 +20,9 @@ const dataforseoTaskSchema = z }) .passthrough(); +export type DataforseoTask = z.infer; +export const successfulDataforseoTaskSchema = dataforseoTaskSchema; + export const dataforseoResponseSchema = z .object({ status_code: z.number().optional(), diff --git a/src/serverFunctions/backlinks.ts b/src/serverFunctions/backlinks.ts index accde9c..0bae4fd 100644 --- a/src/serverFunctions/backlinks.ts +++ b/src/serverFunctions/backlinks.ts @@ -13,16 +13,21 @@ export const getBacklinksOverview = createServerFn({ }) .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) - .handler(async ({ data }) => { + .handler(async ({ data, context }) => { try { - return await BacklinksService.getOverview({ + const input = { target: data.target, scope: data.scope, includeSubdomains: data.includeSubdomains, includeIndirectLinks: data.includeIndirectLinks, excludeInternalBacklinks: data.excludeInternalBacklinks, status: data.status, + }; + const profile = await BacklinksService.profileOverview(input, { + organizationId: context.organizationId, + userEmail: context.userEmail, }); + return profile.overview; } catch (error) { if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") { const checkedAt = new Date().toISOString(); @@ -40,16 +45,21 @@ export const getBacklinksReferringDomains = createServerFn({ }) .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) - .handler(async ({ data }) => { + .handler(async ({ data, context }) => { try { - return await BacklinksService.getReferringDomains({ + const input = { target: data.target, scope: data.scope, includeSubdomains: data.includeSubdomains, includeIndirectLinks: data.includeIndirectLinks, excludeInternalBacklinks: data.excludeInternalBacklinks, status: data.status, + }; + const profile = await BacklinksService.profileReferringDomains(input, { + organizationId: context.organizationId, + userEmail: context.userEmail, }); + return profile.rows; } catch (error) { await updateBacklinksAccessStatusOnError(error); throw error; @@ -61,16 +71,21 @@ export const getBacklinksTopPages = createServerFn({ }) .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data)) - .handler(async ({ data }) => { + .handler(async ({ data, context }) => { try { - return await BacklinksService.getTopPages({ + const input = { target: data.target, scope: data.scope, includeSubdomains: data.includeSubdomains, includeIndirectLinks: data.includeIndirectLinks, excludeInternalBacklinks: data.excludeInternalBacklinks, status: data.status, + }; + const profile = await BacklinksService.profileTopPages(input, { + organizationId: context.organizationId, + userEmail: context.userEmail, }); + return profile.rows; } catch (error) { await updateBacklinksAccessStatusOnError(error); throw error; diff --git a/src/serverFunctions/backlinksAccess.ts b/src/serverFunctions/backlinksAccess.ts index b612631..d0e6596 100644 --- a/src/serverFunctions/backlinksAccess.ts +++ b/src/serverFunctions/backlinksAccess.ts @@ -6,9 +6,9 @@ import { setBacklinksAccessStatus, } from "@/server/features/backlinks/backlinksAccess"; import { AppError } from "@/server/lib/errors"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { requireProjectContext } from "@/serverFunctions/middleware"; -import { fetchBacklinksSummaryRaw } from "@/server/lib/dataforseoBacklinks"; import { backlinksProjectSchema } from "@/types/schemas/backlinks"; const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000; @@ -25,7 +25,7 @@ export const testBacklinksAccess = createServerFn({ }) .middleware(requireProjectContext) .inputValidator((data: unknown) => backlinksProjectSchema.parse(data)) - .handler(async () => { + .handler(async ({ context }) => { if (await isHostedServerAuthMode()) { // Hosted deployments do not run the manual DataForSEO access test here; // backlinks access is treated as platform-managed in this mode. @@ -38,9 +38,13 @@ export const testBacklinksAccess = createServerFn({ } const checkedAt = new Date().toISOString(); + const dataforseo = createDataforseoClient({ + organizationId: context.organizationId, + userEmail: context.userEmail, + }); try { - await fetchBacklinksSummaryRaw({ + await dataforseo.backlinks.summary({ target: "dataforseo.com", includeSubdomains: true, includeIndirectLinks: true, diff --git a/src/serverFunctions/billing.ts b/src/serverFunctions/billing.ts new file mode 100644 index 0000000..47042b6 --- /dev/null +++ b/src/serverFunctions/billing.ts @@ -0,0 +1,104 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { + AUTUMN_SEO_DATA_CREDITS_PER_USD, + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + autumnSeoDataCreditsToUsd, + AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, + AUTUMN_PAID_PLAN_ID, +} from "@/shared/billing"; +import { autumn } from "@/server/billing/autumn"; +import { + getOrCreateOrganizationCustomer, + hasActivePaidPlan, +} from "@/server/billing/subscription"; +import { AppError } from "@/server/lib/errors"; +import { requireEnsuredUserContext } from "@/serverFunctions/middleware"; + +const billingReturnUrlSchema = z.object({ + returnUrl: z.string().url(), +}); + +const backlinksTopUpSchema = z.object({ + returnUrl: z.string().url(), + amount: z.number().int().min(10).max(99), +}); + +export const getHostedBillingStatus = createServerFn({ method: "GET" }) + .middleware(requireEnsuredUserContext) + .handler(async ({ context }) => { + const customer = await getOrCreateOrganizationCustomer(context); + const balance = + customer.balances[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID] ?? null; + const hasPaidPlan = hasActivePaidPlan(customer); + + return { + customerId: customer.id, + hasPaidPlan, + balance: balance + ? { + featureId: balance.featureId, + granted: autumnSeoDataCreditsToUsd(balance.granted), + remaining: autumnSeoDataCreditsToUsd(balance.remaining), + usage: autumnSeoDataCreditsToUsd(balance.usage), + nextResetAt: balance.nextResetAt ?? null, + } + : null, + }; + }); + +export const createHostedSubscriptionCheckout = createServerFn({ + method: "POST", +}) + .middleware(requireEnsuredUserContext) + .inputValidator((data: unknown) => billingReturnUrlSchema.parse(data)) + .handler(async ({ context, data }) => { + const customer = await getOrCreateOrganizationCustomer(context); + const response = await autumn.billing.attach({ + customerId: customer.id, + planId: AUTUMN_PAID_PLAN_ID, + redirectMode: "always", + successUrl: data.returnUrl, + }); + + return { url: response.paymentUrl }; + }); + +export const createBacklinksTopUpCheckout = createServerFn({ method: "POST" }) + .middleware(requireEnsuredUserContext) + .inputValidator((data: unknown) => backlinksTopUpSchema.parse(data)) + .handler(async ({ context, data }) => { + const customer = await getOrCreateOrganizationCustomer(context); + + if (!hasActivePaidPlan(customer)) { + throw new AppError("PAYMENT_REQUIRED"); + } + + const response = await autumn.billing.attach({ + customerId: customer.id, + planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, + redirectMode: "always", + successUrl: data.returnUrl, + featureQuantities: [ + { + featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + quantity: Math.round(data.amount * AUTUMN_SEO_DATA_CREDITS_PER_USD), + }, + ], + }); + + return { url: response.paymentUrl }; + }); + +export const openHostedBillingPortal = createServerFn({ method: "POST" }) + .middleware(requireEnsuredUserContext) + .inputValidator((data: unknown) => billingReturnUrlSchema.parse(data)) + .handler(async ({ context, data }) => { + const customer = await getOrCreateOrganizationCustomer(context); + const response = await autumn.billing.openCustomerPortal({ + customerId: customer.id, + returnUrl: data.returnUrl, + }); + + return { url: response.url }; + }); diff --git a/src/serverFunctions/domain.ts b/src/serverFunctions/domain.ts index 8f090ea..1c3a33a 100644 --- a/src/serverFunctions/domain.ts +++ b/src/serverFunctions/domain.ts @@ -6,4 +6,9 @@ import { DomainService } from "@/server/features/domain/services/DomainService"; export const getDomainOverview = createServerFn({ method: "POST" }) .middleware(requireAuthenticatedContext) .inputValidator((data: unknown) => domainOverviewSchema.parse(data)) - .handler(async ({ data }) => DomainService.getOverview(data)); + .handler(async ({ data, context }) => + DomainService.getOverview(data, { + organizationId: context.organizationId, + userEmail: context.userEmail, + }), + ); diff --git a/src/serverFunctions/keywords.ts b/src/serverFunctions/keywords.ts index 619ac33..15a911f 100644 --- a/src/serverFunctions/keywords.ts +++ b/src/serverFunctions/keywords.ts @@ -16,10 +16,16 @@ export const researchKeywords = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .inputValidator((data: unknown) => researchKeywordsSchema.parse(data)) .handler(async ({ data, context }) => { - return KeywordResearchService.research({ - ...data, - projectId: context.project.id, - }); + return KeywordResearchService.research( + { + ...data, + projectId: context.project.id, + }, + { + organizationId: context.organizationId, + userEmail: context.userEmail, + }, + ); }); export const saveKeywords = createServerFn({ method: "POST" }) @@ -54,4 +60,9 @@ export const removeSavedKeyword = createServerFn({ export const getSerpAnalysis = createServerFn({ method: "POST" }) .middleware(requireAuthenticatedContext) .inputValidator((data: unknown) => serpAnalysisSchema.parse(data)) - .handler(async ({ data }) => KeywordResearchService.getSerpAnalysis(data)); + .handler(async ({ data, context }) => + KeywordResearchService.getSerpAnalysis(data, { + organizationId: context.organizationId, + userEmail: context.userEmail, + }), + ); diff --git a/src/serverFunctions/middleware.ts b/src/serverFunctions/middleware.ts index 7775457..37c313d 100644 --- a/src/serverFunctions/middleware.ts +++ b/src/serverFunctions/middleware.ts @@ -3,6 +3,7 @@ import { AppError } from "@/server/lib/errors"; import { errorHandlingMiddleware } from "@/middleware/errorHandling"; import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; import { ensureUserMiddleware } from "@/middleware/ensureUser"; +import { requireHostedPaidSubscription } from "@/server/billing/subscription"; type AuthenticatedServerFunctionContext = EnsuredUserContext; @@ -42,6 +43,17 @@ export const globalServerFunctionMiddleware = [ ] as const; export const requireAuthenticatedContext = [ + createMiddleware({ type: "function" }).server(async ({ next, context }) => { + const authenticatedContext = getAuthenticatedContext(context); + await requireHostedPaidSubscription(authenticatedContext); + + return next({ + context: authenticatedContext, + }); + }), +] as const; + +export const requireEnsuredUserContext = [ createMiddleware({ type: "function" }).server(({ next, context }) => next({ context: getAuthenticatedContext(context), @@ -50,9 +62,11 @@ export const requireAuthenticatedContext = [ ] as const; export const requireProjectContext = [ - createMiddleware({ type: "function" }).server(({ next, context }) => { + createMiddleware({ type: "function" }).server(async ({ next, context }) => { const authenticatedContext = getAuthenticatedContext(context); + await requireHostedPaidSubscription(authenticatedContext); + if (!authenticatedContext.project) { throw new AppError( "INTERNAL_ERROR", diff --git a/src/shared/billing.ts b/src/shared/billing.ts new file mode 100644 index 0000000..aa5865c --- /dev/null +++ b/src/shared/billing.ts @@ -0,0 +1,16 @@ +export const BILLING_ROUTE = "/billing"; + +export const AUTUMN_PAID_PLAN_ID = "base-plan"; +export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up"; +export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits"; +export const AUTUMN_SEO_DATA_USAGE_FEATURE_ID = "seo_data_usage"; +export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000; +export const MINIMUM_SEO_DATA_BALANCE_USD = 0.15; + +export function roundUsdForBilling(value: number) { + return Math.round(value * 100000) / 100000; +} + +export function autumnSeoDataCreditsToUsd(credits: number) { + return credits / AUTUMN_SEO_DATA_CREDITS_PER_USD; +} diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index 351b425..54e4195 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -3,6 +3,7 @@ import { z } from "zod"; const ERROR_CODES = [ "UNAUTHENTICATED", "AUTH_CONFIG_MISSING", + "PAYMENT_REQUIRED", "FORBIDDEN", "NOT_FOUND", "AUDIT_CAPACITY_REACHED",