-
- {label}
-
-
- {formatCreditAmount(value)}
-
+
+
+
+ {args.title}
+
+ {args.badge ? (
+
+ {args.badge}
+
+ ) : null}
+
+
+ {formatCreditAmount(args.remaining)}
+
+
+
+ Granted
+
+ {formatCreditAmount(args.granted)}
+
+
+
+ Used
+
+ {formatCreditAmount(args.usage)}
+
+
+
);
}
diff --git a/src/client/features/billing/HostedBillingContentUtils.test.ts b/src/client/features/billing/HostedBillingContentUtils.test.ts
new file mode 100644
index 0000000..a2a9122
--- /dev/null
+++ b/src/client/features/billing/HostedBillingContentUtils.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import {
+ formatCreditAmount,
+ formatResetDate,
+ parseTopUpAmount,
+} from "./HostedBillingContentUtils";
+
+describe("formatResetDate", () => {
+ it("formats a valid Unix timestamp in ms", () => {
+ const date = new Date(2026, 3, 15); // April 15, local time
+ const result = formatResetDate(date.getTime());
+ expect(result).toBe("Resets Apr 15");
+ });
+
+ it("returns null for null input", () => {
+ expect(formatResetDate(null)).toBeNull();
+ });
+});
+
+describe("parseTopUpAmount", () => {
+ it("accepts valid whole-dollar amounts", () => {
+ expect(parseTopUpAmount("20")).toEqual({ isValid: true, parsed: 20 });
+ expect(parseTopUpAmount("10")).toEqual({ isValid: true, parsed: 10 });
+ expect(parseTopUpAmount("99")).toEqual({ isValid: true, parsed: 99 });
+ });
+
+ it("rejects amounts below minimum", () => {
+ expect(parseTopUpAmount("9")).toEqual({ isValid: false, parsed: 20 });
+ });
+
+ it("rejects amounts above maximum", () => {
+ expect(parseTopUpAmount("100")).toEqual({ isValid: false, parsed: 20 });
+ });
+
+ it("rejects non-numeric input", () => {
+ expect(parseTopUpAmount("abc")).toEqual({ isValid: false, parsed: 20 });
+ });
+});
+
+describe("formatCreditAmount", () => {
+ it("converts credits to formatted USD", () => {
+ expect(formatCreditAmount(5000)).toBe("$5.00");
+ expect(formatCreditAmount(1000)).toBe("$1.00");
+ expect(formatCreditAmount(0)).toBe("$0.00");
+ });
+});
diff --git a/src/client/features/billing/HostedBillingContentUtils.ts b/src/client/features/billing/HostedBillingContentUtils.ts
index 259ba26..3ea5c17 100644
--- a/src/client/features/billing/HostedBillingContentUtils.ts
+++ b/src/client/features/billing/HostedBillingContentUtils.ts
@@ -43,6 +43,15 @@ export function formatPlanPrice(
return `${formatUsd(amount, amount % 1 === 0 ? 0 : 2)}/${intervalToLabel(interval)}`;
}
+export function formatResetDate(timestampMs: number | null): string | null {
+ if (timestampMs == null) return null;
+
+ const date = new Date(timestampMs);
+ if (Number.isNaN(date.getTime())) return null;
+
+ return `Resets ${date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
+}
+
function formatUsd(value: number, minimumFractionDigits = 2) {
return new Intl.NumberFormat("en-US", {
style: "currency",
diff --git a/src/server/lib/dataforseoClient.test.ts b/src/server/lib/dataforseoClient.test.ts
new file mode 100644
index 0000000..5e40b04
--- /dev/null
+++ b/src/server/lib/dataforseoClient.test.ts
@@ -0,0 +1,240 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+} from "@/shared/billing";
+
+const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } =
+ vi.hoisted(() => ({
+ checkMock: vi.fn(),
+ trackMock: vi.fn(),
+ getOrCreateMock: vi.fn(),
+ isHostedServerAuthModeMock: vi.fn(),
+ }));
+
+vi.mock("@/server/billing/autumn", () => ({
+ autumn: {
+ check: checkMock,
+ track: trackMock,
+ },
+}));
+
+vi.mock("@/server/billing/subscription", () => ({
+ getOrCreateOrganizationCustomer: getOrCreateMock,
+}));
+
+vi.mock("@/server/lib/runtime-env", () => ({
+ isHostedServerAuthMode: isHostedServerAuthModeMock,
+}));
+
+vi.mock("@/server/lib/dataforseo", () => ({
+ fetchKeywordIdeasRaw: vi.fn(),
+ fetchKeywordSuggestionsRaw: vi.fn(),
+ fetchRelatedKeywordsRaw: vi.fn(),
+ fetchDomainRankOverviewRaw: vi.fn(),
+ fetchRankedKeywordsRaw: vi.fn(),
+ fetchLiveSerpItemsRaw: vi.fn(),
+}));
+
+vi.mock("@/server/lib/dataforseoLighthouse", () => ({
+ fetchDataforseoLighthouseResultRaw: vi.fn(),
+}));
+
+vi.mock("@/server/lib/dataforseoBacklinks", () => ({
+ fetchBacklinksRowsRaw: vi.fn(),
+ fetchBacklinksSummaryRaw: vi.fn(),
+ fetchDomainPagesSummaryRaw: vi.fn(),
+ fetchNewLostTimeseriesRaw: vi.fn(),
+ fetchReferringDomainsRaw: vi.fn(),
+ fetchTimeseriesSummaryRaw: vi.fn(),
+}));
+
+import { createDataforseoClient } from "./dataforseoClient";
+import { fetchBacklinksSummaryRaw } from "./dataforseoBacklinks";
+
+const billingCustomer = {
+ organizationId: "org_123",
+ userEmail: "alice@example.com",
+};
+
+const backlinksInput = {
+ target: "example.com",
+ includeSubdomains: true,
+ includeIndirectLinks: true,
+ excludeInternalBacklinks: true,
+ status: "live" as const,
+};
+
+function setupHostedMode() {
+ isHostedServerAuthModeMock.mockResolvedValue(true);
+ getOrCreateMock.mockResolvedValue({ id: "org_123" });
+}
+
+function mockBalances(monthly: number, topup: number) {
+ checkMock.mockImplementation(async (args: { featureId: string }) => {
+ if (args.featureId === AUTUMN_SEO_DATA_BALANCE_FEATURE_ID) {
+ return { allowed: true, balance: { remaining: monthly } };
+ }
+ if (args.featureId === AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID) {
+ return { allowed: true, balance: { remaining: topup } };
+ }
+ return { allowed: false, balance: null };
+ });
+}
+
+function mockDataforseoResult(costUsd: number) {
+ vi.mocked(fetchBacklinksSummaryRaw).mockResolvedValue({
+ data: { rank: 42 },
+ billing: { costUsd, path: ["backlinks", "summary"], resultCount: 1 },
+ });
+}
+
+describe("meterDataforseoCall with split balances", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("skips billing in non-hosted mode", async () => {
+ isHostedServerAuthModeMock.mockResolvedValue(false);
+ mockDataforseoResult(0.05);
+
+ const client = createDataforseoClient(billingCustomer);
+ const result = await client.backlinks.summary(backlinksInput);
+
+ expect(result).toEqual({ rank: 42 });
+ expect(checkMock).not.toHaveBeenCalled();
+ expect(trackMock).not.toHaveBeenCalled();
+ });
+
+ it("checks both monthly and topup balances in parallel", async () => {
+ setupHostedMode();
+ mockBalances(5000, 3000);
+ mockDataforseoResult(0.05);
+
+ const client = createDataforseoClient(billingCustomer);
+ await client.backlinks.summary(backlinksInput);
+
+ expect(checkMock).toHaveBeenCalledTimes(2);
+ expect(checkMock).toHaveBeenCalledWith({
+ customerId: "org_123",
+ featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ });
+ expect(checkMock).toHaveBeenCalledWith({
+ customerId: "org_123",
+ featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ });
+ });
+
+ it("deducts entirely from monthly when monthly has enough", async () => {
+ setupHostedMode();
+ mockBalances(5000, 3000);
+ mockDataforseoResult(0.05);
+
+ const client = createDataforseoClient(billingCustomer);
+ await client.backlinks.summary(backlinksInput);
+
+ expect(trackMock).toHaveBeenCalledTimes(1);
+ expect(trackMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ customerId: "org_123",
+ featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ value: 50,
+ }),
+ );
+ });
+
+ it("deducts entirely from topup when monthly is empty", async () => {
+ setupHostedMode();
+ mockBalances(0, 5000);
+ mockDataforseoResult(0.05);
+
+ const client = createDataforseoClient(billingCustomer);
+ await client.backlinks.summary(backlinksInput);
+
+ expect(trackMock).toHaveBeenCalledTimes(1);
+ expect(trackMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ customerId: "org_123",
+ featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ value: 50,
+ }),
+ );
+ });
+
+ it("splits deduction across monthly and topup when monthly is partially sufficient", async () => {
+ setupHostedMode();
+ mockBalances(30, 5000);
+ mockDataforseoResult(0.05);
+
+ const client = createDataforseoClient(billingCustomer);
+ await client.backlinks.summary(backlinksInput);
+
+ expect(trackMock).toHaveBeenCalledTimes(2);
+ expect(trackMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ customerId: "org_123",
+ featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ value: 30,
+ }),
+ );
+ expect(trackMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ customerId: "org_123",
+ featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ value: 20,
+ }),
+ );
+ });
+
+ it("throws PAYMENT_REQUIRED when combined balance is below minimum", async () => {
+ setupHostedMode();
+ // minimum is 150 credits (0.15 USD * 1000); 50 + 50 = 100 < 150
+ mockBalances(50, 50);
+
+ const client = createDataforseoClient(billingCustomer);
+ await expect(
+ client.backlinks.summary(backlinksInput),
+ ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
+
+ expect(trackMock).not.toHaveBeenCalled();
+ });
+
+ it("throws PAYMENT_REQUIRED when both balances are zero", async () => {
+ setupHostedMode();
+ mockBalances(0, 0);
+
+ const client = createDataforseoClient(billingCustomer);
+ await expect(
+ client.backlinks.summary(backlinksInput),
+ ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
+ });
+
+ it("includes balanceFeatureId in track properties", async () => {
+ setupHostedMode();
+ mockBalances(30, 5000);
+ mockDataforseoResult(0.05);
+
+ const client = createDataforseoClient(billingCustomer);
+ await client.backlinks.summary(backlinksInput);
+
+ const monthlyCall = trackMock.mock.calls.find(
+ (call: unknown[]) =>
+ (call[0] as { featureId: string }).featureId ===
+ AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ );
+ const topupCall = trackMock.mock.calls.find(
+ (call: unknown[]) =>
+ (call[0] as { featureId: string }).featureId ===
+ AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ );
+
+ expect(
+ (monthlyCall?.[0] as { properties: { balanceFeatureId: string } })
+ .properties.balanceFeatureId,
+ ).toBe(AUTUMN_SEO_DATA_BALANCE_FEATURE_ID);
+ expect(
+ (topupCall?.[0] as { properties: { balanceFeatureId: string } })
+ .properties.balanceFeatureId,
+ ).toBe(AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID);
+ });
+});
diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts
index 7d9c823..86dd7f7 100644
--- a/src/server/lib/dataforseoClient.ts
+++ b/src/server/lib/dataforseoClient.ts
@@ -1,8 +1,8 @@
-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,
+ AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ MINIMUM_SEO_DATA_BALANCE_USD,
roundUsdForBilling,
} from "@/shared/billing";
import { autumn } from "@/server/billing/autumn";
@@ -192,7 +192,7 @@ async function meterDataforseoCall
(
const billingCustomer = await getOrCreateOrganizationCustomer(customer);
- await assertSeoDataBalanceAvailable({
+ const { monthlyRemaining } = await assertSeoDataBalanceAvailable({
customerId: billingCustomer.id,
minimumBalanceUsd: MINIMUM_SEO_DATA_BALANCE_USD,
});
@@ -202,6 +202,7 @@ async function meterDataforseoCall(
await trackDataforseoCost({
customerId: billingCustomer.id,
billing: result.billing,
+ monthlyRemaining,
});
return result.data;
@@ -211,43 +212,77 @@ 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,
- ),
- });
+ const minimumCredits = Math.ceil(
+ roundUsdForBilling(args.minimumBalanceUsd) *
+ AUTUMN_SEO_DATA_CREDITS_PER_USD,
+ );
- if (!result.allowed) {
+ const [monthlyCheck, topupCheck] = await Promise.all([
+ autumn.check({
+ customerId: args.customerId,
+ featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ }),
+ autumn.check({
+ customerId: args.customerId,
+ featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ }),
+ ]);
+
+ const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0;
+ const topupRemaining = topupCheck.balance?.remaining ?? 0;
+
+ if (monthlyRemaining + topupRemaining < minimumCredits) {
throw new AppError("PAYMENT_REQUIRED");
}
+
+ return { monthlyRemaining };
}
async function trackDataforseoCost(args: {
customerId: string;
billing: DataforseoApiCallCost;
+ monthlyRemaining: number;
}) {
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,
- },
- });
+ const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits);
+ const topupDeduct = totalCostCredits - monthlyDeduct;
+
+ const properties = {
+ provider: "dataforseo",
+ currency: "USD",
+ paths: [args.billing.path.join("/")],
+ totalCostUsd,
+ totalCostCredits,
+ fromCache: false,
+ };
+
+ if (monthlyDeduct > 0) {
+ await autumn.track({
+ customerId: args.customerId,
+ featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ value: monthlyDeduct,
+ properties: {
+ ...properties,
+ balanceFeatureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
+ },
+ });
+ }
+
+ if (topupDeduct > 0) {
+ await autumn.track({
+ customerId: args.customerId,
+ featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ value: topupDeduct,
+ properties: {
+ ...properties,
+ balanceFeatureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
+ },
+ });
+ }
}
export type { LabsKeywordDataItem, SerpLiveItem };
diff --git a/src/shared/billing.ts b/src/shared/billing.ts
index 36095a5..27e65bf 100644
--- a/src/shared/billing.ts
+++ b/src/shared/billing.ts
@@ -5,6 +5,7 @@ export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up";
export const AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID =
"managed_service_access";
export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits";
+export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_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;