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