hosted: add free trial plan and onboarding (#104)

This commit is contained in:
Ben Senescu 2026-04-08 19:06:35 -04:00 committed by GitHub
parent 01c11b0c5f
commit 1d9a503335
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 493 additions and 221 deletions

View File

@ -47,7 +47,7 @@ export function BillingUsageChart() {
const totalSpend = chartData.reduce((sum, d) => sum + d.credits, 0); const totalSpend = chartData.reduce((sum, d) => sum + d.credits, 0);
return ( return (
<div className="rounded-lg border border-base-300 p-4 space-y-3"> <div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
<div className="flex items-baseline justify-between gap-4"> <div className="flex items-baseline justify-between gap-4">
<span className="font-semibold">Usage</span> <span className="font-semibold">Usage</span>
<span className="text-xs text-base-content/50">Last 30 days</span> <span className="text-xs text-base-content/50">Last 30 days</span>
@ -69,7 +69,7 @@ export function BillingUsageChart() {
width={chartWidth} width={chartWidth}
height={128} height={128}
data={chartData} data={chartData}
margin={{ top: 4, right: 0, bottom: 0, left: -20 }} margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
> >
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"

View File

@ -0,0 +1,129 @@
import { Link } from "@tanstack/react-router";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
BILLING_ROUTE,
LOW_CREDITS_THRESHOLD_USD,
SUBSCRIBE_ROUTE,
autumnSeoDataCreditsToUsd,
} from "@/shared/billing";
export function FreePlanBanner() {
return (
<AutumnProvider>
<FreePlanBannerContent />
</AutumnProvider>
);
}
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 ? (
<Link
to={SUBSCRIBE_ROUTE}
search={{ upgrade: true }}
className="link link-primary font-medium"
>
Upgrade your plan
</Link>
) : (
<Link to={BILLING_ROUTE} className="link link-primary font-medium">
Buy more credits
</Link>
);
if (isOutOfCredits) {
return (
<BannerShell variant="error">
You&rsquo;ve used all your credits. {creditsActionLink} to continue
using OpenSEO.
</BannerShell>
);
}
if (isLowCredits) {
return (
<BannerShell variant="warning">
You&rsquo;re running low on credits. {creditsActionLink} to keep using
OpenSEO.
</BannerShell>
);
}
if (isFreePlan) {
return (
<BannerShell variant="info">
We hope you&rsquo;re enjoying OpenSEO!{" "}
<Link
to={SUBSCRIBE_ROUTE}
search={{ upgrade: true }}
className="link link-primary font-medium"
>
Upgrade anytime
</Link>{" "}
or{" "}
<Link to="/support" className="link link-primary font-medium">
reach out with questions
</Link>
.
</BannerShell>
);
}
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 (
<div className="shrink-0 px-4 py-2.5 md:px-6">
<div className="mx-auto max-w-7xl">
<div className={`alert text-sm ${alertClass}`}>
<span>{children}</span>
</div>
</div>
</div>
);
}

View File

@ -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";
}

View File

@ -2,26 +2,24 @@ import { describe, expect, it } from "vitest";
import { getBillingRouteState, getSubscribeRouteState } from "./route-state"; import { getBillingRouteState, getSubscribeRouteState } from "./route-state";
describe("getBillingRouteState", () => { describe("getBillingRouteState", () => {
it("redirects unpaid customers after a successful customer lookup", () => { it("shows ready after successful customer lookup", () => {
expect( expect(
getBillingRouteState({ getBillingRouteState({
hasSession: true, hasSession: true,
isSessionPending: false, isSessionPending: false,
isCustomerLoading: false, isCustomerLoading: false,
isCustomerError: 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( expect(
getBillingRouteState({ getBillingRouteState({
hasSession: true, hasSession: true,
isSessionPending: false, isSessionPending: false,
isCustomerLoading: false, isCustomerLoading: false,
isCustomerError: true, isCustomerError: true,
hasManagedServiceAccess: false,
}), }),
).toBe("error"); ).toBe("error");
}); });
@ -33,7 +31,6 @@ describe("getBillingRouteState", () => {
isSessionPending: true, isSessionPending: true,
isCustomerLoading: false, isCustomerLoading: false,
isCustomerError: false, isCustomerError: false,
hasManagedServiceAccess: false,
}), }),
).toBe("loading"); ).toBe("loading");
@ -43,20 +40,19 @@ describe("getBillingRouteState", () => {
isSessionPending: false, isSessionPending: false,
isCustomerLoading: true, isCustomerLoading: true,
isCustomerError: false, isCustomerError: false,
hasManagedServiceAccess: false,
}), }),
).toBe("loading"); ).toBe("loading");
}); });
}); });
describe("getSubscribeRouteState", () => { 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( expect(
getSubscribeRouteState({ getSubscribeRouteState({
hasSession: true, hasSession: true,
isCustomerLoading: false, isCustomerLoading: false,
isCustomerError: true, isCustomerError: true,
hasManagedServiceAccess: false, planStatus: "free",
}), }),
).toBe("error"); ).toBe("error");
}); });
@ -67,8 +63,19 @@ describe("getSubscribeRouteState", () => {
hasSession: true, hasSession: true,
isCustomerLoading: false, isCustomerLoading: false,
isCustomerError: false, isCustomerError: false,
hasManagedServiceAccess: true, planStatus: "paid",
}), }),
).toBe("redirectToApp"); ).toBe("redirectToApp");
}); });
it("shows welcome page for free plan users", () => {
expect(
getSubscribeRouteState({
hasSession: true,
isCustomerLoading: false,
isCustomerError: false,
planStatus: "free",
}),
).toBe("showWelcome");
});
}); });

View File

@ -1,9 +1,10 @@
import type { PlanStatus } from "@/client/features/billing/plan-detection";
export function getBillingRouteState(args: { export function getBillingRouteState(args: {
hasSession: boolean; hasSession: boolean;
isSessionPending: boolean; isSessionPending: boolean;
isCustomerLoading: boolean; isCustomerLoading: boolean;
isCustomerError: boolean; isCustomerError: boolean;
hasManagedServiceAccess: boolean;
}) { }) {
if (args.isSessionPending || !args.hasSession || args.isCustomerLoading) { if (args.isSessionPending || !args.hasSession || args.isCustomerLoading) {
return "loading" as const; return "loading" as const;
@ -13,10 +14,6 @@ export function getBillingRouteState(args: {
return "error" as const; return "error" as const;
} }
if (!args.hasManagedServiceAccess) {
return "redirectToSubscribe" as const;
}
return "ready" as const; return "ready" as const;
} }
@ -24,7 +21,7 @@ export function getSubscribeRouteState(args: {
hasSession: boolean; hasSession: boolean;
isCustomerLoading: boolean; isCustomerLoading: boolean;
isCustomerError: boolean; isCustomerError: boolean;
hasManagedServiceAccess: boolean; planStatus: PlanStatus;
}) { }) {
if (!args.hasSession || args.isCustomerLoading) { if (!args.hasSession || args.isCustomerLoading) {
return "loading" as const; return "loading" as const;
@ -34,9 +31,9 @@ export function getSubscribeRouteState(args: {
return "error" as const; return "error" as const;
} }
if (args.hasManagedServiceAccess) { if (args.planStatus === "paid") {
return "redirectToApp" as const; return "redirectToApp" as const;
} }
return "ready" as const; return "showWelcome" as const;
} }

View File

@ -130,6 +130,7 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
lastSearchKeyword, lastSearchKeyword,
lastSearchLocationCode, lastSearchLocationCode,
researchError, researchError,
researchMutationError: researchMutation.error,
searchedKeyword, searchedKeyword,
isLoading: researchMutation.isPending, isLoading: researchMutation.isPending,
beginSearch, beginSearch,

View File

@ -1,4 +1,7 @@
import { Link } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft } from "lucide-react"; 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 { useKeywordResearchController } from "@/client/features/keywords/state/useKeywordResearchController";
import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController"; import type { KeywordResearchControllerInput } from "@/client/features/keywords/state/useKeywordResearchController";
import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState"; import { KeywordResearchEmptyState } from "./KeywordResearchEmptyState";
@ -64,6 +67,9 @@ function KeywordResearchContent({
} }
if (controller.researchError) { if (controller.researchError) {
const isCreditsError =
getErrorCode(controller.researchMutationError) === "INSUFFICIENT_CREDITS";
return ( return (
<div className="space-y-4 pt-1"> <div className="space-y-4 pt-1">
{recentSearchesButton} {recentSearchesButton}
@ -73,12 +79,18 @@ function KeywordResearchContent({
<AlertCircle className="mt-0.5 size-4 shrink-0" /> <AlertCircle className="mt-0.5 size-4 shrink-0" />
<p className="text-sm">{controller.researchError}</p> <p className="text-sm">{controller.researchError}</p>
</div> </div>
<button {isCreditsError ? (
className="btn btn-sm" <Link to={BILLING_ROUTE} className="btn btn-sm">
onClick={() => controller.onSearch()} Go to Billing
> </Link>
Try again ) : (
</button> <button
className="btn btn-sm"
onClick={() => controller.onSearch()}
>
Try again
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -133,6 +133,7 @@ export function useKeywordResearchController(
overviewKeyword: state.overviewKeyword, overviewKeyword: state.overviewKeyword,
removeHistoryItem: state.removeHistoryItem, removeHistoryItem: state.removeHistoryItem,
researchError: state.researchError, researchError: state.researchError,
researchMutationError: state.researchMutationError,
resetView, resetView,
resetFilters: state.resetFilters, resetFilters: state.resetFilters,
rows: state.rows, rows: state.rows,
@ -200,6 +201,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
lastSearchKeyword, lastSearchKeyword,
lastSearchLocationCode, lastSearchLocationCode,
researchError, researchError,
researchMutationError,
searchedKeyword, searchedKeyword,
isLoading, isLoading,
beginSearch, beginSearch,
@ -299,6 +301,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
removeHistoryItem, removeHistoryItem,
resetResearch, resetResearch,
researchError, researchError,
researchMutationError,
runSearch, runSearch,
resetFilters, resetFilters,
rows, rows,

View File

@ -25,9 +25,11 @@ const SUPPORT_PATH = "/support";
export function AuthenticatedAppLayout({ export function AuthenticatedAppLayout({
children, children,
projectId, projectId,
banner,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
projectId?: string; projectId?: string;
banner?: React.ReactNode;
}) { }) {
const location = useLocation(); const location = useLocation();
const [drawerOpen, setDrawerOpen] = React.useState(false); const [drawerOpen, setDrawerOpen] = React.useState(false);
@ -119,6 +121,8 @@ export function AuthenticatedAppLayout({
seoApiKeyStatusError={seoApiKeyStatusError} seoApiKeyStatusError={seoApiKeyStatusError}
/> />
{banner}
<AppContent <AppContent
drawerOpen={drawerOpen} drawerOpen={drawerOpen}
projectId={projectId ?? null} projectId={projectId ?? null}

View File

@ -6,6 +6,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
"OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.", "OpenSEO auth is not configured. Follow the README setup steps for Cloudflare Access.",
PAYMENT_REQUIRED: PAYMENT_REQUIRED:
"An active hosted subscription is required before you can use OpenSEO.", "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.", FORBIDDEN: "You do not have access to this resource.",
NOT_FOUND: "The requested resource was not found.", NOT_FOUND: "The requested resource was not found.",
AUDIT_CAPACITY_REACHED: AUDIT_CAPACITY_REACHED:

View File

@ -24,6 +24,7 @@ const hostedBaseUrlSchema = z
function createAuth() { function createAuth() {
const baseUrl = getHostedBaseUrl(); const baseUrl = getHostedBaseUrl();
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const auth = betterAuth({ const auth = betterAuth({
baseURL: baseUrl, baseURL: baseUrl,
@ -31,7 +32,7 @@ function createAuth() {
...baseAuthConfig, ...baseAuthConfig,
emailAndPassword: { emailAndPassword: {
...baseAuthConfig.emailAndPassword, ...baseAuthConfig.emailAndPassword,
requireEmailVerification: true, requireEmailVerification: !bypassEmail,
resetPasswordTokenExpiresIn: 60 * 60, resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true, revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => { sendResetPassword: async ({ user, url }) => {
@ -41,16 +42,18 @@ function createAuth() {
}); });
}, },
}, },
emailVerification: { emailVerification: bypassEmail
sendOnSignUp: true, ? undefined
autoSignInAfterVerification: true, : {
sendVerificationEmail: async ({ user, url }) => { sendOnSignUp: true,
await sendHostedVerificationEmail({ autoSignInAfterVerification: true,
email: user.email, sendVerificationEmail: async ({ user, url }) => {
confirmationUrl: url, await sendHostedVerificationEmail({
}); email: user.email,
}, confirmationUrl: url,
}, });
},
},
trustedOrigins: getTrustedOrigins(baseUrl), trustedOrigins: getTrustedOrigins(baseUrl),
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "sqlite", provider: "sqlite",
@ -138,7 +141,10 @@ export function hasHostedAuthConfig() {
try { try {
getHostedBaseUrl(); getHostedBaseUrl();
getHostedSecret(); getHostedSecret();
return hasHostedAuthEmailConfig(); return (
Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" ||
hasHostedAuthEmailConfig()
);
} catch { } catch {
return false; return false;
} }

View File

@ -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 { AutumnProvider, useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart"; import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils"; import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
import { getBillingRouteState } from "@/client/features/billing/route-state"; import { getBillingRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { import {
AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, AUTUMN_PAID_PLAN_ID,
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
LOW_CREDITS_THRESHOLD_USD,
AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
SUBSCRIBE_ROUTE,
autumnSeoDataCreditsToUsd, autumnSeoDataCreditsToUsd,
} from "@/shared/billing"; } from "@/shared/billing";
@ -35,7 +36,6 @@ function BillingPage() {
} }
function BillingPageContent() { function BillingPageContent() {
const navigate = useNavigate();
const { data: session, isPending: isSessionPending } = useSession(); const { data: session, isPending: isSessionPending } = useSession();
const [topUpAmount, setTopUpAmount] = useState("20"); const [topUpAmount, setTopUpAmount] = useState("20");
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
@ -47,15 +47,13 @@ function BillingPageContent() {
}, },
}); });
const hasManagedServiceAccess = Boolean( const planStatus = getCustomerPlanStatus(customerQuery.data);
customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID], const isFreePlan = planStatus === "free";
);
const billingRouteState = getBillingRouteState({ const billingRouteState = getBillingRouteState({
hasSession: Boolean(session?.user?.id), hasSession: Boolean(session?.user?.id),
isSessionPending, isSessionPending,
isCustomerLoading: customerQuery.isLoading, isCustomerLoading: customerQuery.isLoading,
isCustomerError: customerQuery.isError, isCustomerError: customerQuery.isError,
hasManagedServiceAccess,
}); });
const monthlyRemaining = autumnSeoDataCreditsToUsd( const monthlyRemaining = autumnSeoDataCreditsToUsd(
@ -71,18 +69,7 @@ function BillingPageContent() {
const { isValid: isValidTopUp, parsed: parsedTopUpAmount } = const { isValid: isValidTopUp, parsed: parsedTopUpAmount } =
parseTopUpAmount(topUpAmount); parseTopUpAmount(topUpAmount);
useEffect(() => { if (billingRouteState === "loading") {
if (billingRouteState !== "redirectToSubscribe") {
return;
}
void navigate({ href: SUBSCRIBE_ROUTE, replace: true });
}, [billingRouteState, navigate]);
if (
billingRouteState === "loading" ||
billingRouteState === "redirectToSubscribe"
) {
return null; return null;
} }
@ -139,7 +126,7 @@ function BillingPageContent() {
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
{/* Subscription card */} {/* Subscription card */}
<div className="flex flex-col justify-between rounded-lg border border-base-300 p-4 gap-4"> <div className="flex flex-col justify-between rounded-lg border border-base-300 bg-base-100 p-4 gap-4">
<div> <div>
<div className="text-2xl font-semibold tabular-nums"> <div className="text-2xl font-semibold tabular-nums">
${totalRemaining.toFixed(2)}{" "} ${totalRemaining.toFixed(2)}{" "}
@ -147,100 +134,165 @@ function BillingPageContent() {
remaining remaining
</span> </span>
</div> </div>
<div className="mt-1 flex gap-3 text-xs text-base-content/50"> {!isFreePlan ? (
<span className="tabular-nums"> <div className="mt-1 flex gap-3 text-xs text-base-content/50">
Monthly ${monthlyRemaining.toFixed(2)} <span className="tabular-nums">
</span> Monthly ${monthlyRemaining.toFixed(2)}
<span>&middot;</span> </span>
<span className="tabular-nums"> <span>&middot;</span>
Top-ups ${topUpRemaining.toFixed(2)} <span className="tabular-nums">
</span> Top-ups ${topUpRemaining.toFixed(2)}
</div> </span>
</div> </div>
) : null}
<div className="text-sm"> {totalRemaining <= 0 ? (
<span className="font-medium">Subscription</span>{" "} <p className="mt-2 text-xs text-error">
<span className="text-base-content/50"> You&rsquo;ve used all your credits.{" "}
{hasManagedServiceAccess ? "Active" : "Inactive"} {isFreePlan
</span> ? "Upgrade your plan to continue."
</div> : "Buy more credits below to continue."}
</p>
<button ) : totalRemaining < LOW_CREDITS_THRESHOLD_USD ? (
className="btn btn-soft btn-sm w-full" <p className="mt-2 text-xs text-amber-600">
disabled={isPending} You&rsquo;re running low on credits.{" "}
onClick={() => {isFreePlan
void runAction( ? "Upgrade to get $10/month."
() => : "Buy more credits below."}
customerQuery.openCustomerPortal({ </p>
returnUrl: window.location.href,
}),
"We couldn't open the billing portal. Please try again.",
)
}
>
{isPending ? "Redirecting..." : "Manage subscription"}
</button>
</div>
{/* Buy credits card */}
<div className="rounded-lg border border-base-300 p-4 space-y-3">
<div>
<span className="font-semibold">Buy credits</span>
<p className="mt-1 text-sm text-base-content/60">
Top-up credits never expire and are used after your monthly
credits.
</p>
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm text-base-content/60">$</span>
<input
type="number"
min={10}
max={99}
step={1}
inputMode="numeric"
className="input input-bordered input-sm w-full"
value={topUpAmount}
onChange={(e) => setTopUpAmount(e.target.value)}
/>
</div>
{topUpAmount.trim() !== "" && !isValidTopUp ? (
<p className="mt-1 text-xs text-error">Enter between $10$99.</p>
) : null} ) : null}
</div> </div>
<button <div className="text-sm">
className="btn btn-soft btn-sm w-full" <span className="font-medium">Plan</span>{" "}
disabled={isPending || !isValidTopUp || !hasManagedServiceAccess} <span className="text-base-content/50">
onClick={() => {isFreePlan ? "Free Trial" : "Base Plan"}
void runAction( </span>
() => </div>
customerQuery.attach({
planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID, {isFreePlan ? (
redirectMode: "always", <div className="space-y-3 border-t border-base-300 pt-3">
successUrl: window.location.href, <div className="flex items-baseline justify-between gap-4">
featureQuantities: [ <span className="text-sm font-medium">Base Plan</span>
{ <span className="text-sm font-medium tabular-nums">
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, $10/month
quantity: Math.round( </span>
parsedTopUpAmount * AUTUMN_SEO_DATA_CREDITS_PER_USD, </div>
), <ul className="space-y-1.5">
}, {[
], "Access to all OpenSEO features",
}), "Includes $10.00 of Usage Credits each month",
"We couldn't start the checkout. Please try again.", ].map((item) => (
) <li
} key={item}
> className="flex gap-2 text-xs text-base-content/60"
Buy credits >
</button> <span className="text-base-content/30 mt-[1px] shrink-0">
&mdash;
</span>
{item}
</li>
))}
</ul>
<button
className="btn btn-soft btn-sm w-full"
disabled={isPending}
onClick={() =>
void runAction(
() =>
customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: window.location.href,
}),
"We couldn't start the checkout. Please try again.",
)
}
>
Upgrade Plan
</button>
</div>
) : (
<button
className="btn btn-soft btn-sm w-full"
disabled={isPending}
onClick={() =>
void runAction(
() =>
customerQuery.openCustomerPortal({
returnUrl: window.location.href,
}),
"We couldn't open the billing portal. Please try again.",
)
}
>
Manage subscription
</button>
)}
</div> </div>
{/* Buy credits card — paid plan only */}
{!isFreePlan ? (
<div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
<div>
<span className="font-semibold">Buy credits</span>
<p className="mt-1 text-sm text-base-content/60">
Top-up credits never expire and are used after your monthly
credits.
</p>
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm text-base-content/60">$</span>
<input
type="number"
min={10}
max={99}
step={1}
inputMode="numeric"
className="input input-bordered input-sm w-full"
value={topUpAmount}
onChange={(e) => setTopUpAmount(e.target.value)}
/>
</div>
{topUpAmount.trim() !== "" && !isValidTopUp ? (
<p className="mt-1 text-xs text-error">
Enter between $10$99.
</p>
) : null}
</div>
<button
className="btn btn-soft btn-sm w-full"
disabled={isPending || !isValidTopUp}
onClick={() =>
void runAction(
() =>
customerQuery.attach({
planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
redirectMode: "always",
successUrl: window.location.href,
featureQuantities: [
{
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
quantity: Math.round(
parsedTopUpAmount * AUTUMN_SEO_DATA_CREDITS_PER_USD,
),
},
],
}),
"We couldn't start the checkout. Please try again.",
)
}
>
Buy credits
</button>
</div>
) : null}
</div> </div>
{/* Usage chart */} {/* Usage chart */}
{hasManagedServiceAccess ? <BillingUsageChart /> : null} <BillingUsageChart />
{error ? <p className="text-sm text-error">{error}</p> : null} {error ? <p className="text-sm text-error">{error}</p> : null}

View File

@ -71,8 +71,7 @@ function SignUpPage() {
password: value.password, password: value.password,
callbackURL: (() => { callbackURL: (() => {
const url = new URL("/verify-email", window.location.origin); const url = new URL("/verify-email", window.location.origin);
if (redirectTo !== "/") url.searchParams.set("redirect", "/subscribe");
url.searchParams.set("redirect", redirectTo);
return url.toString(); return url.toString();
})(), })(),
}); });

View File

@ -1,18 +1,20 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AutumnProvider, useCustomer } from "autumn-js/react"; import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { User } from "lucide-react"; import { ArrowRight, User } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { getSubscribeRouteState } from "@/client/features/billing/route-state";
import { import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID, import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
AUTUMN_PAID_PLAN_ID,
} from "@/shared/billing";
export const Route = createFileRoute("/_authenticated/subscribe")({ export const Route = createFileRoute("/_authenticated/subscribe")({
validateSearch: (search: Record<string, unknown>) => ({
upgrade:
search.upgrade === true || search.upgrade === "true" ? true : undefined,
}),
component: SubscribePage, component: SubscribePage,
}); });
@ -26,6 +28,7 @@ function SubscribePage() {
function SubscribePageContent() { function SubscribePageContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const { upgrade: isUpgradeFlow } = Route.useSearch();
const { data: session } = useSession(); const { data: session } = useSession();
const [isAttaching, setIsAttaching] = useState(false); const [isAttaching, setIsAttaching] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -39,14 +42,12 @@ function SubscribePageContent() {
}, },
}); });
const hasManagedServiceAccess = Boolean( const planStatus = getCustomerPlanStatus(customerQuery.data);
customerQuery.data?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID],
);
const subscribeRouteState = getSubscribeRouteState({ const subscribeRouteState = getSubscribeRouteState({
hasSession: Boolean(session?.user?.id), hasSession: Boolean(session?.user?.id),
isCustomerLoading: customerQuery.isLoading, isCustomerLoading: customerQuery.isLoading,
isCustomerError: customerQuery.isError, isCustomerError: customerQuery.isError,
hasManagedServiceAccess, planStatus,
}); });
useEffect(() => { useEffect(() => {
@ -119,28 +120,41 @@ function SubscribePageContent() {
} }
} }
const firstName = session?.user?.name?.split(" ")[0] || "";
return ( return (
<div className="w-full max-w-xs space-y-6"> <div className="w-full max-w-sm space-y-6">
<SubscribePageAccountMenu email={session?.user?.email} /> <SubscribePageAccountMenu email={session?.user?.email} />
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<img <img
src="/transparent-logo.png" src="/transparent-logo.png"
alt="OpenSEO" alt="OpenSEO"
className="mx-auto size-10 rounded-lg" className="mx-auto size-10 rounded-lg"
/> />
<h1 className="text-xl font-semibold">Get started</h1> <h1 className="text-xl font-semibold">
{isUpgradeFlow
? "Upgrade your plan"
: firstName
? `Welcome to OpenSEO, ${firstName}!`
: "Welcome to OpenSEO!"}
</h1>
<p className="text-sm text-base-content/60">
SEO on your terms. All your SEO tools in one place at a fair price.
</p>
</div> </div>
<div className="rounded-lg border border-base-300 p-4"> <div className="rounded-lg border border-base-300 p-5 space-y-4">
<div className="flex items-baseline justify-between gap-4"> <div className="flex items-baseline justify-between gap-4">
<span className="font-semibold">Base Plan</span> <span className="font-semibold">Base Plan</span>
<span className="text-lg font-semibold tabular-nums">$10/month</span> <span className="text-lg font-semibold tabular-nums">$10/month</span>
</div> </div>
<ul className="mt-3 space-y-2">
<ul className="space-y-2">
{[ {[
"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", "Includes $10.00 of Usage Credits each month",
"Credits are consumed as you go for SEO data and AI features",
].map((item) => ( ].map((item) => (
<li <li
key={item} key={item}
@ -153,21 +167,49 @@ function SubscribePageContent() {
</li> </li>
))} ))}
</ul> </ul>
{error ? <p className="text-sm text-error">{error}</p> : null}
<button
className="btn btn-soft w-full"
disabled={isAttaching}
onClick={() => void handleSubscribe()}
>
{isAttaching ? "Redirecting..." : "Subscribe"}
</button>
<p className="text-center text-xs text-base-content/50">
Cancel anytime no commitment. Powered by Stripe.
</p>
</div> </div>
{error ? <p className="text-sm text-error">{error}</p> : null} <div className="text-center space-y-2">
{isUpgradeFlow ? (
<button <button
className="btn btn-soft w-full" type="button"
disabled={isAttaching} className="inline-flex cursor-pointer items-center gap-1.5 text-sm font-medium text-base-content/70 hover:text-base-content transition-colors"
onClick={() => void handleSubscribe()} onClick={() => void navigate({ to: "/", replace: true })}
> >
{isAttaching ? "Redirecting..." : "Subscribe"} <ArrowRight className="size-3.5 rotate-180" />
</button> Back to app
</button>
<p className="text-center text-xs text-base-content/50"> ) : (
Cancel anytime no commitment. Powered by Stripe. <>
</p> <p className="text-sm text-base-content/60">
Or try it free you have $0.50 of credits to explore before
committing.
</p>
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 text-sm font-medium text-base-content/70 hover:text-base-content transition-colors"
onClick={() => void navigate({ to: "/", replace: true })}
>
Continue with free trial
<ArrowRight className="size-3.5" />
</button>
</>
)}
</div>
</div> </div>
); );
} }

View File

@ -1,6 +1,8 @@
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
import { getErrorCode } from "@/client/lib/error-messages"; import { getErrorCode } from "@/client/lib/error-messages";
import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { import {
getCurrentAuthRedirectFromHref, getCurrentAuthRedirectFromHref,
getSignInSearch, getSignInSearch,
@ -33,7 +35,10 @@ function ProjectLayout() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
return ( return (
<AuthenticatedAppLayout projectId={projectId}> <AuthenticatedAppLayout
projectId={projectId}
banner={isHostedClientAuthMode() ? <FreePlanBanner /> : undefined}
>
<Outlet /> <Outlet />
</AuthenticatedAppLayout> </AuthenticatedAppLayout>
); );

View File

@ -86,6 +86,7 @@ describe("subscription billing", () => {
expect(getOrCreateMock).toHaveBeenCalledWith({ expect(getOrCreateMock).toHaveBeenCalledWith({
customerId: "org_123", customerId: "org_123",
email: "alice@example.com",
}); });
}); });
}); });

View File

@ -16,6 +16,7 @@ export async function getOrCreateOrganizationCustomer(
) { ) {
const customer = await autumn.customers.getOrCreate({ const customer = await autumn.customers.getOrCreate({
customerId: context.organizationId, customerId: context.organizationId,
email: context.userEmail,
}); });
if (!customer.id) { if (!customer.id) {

View File

@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
SEO_DATA_COST_MARKUP,
} from "@/shared/billing"; } from "@/shared/billing";
interface TrackCallArg { 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 () => { it("deducts entirely from monthly when monthly has enough", async () => {
setupHostedMode(); setupHostedMode();
mockBalances(5000, 3000); mockBalances(5000, 3000);
mockDataforseoResult(0.05); mockDataforseoResult(RAW_COST);
const client = createDataforseoClient(billingCustomer); const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput); await client.backlinks.summary(backlinksInput);
@ -152,7 +159,7 @@ describe("meterDataforseoCall with split balances", () => {
expect.objectContaining({ expect.objectContaining({
customerId: "org_123", customerId: "org_123",
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, 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 () => { it("deducts entirely from topup when monthly is empty", async () => {
setupHostedMode(); setupHostedMode();
mockBalances(0, 5000); mockBalances(0, 5000);
mockDataforseoResult(0.05); mockDataforseoResult(RAW_COST);
const client = createDataforseoClient(billingCustomer); const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput); await client.backlinks.summary(backlinksInput);
@ -170,15 +177,16 @@ describe("meterDataforseoCall with split balances", () => {
expect.objectContaining({ expect.objectContaining({
customerId: "org_123", customerId: "org_123",
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, 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 () => { it("splits deduction across monthly and topup when monthly is partially sufficient", async () => {
setupHostedMode(); setupHostedMode();
mockBalances(30, 5000); const monthlyAvailable = 30;
mockDataforseoResult(0.05); mockBalances(monthlyAvailable, 5000);
mockDataforseoResult(RAW_COST);
const client = createDataforseoClient(billingCustomer); const client = createDataforseoClient(billingCustomer);
await client.backlinks.summary(backlinksInput); await client.backlinks.summary(backlinksInput);
@ -188,39 +196,29 @@ describe("meterDataforseoCall with split balances", () => {
expect.objectContaining({ expect.objectContaining({
customerId: "org_123", customerId: "org_123",
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
value: 30, value: monthlyAvailable,
}), }),
); );
expect(trackMock).toHaveBeenCalledWith( expect(trackMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
customerId: "org_123", customerId: "org_123",
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, 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 () => { it("throws INSUFFICIENT_CREDITS when both balances are exactly zero", 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(); setupHostedMode();
mockBalances(0, 0); mockBalances(0, 0);
mockDataforseoResult(0.05);
const client = createDataforseoClient(billingCustomer); const client = createDataforseoClient(billingCustomer);
await expect( await expect(
client.backlinks.summary(backlinksInput), 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 () => { it("includes balanceFeatureId in track properties", async () => {

View File

@ -2,7 +2,7 @@ import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD, AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
MINIMUM_SEO_DATA_BALANCE_USD, SEO_DATA_COST_MARKUP,
roundUsdForBilling, roundUsdForBilling,
} from "@/shared/billing"; } from "@/shared/billing";
import { autumn } from "@/server/billing/autumn"; import { autumn } from "@/server/billing/autumn";
@ -222,10 +222,9 @@ async function meterDataforseoCall<T>(
const billingCustomer = await getOrCreateOrganizationCustomer(customer); const billingCustomer = await getOrCreateOrganizationCustomer(customer);
const { monthlyRemaining } = await assertSeoDataBalanceAvailable({ const { monthlyRemaining } = await assertSeoDataBalanceAvailable(
customerId: billingCustomer.id, billingCustomer.id,
minimumBalanceUsd: MINIMUM_SEO_DATA_BALANCE_USD, );
});
const result = await execute(); const result = await execute();
@ -239,22 +238,14 @@ async function meterDataforseoCall<T>(
return result.data; return result.data;
} }
async function assertSeoDataBalanceAvailable(args: { async function assertSeoDataBalanceAvailable(customerId: string) {
customerId: string;
minimumBalanceUsd: number;
}) {
const minimumCredits = Math.ceil(
roundUsdForBilling(args.minimumBalanceUsd) *
AUTUMN_SEO_DATA_CREDITS_PER_USD,
);
const [monthlyCheck, topupCheck] = await Promise.all([ const [monthlyCheck, topupCheck] = await Promise.all([
autumn.check({ autumn.check({
customerId: args.customerId, customerId,
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
}), }),
autumn.check({ autumn.check({
customerId: args.customerId, customerId,
featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, featureId: AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
}), }),
]); ]);
@ -262,8 +253,8 @@ async function assertSeoDataBalanceAvailable(args: {
const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0; const monthlyRemaining = monthlyCheck.balance?.remaining ?? 0;
const topupRemaining = topupCheck.balance?.remaining ?? 0; const topupRemaining = topupCheck.balance?.remaining ?? 0;
if (monthlyRemaining + topupRemaining < minimumCredits) { if (monthlyRemaining + topupRemaining <= 0) {
throw new AppError("PAYMENT_REQUIRED"); throw new AppError("INSUFFICIENT_CREDITS");
} }
return { monthlyRemaining }; return { monthlyRemaining };
@ -275,7 +266,9 @@ async function trackDataforseoCost(args: {
billing: DataforseoApiCallCost; billing: DataforseoApiCallCost;
monthlyRemaining: number; monthlyRemaining: number;
}) { }) {
const totalCostUsd = roundUsdForBilling(args.billing.costUsd); const totalCostUsd = roundUsdForBilling(
args.billing.costUsd * SEO_DATA_COST_MARKUP,
);
const totalCostCredits = Math.ceil( const totalCostCredits = Math.ceil(
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD, totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
); );

View File

@ -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_BALANCE_FEATURE_ID = "usage_credits";
export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits"; export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits";
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000; 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) { export function roundUsdForBilling(value: number) {
return Math.round(value * 100000) / 100000; return Math.round(value * 100000) / 100000;

View File

@ -4,6 +4,7 @@ const ERROR_CODES = [
"UNAUTHENTICATED", "UNAUTHENTICATED",
"AUTH_CONFIG_MISSING", "AUTH_CONFIG_MISSING",
"PAYMENT_REQUIRED", "PAYMENT_REQUIRED",
"INSUFFICIENT_CREDITS",
"FORBIDDEN", "FORBIDDEN",
"NOT_FOUND", "NOT_FOUND",
"AUDIT_CAPACITY_REACHED", "AUDIT_CAPACITY_REACHED",
@ -24,6 +25,7 @@ const NON_REPORTABLE_ERROR_CODES = new Set<ErrorCode>([
"UNAUTHENTICATED", "UNAUTHENTICATED",
"NOT_FOUND", "NOT_FOUND",
"PAYMENT_REQUIRED", "PAYMENT_REQUIRED",
"INSUFFICIENT_CREDITS",
"VALIDATION_ERROR", "VALIDATION_ERROR",
]); ]);