Add autumn metered pricing for hosted mode (#44)

* feat: add hosted billing gate and backlinks metering

* refine hosted billing onboarding flow

* fix: align Autumn credit billing flows

* refactor: simplify Autumn billing flow

* refactor: narrow backlinks billing context

* refactor: require billing identity for backlinks profiles

* refactor: colocate backlinks billing flow

* clean

* refactor: centralize DataForSEO billing client

* refactor: simplify dataforseo billing flow

* chore: fix ci check lint issues

* fix: stabilize backlinks chart sizing

Measure chart container width before rendering the backlinks charts so Recharts does not mount at 0x0 inside responsive layouts.

* docs: document hosted DataForSEO metering

Capture the Autumn credit model and require hosted code to go through the metered client path so provider usage is harder to bypass.

* fix: enforce hosted billing access checks

* fix: preserve hosted billing subscription access

* fix: handle hosted billing UI failures
This commit is contained in:
Ben Senescu 2026-03-25 13:02:12 -04:00 committed by GitHub
parent bfc6a97466
commit 739b3f0b6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1829 additions and 613 deletions

View File

@ -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.

View File

@ -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",

69
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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<string, string>): BacklinksLookupInput {
};
}
function buildBillingCustomer(
cliArgs: Record<string, string>,
): 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<string, string> = {};
@ -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(

View File

@ -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 <EmptyChartState />;
}
return (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<div
ref={containerRef}
className="h-56 min-w-0"
aria-label="Backlink trend chart"
>
{chartWidth > 0 ? (
<LineChart
width={chartWidth}
height={224}
data={data}
margin={{ left: 8, right: 8, top: 8, bottom: 0 }}
>
@ -64,7 +72,7 @@ export function BacklinksTrendChart({
name="Referring domains"
/>
</LineChart>
</ResponsiveContainer>
) : null}
</div>
);
}
@ -74,14 +82,22 @@ export function BacklinksNewLostChart({
}: {
data: BacklinksOverviewData["newLostTrends"];
}) {
const { containerRef, chartWidth } = useChartWidth();
if (data.length === 0) {
return <EmptyChartState />;
}
return (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<div
ref={containerRef}
className="h-56 min-w-0"
aria-label="New and lost backlinks chart"
>
{chartWidth > 0 ? (
<LineChart
width={chartWidth}
height={224}
data={data}
margin={{ left: 8, right: 8, top: 8, bottom: 0 }}
>
@ -118,11 +134,38 @@ export function BacklinksNewLostChart({
name="Lost backlinks"
/>
</LineChart>
</ResponsiveContainer>
) : null}
</div>
);
}
function useChartWidth() {
const containerRef = useRef<HTMLDivElement | null>(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 (
<div className="flex h-56 items-center justify-center rounded-xl border border-dashed border-base-300 text-sm text-base-content/55">

View File

@ -0,0 +1,102 @@
import { CreditCard } from "lucide-react";
import type { ReactNode } from "react";
export function BillingHeader({ hasPaidPlan }: { hasPaidPlan: boolean }) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-base-content/60">
<CreditCard className="h-4 w-4" />
Hosted billing
</div>
<h1 className="text-3xl font-semibold">
{hasPaidPlan ? "Billing" : "Choose a plan"}
</h1>
<p className="max-w-2xl text-base-content/70">
{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."}
</p>
</div>
);
}
export function SubscriptionIntro({ hasPaidPlan }: { hasPaidPlan: boolean }) {
return (
<div>
<h2 className="text-xl font-semibold">
{hasPaidPlan ? "Subscription" : "Managed service access"}
</h2>
<p className="text-sm text-base-content/65">
{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."}
</p>
</div>
);
}
export function SubscriptionStatusCard({
hasPaidPlan,
}: {
hasPaidPlan: boolean;
}) {
return (
<div className="rounded-2xl border border-base-300 bg-base-200/50 p-4">
<div className="text-sm text-base-content/60">
{hasPaidPlan ? "Current status" : "Base plan"}
</div>
<div className="mt-1 text-2xl font-semibold">
{hasPaidPlan ? "Active" : "$5/month"}
</div>
<div className="mt-2 text-sm text-base-content/70">
{hasPaidPlan
? "Your organization can use hosted OpenSEO features."
: "Includes $5 of usage credits every month."}
</div>
</div>
);
}
export function BillingAlerts(args: {
actionError: string | null;
hasPaidPlan: boolean;
}) {
return (
<>
{args.actionError ? (
<div className="alert alert-error">
<span>{args.actionError}</span>
</div>
) : null}
{!args.hasPaidPlan ? (
<div className="alert alert-warning">
<span>
Subscribe to the base plan first. After that, you can manage your
plan and buy more credits here.
</span>
</div>
) : null}
</>
);
}
export function CenteredCard(args: {
title: string;
body: string;
action?: ReactNode;
}) {
return (
<div className="mx-auto flex h-full w-full max-w-3xl items-center justify-center p-6">
<div className="card w-full max-w-xl border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-3">
<h1 className="text-2xl font-semibold">{args.title}</h1>
<p className="text-base-content/70">{args.body}</p>
{args.action ? (
<div className="card-actions justify-start pt-2">{args.action}</div>
) : null}
</div>
</div>
</div>
);
}

View File

@ -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({
<TopNav
drawerOpen={drawerOpen}
projectId={projectId ?? null}
pathname={location.pathname}
onOpenDrawer={() => 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 (
<div className="navbar bg-base-100 border-b border-base-300 shrink-0 gap-2">
<div className="flex-none flex items-center md:hidden">
<div className="navbar shrink-0 gap-2 border-b border-base-300 bg-base-100">
<div className="flex flex-none items-center md:hidden">
{projectId ? (
<button
type="button"
@ -146,26 +156,27 @@ function TopNav({
<Menu className="h-6 w-6" />
</button>
) : null}
<span className="font-semibold text-base-content ml-1">OpenSEO</span>
<span className="ml-1 font-semibold text-base-content">OpenSEO</span>
</div>
<div className="hidden md:flex items-center gap-1">
<span className="text-lg font-semibold text-base-content px-2">
<div className="hidden items-center gap-1 md:flex">
<span className="px-2 text-lg font-semibold text-base-content">
OpenSEO
</span>
{projectId
? projectNavItems.map((item) => {
const { icon: Icon, ...linkProps } = item;
const { icon: Icon, matchSegment, ...linkProps } = item;
const isActive = pathname.includes(matchSegment);
return (
<Link
key={linkProps.to}
{...linkProps}
activeOptions={{ exact: false, includeSearch: false }}
className="btn btn-sm gap-2 btn-ghost text-base-content/60 hover:text-base-content"
activeProps={{
className:
"bg-primary/10 text-primary font-medium border-transparent hover:text-primary",
}}
className={`btn btn-sm gap-2 ${
isActive
? "border-transparent bg-primary/10 font-medium text-primary"
: "btn-ghost text-base-content/60 hover:text-base-content"
}`}
>
<Icon className="h-4 w-4" />
{item.label}
@ -177,7 +188,7 @@ function TopNav({
<div className="flex-1" />
<div className="flex-none hidden md:flex items-center gap-2">
<div className="hidden flex-none items-center gap-2 md:flex">
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
<div
className="tooltip tooltip-left before:whitespace-nowrap"
@ -185,7 +196,7 @@ function TopNav({
>
<button
type="button"
className="flex h-10 items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80 cursor-default"
className="flex h-10 cursor-default items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
aria-label="Current project"
>
<span className="max-w-28 truncate text-sm font-medium text-base-content">
@ -195,16 +206,11 @@ function TopNav({
</button>
</div>
{isHostedMode ? (
<>
<div className="mx-1 h-6 w-px bg-base-300" />
<HostedSessionActions />
</>
) : null}
<HostedSessionActions />
</div>
</div>
{isHostedMode ? <HostedSessionActions mobileOnly /> : null}
<HostedSessionActions mobileOnly />
</div>
);
}
@ -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 (
<div className={mobileOnly ? "flex-none md:hidden ml-2" : "flex-none"}>
const menu = (
<div className={mobileOnly ? "ml-2 flex-none md:hidden" : "flex-none"}>
<div className="dropdown dropdown-end">
<button
type="button"
@ -251,6 +258,12 @@ function HostedSessionActions({
{session.user.email}
</span>
</li>
<li>
<a href={BILLING_ROUTE} className="flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Billing
</a>
</li>
<li>
<button
type="button"
@ -264,4 +277,15 @@ function HostedSessionActions({
</div>
</div>
);
if (mobileOnly) {
return menu;
}
return (
<>
<div className="mx-1 h-6 w-px bg-base-300" />
{menu}
</>
);
}

View File

@ -4,6 +4,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
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:

View File

@ -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,
}

355
src/routes/_app/billing.tsx Normal file
View File

@ -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<string | null>(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 (
<div className="flex h-full items-center justify-center">
<LoaderCircle className="h-6 w-6 animate-spin text-base-content/60" />
</div>
);
}
if (!session?.user?.id) {
return (
<CenteredCard
title="Sign in to manage billing"
body="Your hosted billing settings are tied to your OpenSEO organization."
action={
<a className="btn btn-primary" href="/sign-in">
Go to sign in
</a>
}
/>
);
}
if (billingStatusQuery.isError) {
return (
<CenteredCard
title="Billing unavailable"
body="We could not load your billing details right now. Please reload and try again."
/>
);
}
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 (
<CenteredCard
title="Redirecting to checkout..."
body="Please wait while we open your hosted billing flow."
/>
);
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 md:p-6">
<BillingHeader hasPaidPlan={hasPaidPlan} />
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<SubscriptionIntro hasPaidPlan={hasPaidPlan} />
<SubscriptionStatusCard hasPaidPlan={hasPaidPlan} />
{!hasPaidPlan ? (
<button
type="button"
className="btn btn-primary btn-block sm:btn-wide"
disabled={isActionPending}
onClick={() => startSubscriptionMutation.mutate()}
>
{startSubscriptionMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Start base subscription
</button>
) : null}
{hasPaidPlan ? (
<button
type="button"
className="btn btn-outline btn-block sm:btn-wide"
disabled={isActionPending}
onClick={() => billingPortalMutation.mutate()}
>
{billingPortalMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Open billing portal
</button>
) : null}
</div>
</section>
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<div>
<h2 className="text-lg font-semibold text-base-content">
SEO data credits
</h2>
<p className="mt-1 text-sm text-base-content/70">
Buy extra usage credits for DataForSEO-powered features like
backlinks.
</p>
</div>
<div className="rounded-box border border-base-300 bg-base-200/60 p-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-base-content/70">Remaining</span>
<span className="text-2xl font-semibold text-base-content">
{formatUsd(balance?.remaining ?? 0)}
</span>
</div>
<div className="mt-3 grid gap-2 text-sm text-base-content/70 sm:grid-cols-2">
<div className="rounded-md bg-base-100 px-3 py-2">
<span className="block text-xs uppercase tracking-wide text-base-content/50">
Granted
</span>
<span className="font-medium text-base-content">
{formatUsd(balance?.granted ?? 0)}
</span>
</div>
<div className="rounded-md bg-base-100 px-3 py-2">
<span className="block text-xs uppercase tracking-wide text-base-content/50">
Used
</span>
<span className="font-medium text-base-content">
{formatUsd(balance?.usage ?? 0)}
</span>
</div>
</div>
</div>
<label className="form-control gap-2">
<span className="label-text font-medium">Top up amount</span>
<div className="join">
<span className="join-item inline-flex items-center rounded-l-btn border border-base-300 bg-base-200 px-4 text-base-content/70">
$
</span>
<input
type="number"
min={10}
max={99}
step={1}
inputMode="numeric"
className="input join-item input-bordered w-full"
value={topUpAmount}
onChange={(event) => setTopUpAmount(event.target.value)}
/>
</div>
<span className="label-text-alt text-base-content/60">
{hasPaidPlan
? "Enter a whole-dollar amount between $10 and $99."
: "Start the base subscription before buying extra credits."}
</span>
</label>
<button
type="button"
className="btn btn-secondary btn-block"
disabled={topUpDisabled}
onClick={() => {
if (topUpDisabled) return;
topUpMutation.mutate({ amount: parsedTopUpAmount });
}}
>
{topUpMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Buy credits
</button>
<p className="text-xs leading-relaxed text-base-content/60">
Credit purchases use our hosted checkout flow and apply to your
organization balance.
</p>
</div>
</section>
</div>
<BillingAlerts actionError={actionError} hasPaidPlan={hasPaidPlan} />
<div className="flex items-center gap-2 text-sm text-base-content/60">
<ExternalLink className="h-4 w-4" />
<span>Hosted billing is powered by Autumn.</span>
</div>
</div>
);
}
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);
}

View File

@ -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 (
<div className="flex items-center justify-center h-full p-4">
<div className="flex flex-col items-center gap-3 max-w-xl text-center">
<p className="text-base-content/80">
Redirecting you to billing so you can start a hosted subscription.
</p>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center h-full p-4">
<div className="flex flex-col items-center gap-3 max-w-xl">

View File

@ -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"),
});

View File

@ -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",
});
});
});

View File

@ -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");
}
}

View File

@ -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<string, string>();
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 {

View File

@ -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<BacklinksOverviewResult> {
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,

View File

@ -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;
}

View File

@ -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<BacklinksOverviewProfile> {
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<ReferringDomainsProfile> {
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<TopPagesProfile> {
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<typeof normalizeBacklinksTarget>;
now: Date;
summary: Awaited<ReturnType<typeof fetchBacklinksSummaryRaw>>;
backlinks: Awaited<ReturnType<typeof fetchBacklinksRowsRaw>>;
trends: Awaited<ReturnType<typeof fetchTimeseriesSummaryRaw>>;
newLostTrends: Awaited<ReturnType<typeof fetchNewLostTimeseriesRaw>>;
summary: Awaited<ReturnType<typeof fetchBacklinksSummaryRaw>>["data"];
backlinks: Awaited<ReturnType<typeof fetchBacklinksRowsRaw>>["data"];
trends: Awaited<ReturnType<typeof fetchTimeseriesSummaryRaw>>["data"];
newLostTrends: Awaited<ReturnType<typeof fetchNewLostTimeseriesRaw>>["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<T>(data: T): BacklinksApiResponse<T> {
return {
data,
billing: {
endpoint: "",
path: [],
costUsd: 0,
resultCount: null,
rowsReturned: 0,
},
};
}

View File

@ -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<DomainOverviewResult> {
async function getOverview(
input: {
domain: string;
includeSubdomains: boolean;
locationCode: number;
languageCode: string;
},
billingCustomer: BillingCustomerContext,
): Promise<DomainOverviewResult> {
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];

View File

@ -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<FetchResearchRowsParams, "source">,
dataforseo: ReturnType<typeof createDataforseoClient>,
) {
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<string>();
@ -103,28 +101,31 @@ async function fetchRelatedRows(
export async function fetchResearchRowsBySource(
params: FetchResearchRowsParams,
billingCustomer: BillingCustomerContext,
): Promise<EnrichedKeyword[]> {
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,
}),
);
}

View File

@ -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<EnrichedKeyword[]> {
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<ResearchResult> {
const attempts: SourceAttempt[] = [];
let lastSource: KeywordSource = "related";
@ -110,7 +116,12 @@ async function fetchAutoRows(
const seenKeywords = new Set<string>();
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<KeywordMode, "auto">,
input: ResearchKeywordsInput,
seedKeyword: string,
billingCustomer: BillingCustomerContext,
): Promise<ResearchResult> {
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<ResearchResult> {
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);

View File

@ -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<SerpAnalysisResult> {
async function getSerpLiveAnalysis(
input: {
keyword: string;
locationCode: number;
languageCode: string;
},
billingCustomer: BillingCustomerContext,
): Promise<SerpAnalysisResult> {
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 };

View File

@ -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<T extends { status_code?: number; status_message?: string }>(
type DataforseoTaskLike = {
status_code?: number;
status_message?: string;
path?: string[];
cost?: number;
result_count?: number | null;
result?: DataforseoTask["result"];
};
function assertOk<T extends DataforseoTaskLike>(
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<T extends { status_code?: number; status_message?: string }>(
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<RelatedKeywordItem[]> {
): Promise<DataforseoApiResponse<RelatedKeywordItem[]>> {
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<DataforseoTask>(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<LabsKeywordDataItem[]> {
): Promise<DataforseoApiResponse<LabsKeywordDataItem[]>> {
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<DataforseoTask>(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<LabsKeywordDataItem[]> {
): Promise<DataforseoApiResponse<LabsKeywordDataItem[]>> {
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<DataforseoTask>(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<DomainMetricsItem[]> {
): Promise<DataforseoApiResponse<DomainMetricsItem[]>> {
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<DataforseoTask>(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<DomainRankedKeywordItem[]> {
): Promise<DataforseoApiResponse<DomainRankedKeywordItem[]>> {
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<DataforseoTask>(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<SerpLiveItem[]> {
): Promise<DataforseoApiResponse<SerpLiveItem[]>> {
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<DataforseoTask>(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),
};
}
// ---------------------------------------------------------------------------

View File

@ -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<BacklinksApiCallCost, "rowsReturned">;
};
export type BacklinksApiResponse<T> = {
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<typeof data>;
billing: response.billing,
} satisfies DataforseoApiResponse<typeof data>;
}
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<typeof data>;
billing: response.billing,
} satisfies DataforseoApiResponse<typeof data>;
}
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<typeof data>;
billing: response.billing,
} satisfies DataforseoApiResponse<typeof data>;
}
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<typeof data>;
billing: response.billing,
} satisfies DataforseoApiResponse<typeof data>;
}
export async function fetchTimeseriesSummaryRaw(
@ -279,11 +264,8 @@ export async function fetchTimeseriesSummaryRaw(
);
return {
data,
billing: {
...response.billing,
rowsReturned: data.length,
},
} satisfies BacklinksApiResponse<typeof data>;
billing: response.billing,
} satisfies DataforseoApiResponse<typeof data>;
}
export async function fetchNewLostTimeseriesRaw(
@ -307,9 +289,6 @@ export async function fetchNewLostTimeseriesRaw(
);
return {
data,
billing: {
...response.billing,
rowsReturned: data.length,
},
} satisfies BacklinksApiResponse<typeof data>;
billing: response.billing,
} satisfies DataforseoApiResponse<typeof data>;
}

View File

@ -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<T>(
customer: BillingCustomerContext,
execute: () => Promise<DataforseoApiResponse<T>>,
): Promise<T> {
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 };

View File

@ -0,0 +1,10 @@
export type DataforseoApiCallCost = {
path: string[];
costUsd: number;
resultCount: number | null;
};
export type DataforseoApiResponse<T> = {
data: T;
billing: DataforseoApiCallCost;
};

View File

@ -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<typeof dataforseoTaskSchema>;
export const successfulDataforseoTaskSchema = dataforseoTaskSchema;
export const dataforseoResponseSchema = z
.object({
status_code: z.number().optional(),

View File

@ -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;

View File

@ -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,

View File

@ -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 };
});

View File

@ -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,
}),
);

View File

@ -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,
}),
);

View File

@ -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",

16
src/shared/billing.ts Normal file
View File

@ -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;
}

View File

@ -3,6 +3,7 @@ import { z } from "zod";
const ERROR_CODES = [
"UNAUTHENTICATED",
"AUTH_CONFIG_MISSING",
"PAYMENT_REQUIRED",
"FORBIDDEN",
"NOT_FOUND",
"AUDIT_CAPACITY_REACHED",