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