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

94 lines
2.7 KiB
TypeScript

import { buildCacheKey, getCached, setCached } from "@/server/lib/kv-cache";
import { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinks";
import {
profileBacklinksOverview,
profileReferringDomainsRows,
profileTopPagesRows,
type BacklinksCache,
} from "@/server/features/backlinks/services/backlinksServiceData";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import type { BacklinksLookupInput } from "@/types/schemas/backlinks";
const defaultCache: BacklinksCache = {
get: getCached,
set: setCached,
};
function createBacklinksService(cache: BacklinksCache = defaultCache) {
return {
async profileOverview(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
) {
return profileBacklinksOverview(
cache,
buildOverviewCacheKey(input, billingCustomer),
input,
billingCustomer,
);
},
async profileReferringDomains(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
) {
return profileReferringDomainsRows(
cache,
buildTabCacheKey("backlinks:referring-domains", input, billingCustomer),
input,
billingCustomer,
);
},
async profileTopPages(
input: BacklinksLookupInput,
billingCustomer: BillingCustomerContext,
) {
return profileTopPagesRows(
cache,
buildTabCacheKey("backlinks:top-pages", input, billingCustomer),
input,
billingCustomer,
);
},
} as const;
}
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,
includeIndirectLinks: input.includeIndirectLinks,
excludeInternalBacklinks: input.excludeInternalBacklinks,
status: input.status,
});
}
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,
includeIndirectLinks: input.includeIndirectLinks,
excludeInternalBacklinks: input.excludeInternalBacklinks,
status: input.status,
});
}
export const BacklinksService = createBacklinksService();
export { createBacklinksService };