diff --git a/src/client/features/billing/BillingRouteParts.tsx b/src/client/features/billing/BillingRouteParts.tsx index 32c0f01..1a57f81 100644 --- a/src/client/features/billing/BillingRouteParts.tsx +++ b/src/client/features/billing/BillingRouteParts.tsx @@ -17,8 +17,8 @@ export function BillingHeader(args: {

{args.hasManagedServiceAccess - ? `OpenSEO hosted usage is metered against your shared backlinks balance. ${args.basePlanName} includes ${args.includedCreditsLabel} of usage credits each cycle, and extra top-ups carry forward.` - : `You need an active ${args.basePlanName} subscription to use OpenSEO's managed service. It includes ${args.includedCreditsLabel} of usage credits each cycle, and you can buy more at any time.`} + ? `${args.basePlanName} includes ${args.includedCreditsLabel} of usage credits each month. Monthly credits are used first; purchased top-ups never expire.` + : `You need an active ${args.basePlanName} subscription to use OpenSEO's managed service. It includes ${args.includedCreditsLabel} of usage credits each month, and you can buy more at any time.`}

); @@ -38,7 +38,7 @@ export function SubscriptionIntro(args: {

{args.hasManagedServiceAccess ? "Hosted workspaces need an active paid plan before project pages and DataForSEO-backed features are available." - : `Start ${args.basePlanName} to unlock OpenSEO's managed service and your included monthly usage credits.`} + : `Start ${args.basePlanName} to unlock OpenSEO's managed service and your included monthly credits.`}

); diff --git a/src/client/features/billing/HostedBillingContent.tsx b/src/client/features/billing/HostedBillingContent.tsx index bcde3a9..8cb0c22 100644 --- a/src/client/features/billing/HostedBillingContent.tsx +++ b/src/client/features/billing/HostedBillingContent.tsx @@ -14,10 +14,12 @@ import { AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, + AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, } from "@/shared/billing"; import { formatCreditAmount, formatPlanPrice, + formatResetDate, getIncludedFeatureQuantity, parseTopUpAmount, } from "@/client/features/billing/HostedBillingContentUtils"; @@ -55,8 +57,10 @@ export function HostedBillingContent({ const customer = customerQuery.data; const basePlan = plans.find((plan) => plan.id === AUTUMN_PAID_PLAN_ID) ?? null; - const balance = + const monthlyBalance = customer?.balances?.[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID] ?? null; + const topupBalance = + customer?.balances?.[AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID] ?? null; const hasManagedServiceAccess = Boolean( customer?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], ); @@ -136,7 +140,8 @@ export function HostedBillingContent({ /> void; onTopUpAmountChange: (value: string) => void; }) { + const totalRemaining = + (args.monthlyBalance?.remaining ?? 0) + (args.topupBalance?.remaining ?? 0); + return (
@@ -258,22 +270,36 @@ function SeoDataCreditsSection(args: { SEO data credits

- Buy extra usage credits for DataForSEO-powered features like - backlinks. + Monthly credits are used first. Purchased top-ups never expire.

- Remaining + + Total remaining + - {formatCreditAmount(args.balance?.remaining ?? 0)} + {formatCreditAmount(totalRemaining)}
-
- - -
+
+ +
+ +
); } -function CreditStat({ label, value }: { label: string; value: number }) { +function CreditPoolCard(args: { + title: string; + badge: string | null; + remaining: number; + granted: number; + usage: number; +}) { return ( -
- - {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;