Ben Senescu 739b3f0b6a
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
2026-03-25 13:02:12 -04:00

86 lines
2.7 KiB
TypeScript

import { createServerFn } from "@tanstack/react-start";
import {
buildBacklinksDisabledAccessStatus,
buildVerifiedBacklinksAccessStatus,
getBacklinksAccessStatus,
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 { backlinksProjectSchema } from "@/types/schemas/backlinks";
const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000;
export const getBacklinksAccessSetupStatus = createServerFn({
method: "GET",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async () => getBacklinksAccessStatus());
export const testBacklinksAccess = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.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.
return getBacklinksAccessStatus();
}
const cachedStatus = await getBacklinksAccessStatus();
if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) {
return cachedStatus;
}
const checkedAt = new Date().toISOString();
const dataforseo = createDataforseoClient({
organizationId: context.organizationId,
userEmail: context.userEmail,
});
try {
await dataforseo.backlinks.summary({
target: "dataforseo.com",
includeSubdomains: true,
includeIndirectLinks: true,
excludeInternalBacklinks: true,
status: "live",
});
const status = buildVerifiedBacklinksAccessStatus(checkedAt);
await setBacklinksAccessStatus(status);
return status;
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const status = buildBacklinksDisabledAccessStatus(
checkedAt,
error.code,
);
await setBacklinksAccessStatus(status);
return status;
}
throw error;
}
});
function isRecentVerifiedBacklinksAccessCheck(
status: Awaited<ReturnType<typeof getBacklinksAccessStatus>>,
) {
if (!status.enabled || !status.lastCheckedAt) {
return false;
}
const lastChecked = Date.parse(status.lastCheckedAt);
if (Number.isNaN(lastChecked)) {
return false;
}
return Date.now() - lastChecked < BACKLINKS_ACCESS_CHECK_COOLDOWN_MS;
}