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": "^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
10
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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 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)
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
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 { 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",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user