refactor: move hosted billing to Autumn hooks (#48)

* refactor: move lighthouse audits to dataforseo (#43)

* refactor: move lighthouse audits to dataforseo

* chore: remove obsolete audit settings modal

* refactor: rename psi flows to lighthouse

* save

* refactor: simplify audit lighthouse storage flow

* fix: separate lighthouse metrics from actionable audits

* refactor: remove redundant audit project inputs

* feat: redesign lighthouse issues screen with score gauges and table layout

Replace flat score cards with circular SVG gauges, condense metrics into
a compact grid, and switch issue list from cards to an expandable table
with fixed column widths.


* test: harden lighthouse regression coverage

* fix: restore project-scoped audit inputs

* refactor: simplify lighthouse payload handling

* refactor: inline lighthouse server handlers

* refactor: share audit workflow types

* refactor: simplify lighthouse payload flows

* save

* refactor: drop project pagespeed api key

* fix: restore lighthouse issues loading with resilient project context

* fix: restore audit issues back navigation

* refactor: simplify project context and lighthouse error handling

* fix: tolerate DataForSEO lighthouse payload drift

* refactor: route audit lighthouse through dataforseo client

---------


* refactor: move hosted billing to Autumn hooks

* fix: satisfy ci checks for hosted billing

* refactor: simplify hosted Autumn billing integration

Limit Autumn wiring to the billing route and remove billing-specific indirection so the hosted billing flow is easier to reason about. Stop sending organization IDs as fake customer names to keep customer identity data honest.

* fix: satisfy ci line-limit check for hosted billing

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Senescu 2026-03-26 22:28:28 -04:00 committed by GitHub
parent 4f33a52e0e
commit 724a92db0c
13 changed files with 603 additions and 497 deletions

View File

@ -53,7 +53,7 @@
"@tanstack/react-router": "^1.136.3", "@tanstack/react-router": "^1.136.3",
"@tanstack/react-router-devtools": "^1.136.3", "@tanstack/react-router-devtools": "^1.136.3",
"@tanstack/react-start": "^1.136.3", "@tanstack/react-start": "^1.136.3",
"autumn-js": "^1.1.2", "autumn-js": "^1.1.7",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cloudflare": "^5.2.0", "cloudflare": "^5.2.0",

10
pnpm-lock.yaml generated
View File

@ -30,8 +30,8 @@ importers:
specifier: ^1.136.3 specifier: ^1.136.3
version: 1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) version: 1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
autumn-js: autumn-js:
specifier: ^1.1.2 specifier: ^1.1.7
version: 1.1.2(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4)
better-auth: better-auth:
specifier: ^1.5.5 specifier: ^1.5.5
version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
@ -2044,8 +2044,8 @@ packages:
asynckit@0.4.0: asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autumn-js@1.1.2: autumn-js@1.1.7:
resolution: {integrity: sha512-glSAvGJ6G8cJKLH6enonQ0WverZUMnGirv6hYzMBZViFqAiU6IDrEbbD0Xl7LGUN/pUjd9JgVwrmxeu3cnLO0g==} resolution: {integrity: sha512-wd7dbaVY1ZORIS2nSIvcuroD6wfbW3qZhiKB/1zN2r851116iVILuaV83cC6GRMKs6b93dFOjpuMmnUtlRYj3A==}
peerDependencies: peerDependencies:
better-auth: ^1.3.17 better-auth: ^1.3.17
better-call: ^1.0.12 better-call: ^1.0.12
@ -5066,7 +5066,7 @@ snapshots:
asynckit@0.4.0: {} asynckit@0.4.0: {}
autumn-js@1.1.2(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4): autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.162.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4):
dependencies: dependencies:
query-string: 9.3.1 query-string: 9.3.1
rou3: 0.6.3 rou3: 0.6.3

View File

@ -1,7 +1,11 @@
import { CreditCard } from "lucide-react"; import { CreditCard } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function BillingHeader({ hasPaidPlan }: { hasPaidPlan: boolean }) { export function BillingHeader(args: {
hasManagedServiceAccess: boolean;
basePlanName: string;
includedCreditsLabel: string;
}) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-base-content/60"> <div className="flex items-center gap-2 text-sm text-base-content/60">
@ -9,49 +13,55 @@ export function BillingHeader({ hasPaidPlan }: { hasPaidPlan: boolean }) {
Hosted billing Hosted billing
</div> </div>
<h1 className="text-3xl font-semibold"> <h1 className="text-3xl font-semibold">
{hasPaidPlan ? "Billing" : "Choose a plan"} {args.hasManagedServiceAccess ? "Billing" : "Choose a plan"}
</h1> </h1>
<p className="max-w-2xl text-base-content/70"> <p className="max-w-2xl text-base-content/70">
{hasPaidPlan {args.hasManagedServiceAccess
? "OpenSEO hosted usage is metered against your shared backlinks balance. Your monthly plan covers the first $5, and extra top-ups carry forward." ? `OpenSEO hosted usage is metered against your shared backlinks balance. ${args.basePlanName} includes ${args.includedCreditsLabel} of usage credits each cycle, and extra top-ups carry forward.`
: "You need a paid plan to use OpenSEO's managed service. The base plan costs $5/month and includes $5 of usage credits. If you use all of those credits, you can buy more at any time."} : `You need an active ${args.basePlanName} subscription to use OpenSEO's managed service. It includes ${args.includedCreditsLabel} of usage credits each cycle, and you can buy more at any time.`}
</p> </p>
</div> </div>
); );
} }
export function SubscriptionIntro({ hasPaidPlan }: { hasPaidPlan: boolean }) { export function SubscriptionIntro(args: {
hasManagedServiceAccess: boolean;
basePlanName: string;
}) {
return ( return (
<div> <div>
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{hasPaidPlan ? "Subscription" : "Managed service access"} {args.hasManagedServiceAccess
? "Subscription"
: "Managed service access"}
</h2> </h2>
<p className="text-sm text-base-content/65"> <p className="text-sm text-base-content/65">
{hasPaidPlan {args.hasManagedServiceAccess
? "Hosted workspaces need an active paid plan before project pages and DataForSEO-backed features are available." ? "Hosted workspaces need an active paid plan before project pages and DataForSEO-backed features are available."
: "Start the base plan to unlock OpenSEO's managed service and your included monthly usage credits."} : `Start ${args.basePlanName} to unlock OpenSEO's managed service and your included monthly usage credits.`}
</p> </p>
</div> </div>
); );
} }
export function SubscriptionStatusCard({ export function SubscriptionStatusCard(args: {
hasPaidPlan, hasManagedServiceAccess: boolean;
}: { basePlanName: string;
hasPaidPlan: boolean; basePlanPrice: string;
includedCreditsLabel: string;
}) { }) {
return ( return (
<div className="rounded-2xl border border-base-300 bg-base-200/50 p-4"> <div className="rounded-2xl border border-base-300 bg-base-200/50 p-4">
<div className="text-sm text-base-content/60"> <div className="text-sm text-base-content/60">
{hasPaidPlan ? "Current status" : "Base plan"} {args.hasManagedServiceAccess ? "Current status" : args.basePlanName}
</div> </div>
<div className="mt-1 text-2xl font-semibold"> <div className="mt-1 text-2xl font-semibold">
{hasPaidPlan ? "Active" : "$5/month"} {args.hasManagedServiceAccess ? "Active" : args.basePlanPrice}
</div> </div>
<div className="mt-2 text-sm text-base-content/70"> <div className="mt-2 text-sm text-base-content/70">
{hasPaidPlan {args.hasManagedServiceAccess
? "Your organization can use hosted OpenSEO features." ? "Your organization can use hosted OpenSEO features."
: "Includes $5 of usage credits every month."} : `Includes ${args.includedCreditsLabel} of usage credits every cycle.`}
</div> </div>
</div> </div>
); );
@ -59,7 +69,8 @@ export function SubscriptionStatusCard({
export function BillingAlerts(args: { export function BillingAlerts(args: {
actionError: string | null; actionError: string | null;
hasPaidPlan: boolean; hasManagedServiceAccess: boolean;
basePlanName: string;
}) { }) {
return ( return (
<> <>
@ -69,11 +80,11 @@ export function BillingAlerts(args: {
</div> </div>
) : null} ) : null}
{!args.hasPaidPlan ? ( {!args.hasManagedServiceAccess ? (
<div className="alert alert-warning"> <div className="alert alert-warning">
<span> <span>
Subscribe to the base plan first. After that, you can manage your Subscribe to {args.basePlanName} first. After that, you can manage
plan and buy more credits here. your plan and buy more credits here.
</span> </span>
</div> </div>
) : null} ) : null}

View File

@ -0,0 +1,335 @@
import type { UseCustomerResult } from "autumn-js/react";
import { ExternalLink, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
BillingAlerts,
BillingHeader,
SubscriptionIntro,
SubscriptionStatusCard,
} from "@/client/features/billing/BillingRouteParts";
import {
AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
AUTUMN_PAID_PLAN_ID,
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_CREDITS_PER_USD,
AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
} from "@/shared/billing";
import {
formatCreditAmount,
formatPlanPrice,
getIncludedFeatureQuantity,
parseTopUpAmount,
} from "@/client/features/billing/HostedBillingContentUtils";
type BillingAction = "start-plan" | "open-portal" | "top-up" | null;
type BillingCustomerQuery = Pick<
UseCustomerResult,
"data" | "attach" | "openCustomerPortal" | "refetch"
>;
type BillingPlan = {
id: string;
name: string;
items: Array<{ featureId: string; included: number }>;
price?: {
amount?: number | null;
interval?: string | null;
} | null;
};
type HostedBillingContentProps = {
customerQuery: BillingCustomerQuery;
plans: BillingPlan[];
};
export function HostedBillingContent({
customerQuery,
plans,
}: HostedBillingContentProps) {
const [actionError, setActionError] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<BillingAction>(null);
const [topUpAmount, setTopUpAmount] = useState("20");
const customer = customerQuery.data;
const basePlan =
plans.find((plan) => plan.id === AUTUMN_PAID_PLAN_ID) ?? null;
const balance =
customer?.balances?.[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID] ?? null;
const hasManagedServiceAccess = Boolean(
customer?.flags?.[AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID],
);
const isActionPending = pendingAction !== null;
const basePlanName = basePlan?.name ?? "Base Plan";
const basePlanPrice = formatPlanPrice(
basePlan?.price?.amount,
basePlan?.price?.interval,
);
const includedCreditsLabel = formatCreditAmount(
getIncludedFeatureQuantity(basePlan, AUTUMN_SEO_DATA_BALANCE_FEATURE_ID),
);
const { isValid: isValidTopUpAmount, parsed: parsedTopUpAmount } =
parseTopUpAmount(topUpAmount);
const topUpDisabled =
!hasManagedServiceAccess || isActionPending || !isValidTopUpAmount;
const runBillingAction = async (
action: BillingAction,
callback: () => Promise<unknown>,
fallbackMessage: string,
) => {
setActionError(null);
setPendingAction(action);
try {
await callback();
if (action !== "open-portal") {
await customerQuery.refetch();
}
} catch (error) {
setActionError(getStandardErrorMessage(error, fallbackMessage));
} finally {
setPendingAction(null);
}
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 md:p-6">
<BillingHeader
hasManagedServiceAccess={hasManagedServiceAccess}
basePlanName={basePlanName}
includedCreditsLabel={includedCreditsLabel}
/>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<SubscriptionSection
basePlanName={basePlanName}
basePlanPrice={basePlanPrice}
hasManagedServiceAccess={hasManagedServiceAccess}
includedCreditsLabel={includedCreditsLabel}
isActionPending={isActionPending}
isPortalPending={pendingAction === "open-portal"}
isStartPlanPending={pendingAction === "start-plan"}
onOpenPortal={() => {
void runBillingAction(
"open-portal",
() =>
customerQuery.openCustomerPortal({
returnUrl: window.location.href,
}),
"We could not open the billing portal. Please try again.",
);
}}
onStartPlan={() => {
void runBillingAction(
"start-plan",
() =>
customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always",
successUrl: window.location.href,
}),
"We could not open the hosted billing flow. Please try again.",
);
}}
/>
<SeoDataCreditsSection
balance={balance}
basePlanName={basePlanName}
hasManagedServiceAccess={hasManagedServiceAccess}
isTopUpPending={pendingAction === "top-up"}
topUpAmount={topUpAmount}
topUpDisabled={topUpDisabled}
onTopUpAmountChange={setTopUpAmount}
onTopUp={() => {
void runBillingAction(
"top-up",
() =>
customerQuery.attach({
planId: AUTUMN_SEO_DATA_TOP_UP_PLAN_ID,
redirectMode: "always",
successUrl: window.location.href,
featureQuantities: [
{
featureId: AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
quantity: Math.round(
parsedTopUpAmount * AUTUMN_SEO_DATA_CREDITS_PER_USD,
),
},
],
}),
"We could not open the credit purchase flow. Please try again.",
);
}}
/>
</div>
<BillingAlerts
actionError={actionError}
hasManagedServiceAccess={hasManagedServiceAccess}
basePlanName={basePlanName}
/>
<div className="flex items-center gap-2 text-sm text-base-content/60">
<ExternalLink className="h-4 w-4" />
<span>Hosted billing is powered by Autumn.</span>
</div>
</div>
);
}
function SubscriptionSection(args: {
basePlanName: string;
basePlanPrice: string;
hasManagedServiceAccess: boolean;
includedCreditsLabel: string;
isActionPending: boolean;
isPortalPending: boolean;
isStartPlanPending: boolean;
onOpenPortal: () => void;
onStartPlan: () => void;
}) {
return (
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<SubscriptionIntro
hasManagedServiceAccess={args.hasManagedServiceAccess}
basePlanName={args.basePlanName}
/>
<SubscriptionStatusCard
hasManagedServiceAccess={args.hasManagedServiceAccess}
basePlanName={args.basePlanName}
basePlanPrice={args.basePlanPrice}
includedCreditsLabel={args.includedCreditsLabel}
/>
{args.hasManagedServiceAccess ? (
<button
type="button"
className="btn btn-outline btn-block sm:btn-wide"
disabled={args.isActionPending}
onClick={args.onOpenPortal}
>
{args.isPortalPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Open billing portal
</button>
) : (
<button
type="button"
className="btn btn-primary btn-block sm:btn-wide"
disabled={args.isActionPending}
onClick={args.onStartPlan}
>
{args.isStartPlanPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Start {args.basePlanName}
</button>
)}
</div>
</section>
);
}
function SeoDataCreditsSection(args: {
balance: {
granted: number;
remaining: number;
usage: number;
} | null;
basePlanName: string;
hasManagedServiceAccess: boolean;
isTopUpPending: boolean;
topUpAmount: string;
topUpDisabled: boolean;
onTopUp: () => void;
onTopUpAmountChange: (value: string) => void;
}) {
return (
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<div>
<h2 className="text-lg font-semibold text-base-content">
SEO data credits
</h2>
<p className="mt-1 text-sm text-base-content/70">
Buy extra usage credits for DataForSEO-powered features like
backlinks.
</p>
</div>
<div className="rounded-box border border-base-300 bg-base-200/60 p-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-base-content/70">Remaining</span>
<span className="text-2xl font-semibold text-base-content">
{formatCreditAmount(args.balance?.remaining ?? 0)}
</span>
</div>
<div className="mt-3 grid gap-2 text-sm text-base-content/70 sm:grid-cols-2">
<CreditStat label="Granted" value={args.balance?.granted ?? 0} />
<CreditStat label="Used" value={args.balance?.usage ?? 0} />
</div>
</div>
<label className="form-control gap-2">
<span className="label-text font-medium">Top up amount</span>
<div className="join">
<span className="join-item inline-flex items-center rounded-l-btn border border-base-300 bg-base-200 px-4 text-base-content/70">
$
</span>
<input
type="number"
min={10}
max={99}
step={1}
inputMode="numeric"
className="input join-item input-bordered w-full"
value={args.topUpAmount}
onChange={(event) => args.onTopUpAmountChange(event.target.value)}
/>
</div>
<span className="label-text-alt text-base-content/60">
{args.hasManagedServiceAccess
? "Enter a whole-dollar amount between $10 and $99."
: `Start ${args.basePlanName} before buying extra credits.`}
</span>
</label>
<button
type="button"
className="btn btn-secondary btn-block"
disabled={args.topUpDisabled}
onClick={args.onTopUp}
>
{args.isTopUpPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Buy credits
</button>
<p className="text-xs leading-relaxed text-base-content/60">
Credit purchases use our hosted checkout flow and apply to your
organization balance.
</p>
</div>
</section>
);
}
function CreditStat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-base-100 px-3 py-2">
<span className="block text-xs uppercase tracking-wide text-base-content/50">
{label}
</span>
<span className="font-medium text-base-content">
{formatCreditAmount(value)}
</span>
</div>
);
}

View File

@ -0,0 +1,74 @@
import { autumnSeoDataCreditsToUsd } from "@/shared/billing";
export function getIncludedFeatureQuantity(
plan: { items: Array<{ featureId: string; included: number }> } | null,
featureId: string,
) {
return (
plan?.items.find((item) => item.featureId === featureId)?.included ?? 0
);
}
export function parseTopUpAmount(value: string) {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return {
isValid: false,
parsed: 20,
};
}
const parsed = Number(trimmed);
const isValid = Number.isInteger(parsed) && parsed >= 10 && parsed <= 99;
return {
isValid,
parsed: isValid ? parsed : 20,
};
}
export function formatCreditAmount(value: number) {
return formatUsd(autumnSeoDataCreditsToUsd(value));
}
export function formatPlanPrice(
amount?: number | null,
interval?: string | null,
) {
if (typeof amount !== "number" || !interval) {
return "$5/month";
}
return `${formatUsd(amount, amount % 1 === 0 ? 0 : 2)}/${intervalToLabel(interval)}`;
}
function formatUsd(value: number, minimumFractionDigits = 2) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits,
maximumFractionDigits: 2,
}).format(value);
}
function intervalToLabel(interval: string) {
switch (interval) {
case "month":
return "month";
case "year":
return "year";
case "quarter":
return "quarter";
case "semi_annual":
return "6 months";
case "week":
return "week";
case "day":
return "day";
case "one_off":
return "one-time";
default:
return interval;
}
}

View File

@ -19,6 +19,7 @@ import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as AppBillingRouteImport } from './routes/_app/billing'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
@ -79,6 +80,11 @@ const AppBillingRoute = AppBillingRouteImport.update({
path: '/billing', path: '/billing',
getParentRoute: () => AppRouteRoute, getParentRoute: () => AppRouteRoute,
} as any) } as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$',
path: '/api/autumn/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$', id: '/api/auth/$',
path: '/api/auth/$', path: '/api/auth/$',
@ -155,6 +161,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -175,6 +182,7 @@ export interface FileRoutesByTo {
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute '/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
@ -199,6 +207,7 @@ export interface FileRoutesById {
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -222,6 +231,7 @@ export interface FileRouteTypes {
| '/p/$projectId' | '/p/$projectId'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
@ -242,6 +252,7 @@ export interface FileRouteTypes {
| '/sign-up' | '/sign-up'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/domain' | '/p/$projectId/domain'
@ -265,6 +276,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId' | '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$'
| '/_project/p/$projectId/ai' | '/_project/p/$projectId/ai'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
@ -284,6 +296,7 @@ export interface RootRouteChildren {
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
VerifyEmailRoute: typeof VerifyEmailRoute VerifyEmailRoute: typeof VerifyEmailRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -358,6 +371,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppBillingRouteImport preLoaderRoute: typeof AppBillingRouteImport
parentRoute: typeof AppRouteRoute parentRoute: typeof AppRouteRoute
} }
'/api/autumn/$': {
id: '/api/autumn/$'
path: '/api/autumn/$'
fullPath: '/api/autumn/$'
preLoaderRoute: typeof ApiAutumnSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': { '/api/auth/$': {
id: '/api/auth/$' id: '/api/auth/$'
path: '/api/auth/$' path: '/api/auth/$'
@ -536,6 +556,7 @@ const rootRouteChildren: RootRouteChildren = {
ResetPasswordRoute: ResetPasswordRoute, ResetPasswordRoute: ResetPasswordRoute,
VerifyEmailRoute: VerifyEmailRoute, VerifyEmailRoute: VerifyEmailRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@ -1,23 +1,10 @@
import { createFileRoute, notFound } from "@tanstack/react-router"; import { createFileRoute, notFound } from "@tanstack/react-router";
import { useMutation, useQuery } from "@tanstack/react-query"; import { AutumnProvider, useCustomer, useListPlans } from "autumn-js/react";
import { ExternalLink, LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { useState } from "react"; import { CenteredCard } from "@/client/features/billing/BillingRouteParts";
import { import { HostedBillingContent } from "@/client/features/billing/HostedBillingContent";
BillingAlerts,
BillingHeader,
CenteredCard,
SubscriptionIntro,
SubscriptionStatusCard,
} from "@/client/features/billing/BillingRouteParts";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
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 {
createBacklinksTopUpCheckout,
createHostedSubscriptionCheckout,
getHostedBillingStatus,
openHostedBillingPortal,
} from "@/serverFunctions/billing";
export const Route = createFileRoute("/_app/billing")({ export const Route = createFileRoute("/_app/billing")({
beforeLoad: () => { beforeLoad: () => {
@ -29,24 +16,32 @@ export const Route = createFileRoute("/_app/billing")({
}); });
function BillingPage() { function BillingPage() {
const { data: session, isPending: isSessionPending } = useSession(); return (
const [actionError, setActionError] = useState<string | null>(null); <AutumnProvider>
const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false); <BillingPageContent />
const [topUpAmount, setTopUpAmount] = useState("20"); </AutumnProvider>
);
}
const billingStatusQuery = useQuery({ function BillingPageContent() {
queryKey: ["billing", "status"], const { data: session, isPending: isSessionPending } = useSession();
queryFn: () => getHostedBillingStatus(),
enabled: Boolean(session?.user?.id), const customerQuery = useCustomer({
queryOptions: {
enabled: Boolean(session?.user?.id),
},
}); });
const { billingPortalMutation, startSubscriptionMutation, topUpMutation } = const plansQuery = useListPlans({
useBillingActions({ queryOptions: {
setActionError, enabled: Boolean(session?.user?.id),
setIsRedirectingToCheckout, },
}); });
if (isSessionPending || (session?.user?.id && billingStatusQuery.isLoading)) { if (
isSessionPending ||
(session?.user?.id && (customerQuery.isLoading || plansQuery.isLoading))
) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<LoaderCircle className="h-6 w-6 animate-spin text-base-content/60" /> <LoaderCircle className="h-6 w-6 animate-spin text-base-content/60" />
@ -68,7 +63,7 @@ function BillingPage() {
); );
} }
if (billingStatusQuery.isError) { if (customerQuery.isError || plansQuery.isError) {
return ( return (
<CenteredCard <CenteredCard
title="Billing unavailable" title="Billing unavailable"
@ -77,279 +72,10 @@ function BillingPage() {
); );
} }
const billing = billingStatusQuery.data;
const hasPaidPlan = billing?.hasPaidPlan ?? false;
const balance = billing?.balance;
const isActionPending =
startSubscriptionMutation.isPending ||
topUpMutation.isPending ||
billingPortalMutation.isPending;
const { isValid: isValidTopUpAmount, parsed: parsedTopUpAmount } =
parseTopUpAmount(topUpAmount);
const topUpDisabled = !hasPaidPlan || isActionPending || !isValidTopUpAmount;
if (isRedirectingToCheckout) {
return (
<CenteredCard
title="Redirecting to checkout..."
body="Please wait while we open your hosted billing flow."
/>
);
}
return ( return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 md:p-6"> <HostedBillingContent
<BillingHeader hasPaidPlan={hasPaidPlan} /> customerQuery={customerQuery}
plans={plansQuery.data ?? []}
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]"> />
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<SubscriptionIntro hasPaidPlan={hasPaidPlan} />
<SubscriptionStatusCard hasPaidPlan={hasPaidPlan} />
{!hasPaidPlan ? (
<button
type="button"
className="btn btn-primary btn-block sm:btn-wide"
disabled={isActionPending}
onClick={() => startSubscriptionMutation.mutate()}
>
{startSubscriptionMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Start base subscription
</button>
) : null}
{hasPaidPlan ? (
<button
type="button"
className="btn btn-outline btn-block sm:btn-wide"
disabled={isActionPending}
onClick={() => billingPortalMutation.mutate()}
>
{billingPortalMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Open billing portal
</button>
) : null}
</div>
</section>
<section className="card border border-base-300 bg-base-100 shadow-sm">
<div className="card-body gap-4">
<div>
<h2 className="text-lg font-semibold text-base-content">
SEO data credits
</h2>
<p className="mt-1 text-sm text-base-content/70">
Buy extra usage credits for DataForSEO-powered features like
backlinks.
</p>
</div>
<div className="rounded-box border border-base-300 bg-base-200/60 p-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-base-content/70">Remaining</span>
<span className="text-2xl font-semibold text-base-content">
{formatUsd(balance?.remaining ?? 0)}
</span>
</div>
<div className="mt-3 grid gap-2 text-sm text-base-content/70 sm:grid-cols-2">
<div className="rounded-md bg-base-100 px-3 py-2">
<span className="block text-xs uppercase tracking-wide text-base-content/50">
Granted
</span>
<span className="font-medium text-base-content">
{formatUsd(balance?.granted ?? 0)}
</span>
</div>
<div className="rounded-md bg-base-100 px-3 py-2">
<span className="block text-xs uppercase tracking-wide text-base-content/50">
Used
</span>
<span className="font-medium text-base-content">
{formatUsd(balance?.usage ?? 0)}
</span>
</div>
</div>
</div>
<label className="form-control gap-2">
<span className="label-text font-medium">Top up amount</span>
<div className="join">
<span className="join-item inline-flex items-center rounded-l-btn border border-base-300 bg-base-200 px-4 text-base-content/70">
$
</span>
<input
type="number"
min={10}
max={99}
step={1}
inputMode="numeric"
className="input join-item input-bordered w-full"
value={topUpAmount}
onChange={(event) => setTopUpAmount(event.target.value)}
/>
</div>
<span className="label-text-alt text-base-content/60">
{hasPaidPlan
? "Enter a whole-dollar amount between $10 and $99."
: "Start the base subscription before buying extra credits."}
</span>
</label>
<button
type="button"
className="btn btn-secondary btn-block"
disabled={topUpDisabled}
onClick={() => {
if (topUpDisabled) return;
topUpMutation.mutate({ amount: parsedTopUpAmount });
}}
>
{topUpMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
Buy credits
</button>
<p className="text-xs leading-relaxed text-base-content/60">
Credit purchases use our hosted checkout flow and apply to your
organization balance.
</p>
</div>
</section>
</div>
<BillingAlerts actionError={actionError} hasPaidPlan={hasPaidPlan} />
<div className="flex items-center gap-2 text-sm text-base-content/60">
<ExternalLink className="h-4 w-4" />
<span>Hosted billing is powered by Autumn.</span>
</div>
</div>
); );
} }
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);
}

View File

@ -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);
},
},
},
});

View File

@ -1,21 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; 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(() => ({ const { checkMock, getOrCreateMock, isHostedServerAuthModeMock } = vi.hoisted(
getOrCreateMock: vi.fn(), () => ({
})); checkMock: vi.fn(),
getOrCreateMock: vi.fn(),
isHostedServerAuthModeMock: vi.fn(),
}),
);
vi.mock("@/server/billing/autumn", () => ({ vi.mock("@/server/billing/autumn", () => ({
autumn: { autumn: {
check: checkMock,
customers: { customers: {
getOrCreate: getOrCreateMock, getOrCreate: getOrCreateMock,
}, },
}, },
})); }));
vi.mock("@/server/lib/runtime-env", () => ({
isHostedServerAuthMode: isHostedServerAuthModeMock,
}));
import { import {
customerHasManagedServiceAccess,
getOrCreateOrganizationCustomer, getOrCreateOrganizationCustomer,
hasActivePaidPlan, requireManagedServiceAccess,
} from "./subscription"; } from "./subscription";
describe("subscription billing", () => { describe("subscription billing", () => {
@ -23,37 +33,47 @@ describe("subscription billing", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("keeps access when an active plan is scheduled to cancel", () => { it("checks the managed service access entitlement", async () => {
expect( checkMock.mockResolvedValue({ allowed: true });
hasActivePaidPlan({
subscriptions: [ await expect(customerHasManagedServiceAccess("org_123")).resolves.toBe(
{ true,
planId: AUTUMN_PAID_PLAN_ID, );
status: "active",
pastDue: false, expect(checkMock).toHaveBeenCalledWith({
canceledAt: Date.now(), customerId: "org_123",
}, featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
], });
}),
).toBe(true);
}); });
it("rejects past-due paid plans", () => { it("skips the managed service check outside hosted mode", async () => {
expect( isHostedServerAuthModeMock.mockResolvedValue(false);
hasActivePaidPlan({
subscriptions: [ await expect(
{ requireManagedServiceAccess({
planId: AUTUMN_PAID_PLAN_ID, organizationId: "org_123",
status: "active", userEmail: "alice@example.com",
pastDue: true,
canceledAt: null,
},
],
}), }),
).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" }); getOrCreateMock.mockResolvedValue({ id: "cust_123" });
await getOrCreateOrganizationCustomer({ await getOrCreateOrganizationCustomer({
@ -63,7 +83,6 @@ describe("subscription billing", () => {
expect(getOrCreateMock).toHaveBeenCalledWith({ expect(getOrCreateMock).toHaveBeenCalledWith({
customerId: "org_123", customerId: "org_123",
name: "org_123",
}); });
}); });
}); });

View File

@ -1,5 +1,5 @@
import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; 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 { autumn } from "@/server/billing/autumn";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
@ -14,7 +14,6 @@ export async function getOrCreateOrganizationCustomer(
) { ) {
const customer = await autumn.customers.getOrCreate({ const customer = await autumn.customers.getOrCreate({
customerId: context.organizationId, customerId: context.organizationId,
name: context.organizationId,
}); });
if (!customer.id) { if (!customer.id) {
@ -27,23 +26,16 @@ export async function getOrCreateOrganizationCustomer(
}; };
} }
export function hasActivePaidPlan(customer: { export async function customerHasManagedServiceAccess(customerId: string) {
subscriptions: Array<{ const result = await autumn.check({
planId: string; customerId,
status: string; featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
pastDue: boolean; });
canceledAt: number | null;
}>; return result.allowed;
}) {
return customer.subscriptions.some(
(subscription) =>
subscription.planId === AUTUMN_PAID_PLAN_ID &&
subscription.status === "active" &&
!subscription.pastDue,
);
} }
export async function requireHostedPaidSubscription( export async function requireManagedServiceAccess(
context: BillingCustomerContext, context: BillingCustomerContext,
) { ) {
if (!(await isHostedServerAuthMode())) { if (!(await isHostedServerAuthMode())) {
@ -51,7 +43,7 @@ export async function requireHostedPaidSubscription(
} }
const customer = await getOrCreateOrganizationCustomer(context); const customer = await getOrCreateOrganizationCustomer(context);
if (!hasActivePaidPlan(customer)) { if (!(await customerHasManagedServiceAccess(customer.id))) {
throw new AppError("PAYMENT_REQUIRED"); throw new AppError("PAYMENT_REQUIRED");
} }
} }

View File

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

View File

@ -3,7 +3,7 @@ import { AppError } from "@/server/lib/errors";
import { errorHandlingMiddleware } from "@/middleware/errorHandling"; import { errorHandlingMiddleware } from "@/middleware/errorHandling";
import type { EnsuredUserContext } from "@/middleware/ensure-user/types"; import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
import { ensureUserMiddleware } from "@/middleware/ensureUser"; import { ensureUserMiddleware } from "@/middleware/ensureUser";
import { requireHostedPaidSubscription } from "@/server/billing/subscription"; import { requireManagedServiceAccess } from "@/server/billing/subscription";
type AuthenticatedServerFunctionContext = EnsuredUserContext; type AuthenticatedServerFunctionContext = EnsuredUserContext;
@ -45,7 +45,7 @@ export const globalServerFunctionMiddleware = [
export const requireAuthenticatedContext = [ export const requireAuthenticatedContext = [
createMiddleware({ type: "function" }).server(async ({ next, context }) => { createMiddleware({ type: "function" }).server(async ({ next, context }) => {
const authenticatedContext = getAuthenticatedContext(context); const authenticatedContext = getAuthenticatedContext(context);
await requireHostedPaidSubscription(authenticatedContext); await requireManagedServiceAccess(authenticatedContext);
return next({ return next({
context: authenticatedContext, context: authenticatedContext,
@ -53,19 +53,11 @@ export const requireAuthenticatedContext = [
}), }),
] as const; ] as const;
export const requireEnsuredUserContext = [
createMiddleware({ type: "function" }).server(({ next, context }) =>
next({
context: getAuthenticatedContext(context),
}),
),
] as const;
export const requireProjectContext = [ export const requireProjectContext = [
createMiddleware({ type: "function" }).server(async ({ next, context }) => { createMiddleware({ type: "function" }).server(async ({ next, context }) => {
const authenticatedContext = getAuthenticatedContext(context); const authenticatedContext = getAuthenticatedContext(context);
await requireHostedPaidSubscription(authenticatedContext); await requireManagedServiceAccess(authenticatedContext);
if (!authenticatedContext.project) { if (!authenticatedContext.project) {
throw new AppError( throw new AppError(

View File

@ -2,6 +2,8 @@ export const BILLING_ROUTE = "/billing";
export const AUTUMN_PAID_PLAN_ID = "base-plan"; export const AUTUMN_PAID_PLAN_ID = "base-plan";
export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up"; 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_BALANCE_FEATURE_ID = "usage_credits";
export const AUTUMN_SEO_DATA_USAGE_FEATURE_ID = "seo_data_usage"; export const AUTUMN_SEO_DATA_USAGE_FEATURE_ID = "seo_data_usage";
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000; export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;