hosted: add free trial plan and onboarding (#104)
This commit is contained in:
parent
01c11b0c5f
commit
1d9a503335
@ -47,7 +47,7 @@ export function BillingUsageChart() {
|
||||
const totalSpend = chartData.reduce((sum, d) => sum + d.credits, 0);
|
||||
|
||||
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">
|
||||
<span className="font-semibold">Usage</span>
|
||||
<span className="text-xs text-base-content/50">Last 30 days</span>
|
||||
@ -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 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
|
||||
129
src/client/features/billing/FreePlanBanner.tsx
Normal file
129
src/client/features/billing/FreePlanBanner.tsx
Normal 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’ve used all your credits. {creditsActionLink} to continue
|
||||
using OpenSEO.
|
||||
</BannerShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLowCredits) {
|
||||
return (
|
||||
<BannerShell variant="warning">
|
||||
You’re running low on credits. {creditsActionLink} to keep using
|
||||
OpenSEO.
|
||||
</BannerShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFreePlan) {
|
||||
return (
|
||||
<BannerShell variant="info">
|
||||
We hope you’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>
|
||||
);
|
||||
}
|
||||
17
src/client/features/billing/plan-detection.ts
Normal file
17
src/client/features/billing/plan-detection.ts
Normal 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";
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -130,6 +130,7 @@ export function useKeywordResearchData(addSearch: AddSearchFn) {
|
||||
lastSearchKeyword,
|
||||
lastSearchLocationCode,
|
||||
researchError,
|
||||
researchMutationError: researchMutation.error,
|
||||
searchedKeyword,
|
||||
isLoading: researchMutation.isPending,
|
||||
beginSearch,
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-4 pt-1">
|
||||
{recentSearchesButton}
|
||||
@ -73,12 +79,18 @@ function KeywordResearchContent({
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<p className="text-sm">{controller.researchError}</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => controller.onSearch()}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{isCreditsError ? (
|
||||
<Link to={BILLING_ROUTE} className="btn btn-sm">
|
||||
Go to Billing
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => controller.onSearch()}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
<AppContent
|
||||
drawerOpen={drawerOpen}
|
||||
projectId={projectId ?? null}
|
||||
|
||||
@ -6,6 +6,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
|
||||
"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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{/* 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 className="text-2xl font-semibold tabular-nums">
|
||||
${totalRemaining.toFixed(2)}{" "}
|
||||
@ -147,100 +134,165 @@ function BillingPageContent() {
|
||||
remaining
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-3 text-xs text-base-content/50">
|
||||
<span className="tabular-nums">
|
||||
Monthly ${monthlyRemaining.toFixed(2)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="tabular-nums">
|
||||
Top-ups ${topUpRemaining.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">Subscription</span>{" "}
|
||||
<span className="text-base-content/50">
|
||||
{hasManagedServiceAccess ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</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.",
|
||||
)
|
||||
}
|
||||
>
|
||||
{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>
|
||||
{!isFreePlan ? (
|
||||
<div className="mt-1 flex gap-3 text-xs text-base-content/50">
|
||||
<span className="tabular-nums">
|
||||
Monthly ${monthlyRemaining.toFixed(2)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="tabular-nums">
|
||||
Top-ups ${topUpRemaining.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{totalRemaining <= 0 ? (
|
||||
<p className="mt-2 text-xs text-error">
|
||||
You’ve used all your credits.{" "}
|
||||
{isFreePlan
|
||||
? "Upgrade your plan to continue."
|
||||
: "Buy more credits below to continue."}
|
||||
</p>
|
||||
) : totalRemaining < LOW_CREDITS_THRESHOLD_USD ? (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
You’re running low on credits.{" "}
|
||||
{isFreePlan
|
||||
? "Upgrade to get $10/month."
|
||||
: "Buy more credits below."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-soft btn-sm w-full"
|
||||
disabled={isPending || !isValidTopUp || !hasManagedServiceAccess}
|
||||
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 className="text-sm">
|
||||
<span className="font-medium">Plan</span>{" "}
|
||||
<span className="text-base-content/50">
|
||||
{isFreePlan ? "Free Trial" : "Base Plan"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isFreePlan ? (
|
||||
<div className="space-y-3 border-t border-base-300 pt-3">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<span className="text-sm font-medium">Base Plan</span>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
$10/month
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{[
|
||||
"Access to all OpenSEO features",
|
||||
"Includes $10.00 of Usage Credits each month",
|
||||
].map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex gap-2 text-xs text-base-content/60"
|
||||
>
|
||||
<span className="text-base-content/30 mt-[1px] shrink-0">
|
||||
—
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Usage chart */}
|
||||
{hasManagedServiceAccess ? <BillingUsageChart /> : null}
|
||||
<BillingUsageChart />
|
||||
|
||||
{error ? <p className="text-sm text-error">{error}</p> : null}
|
||||
|
||||
|
||||
@ -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();
|
||||
})(),
|
||||
});
|
||||
|
||||
@ -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<string, unknown>) => ({
|
||||
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<string | null>(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 (
|
||||
<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} />
|
||||
|
||||
<div className="text-center space-y-3">
|
||||
<img
|
||||
src="/transparent-logo.png"
|
||||
alt="OpenSEO"
|
||||
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 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">
|
||||
<span className="font-semibold">Base Plan</span>
|
||||
<span className="text-lg font-semibold tabular-nums">$10/month</span>
|
||||
</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",
|
||||
"Credits are consumed as you go for SEO data and AI features",
|
||||
].map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
@ -153,21 +167,49 @@ function SubscribePageContent() {
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{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 className="text-center space-y-2">
|
||||
{isUpgradeFlow ? (
|
||||
<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 })}
|
||||
>
|
||||
<ArrowRight className="size-3.5 rotate-180" />
|
||||
Back to app
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<AuthenticatedAppLayout projectId={projectId}>
|
||||
<AuthenticatedAppLayout
|
||||
projectId={projectId}
|
||||
banner={isHostedClientAuthMode() ? <FreePlanBanner /> : undefined}
|
||||
>
|
||||
<Outlet />
|
||||
</AuthenticatedAppLayout>
|
||||
);
|
||||
|
||||
@ -86,6 +86,7 @@ describe("subscription billing", () => {
|
||||
|
||||
expect(getOrCreateMock).toHaveBeenCalledWith({
|
||||
customerId: "org_123",
|
||||
email: "alice@example.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,6 +16,7 @@ export async function getOrCreateOrganizationCustomer(
|
||||
) {
|
||||
const customer = await autumn.customers.getOrCreate({
|
||||
customerId: context.organizationId,
|
||||
email: context.userEmail,
|
||||
});
|
||||
|
||||
if (!customer.id) {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<T>(
|
||||
|
||||
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<T>(
|
||||
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,
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<ErrorCode>([
|
||||
"UNAUTHENTICATED",
|
||||
"NOT_FOUND",
|
||||
"PAYMENT_REQUIRED",
|
||||
"INSUFFICIENT_CREDITS",
|
||||
"VALIDATION_ERROR",
|
||||
]);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user