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:
parent
bfc6a97466
commit
739b3f0b6a
49
adr/0002-hosted-dataforseo-metering-with-autumn.md
Normal file
49
adr/0002-hosted-dataforseo-metering-with-autumn.md
Normal 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.
|
||||
@ -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
69
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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">
|
||||
|
||||
102
src/client/features/billing/BillingRouteParts.tsx
Normal file
102
src/client/features/billing/BillingRouteParts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
355
src/routes/_app/billing.tsx
Normal 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);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
6
src/server/billing/autumn.ts
Normal file
6
src/server/billing/autumn.ts
Normal 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"),
|
||||
});
|
||||
69
src/server/billing/subscription.test.ts
Normal file
69
src/server/billing/subscription.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
57
src/server/billing/subscription.ts
Normal file
57
src/server/billing/subscription.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
243
src/server/lib/dataforseoClient.ts
Normal file
243
src/server/lib/dataforseoClient.ts
Normal 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 };
|
||||
10
src/server/lib/dataforseoCost.ts
Normal file
10
src/server/lib/dataforseoCost.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type DataforseoApiCallCost = {
|
||||
path: string[];
|
||||
costUsd: number;
|
||||
resultCount: number | null;
|
||||
};
|
||||
|
||||
export type DataforseoApiResponse<T> = {
|
||||
data: T;
|
||||
billing: DataforseoApiCallCost;
|
||||
};
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
104
src/serverFunctions/billing.ts
Normal file
104
src/serverFunctions/billing.ts
Normal 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 };
|
||||
});
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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
16
src/shared/billing.ts
Normal 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;
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
const ERROR_CODES = [
|
||||
"UNAUTHENTICATED",
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"PAYMENT_REQUIRED",
|
||||
"FORBIDDEN",
|
||||
"NOT_FOUND",
|
||||
"AUDIT_CAPACITY_REACHED",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user