diff --git a/src/client/features/billing/BillingUsageChart.tsx b/src/client/features/billing/BillingUsageChart.tsx index 21ff930..a8f42c2 100644 --- a/src/client/features/billing/BillingUsageChart.tsx +++ b/src/client/features/billing/BillingUsageChart.tsx @@ -47,7 +47,7 @@ export function BillingUsageChart() { const totalSpend = chartData.reduce((sum, d) => sum + d.credits, 0); return ( -
+
Usage Last 30 days @@ -69,7 +69,7 @@ export function BillingUsageChart() { width={chartWidth} height={128} data={chartData} - margin={{ top: 4, right: 0, bottom: 0, left: -20 }} + margin={{ top: 4, right: 0, bottom: 0, left: 0 }} > + + + ); +} + +function FreePlanBannerContent() { + const { data: session } = useSession(); + const customerQuery = useCustomer({ + queryOptions: { + enabled: Boolean(session?.user?.id), + }, + }); + + if (customerQuery.isLoading || !customerQuery.data) { + return null; + } + + const planStatus = getCustomerPlanStatus(customerQuery.data); + const isFreePlan = planStatus === "free"; + + const monthlyRemaining = autumnSeoDataCreditsToUsd( + customerQuery.data.balances?.[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID] + ?.remaining ?? 0, + ); + const topUpRemaining = autumnSeoDataCreditsToUsd( + customerQuery.data.balances?.[AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID] + ?.remaining ?? 0, + ); + const totalRemaining = monthlyRemaining + topUpRemaining; + + const isOutOfCredits = totalRemaining <= 0; + const isLowCredits = + !isOutOfCredits && totalRemaining < LOW_CREDITS_THRESHOLD_USD; + + const creditsActionLink = isFreePlan ? ( + + Upgrade your plan + + ) : ( + + Buy more credits + + ); + + if (isOutOfCredits) { + return ( + + You’ve used all your credits. {creditsActionLink} to continue + using OpenSEO. + + ); + } + + if (isLowCredits) { + return ( + + You’re running low on credits. {creditsActionLink} to keep using + OpenSEO. + + ); + } + + if (isFreePlan) { + return ( + + We hope you’re enjoying OpenSEO!{" "} + + Upgrade anytime + {" "} + or{" "} + + reach out with questions + + . + + ); + } + + return null; +} + +function BannerShell({ + variant, + children, +}: { + variant: "info" | "warning" | "error"; + children: React.ReactNode; +}) { + const alertClass = + variant === "error" + ? "alert-error" + : variant === "warning" + ? "alert-warning" + : "alert-info"; + + return ( +
+
+
+ {children} +
+
+
+ ); +} diff --git a/src/client/features/billing/plan-detection.ts b/src/client/features/billing/plan-detection.ts new file mode 100644 index 0000000..f0b3199 --- /dev/null +++ b/src/client/features/billing/plan-detection.ts @@ -0,0 +1,17 @@ +import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; + +export type PlanStatus = "free" | "paid"; + +export function getCustomerPlanStatus( + customer: + | { subscriptions?: Array<{ planId: string; status: string }> } + | undefined, +): PlanStatus { + if (!customer?.subscriptions) return "free"; + + const hasActivePaid = customer.subscriptions.some( + (sub) => sub.planId === AUTUMN_PAID_PLAN_ID && sub.status === "active", + ); + + return hasActivePaid ? "paid" : "free"; +} diff --git a/src/client/features/billing/route-state.test.ts b/src/client/features/billing/route-state.test.ts index 2556e02..1559d05 100644 --- a/src/client/features/billing/route-state.test.ts +++ b/src/client/features/billing/route-state.test.ts @@ -2,26 +2,24 @@ import { describe, expect, it } from "vitest"; import { getBillingRouteState, getSubscribeRouteState } from "./route-state"; describe("getBillingRouteState", () => { - it("redirects unpaid customers after a successful customer lookup", () => { + it("shows ready after successful customer lookup", () => { expect( getBillingRouteState({ hasSession: true, isSessionPending: false, isCustomerLoading: false, isCustomerError: false, - hasManagedServiceAccess: false, }), - ).toBe("redirectToSubscribe"); + ).toBe("ready"); }); - it("shows an error state instead of redirecting on billing lookup failures", () => { + it("shows an error state on billing lookup failures", () => { expect( getBillingRouteState({ hasSession: true, isSessionPending: false, isCustomerLoading: false, isCustomerError: true, - hasManagedServiceAccess: false, }), ).toBe("error"); }); @@ -33,7 +31,6 @@ describe("getBillingRouteState", () => { isSessionPending: true, isCustomerLoading: false, isCustomerError: false, - hasManagedServiceAccess: false, }), ).toBe("loading"); @@ -43,20 +40,19 @@ describe("getBillingRouteState", () => { isSessionPending: false, isCustomerLoading: true, isCustomerError: false, - hasManagedServiceAccess: false, }), ).toBe("loading"); }); }); describe("getSubscribeRouteState", () => { - it("shows an error state instead of a subscribe CTA on billing lookup failures", () => { + it("shows an error state on billing lookup failures", () => { expect( getSubscribeRouteState({ hasSession: true, isCustomerLoading: false, isCustomerError: true, - hasManagedServiceAccess: false, + planStatus: "free", }), ).toBe("error"); }); @@ -67,8 +63,19 @@ describe("getSubscribeRouteState", () => { hasSession: true, isCustomerLoading: false, isCustomerError: false, - hasManagedServiceAccess: true, + planStatus: "paid", }), ).toBe("redirectToApp"); }); + + it("shows welcome page for free plan users", () => { + expect( + getSubscribeRouteState({ + hasSession: true, + isCustomerLoading: false, + isCustomerError: false, + planStatus: "free", + }), + ).toBe("showWelcome"); + }); }); diff --git a/src/client/features/billing/route-state.ts b/src/client/features/billing/route-state.ts index f40a69e..a83748d 100644 --- a/src/client/features/billing/route-state.ts +++ b/src/client/features/billing/route-state.ts @@ -1,9 +1,10 @@ +import type { PlanStatus } from "@/client/features/billing/plan-detection"; + export function getBillingRouteState(args: { hasSession: boolean; isSessionPending: boolean; isCustomerLoading: boolean; isCustomerError: boolean; - hasManagedServiceAccess: boolean; }) { if (args.isSessionPending || !args.hasSession || args.isCustomerLoading) { return "loading" as const; @@ -13,10 +14,6 @@ export function getBillingRouteState(args: { return "error" as const; } - if (!args.hasManagedServiceAccess) { - return "redirectToSubscribe" as const; - } - return "ready" as const; } @@ -24,7 +21,7 @@ export function getSubscribeRouteState(args: { hasSession: boolean; isCustomerLoading: boolean; isCustomerError: boolean; - hasManagedServiceAccess: boolean; + planStatus: PlanStatus; }) { if (!args.hasSession || args.isCustomerLoading) { return "loading" as const; @@ -34,9 +31,9 @@ export function getSubscribeRouteState(args: { return "error" as const; } - if (args.hasManagedServiceAccess) { + if (args.planStatus === "paid") { return "redirectToApp" as const; } - return "ready" as const; + return "showWelcome" as const; } diff --git a/src/client/features/keywords/hooks/useKeywordResearchData.ts b/src/client/features/keywords/hooks/useKeywordResearchData.ts index 1459b97..765896b 100644 --- a/src/client/features/keywords/hooks/useKeywordResearchData.ts +++ b/src/client/features/keywords/hooks/useKeywordResearchData.ts @@ -130,6 +130,7 @@ export function useKeywordResearchData(addSearch: AddSearchFn) { lastSearchKeyword, lastSearchLocationCode, researchError, + researchMutationError: researchMutation.error, searchedKeyword, isLoading: researchMutation.isPending, beginSearch, diff --git a/src/client/features/keywords/page/KeywordResearchPage.tsx b/src/client/features/keywords/page/KeywordResearchPage.tsx index bd09f8a..551c4a2 100644 --- a/src/client/features/keywords/page/KeywordResearchPage.tsx +++ b/src/client/features/keywords/page/KeywordResearchPage.tsx @@ -1,4 +1,7 @@ +import { Link } from "@tanstack/react-router"; import { AlertCircle, ArrowLeft } from "lucide-react"; +import { getErrorCode } from "@/client/lib/error-messages"; +import { BILLING_ROUTE } from "@/shared/billing"; import { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController"; import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState"; @@ -64,6 +67,9 @@ function KeywordResearchContent({ } if (controller.researchError) { + const isCreditsError = + getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS"; + return (
{recentSearchesButton} @@ -73,12 +79,18 @@ function KeywordResearchContent({

{controller.researchError}

- + {isCreditsError ? ( + + Go to Billing + + ) : ( + + )}
diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index 2319ed8..64c2dca 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -133,6 +133,7 @@ export function useKeywordResearchController( overviewKeyword: state.overviewKeyword, removeHistoryItem: state.removeHistoryItem, researchError: state.researchError, + researchMutationError: state.researchMutationError, resetView, resetFilters: state.resetFilters, rows: state.rows, @@ -200,6 +201,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) { lastSearchKeyword, lastSearchLocationCode, researchError, + researchMutationError, searchedKeyword, isLoading, beginSearch, @@ -299,6 +301,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) { removeHistoryItem, resetResearch, researchError, + researchMutationError, runSearch, resetFilters, rows, diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index b4527aa..db369fc 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -25,9 +25,11 @@ const SUPPORT_PATH = "/support"; export function AuthenticatedAppLayout({ children, projectId, + banner, }: { children: React.ReactNode; projectId?: string; + banner?: React.ReactNode; }) { const location = useLocation(); const [drawerOpen, setDrawerOpen] = React.useState(false); @@ -119,6 +121,8 @@ export function AuthenticatedAppLayout({ seoApiKeyStatusError={seoApiKeyStatusError} /> + {banner} + = { "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.", + INSUFFICIENT_CREDITS: + "You've run out of credits. Add more credits or upgrade your plan to continue.", FORBIDDEN: "You do not have access to this resource.", NOT_FOUND: "The requested resource was not found.", AUDIT_CAPACITY_REACHED: diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9373309..eb2fc04 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -24,6 +24,7 @@ const hostedBaseUrlSchema = z function createAuth() { const baseUrl = getHostedBaseUrl(); + const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const auth = betterAuth({ baseURL: baseUrl, @@ -31,7 +32,7 @@ function createAuth() { ...baseAuthConfig, emailAndPassword: { ...baseAuthConfig.emailAndPassword, - requireEmailVerification: true, + requireEmailVerification: !bypassEmail, resetPasswordTokenExpiresIn: 60 * 60, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { @@ -41,16 +42,18 @@ function createAuth() { }); }, }, - emailVerification: { - sendOnSignUp: true, - autoSignInAfterVerification: true, - sendVerificationEmail: async ({ user, url }) => { - await sendHostedVerificationEmail({ - email: user.email, - confirmationUrl: url, - }); - }, - }, + emailVerification: bypassEmail + ? undefined + : { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + await sendHostedVerificationEmail({ + email: user.email, + confirmationUrl: url, + }); + }, + }, trustedOrigins: getTrustedOrigins(baseUrl), database: drizzleAdapter(db, { provider: "sqlite", @@ -138,7 +141,10 @@ export function hasHostedAuthConfig() { try { getHostedBaseUrl(); getHostedSecret(); - return hasHostedAuthEmailConfig(); + return ( + Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" || + hasHostedAuthEmailConfig() + ); } catch { return false; } diff --git a/src/routes/_app/billing.tsx b/src/routes/_app/billing.tsx index ff05f8f..2e3a213 100644 --- a/src/routes/_app/billing.tsx +++ b/src/routes/_app/billing.tsx @@ -1,19 +1,20 @@ -import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, notFound } from "@tanstack/react-router"; import { AutumnProvider, useCustomer } from "autumn-js/react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart"; import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils"; import { getBillingRouteState } from "@/client/features/billing/route-state"; +import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { - AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, + AUTUMN_PAID_PLAN_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + LOW_CREDITS_THRESHOLD_USD, AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, - SUBSCRIBE_ROUTE, autumnSeoDataCreditsToUsd, } from "@/shared/billing"; @@ -35,7 +36,6 @@ function BillingPage() { } function BillingPageContent() { - const navigate = useNavigate(); const { data: session, isPending: isSessionPending } = useSession(); const [topUpAmount, setTopUpAmount] = useState("20"); const [isPending, setIsPending] = useState(false); @@ -47,15 +47,13 @@ function BillingPageContent() { }, }); - const hasManagedServiceAccess = Boolean( - customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], - ); + const planStatus = getCustomerPlanStatus(customerQuery.data); + const isFreePlan = planStatus === "free"; const billingRouteState = getBillingRouteState({ hasSession: Boolean(session?.user?.id), isSessionPending, isCustomerLoading: customerQuery.isLoading, isCustomerError: customerQuery.isError, - hasManagedServiceAccess, }); const monthlyRemaining = autumnSeoDataCreditsToUsd( @@ -71,18 +69,7 @@ function BillingPageContent() { const { isValid: isValidTopUp, parsed: parsedTopUpAmount } = parseTopUpAmount(topUpAmount); - useEffect(() => { - if (billingRouteState !== "redirectToSubscribe") { - return; - } - - void navigate({ href: SUBSCRIBE_ROUTE, replace: true }); - }, [billingRouteState, navigate]); - - if ( - billingRouteState === "loading" || - billingRouteState === "redirectToSubscribe" - ) { + if (billingRouteState === "loading") { return null; } @@ -139,7 +126,7 @@ function BillingPageContent() {
{/* Subscription card */} -
+
${totalRemaining.toFixed(2)}{" "} @@ -147,100 +134,165 @@ function BillingPageContent() { remaining
-
- - Monthly ${monthlyRemaining.toFixed(2)} - - · - - Top-ups ${topUpRemaining.toFixed(2)} - -
-
- -
- Subscription{" "} - - {hasManagedServiceAccess ? "Active" : "Inactive"} - -
- - -
- - {/* Buy credits card */} -
-
- Buy credits -

- Top-up credits never expire and are used after your monthly - credits. -

-
- -
-
- $ - setTopUpAmount(e.target.value)} - /> -
- {topUpAmount.trim() !== "" && !isValidTopUp ? ( -

Enter between $10–$99.

+ {!isFreePlan ? ( +
+ + Monthly ${monthlyRemaining.toFixed(2)} + + · + + Top-ups ${topUpRemaining.toFixed(2)} + +
+ ) : null} + {totalRemaining <= 0 ? ( +

+ You’ve used all your credits.{" "} + {isFreePlan + ? "Upgrade your plan to continue." + : "Buy more credits below to continue."} +

+ ) : totalRemaining < LOW_CREDITS_THRESHOLD_USD ? ( +

+ You’re running low on credits.{" "} + {isFreePlan + ? "Upgrade to get $10/month." + : "Buy more credits below."} +

) : null}
- +
+ Plan{" "} + + {isFreePlan ? "Free Trial" : "Base Plan"} + +
+ + {isFreePlan ? ( +
+
+ Base Plan + + $10/month + +
+
    + {[ + "Access to all OpenSEO features", + "Includes $10.00 of Usage Credits each month", + ].map((item) => ( +
  • + + — + + {item} +
  • + ))} +
+ +
+ ) : ( + + )}
+ + {/* Buy credits card — paid plan only */} + {!isFreePlan ? ( +
+
+ Buy credits +

+ Top-up credits never expire and are used after your monthly + credits. +

+
+ +
+
+ $ + setTopUpAmount(e.target.value)} + /> +
+ {topUpAmount.trim() !== "" && !isValidTopUp ? ( +

+ Enter between $10–$99. +

+ ) : null} +
+ + +
+ ) : null}
{/* Usage chart */} - {hasManagedServiceAccess ? : null} + {error ?

{error}

: null} diff --git a/src/routes/_auth.sign-up.tsx b/src/routes/_auth.sign-up.tsx index c4587a0..51613b5 100644 --- a/src/routes/_auth.sign-up.tsx +++ b/src/routes/_auth.sign-up.tsx @@ -71,8 +71,7 @@ function SignUpPage() { password: value.password, callbackURL: (() => { const url = new URL("/verify-email", window.location.origin); - if (redirectTo !== "/") - url.searchParams.set("redirect", redirectTo); + url.searchParams.set("redirect", "/subscribe"); return url.toString(); })(), }); diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx index b74d162..2bfd259 100644 --- a/src/routes/_authenticated.subscribe.tsx +++ b/src/routes/_authenticated.subscribe.tsx @@ -1,18 +1,20 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { AutumnProvider, useCustomer } from "autumn-js/react"; import { useEffect, useState } from "react"; -import { User } from "lucide-react"; +import { ArrowRight, User } from "lucide-react"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { captureClientEvent } from "@/client/lib/posthog"; import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getSubscribeRouteState } from "@/client/features/billing/route-state"; -import { - AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, - AUTUMN_PAID_PLAN_ID, -} from "@/shared/billing"; +import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; +import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; export const Route = createFileRoute("/_authenticated/subscribe")({ + validateSearch: (search: Record) => ({ + upgrade: + search.upgrade === true || search.upgrade === "true" ? true : undefined, + }), component: SubscribePage, }); @@ -26,6 +28,7 @@ function SubscribePage() { function SubscribePageContent() { const navigate = useNavigate(); + const { upgrade: isUpgradeFlow } = Route.useSearch(); const { data: session } = useSession(); const [isAttaching, setIsAttaching] = useState(false); const [error, setError] = useState(null); @@ -39,14 +42,12 @@ function SubscribePageContent() { }, }); - const hasManagedServiceAccess = Boolean( - customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], - ); + const planStatus = getCustomerPlanStatus(customerQuery.data); const subscribeRouteState = getSubscribeRouteState({ hasSession: Boolean(session?.user?.id), isCustomerLoading: customerQuery.isLoading, isCustomerError: customerQuery.isError, - hasManagedServiceAccess, + planStatus, }); useEffect(() => { @@ -119,28 +120,41 @@ function SubscribePageContent() { } } + const firstName = session?.user?.name?.split(" ")[0] || ""; + return ( -
+
+
OpenSEO -

Get started

+

+ {isUpgradeFlow + ? "Upgrade your plan" + : firstName + ? `Welcome to OpenSEO, ${firstName}!` + : "Welcome to OpenSEO!"} +

+

+ SEO on your terms. All your SEO tools in one place at a fair price. +

-
+
Base Plan $10/month
-
    + +
      {[ - "Access to OpenSEO's managed service", + "Access to all OpenSEO features", + "Do keyword research, backlink analysis and site audits", "Includes $10.00 of Usage Credits each month", - "Credits are consumed as you go for SEO data and AI features", ].map((item) => (
    • ))}
    + + {error ?

    {error}

    : null} + + + +

    + Cancel anytime — no commitment. Powered by Stripe. +

- {error ?

{error}

: null} - - - -

- Cancel anytime — no commitment. Powered by Stripe. -

+
+ {isUpgradeFlow ? ( + + ) : ( + <> +

+ Or try it free — you have $0.50 of credits to explore before + committing. +

+ + + )} +
); } diff --git a/src/routes/_project/p/$projectId/route.tsx b/src/routes/_project/p/$projectId/route.tsx index 6701b29..0aabb40 100644 --- a/src/routes/_project/p/$projectId/route.tsx +++ b/src/routes/_project/p/$projectId/route.tsx @@ -1,6 +1,8 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; import { getErrorCode } from "@/client/lib/error-messages"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getCurrentAuthRedirectFromHref, getSignInSearch, @@ -33,7 +35,10 @@ function ProjectLayout() { const { projectId } = Route.useParams(); return ( - + : undefined} + > ); diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts index 784c6ae..306ff5e 100644 --- a/src/server/billing/subscription.test.ts +++ b/src/server/billing/subscription.test.ts @@ -86,6 +86,7 @@ describe("subscription billing", () => { expect(getOrCreateMock).toHaveBeenCalledWith({ customerId: "org_123", + email: "alice@example.com", }); }); }); diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 13ce11f..0408eab 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -16,6 +16,7 @@ export async function getOrCreateOrganizationCustomer( ) { const customer = await autumn.customers.getOrCreate({ customerId: context.organizationId, + email: context.userEmail, }); if (!customer.id) { diff --git a/src/server/lib/dataforseoClient.test.ts b/src/server/lib/dataforseoClient.test.ts index 3afb3a2..dfea24a 100644 --- a/src/server/lib/dataforseoClient.test.ts +++ b/src/server/lib/dataforseoClient.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, + SEO_DATA_COST_MARKUP, } from "@/shared/billing"; interface TrackCallArg { @@ -139,10 +141,15 @@ describe("meterDataforseoCall with split balances", () => { }); }); + const RAW_COST = 0.05; + const EXPECTED_CREDITS = Math.ceil( + RAW_COST * SEO_DATA_COST_MARKUP * AUTUMN_SEO_DATA_CREDITS_PER_USD, + ); + it("deducts entirely from monthly when monthly has enough", async () => { setupHostedMode(); mockBalances(5000, 3000); - mockDataforseoResult(0.05); + mockDataforseoResult(RAW_COST); const client = createDataforseoClient(billingCustomer); await client.backlinks.summary(backlinksInput); @@ -152,7 +159,7 @@ describe("meterDataforseoCall with split balances", () => { expect.objectContaining({ customerId: "org_123", featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, - value: 50, + value: EXPECTED_CREDITS, }), ); }); @@ -160,7 +167,7 @@ describe("meterDataforseoCall with split balances", () => { it("deducts entirely from topup when monthly is empty", async () => { setupHostedMode(); mockBalances(0, 5000); - mockDataforseoResult(0.05); + mockDataforseoResult(RAW_COST); const client = createDataforseoClient(billingCustomer); await client.backlinks.summary(backlinksInput); @@ -170,15 +177,16 @@ describe("meterDataforseoCall with split balances", () => { expect.objectContaining({ customerId: "org_123", featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, - value: 50, + value: EXPECTED_CREDITS, }), ); }); it("splits deduction across monthly and topup when monthly is partially sufficient", async () => { setupHostedMode(); - mockBalances(30, 5000); - mockDataforseoResult(0.05); + const monthlyAvailable = 30; + mockBalances(monthlyAvailable, 5000); + mockDataforseoResult(RAW_COST); const client = createDataforseoClient(billingCustomer); await client.backlinks.summary(backlinksInput); @@ -188,39 +196,29 @@ describe("meterDataforseoCall with split balances", () => { expect.objectContaining({ customerId: "org_123", featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, - value: 30, + value: monthlyAvailable, }), ); expect(trackMock).toHaveBeenCalledWith( expect.objectContaining({ customerId: "org_123", featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, - value: 20, + value: EXPECTED_CREDITS - monthlyAvailable, }), ); }); - 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 () => { + it("throws INSUFFICIENT_CREDITS when both balances are exactly zero", async () => { setupHostedMode(); mockBalances(0, 0); + mockDataforseoResult(0.05); const client = createDataforseoClient(billingCustomer); await expect( client.backlinks.summary(backlinksInput), - ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" }); + ).rejects.toMatchObject({ code: "INSUFFICIENT_CREDITS" }); + + expect(trackMock).not.toHaveBeenCalled(); }); it("includes balanceFeatureId in track properties", async () => { diff --git a/src/server/lib/dataforseoClient.ts b/src/server/lib/dataforseoClient.ts index 044d438..cdfceec 100644 --- a/src/server/lib/dataforseoClient.ts +++ b/src/server/lib/dataforseoClient.ts @@ -2,7 +2,7 @@ import { AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, - MINIMUM_SEO_DATA_BALANCE_USD, + SEO_DATA_COST_MARKUP, roundUsdForBilling, } from "@/shared/billing"; import { autumn } from "@/server/billing/autumn"; @@ -222,10 +222,9 @@ async function meterDataforseoCall( const billingCustomer = await getOrCreateOrganizationCustomer(customer); - const { monthlyRemaining } = await assertSeoDataBalanceAvailable({ - customerId: billingCustomer.id, - minimumBalanceUsd: MINIMUM_SEO_DATA_BALANCE_USD, - }); + const { monthlyRemaining } = await assertSeoDataBalanceAvailable( + billingCustomer.id, + ); const result = await execute(); @@ -239,22 +238,14 @@ async function meterDataforseoCall( return result.data; } -async function assertSeoDataBalanceAvailable(args: { - customerId: string; - minimumBalanceUsd: number; -}) { - const minimumCredits = Math.ceil( - roundUsdForBilling(args.minimumBalanceUsd) * - AUTUMN_SEO_DATA_CREDITS_PER_USD, - ); - +async function assertSeoDataBalanceAvailable(customerId: string) { const [monthlyCheck, topupCheck] = await Promise.all([ autumn.check({ - customerId: args.customerId, + customerId, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, }), autumn.check({ - customerId: args.customerId, + customerId, featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, }), ]); @@ -262,8 +253,8 @@ async function assertSeoDataBalanceAvailable(args: { const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0; const topupRemaining = topupCheck.balance?.remaining ?? 0; - if (monthlyRemaining + topupRemaining < minimumCredits) { - throw new AppError("PAYMENT_REQUIRED"); + if (monthlyRemaining + topupRemaining <= 0) { + throw new AppError("INSUFFICIENT_CREDITS"); } return { monthlyRemaining }; @@ -275,7 +266,9 @@ async function trackDataforseoCost(args: { billing: DataforseoApiCallCost; monthlyRemaining: number; }) { - const totalCostUsd = roundUsdForBilling(args.billing.costUsd); + const totalCostUsd = roundUsdForBilling( + args.billing.costUsd * SEO_DATA_COST_MARKUP, + ); const totalCostCredits = Math.ceil( totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD, ); diff --git a/src/shared/billing.ts b/src/shared/billing.ts index 5f9ee05..900d1bb 100644 --- a/src/shared/billing.ts +++ b/src/shared/billing.ts @@ -8,7 +8,8 @@ export const AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID = 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_CREDITS_PER_USD = 1000; -export const MINIMUM_SEO_DATA_BALANCE_USD = 0.15; +export const SEO_DATA_COST_MARKUP = 1.28; +export const LOW_CREDITS_THRESHOLD_USD = 0.25; export function roundUsdForBilling(value: number) { return Math.round(value * 100000) / 100000; diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index 4e700e2..984ace3 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -4,6 +4,7 @@ const ERROR_CODES = [ "UNAUTHENTICATED", "AUTH_CONFIG_MISSING", "PAYMENT_REQUIRED", + "INSUFFICIENT_CREDITS", "FORBIDDEN", "NOT_FOUND", "AUDIT_CAPACITY_REACHED", @@ -24,6 +25,7 @@ const NON_REPORTABLE_ERROR_CODES = new Set([ "UNAUTHENTICATED", "NOT_FOUND", "PAYMENT_REQUIRED", + "INSUFFICIENT_CREDITS", "VALIDATION_ERROR", ]);