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:
parent
4f33a52e0e
commit
724a92db0c
@ -53,7 +53,7 @@
|
||||
"@tanstack/react-router": "^1.136.3",
|
||||
"@tanstack/react-router-devtools": "^1.136.3",
|
||||
"@tanstack/react-start": "^1.136.3",
|
||||
"autumn-js": "^1.1.2",
|
||||
"autumn-js": "^1.1.7",
|
||||
"better-auth": "^1.5.5",
|
||||
"cheerio": "^1.2.0",
|
||||
"cloudflare": "^5.2.0",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -30,8 +30,8 @@ importers:
|
||||
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))
|
||||
autumn-js:
|
||||
specifier: ^1.1.2
|
||||
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)
|
||||
specifier: ^1.1.7
|
||||
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:
|
||||
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))
|
||||
@ -2044,8 +2044,8 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
autumn-js@1.1.2:
|
||||
resolution: {integrity: sha512-glSAvGJ6G8cJKLH6enonQ0WverZUMnGirv6hYzMBZViFqAiU6IDrEbbD0Xl7LGUN/pUjd9JgVwrmxeu3cnLO0g==}
|
||||
autumn-js@1.1.7:
|
||||
resolution: {integrity: sha512-wd7dbaVY1ZORIS2nSIvcuroD6wfbW3qZhiKB/1zN2r851116iVILuaV83cC6GRMKs6b93dFOjpuMmnUtlRYj3A==}
|
||||
peerDependencies:
|
||||
better-auth: ^1.3.17
|
||||
better-call: ^1.0.12
|
||||
@ -5066,7 +5066,7 @@ snapshots:
|
||||
|
||||
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:
|
||||
query-string: 9.3.1
|
||||
rou3: 0.6.3
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { CreditCard } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function BillingHeader({ hasPaidPlan }: { hasPaidPlan: boolean }) {
|
||||
export function BillingHeader(args: {
|
||||
hasManagedServiceAccess: boolean;
|
||||
basePlanName: string;
|
||||
includedCreditsLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{hasPaidPlan ? "Billing" : "Choose a plan"}
|
||||
{args.hasManagedServiceAccess ? "Billing" : "Choose a plan"}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-base-content/70">
|
||||
{hasPaidPlan
|
||||
? "OpenSEO hosted usage is metered against your shared backlinks balance. Your monthly plan covers the first $5, 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."}
|
||||
{args.hasManagedServiceAccess
|
||||
? `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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionIntro({ hasPaidPlan }: { hasPaidPlan: boolean }) {
|
||||
export function SubscriptionIntro(args: {
|
||||
hasManagedServiceAccess: boolean;
|
||||
basePlanName: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{hasPaidPlan ? "Subscription" : "Managed service access"}
|
||||
{args.hasManagedServiceAccess
|
||||
? "Subscription"
|
||||
: "Managed service access"}
|
||||
</h2>
|
||||
<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."
|
||||
: "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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionStatusCard({
|
||||
hasPaidPlan,
|
||||
}: {
|
||||
hasPaidPlan: boolean;
|
||||
export function SubscriptionStatusCard(args: {
|
||||
hasManagedServiceAccess: boolean;
|
||||
basePlanName: string;
|
||||
basePlanPrice: string;
|
||||
includedCreditsLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-base-300 bg-base-200/50 p-4">
|
||||
<div className="text-sm text-base-content/60">
|
||||
{hasPaidPlan ? "Current status" : "Base plan"}
|
||||
{args.hasManagedServiceAccess ? "Current status" : args.basePlanName}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{hasPaidPlan ? "Active" : "$5/month"}
|
||||
{args.hasManagedServiceAccess ? "Active" : args.basePlanPrice}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-base-content/70">
|
||||
{hasPaidPlan
|
||||
{args.hasManagedServiceAccess
|
||||
? "Your organization can use hosted OpenSEO features."
|
||||
: "Includes $5 of usage credits every month."}
|
||||
: `Includes ${args.includedCreditsLabel} of usage credits every cycle.`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -59,7 +69,8 @@ export function SubscriptionStatusCard({
|
||||
|
||||
export function BillingAlerts(args: {
|
||||
actionError: string | null;
|
||||
hasPaidPlan: boolean;
|
||||
hasManagedServiceAccess: boolean;
|
||||
basePlanName: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@ -69,11 +80,11 @@ export function BillingAlerts(args: {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!args.hasPaidPlan ? (
|
||||
{!args.hasManagedServiceAccess ? (
|
||||
<div className="alert alert-warning">
|
||||
<span>
|
||||
Subscribe to the base plan first. After that, you can manage your
|
||||
plan and buy more credits here.
|
||||
Subscribe to {args.basePlanName} first. After that, you can manage
|
||||
your plan and buy more credits here.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
335
src/client/features/billing/HostedBillingContent.tsx
Normal file
335
src/client/features/billing/HostedBillingContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/client/features/billing/HostedBillingContentUtils.ts
Normal file
74
src/client/features/billing/HostedBillingContentUtils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ import { Route as AppIndexRouteImport } from './routes/_app/index'
|
||||
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
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 AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||
@ -79,6 +80,11 @@ const AppBillingRoute = AppBillingRouteImport.update({
|
||||
path: '/billing',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
||||
id: '/api/autumn/$',
|
||||
path: '/api/autumn/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
@ -155,6 +161,7 @@ export interface FileRoutesByFullPath {
|
||||
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
@ -175,6 +182,7 @@ export interface FileRoutesByTo {
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
@ -199,6 +207,7 @@ export interface FileRoutesById {
|
||||
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
@ -222,6 +231,7 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
@ -242,6 +252,7 @@ export interface FileRouteTypes {
|
||||
| '/sign-up'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/domain'
|
||||
@ -265,6 +276,7 @@ export interface FileRouteTypes {
|
||||
| '/_project/p/$projectId'
|
||||
| '/_app/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/_project/p/$projectId/ai'
|
||||
| '/_project/p/$projectId/audit'
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
@ -284,6 +296,7 @@ export interface RootRouteChildren {
|
||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@ -358,6 +371,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppBillingRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/api/autumn/$': {
|
||||
id: '/api/autumn/$'
|
||||
path: '/api/autumn/$'
|
||||
fullPath: '/api/autumn/$'
|
||||
preLoaderRoute: typeof ApiAutumnSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/$': {
|
||||
id: '/api/auth/$'
|
||||
path: '/api/auth/$'
|
||||
@ -536,6 +556,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ResetPasswordRoute: ResetPasswordRoute,
|
||||
VerifyEmailRoute: VerifyEmailRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@ -1,23 +1,10 @@
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ExternalLink, LoaderCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BillingAlerts,
|
||||
BillingHeader,
|
||||
CenteredCard,
|
||||
SubscriptionIntro,
|
||||
SubscriptionStatusCard,
|
||||
} from "@/client/features/billing/BillingRouteParts";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { AutumnProvider, useCustomer, useListPlans } from "autumn-js/react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { CenteredCard } from "@/client/features/billing/BillingRouteParts";
|
||||
import { HostedBillingContent } from "@/client/features/billing/HostedBillingContent";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import {
|
||||
createBacklinksTopUpCheckout,
|
||||
createHostedSubscriptionCheckout,
|
||||
getHostedBillingStatus,
|
||||
openHostedBillingPortal,
|
||||
} from "@/serverFunctions/billing";
|
||||
|
||||
export const Route = createFileRoute("/_app/billing")({
|
||||
beforeLoad: () => {
|
||||
@ -29,24 +16,32 @@ export const Route = createFileRoute("/_app/billing")({
|
||||
});
|
||||
|
||||
function BillingPage() {
|
||||
const { data: session, isPending: isSessionPending } = useSession();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false);
|
||||
const [topUpAmount, setTopUpAmount] = useState("20");
|
||||
return (
|
||||
<AutumnProvider>
|
||||
<BillingPageContent />
|
||||
</AutumnProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const billingStatusQuery = useQuery({
|
||||
queryKey: ["billing", "status"],
|
||||
queryFn: () => getHostedBillingStatus(),
|
||||
enabled: Boolean(session?.user?.id),
|
||||
function BillingPageContent() {
|
||||
const { data: session, isPending: isSessionPending } = useSession();
|
||||
|
||||
const customerQuery = useCustomer({
|
||||
queryOptions: {
|
||||
enabled: Boolean(session?.user?.id),
|
||||
},
|
||||
});
|
||||
|
||||
const { billingPortalMutation, startSubscriptionMutation, topUpMutation } =
|
||||
useBillingActions({
|
||||
setActionError,
|
||||
setIsRedirectingToCheckout,
|
||||
});
|
||||
const plansQuery = useListPlans({
|
||||
queryOptions: {
|
||||
enabled: Boolean(session?.user?.id),
|
||||
},
|
||||
});
|
||||
|
||||
if (isSessionPending || (session?.user?.id && billingStatusQuery.isLoading)) {
|
||||
if (
|
||||
isSessionPending ||
|
||||
(session?.user?.id && (customerQuery.isLoading || plansQuery.isLoading))
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<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 (
|
||||
<CenteredCard
|
||||
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 (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 md:p-6">
|
||||
<BillingHeader hasPaidPlan={hasPaidPlan} />
|
||||
|
||||
<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>
|
||||
<HostedBillingContent
|
||||
customerQuery={customerQuery}
|
||||
plans={plansQuery.data ?? []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
38
src/routes/api/autumn/$.ts
Normal file
38
src/routes/api/autumn/$.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user