diff --git a/package.json b/package.json index ef71ffd..84192d2 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@tanstack/react-router": "^1.136.3", "@tanstack/react-router-devtools": "^1.136.3", "@tanstack/react-start": "^1.136.3", - "autumn-js": "^1.1.2", + "autumn-js": "^1.1.7", "better-auth": "^1.5.5", "cheerio": "^1.2.0", "cloudflare": "^5.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0de271..c0025a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.136.3 version: 1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) autumn-js: - specifier: ^1.1.2 - version: 1.1.2(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) + specifier: ^1.1.7 + version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) better-auth: specifier: ^1.5.5 version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) @@ -2044,8 +2044,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autumn-js@1.1.2: - resolution: {integrity: sha512-glSAvGJ6G8cJKLH6enonQ0WverZUMnGirv6hYzMBZViFqAiU6IDrEbbD0Xl7LGUN/pUjd9JgVwrmxeu3cnLO0g==} + autumn-js@1.1.7: + resolution: {integrity: sha512-wd7dbaVY1ZORIS2nSIvcuroD6wfbW3qZhiKB/1zN2r851116iVILuaV83cC6GRMKs6b93dFOjpuMmnUtlRYj3A==} peerDependencies: better-auth: ^1.3.17 better-call: ^1.0.12 @@ -5066,7 +5066,7 @@ snapshots: asynckit@0.4.0: {} - autumn-js@1.1.2(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4): + autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4): dependencies: query-string: 9.3.1 rou3: 0.6.3 diff --git a/src/client/features/billing/BillingRouteParts.tsx b/src/client/features/billing/BillingRouteParts.tsx index 4e495b6..32c0f01 100644 --- a/src/client/features/billing/BillingRouteParts.tsx +++ b/src/client/features/billing/BillingRouteParts.tsx @@ -1,7 +1,11 @@ import { CreditCard } from "lucide-react"; import type { ReactNode } from "react"; -export function BillingHeader({ hasPaidPlan }: { hasPaidPlan: boolean }) { +export function BillingHeader(args: { + hasManagedServiceAccess: boolean; + basePlanName: string; + includedCreditsLabel: string; +}) { return (
@@ -9,49 +13,55 @@ export function BillingHeader({ hasPaidPlan }: { hasPaidPlan: boolean }) { Hosted billing

- {hasPaidPlan ? "Billing" : "Choose a plan"} + {args.hasManagedServiceAccess ? "Billing" : "Choose a plan"}

- {hasPaidPlan - ? "OpenSEO hosted usage is metered against your shared backlinks balance. Your monthly plan covers the first $5, and extra top-ups carry forward." - : "You need a paid plan to use OpenSEO's managed service. The base plan costs $5/month and includes $5 of usage credits. If you use all of those credits, you can buy more at any time."} + {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.`}

); } -export function SubscriptionIntro({ hasPaidPlan }: { hasPaidPlan: boolean }) { +export function SubscriptionIntro(args: { + hasManagedServiceAccess: boolean; + basePlanName: string; +}) { return (

- {hasPaidPlan ? "Subscription" : "Managed service access"} + {args.hasManagedServiceAccess + ? "Subscription" + : "Managed service access"}

- {hasPaidPlan + {args.hasManagedServiceAccess ? "Hosted workspaces need an active paid plan before project pages and DataForSEO-backed features are available." - : "Start the base plan 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 usage credits.`}

); } -export function SubscriptionStatusCard({ - hasPaidPlan, -}: { - hasPaidPlan: boolean; +export function SubscriptionStatusCard(args: { + hasManagedServiceAccess: boolean; + basePlanName: string; + basePlanPrice: string; + includedCreditsLabel: string; }) { return (
- {hasPaidPlan ? "Current status" : "Base plan"} + {args.hasManagedServiceAccess ? "Current status" : args.basePlanName}
- {hasPaidPlan ? "Active" : "$5/month"} + {args.hasManagedServiceAccess ? "Active" : args.basePlanPrice}
- {hasPaidPlan + {args.hasManagedServiceAccess ? "Your organization can use hosted OpenSEO features." - : "Includes $5 of usage credits every month."} + : `Includes ${args.includedCreditsLabel} of usage credits every cycle.`}
); @@ -59,7 +69,8 @@ export function SubscriptionStatusCard({ export function BillingAlerts(args: { actionError: string | null; - hasPaidPlan: boolean; + hasManagedServiceAccess: boolean; + basePlanName: string; }) { return ( <> @@ -69,11 +80,11 @@ export function BillingAlerts(args: { ) : null} - {!args.hasPaidPlan ? ( + {!args.hasManagedServiceAccess ? (
- Subscribe to the base plan first. After that, you can manage your - plan and buy more credits here. + Subscribe to {args.basePlanName} first. After that, you can manage + your plan and buy more credits here.
) : null} diff --git a/src/client/features/billing/HostedBillingContent.tsx b/src/client/features/billing/HostedBillingContent.tsx new file mode 100644 index 0000000..bcde3a9 --- /dev/null +++ b/src/client/features/billing/HostedBillingContent.tsx @@ -0,0 +1,335 @@ +import type { UseCustomerResult } from "autumn-js/react"; +import { ExternalLink, LoaderCircle } from "lucide-react"; +import { useState } from "react"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { + BillingAlerts, + BillingHeader, + SubscriptionIntro, + SubscriptionStatusCard, +} from "@/client/features/billing/BillingRouteParts"; +import { + AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, + AUTUMN_PAID_PLAN_ID, + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_CREDITS_PER_USD, + AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, +} from "@/shared/billing"; +import { + formatCreditAmount, + formatPlanPrice, + getIncludedFeatureQuantity, + parseTopUpAmount, +} from "@/client/features/billing/HostedBillingContentUtils"; + +type BillingAction = "start-plan" | "open-portal" | "top-up" | null; + +type BillingCustomerQuery = Pick< + UseCustomerResult, + "data" | "attach" | "openCustomerPortal" | "refetch" +>; + +type BillingPlan = { + id: string; + name: string; + items: Array<{ featureId: string; included: number }>; + price?: { + amount?: number | null; + interval?: string | null; + } | null; +}; + +type HostedBillingContentProps = { + customerQuery: BillingCustomerQuery; + plans: BillingPlan[]; +}; + +export function HostedBillingContent({ + customerQuery, + plans, +}: HostedBillingContentProps) { + const [actionError, setActionError] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + const [topUpAmount, setTopUpAmount] = useState("20"); + + const customer = customerQuery.data; + const basePlan = + plans.find((plan) => plan.id === AUTUMN_PAID_PLAN_ID) ?? null; + const balance = + customer?.balances?.[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID] ?? null; + const hasManagedServiceAccess = Boolean( + customer?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], + ); + const isActionPending = pendingAction !== null; + const basePlanName = basePlan?.name ?? "Base Plan"; + const basePlanPrice = formatPlanPrice( + basePlan?.price?.amount, + basePlan?.price?.interval, + ); + const includedCreditsLabel = formatCreditAmount( + getIncludedFeatureQuantity(basePlan, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID), + ); + const { isValid: isValidTopUpAmount, parsed: parsedTopUpAmount } = + parseTopUpAmount(topUpAmount); + const topUpDisabled = + !hasManagedServiceAccess || isActionPending || !isValidTopUpAmount; + + const runBillingAction = async ( + action: BillingAction, + callback: () => Promise, + fallbackMessage: string, + ) => { + setActionError(null); + setPendingAction(action); + + try { + await callback(); + if (action !== "open-portal") { + await customerQuery.refetch(); + } + } catch (error) { + setActionError(getStandardErrorMessage(error, fallbackMessage)); + } finally { + setPendingAction(null); + } + }; + + return ( +
+ + +
+ { + void runBillingAction( + "open-portal", + () => + customerQuery.openCustomerPortal({ + returnUrl: window.location.href, + }), + "We could not open the billing portal. Please try again.", + ); + }} + onStartPlan={() => { + void runBillingAction( + "start-plan", + () => + customerQuery.attach({ + planId: AUTUMN_PAID_PLAN_ID, + redirectMode: "always", + successUrl: window.location.href, + }), + "We could not open the hosted billing flow. Please try again.", + ); + }} + /> + + { + void runBillingAction( + "top-up", + () => + customerQuery.attach({ + planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, + redirectMode: "always", + successUrl: window.location.href, + featureQuantities: [ + { + featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + quantity: Math.round( + parsedTopUpAmount * AUTUMN_SEO_DATA_CREDITS_PER_USD, + ), + }, + ], + }), + "We could not open the credit purchase flow. Please try again.", + ); + }} + /> +
+ + + +
+ + Hosted billing is powered by Autumn. +
+
+ ); +} + +function SubscriptionSection(args: { + basePlanName: string; + basePlanPrice: string; + hasManagedServiceAccess: boolean; + includedCreditsLabel: string; + isActionPending: boolean; + isPortalPending: boolean; + isStartPlanPending: boolean; + onOpenPortal: () => void; + onStartPlan: () => void; +}) { + return ( +
+
+ + + + + {args.hasManagedServiceAccess ? ( + + ) : ( + + )} +
+
+ ); +} + +function SeoDataCreditsSection(args: { + balance: { + granted: number; + remaining: number; + usage: number; + } | null; + basePlanName: string; + hasManagedServiceAccess: boolean; + isTopUpPending: boolean; + topUpAmount: string; + topUpDisabled: boolean; + onTopUp: () => void; + onTopUpAmountChange: (value: string) => void; +}) { + return ( +
+
+
+

+ SEO data credits +

+

+ Buy extra usage credits for DataForSEO-powered features like + backlinks. +

+
+ +
+
+ Remaining + + {formatCreditAmount(args.balance?.remaining ?? 0)} + +
+
+ + +
+
+ + + + + +

+ Credit purchases use our hosted checkout flow and apply to your + organization balance. +

+
+
+ ); +} + +function CreditStat({ label, value }: { label: string; value: number }) { + return ( +
+ + {label} + + + {formatCreditAmount(value)} + +
+ ); +} diff --git a/src/client/features/billing/HostedBillingContentUtils.ts b/src/client/features/billing/HostedBillingContentUtils.ts new file mode 100644 index 0000000..259ba26 --- /dev/null +++ b/src/client/features/billing/HostedBillingContentUtils.ts @@ -0,0 +1,74 @@ +import { autumnSeoDataCreditsToUsd } from "@/shared/billing"; + +export function getIncludedFeatureQuantity( + plan: { items: Array<{ featureId: string; included: number }> } | null, + featureId: string, +) { + return ( + plan?.items.find((item) => item.featureId === featureId)?.included ?? 0 + ); +} + +export function parseTopUpAmount(value: string) { + const trimmed = value.trim(); + + if (!/^\d+$/.test(trimmed)) { + return { + isValid: false, + parsed: 20, + }; + } + + const parsed = Number(trimmed); + const isValid = Number.isInteger(parsed) && parsed >= 10 && parsed <= 99; + + return { + isValid, + parsed: isValid ? parsed : 20, + }; +} + +export function formatCreditAmount(value: number) { + return formatUsd(autumnSeoDataCreditsToUsd(value)); +} + +export function formatPlanPrice( + amount?: number | null, + interval?: string | null, +) { + if (typeof amount !== "number" || !interval) { + return "$5/month"; + } + + return `${formatUsd(amount, amount % 1 === 0 ? 0 : 2)}/${intervalToLabel(interval)}`; +} + +function formatUsd(value: number, minimumFractionDigits = 2) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits, + maximumFractionDigits: 2, + }).format(value); +} + +function intervalToLabel(interval: string) { + switch (interval) { + case "month": + return "month"; + case "year": + return "year"; + case "quarter": + return "quarter"; + case "semi_annual": + return "6 months"; + case "week": + return "week"; + case "day": + return "day"; + case "one_off": + return "one-time"; + default: + return interval; + } +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b322eb9..ba3acbb 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AppBillingRouteImport } from './routes/_app/billing' +import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' @@ -79,6 +80,11 @@ const AppBillingRoute = AppBillingRouteImport.update({ path: '/billing', getParentRoute: () => AppRouteRoute, } as any) +const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ + id: '/api/autumn/$', + path: '/api/autumn/$', + getParentRoute: () => rootRouteImport, +} as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', @@ -155,6 +161,7 @@ export interface FileRoutesByFullPath { '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/autumn/$': typeof ApiAutumnSplatRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute @@ -175,6 +182,7 @@ export interface FileRoutesByTo { '/sign-up': typeof AuthSignUpRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/autumn/$': typeof ApiAutumnSplatRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute @@ -199,6 +207,7 @@ export interface FileRoutesById { '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/api/autumn/$': typeof ApiAutumnSplatRoute '/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute @@ -222,6 +231,7 @@ export interface FileRouteTypes { | '/p/$projectId' | '/help/dataforseo-api-key' | '/api/auth/$' + | '/api/autumn/$' | '/p/$projectId/ai' | '/p/$projectId/audit' | '/p/$projectId/backlinks' @@ -242,6 +252,7 @@ export interface FileRouteTypes { | '/sign-up' | '/help/dataforseo-api-key' | '/api/auth/$' + | '/api/autumn/$' | '/p/$projectId/ai' | '/p/$projectId/backlinks' | '/p/$projectId/domain' @@ -265,6 +276,7 @@ export interface FileRouteTypes { | '/_project/p/$projectId' | '/_app/help/dataforseo-api-key' | '/api/auth/$' + | '/api/autumn/$' | '/_project/p/$projectId/ai' | '/_project/p/$projectId/audit' | '/_project/p/$projectId/backlinks' @@ -284,6 +296,7 @@ export interface RootRouteChildren { ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailRoute: typeof VerifyEmailRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute + ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute } declare module '@tanstack/react-router' { @@ -358,6 +371,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppBillingRouteImport parentRoute: typeof AppRouteRoute } + '/api/autumn/$': { + id: '/api/autumn/$' + path: '/api/autumn/$' + fullPath: '/api/autumn/$' + preLoaderRoute: typeof ApiAutumnSplatRouteImport + parentRoute: typeof rootRouteImport + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -536,6 +556,7 @@ const rootRouteChildren: RootRouteChildren = { ResetPasswordRoute: ResetPasswordRoute, VerifyEmailRoute: VerifyEmailRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, + ApiAutumnSplatRoute: ApiAutumnSplatRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/_app/billing.tsx b/src/routes/_app/billing.tsx index 7846766..60b0ed0 100644 --- a/src/routes/_app/billing.tsx +++ b/src/routes/_app/billing.tsx @@ -1,23 +1,10 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { ExternalLink, LoaderCircle } from "lucide-react"; -import { useState } from "react"; -import { - BillingAlerts, - BillingHeader, - CenteredCard, - SubscriptionIntro, - SubscriptionStatusCard, -} from "@/client/features/billing/BillingRouteParts"; -import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { AutumnProvider, useCustomer, useListPlans } from "autumn-js/react"; +import { LoaderCircle } from "lucide-react"; +import { CenteredCard } from "@/client/features/billing/BillingRouteParts"; +import { HostedBillingContent } from "@/client/features/billing/HostedBillingContent"; import { useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; -import { - createBacklinksTopUpCheckout, - createHostedSubscriptionCheckout, - getHostedBillingStatus, - openHostedBillingPortal, -} from "@/serverFunctions/billing"; export const Route = createFileRoute("/_app/billing")({ beforeLoad: () => { @@ -29,24 +16,32 @@ export const Route = createFileRoute("/_app/billing")({ }); function BillingPage() { - const { data: session, isPending: isSessionPending } = useSession(); - const [actionError, setActionError] = useState(null); - const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false); - const [topUpAmount, setTopUpAmount] = useState("20"); + return ( + + + + ); +} - const billingStatusQuery = useQuery({ - queryKey: ["billing", "status"], - queryFn: () => getHostedBillingStatus(), - enabled: Boolean(session?.user?.id), +function BillingPageContent() { + const { data: session, isPending: isSessionPending } = useSession(); + + const customerQuery = useCustomer({ + queryOptions: { + enabled: Boolean(session?.user?.id), + }, }); - const { billingPortalMutation, startSubscriptionMutation, topUpMutation } = - useBillingActions({ - setActionError, - setIsRedirectingToCheckout, - }); + const plansQuery = useListPlans({ + queryOptions: { + enabled: Boolean(session?.user?.id), + }, + }); - if (isSessionPending || (session?.user?.id && billingStatusQuery.isLoading)) { + if ( + isSessionPending || + (session?.user?.id && (customerQuery.isLoading || plansQuery.isLoading)) + ) { return (
@@ -68,7 +63,7 @@ function BillingPage() { ); } - if (billingStatusQuery.isError) { + if (customerQuery.isError || plansQuery.isError) { return ( - ); - } - return ( -
- - -
-
-
- - - - - {!hasPaidPlan ? ( - - ) : null} - - {hasPaidPlan ? ( - - ) : null} -
-
- -
-
-
-

- SEO data credits -

-

- Buy extra usage credits for DataForSEO-powered features like - backlinks. -

-
- -
-
- Remaining - - {formatUsd(balance?.remaining ?? 0)} - -
-
-
- - Granted - - - {formatUsd(balance?.granted ?? 0)} - -
-
- - Used - - - {formatUsd(balance?.usage ?? 0)} - -
-
-
- - - - - -

- Credit purchases use our hosted checkout flow and apply to your - organization balance. -

-
-
-
- - - -
- - Hosted billing is powered by Autumn. -
-
+ ); } - -function useBillingActions(args: { - setActionError: (value: string | null) => void; - setIsRedirectingToCheckout: (value: boolean) => void; -}) { - const setBillingActionError = (error: unknown, fallback: string) => { - args.setActionError(getStandardErrorMessage(error, fallback)); - }; - - const billingPortalMutation = useMutation({ - mutationFn: async () => { - args.setActionError(null); - - try { - const result = await openHostedBillingPortal({ - data: { returnUrl: window.location.href }, - }); - redirectToHostedUrl(result.url, args.setActionError); - } catch (error) { - setBillingActionError( - error, - "We could not open the billing portal. Please try again.", - ); - } - }, - }); - - const startSubscriptionMutation = useMutation({ - mutationFn: async () => { - args.setActionError(null); - args.setIsRedirectingToCheckout(true); - - try { - const result = await createHostedSubscriptionCheckout({ - data: { returnUrl: window.location.href }, - }); - redirectToHostedUrl(result.url, args.setActionError); - } catch (error) { - setBillingActionError( - error, - "We could not open the hosted billing flow. Please try again.", - ); - } finally { - args.setIsRedirectingToCheckout(false); - } - }, - }); - - const topUpMutation = useMutation({ - mutationFn: async ({ amount }: { amount: number }) => { - args.setActionError(null); - args.setIsRedirectingToCheckout(true); - - try { - const result = await createBacklinksTopUpCheckout({ - data: { - returnUrl: window.location.href, - amount, - }, - }); - redirectToHostedUrl(result.url, args.setActionError); - } catch (error) { - setBillingActionError( - error, - "We could not open the credit purchase flow. Please try again.", - ); - } finally { - args.setIsRedirectingToCheckout(false); - } - }, - }); - - return { - billingPortalMutation, - startSubscriptionMutation, - topUpMutation, - }; -} - -function redirectToHostedUrl( - url: string | null | undefined, - setActionError: (value: string | null) => void, -) { - if (!url) { - setActionError( - "We could not open the hosted billing flow. Please try again.", - ); - return; - } - - window.location.assign(url); -} - -function parseTopUpAmount(value: string) { - const trimmed = value.trim(); - - if (!/^\d+$/.test(trimmed)) { - return { - isValid: false, - parsed: 20, - }; - } - - const parsed = Number(trimmed); - const isValid = Number.isInteger(parsed) && parsed >= 10 && parsed <= 99; - - return { - isValid, - parsed: isValid ? parsed : 20, - }; -} - -function formatUsd(value: number) { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); -} diff --git a/src/routes/api/autumn/$.ts b/src/routes/api/autumn/$.ts new file mode 100644 index 0000000..d794578 --- /dev/null +++ b/src/routes/api/autumn/$.ts @@ -0,0 +1,38 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { autumnHandler } from "autumn-js/fetch"; +import { env } from "cloudflare:workers"; +import { isHostedAuthMode } from "@/lib/auth-mode"; +import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; + +const handler = autumnHandler({ + identify: async (request) => { + const context = await resolveHostedContext(request.headers); + + return { + customerId: context.organizationId, + }; + }, +}); + +function handleAutumnRequest(request: Request) { + if (!isHostedAuthMode(env.AUTH_MODE)) { + return new Response("Not found", { + status: 404, + }); + } + + return handler(request); +} + +export const Route = createFileRoute("/api/autumn/$")({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + return handleAutumnRequest(request); + }, + POST: async ({ request }: { request: Request }) => { + return handleAutumnRequest(request); + }, + }, + }, +}); diff --git a/src/server/billing/subscription.test.ts b/src/server/billing/subscription.test.ts index 689d5d0..844e9a1 100644 --- a/src/server/billing/subscription.test.ts +++ b/src/server/billing/subscription.test.ts @@ -1,21 +1,31 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; +import { AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID } from "@/shared/billing"; -const { getOrCreateMock } = vi.hoisted(() => ({ - getOrCreateMock: vi.fn(), -})); +const { checkMock, getOrCreateMock, isHostedServerAuthModeMock } = vi.hoisted( + () => ({ + checkMock: vi.fn(), + getOrCreateMock: vi.fn(), + isHostedServerAuthModeMock: vi.fn(), + }), +); vi.mock("@/server/billing/autumn", () => ({ autumn: { + check: checkMock, customers: { getOrCreate: getOrCreateMock, }, }, })); +vi.mock("@/server/lib/runtime-env", () => ({ + isHostedServerAuthMode: isHostedServerAuthModeMock, +})); + import { + customerHasManagedServiceAccess, getOrCreateOrganizationCustomer, - hasActivePaidPlan, + requireManagedServiceAccess, } from "./subscription"; describe("subscription billing", () => { @@ -23,37 +33,47 @@ describe("subscription billing", () => { vi.clearAllMocks(); }); - it("keeps access when an active plan is scheduled to cancel", () => { - expect( - hasActivePaidPlan({ - subscriptions: [ - { - planId: AUTUMN_PAID_PLAN_ID, - status: "active", - pastDue: false, - canceledAt: Date.now(), - }, - ], - }), - ).toBe(true); + it("checks the managed service access entitlement", async () => { + checkMock.mockResolvedValue({ allowed: true }); + + await expect(customerHasManagedServiceAccess("org_123")).resolves.toBe( + true, + ); + + expect(checkMock).toHaveBeenCalledWith({ + customerId: "org_123", + featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, + }); }); - it("rejects past-due paid plans", () => { - expect( - hasActivePaidPlan({ - subscriptions: [ - { - planId: AUTUMN_PAID_PLAN_ID, - status: "active", - pastDue: true, - canceledAt: null, - }, - ], + it("skips the managed service check outside hosted mode", async () => { + isHostedServerAuthModeMock.mockResolvedValue(false); + + await expect( + requireManagedServiceAccess({ + organizationId: "org_123", + userEmail: "alice@example.com", }), - ).toBe(false); + ).resolves.toBeUndefined(); + + expect(getOrCreateMock).not.toHaveBeenCalled(); + expect(checkMock).not.toHaveBeenCalled(); }); - it("does not rewrite the org billing email on lookup", async () => { + it("throws payment required when the org lacks managed service access", async () => { + isHostedServerAuthModeMock.mockResolvedValue(true); + getOrCreateMock.mockResolvedValue({ id: "org_123" }); + checkMock.mockResolvedValue({ allowed: false }); + + await expect( + requireManagedServiceAccess({ + organizationId: "org_123", + userEmail: "alice@example.com", + }), + ).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" }); + }); + + it("looks up the billing customer by organization id", async () => { getOrCreateMock.mockResolvedValue({ id: "cust_123" }); await getOrCreateOrganizationCustomer({ @@ -63,7 +83,6 @@ describe("subscription billing", () => { expect(getOrCreateMock).toHaveBeenCalledWith({ customerId: "org_123", - name: "org_123", }); }); }); diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index 0b8cc91..4db95b8 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -1,5 +1,5 @@ import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; -import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; +import { AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID } from "@/shared/billing"; import { autumn } from "@/server/billing/autumn"; import { AppError } from "@/server/lib/errors"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; @@ -14,7 +14,6 @@ export async function getOrCreateOrganizationCustomer( ) { const customer = await autumn.customers.getOrCreate({ customerId: context.organizationId, - name: context.organizationId, }); if (!customer.id) { @@ -27,23 +26,16 @@ export async function getOrCreateOrganizationCustomer( }; } -export function hasActivePaidPlan(customer: { - subscriptions: Array<{ - planId: string; - status: string; - pastDue: boolean; - canceledAt: number | null; - }>; -}) { - return customer.subscriptions.some( - (subscription) => - subscription.planId === AUTUMN_PAID_PLAN_ID && - subscription.status === "active" && - !subscription.pastDue, - ); +export async function customerHasManagedServiceAccess(customerId: string) { + const result = await autumn.check({ + customerId, + featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, + }); + + return result.allowed; } -export async function requireHostedPaidSubscription( +export async function requireManagedServiceAccess( context: BillingCustomerContext, ) { if (!(await isHostedServerAuthMode())) { @@ -51,7 +43,7 @@ export async function requireHostedPaidSubscription( } const customer = await getOrCreateOrganizationCustomer(context); - if (!hasActivePaidPlan(customer)) { + if (!(await customerHasManagedServiceAccess(customer.id))) { throw new AppError("PAYMENT_REQUIRED"); } } diff --git a/src/serverFunctions/billing.ts b/src/serverFunctions/billing.ts deleted file mode 100644 index 47042b6..0000000 --- a/src/serverFunctions/billing.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; -import { - AUTUMN_SEO_DATA_CREDITS_PER_USD, - AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, - autumnSeoDataCreditsToUsd, - AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, - AUTUMN_PAID_PLAN_ID, -} from "@/shared/billing"; -import { autumn } from "@/server/billing/autumn"; -import { - getOrCreateOrganizationCustomer, - hasActivePaidPlan, -} from "@/server/billing/subscription"; -import { AppError } from "@/server/lib/errors"; -import { requireEnsuredUserContext } from "@/serverFunctions/middleware"; - -const billingReturnUrlSchema = z.object({ - returnUrl: z.string().url(), -}); - -const backlinksTopUpSchema = z.object({ - returnUrl: z.string().url(), - amount: z.number().int().min(10).max(99), -}); - -export const getHostedBillingStatus = createServerFn({ method: "GET" }) - .middleware(requireEnsuredUserContext) - .handler(async ({ context }) => { - const customer = await getOrCreateOrganizationCustomer(context); - const balance = - customer.balances[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID] ?? null; - const hasPaidPlan = hasActivePaidPlan(customer); - - return { - customerId: customer.id, - hasPaidPlan, - balance: balance - ? { - featureId: balance.featureId, - granted: autumnSeoDataCreditsToUsd(balance.granted), - remaining: autumnSeoDataCreditsToUsd(balance.remaining), - usage: autumnSeoDataCreditsToUsd(balance.usage), - nextResetAt: balance.nextResetAt ?? null, - } - : null, - }; - }); - -export const createHostedSubscriptionCheckout = createServerFn({ - method: "POST", -}) - .middleware(requireEnsuredUserContext) - .inputValidator((data: unknown) => billingReturnUrlSchema.parse(data)) - .handler(async ({ context, data }) => { - const customer = await getOrCreateOrganizationCustomer(context); - const response = await autumn.billing.attach({ - customerId: customer.id, - planId: AUTUMN_PAID_PLAN_ID, - redirectMode: "always", - successUrl: data.returnUrl, - }); - - return { url: response.paymentUrl }; - }); - -export const createBacklinksTopUpCheckout = createServerFn({ method: "POST" }) - .middleware(requireEnsuredUserContext) - .inputValidator((data: unknown) => backlinksTopUpSchema.parse(data)) - .handler(async ({ context, data }) => { - const customer = await getOrCreateOrganizationCustomer(context); - - if (!hasActivePaidPlan(customer)) { - throw new AppError("PAYMENT_REQUIRED"); - } - - const response = await autumn.billing.attach({ - customerId: customer.id, - planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, - redirectMode: "always", - successUrl: data.returnUrl, - featureQuantities: [ - { - featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, - quantity: Math.round(data.amount * AUTUMN_SEO_DATA_CREDITS_PER_USD), - }, - ], - }); - - return { url: response.paymentUrl }; - }); - -export const openHostedBillingPortal = createServerFn({ method: "POST" }) - .middleware(requireEnsuredUserContext) - .inputValidator((data: unknown) => billingReturnUrlSchema.parse(data)) - .handler(async ({ context, data }) => { - const customer = await getOrCreateOrganizationCustomer(context); - const response = await autumn.billing.openCustomerPortal({ - customerId: customer.id, - returnUrl: data.returnUrl, - }); - - return { url: response.url }; - }); diff --git a/src/serverFunctions/middleware.ts b/src/serverFunctions/middleware.ts index 37c313d..f96b543 100644 --- a/src/serverFunctions/middleware.ts +++ b/src/serverFunctions/middleware.ts @@ -3,7 +3,7 @@ import { AppError } from "@/server/lib/errors"; import { errorHandlingMiddleware } from "@/middleware/errorHandling"; import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; import { ensureUserMiddleware } from "@/middleware/ensureUser"; -import { requireHostedPaidSubscription } from "@/server/billing/subscription"; +import { requireManagedServiceAccess } from "@/server/billing/subscription"; type AuthenticatedServerFunctionContext = EnsuredUserContext; @@ -45,7 +45,7 @@ export const globalServerFunctionMiddleware = [ export const requireAuthenticatedContext = [ createMiddleware({ type: "function" }).server(async ({ next, context }) => { const authenticatedContext = getAuthenticatedContext(context); - await requireHostedPaidSubscription(authenticatedContext); + await requireManagedServiceAccess(authenticatedContext); return next({ context: authenticatedContext, @@ -53,19 +53,11 @@ export const requireAuthenticatedContext = [ }), ] as const; -export const requireEnsuredUserContext = [ - createMiddleware({ type: "function" }).server(({ next, context }) => - next({ - context: getAuthenticatedContext(context), - }), - ), -] as const; - export const requireProjectContext = [ createMiddleware({ type: "function" }).server(async ({ next, context }) => { const authenticatedContext = getAuthenticatedContext(context); - await requireHostedPaidSubscription(authenticatedContext); + await requireManagedServiceAccess(authenticatedContext); if (!authenticatedContext.project) { throw new AppError( diff --git a/src/shared/billing.ts b/src/shared/billing.ts index aa5865c..36095a5 100644 --- a/src/shared/billing.ts +++ b/src/shared/billing.ts @@ -2,6 +2,8 @@ export const BILLING_ROUTE = "/billing"; export const AUTUMN_PAID_PLAN_ID = "base-plan"; 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_USAGE_FEATURE_ID = "seo_data_usage"; export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;