feat: migrate to api_pro billing and add credits-based usage display (#729)

Summary

  - Migrate from consumer_pro to api_pro billing product across the app
  - Enable Nova app for all users (remove feature flag)
  - Add credits-based usage tracking with tokens abstraction
This commit is contained in:
Prasanna721 2026-02-10 05:53:47 +00:00
parent c15308c503
commit 11a0abd5c0
18 changed files with 309 additions and 103 deletions

View file

@ -2,7 +2,7 @@
import { signIn } from "@lib/auth"
import { usePostHog } from "@lib/posthog"
import { TextSeparator } from "@repo/ui/components/text-separator"
import { TextSeparator } from "@ui/components/text-separator"
import { ExternalAuthButton } from "@ui/button/external-auth"
import { Button } from "@ui/components/button"
import { Badge } from "@ui/components/badge"

View file

@ -15,7 +15,7 @@ export default function NavigationLayout({
const [showAddMemoryView, setShowAddMemoryView] = useState(false)
const pathname = usePathname()
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
const flagEnabled = true
useEffect(() => {
if (flagEnabled && !pathname.includes("/new")) {

View file

@ -16,7 +16,7 @@ import { useFeatureFlagEnabled } from "posthog-js/react"
export default function Page() {
const { user, session } = useAuth()
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
const flagEnabled = true
// TODO: remove this flow after the feature flag is removed
// Old app: localStorage-backed onboarding

View file

@ -7,7 +7,7 @@ import { MobileBanner } from "@/components/new/mobile-banner"
export default function NewLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
const flagEnabled = true
useEffect(() => {
if (!flagEnabled) {

View file

@ -1,10 +1,7 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import {
fetchConsumerProProduct,
fetchMemoriesFeature,
} from "@repo/lib/queries"
import { fetchApiProProduct, fetchMemoriesFeature } from "@repo/lib/queries"
import { Button } from "@repo/ui/components/button"
import { ConnectAIModal } from "./connect-ai-modal"
import { HeadingH2Bold } from "@repo/ui/text/heading/heading-h2-bold"
@ -74,7 +71,7 @@ function Menu({ id }: { id?: string }) {
const memoriesUsed = memoriesCheck?.usage ?? 0
const memoriesLimit = memoriesCheck?.included_usage ?? 0
const { data: proCheck } = fetchConsumerProProduct(autumn)
const { data: proCheck } = fetchApiProProduct(autumn)
useEffect(() => {
if (memoriesCheck) {

View file

@ -59,9 +59,8 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
useEffect(() => {
if (!autumn.isLoading) {
setIsProUser(
autumn.customer?.products.some(
(product) => product.id === "consumer_pro",
) ?? false,
autumn.customer?.products.some((product) => product.id === "api_pro") ??
false,
)
}
}, [autumn.isLoading, autumn.customer])
@ -70,7 +69,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
setIsUpgrading(true)
try {
await autumn.attach({
productId: "consumer_pro",
productId: "api_pro",
successUrl: window.location.href,
})
} catch (error) {

View file

@ -4,7 +4,8 @@ import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useMemoriesUsage } from "@/hooks/use-memories-usage"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { formatUsageNumber, tokensToCredits } from "@/lib/billing-utils"
import {
Dialog,
DialogContent,
@ -107,19 +108,31 @@ export default function Account() {
}
const {
memoriesUsed,
memoriesLimit,
hasProProduct,
tokensUsed,
tokensLimit,
tokensPercent,
searchesUsed,
searchesLimit,
searchesPercent,
currentPlan,
hasPaidPlan,
isLoading: isCheckingStatus,
usagePercent,
} = useMemoriesUsage(autumn)
daysRemaining,
} = useTokenUsage(autumn)
const planDisplayNames: Record<string, string> = {
free: "Free",
pro: "Pro",
scale: "Scale",
enterprise: "Enterprise",
}
// Handlers
const handleUpgrade = async () => {
setIsUpgrading(true)
try {
await autumn.attach({
productId: "consumer_pro",
productId: "api_pro",
successUrl: "https://app.supermemory.ai/new/settings#account",
})
window.location.reload()
@ -222,8 +235,6 @@ export default function Account() {
{allOrgs.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingOrgId === organization.id
const isConsumer =
organization.metadata?.isConsumer === true
return (
<button
key={organization.id}
@ -256,11 +267,6 @@ export default function Account() {
<LoaderIcon className="size-4 text-[#4BA0FA] shrink-0 animate-spin" />
)}
</div>
{!isConsumer && (
<span className="text-[11px] font-medium tracking-[0.3px] px-1.5 py-0.5 rounded-[4px] shrink-0 bg-[#737373]/15 text-[#737373]">
API
</span>
)}
</button>
)
})}
@ -295,7 +301,7 @@ export default function Account() {
<SectionTitle>Billing &amp; Subscription</SectionTitle>
<SettingsCard>
<div className="flex flex-col gap-6">
{hasProProduct ? (
{hasPaidPlan ? (
<>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-4">
@ -305,7 +311,7 @@ export default function Account() {
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
)}
>
Pro plan
{planDisplayNames[currentPlan]} plan
</p>
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
ACTIVE
@ -321,7 +327,8 @@ export default function Account() {
</p>
</div>
<div id="progress-bar" className="flex flex-col gap-3">
{/* Credits Usage Progress */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
className={cn(
@ -329,36 +336,80 @@ export default function Account() {
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Unlimited Memories
Credits Used
</p>
<div className="flex items-center">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
)}
>
{memoriesUsed}/
</span>
<span className="text-[#4BA0FA] text-[20px] leading-none ml-0.5">
</span>
</div>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
)}
>
{tokensToCredits(tokensUsed)} /{" "}
{tokensToCredits(tokensLimit)}
</span>
</div>
<div
id="progress-bar-fill"
className="h-3 w-full rounded-[40px] bg-[#2E353D] blur-[1px] p-px overflow-hidden"
>
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full w-full rounded-[40px]"
className="h-full rounded-[40px]"
style={{
width: `${tokensPercent}%`,
background:
"linear-gradient(to right, #4BA0FA 80.517%, #002757 100%)",
tokensPercent > 80
? "#ef4444"
: "linear-gradient(to right, #4BA0FA 80%, #002757 100%)",
}}
/>
</div>
</div>
{/* Search Queries Progress */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Search Queries
</p>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#4BA0FA]",
)}
>
{formatUsageNumber(searchesUsed)} /{" "}
{formatUsageNumber(searchesLimit)}
</span>
</div>
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${searchesPercent}%`,
background:
searchesPercent > 80
? "#ef4444"
: "linear-gradient(to right, #4BA0FA 80%, #002757 100%)",
}}
/>
</div>
</div>
{/* Days remaining indicator */}
{daysRemaining !== null && (
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Resets in {daysRemaining} day
{daysRemaining !== 1 ? "s" : ""}
</p>
)}
<button
type="button"
onClick={() => {
@ -389,14 +440,17 @@ export default function Account() {
Free plan
</p>
<div className="flex flex-col gap-2">
<PlanFeatureRow icon="x" text="Limited 200 memories" />
<PlanFeatureRow
icon="check"
text="10 credits / 1M tokens"
/>
<PlanFeatureRow icon="check" text="10K search queries" />
<PlanFeatureRow icon="x" text="No connections" />
<PlanFeatureRow icon="check" text="Basic search" />
<PlanFeatureRow icon="check" text="Basic support" />
</div>
</div>
{/* Pro plan card - highlighted */}
{/* Current plan card - highlighted */}
<div
className={cn(
"flex flex-col gap-4 p-4 rounded-[10px]",
@ -405,7 +459,6 @@ export default function Account() {
"relative overflow-hidden",
)}
>
{/* Header with ACTIVE badge */}
<div className="flex items-center justify-between">
<p
className={cn(
@ -413,7 +466,7 @@ export default function Account() {
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Pro plan
{planDisplayNames[currentPlan]} plan
</p>
<span className="bg-[#4BA0FA] text-[#00171A] text-[12px] font-bold tracking-[0.36px] px-1 py-[3px] rounded-[3px] h-[18px] flex items-center justify-center">
ACTIVE
@ -422,7 +475,12 @@ export default function Account() {
<div className="flex flex-col gap-2">
<PlanFeatureRow
icon="check"
text="Unlimited memories"
text="30 credits / 3M tokens"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="100K search queries"
variant="highlight"
/>
<PlanFeatureRow
@ -430,18 +488,12 @@ export default function Account() {
text="10 connections"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Advanced search"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Priority support"
variant="highlight"
/>
</div>
{/* Inset highlight */}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_0.711px_0.711px_0.711px_rgba(255,255,255,0.1)]" />
</div>
</div>
@ -467,6 +519,7 @@ export default function Account() {
</p>
</div>
{/* Credits Usage Progress */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
@ -475,7 +528,7 @@ export default function Account() {
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Memories
Credits Used
</p>
<p
className={cn(
@ -483,18 +536,67 @@ export default function Account() {
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
{memoriesUsed}/{memoriesLimit}
{tokensToCredits(tokensUsed)} /{" "}
{tokensToCredits(tokensLimit)}
</p>
</div>
{/* Progress bar */}
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px">
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px] bg-[#0054AD] transition-all"
style={{ width: `${usagePercent}%` }}
className="h-full rounded-[40px] transition-all"
style={{
width: `${tokensPercent}%`,
background: tokensPercent > 80 ? "#ef4444" : "#0054AD",
}}
/>
</div>
</div>
{/* Search Queries Progress */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
Search Queries
</p>
<p
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
{formatUsageNumber(searchesUsed)} /{" "}
{formatUsageNumber(searchesLimit)}
</p>
</div>
<div className="h-3 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px] transition-all"
style={{
width: `${searchesPercent}%`,
background:
searchesPercent > 80 ? "#ef4444" : "#0054AD",
}}
/>
</div>
</div>
{/* Days remaining indicator */}
{daysRemaining !== null && (
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
Resets in {daysRemaining} day
{daysRemaining !== 1 ? "s" : ""}
</p>
)}
<button
type="button"
onClick={handleUpgrade}
@ -520,7 +622,7 @@ export default function Account() {
Upgrading...
</>
) : (
"Upgrade to Pro - $9/month"
"Upgrade to Pro - $19/month"
)}
{/* Inset blue stroke */}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
@ -538,9 +640,12 @@ export default function Account() {
Free plan
</p>
<div className="flex flex-col gap-2">
<PlanFeatureRow icon="x" text="Limited 200 memories" />
<PlanFeatureRow
icon="check"
text="10 credits / 1M tokens"
/>
<PlanFeatureRow icon="check" text="10K search queries" />
<PlanFeatureRow icon="x" text="No connections" />
<PlanFeatureRow icon="check" text="Basic search" />
<PlanFeatureRow icon="check" text="Basic support" />
</div>
</div>
@ -571,7 +676,12 @@ export default function Account() {
<div className="flex flex-col gap-2">
<PlanFeatureRow
icon="check"
text="Unlimited memories"
text="30 credits / 3M tokens"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="100K search queries"
variant="highlight"
/>
<PlanFeatureRow
@ -579,11 +689,6 @@ export default function Account() {
text="10 connections"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Advanced search"
variant="highlight"
/>
<PlanFeatureRow
icon="check"
text="Priority support"

View file

@ -342,12 +342,12 @@ export default function ConnectionsMCP() {
// Billing data
const {
data: status = {
consumer_pro: { allowed: false, status: null },
api_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const hasProProduct = status.consumer_pro?.status !== null
const hasProProduct = status.api_pro?.status !== null
// Get connections data directly from autumn customer
const connectionsFeature = autumn.customer?.features?.connections
@ -415,7 +415,7 @@ export default function ConnectionsMCP() {
const handleUpgrade = async () => {
try {
await autumn.attach({
productId: "consumer_pro",
productId: "api_pro",
successUrl: "https://app.supermemory.ai/new/settings#connections",
})
window.location.reload()

View file

@ -16,7 +16,7 @@ import { useIsMobile } from "@hooks/use-mobile"
export function NewOnboardingModal() {
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
const flagEnabled = true
const isMobile = useIsMobile()
const [open, setOpen] = useState(false)

View file

@ -24,12 +24,12 @@ export function BillingView() {
const {
data: status = {
consumer_pro: { allowed: false, status: null },
api_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const proStatus = status.consumer_pro
const proStatus = status.api_pro
const proProductStatus = proStatus?.status
const isPastDue = proProductStatus === "past_due"
const hasProProduct = proProductStatus !== null
@ -55,7 +55,7 @@ export function BillingView() {
setIsLoading(true)
try {
await autumn.attach({
productId: "consumer_pro",
productId: "api_pro",
successUrl: "https://app.supermemory.ai/",
})
analytics.upgradeCompleted()

View file

@ -48,7 +48,7 @@ export function ConnectionsTabContent() {
const handleUpgrade = async () => {
try {
await autumn.attach({
productId: "consumer_pro",
productId: "api_pro",
successUrl: "https://app.supermemory.ai/",
})
window.location.reload()
@ -61,9 +61,8 @@ export function ConnectionsTabContent() {
useEffect(() => {
if (!autumn.isLoading) {
setIsProUser(
autumn.customer?.products.some(
(product) => product.id === "consumer_pro",
) ?? false,
autumn.customer?.products.some((product) => product.id === "api_pro") ??
false,
)
}
}, [autumn.isLoading, autumn.customer])

View file

@ -171,7 +171,7 @@ export function IntegrationsView() {
const handleUpgrade = async () => {
try {
await autumn.attach({
productId: "consumer_pro",
productId: "api_pro",
successUrl: "https://app.supermemory.ai/",
})
window.location.reload()
@ -183,9 +183,8 @@ export function IntegrationsView() {
useEffect(() => {
if (!autumn.isLoading) {
setIsProUser(
autumn.customer?.products.some(
(product) => product.id === "consumer_pro",
) ?? false,
autumn.customer?.products.some((product) => product.id === "api_pro") ??
false,
)
}
}, [autumn.isLoading, autumn.customer])

View file

@ -48,12 +48,12 @@ export function ProfileView() {
const {
data: status = {
consumer_pro: { allowed: false, status: null },
api_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const proStatus = status.consumer_pro
const proStatus = status.api_pro
const isPro = proStatus?.allowed ?? false
const proProductStatus = proStatus?.status
const isPastDue = proProductStatus === "past_due"
@ -76,7 +76,7 @@ export function ProfileView() {
setIsLoading(true)
try {
await autumn.attach({
productId: "consumer_pro",
productId: "api_pro",
successUrl: "https://app.supermemory.ai/",
})
window.location.reload()

View file

@ -4,12 +4,12 @@ import type { useCustomer } from "autumn-js/react"
export function useMemoriesUsage(autumn: ReturnType<typeof useCustomer>) {
const {
data: status = {
consumer_pro: { allowed: false, status: null },
api_pro: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
const proStatus = status.consumer_pro
const proStatus = status.api_pro
const hasProProduct = proStatus?.status !== null
const { data: memoriesCheck, isLoading: isLoadingMemories } =

View file

@ -0,0 +1,57 @@
import { fetchSubscriptionStatus } from "@lib/queries"
import type { useCustomer } from "autumn-js/react"
import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils"
export type PlanType = "free" | "pro" | "scale" | "enterprise"
export function useTokenUsage(autumn: ReturnType<typeof useCustomer>) {
const {
data: status = {
api_pro: { allowed: false, status: null },
api_scale: { allowed: false, status: null },
api_enterprise: { allowed: false, status: null },
},
isLoading: isCheckingStatus,
} = fetchSubscriptionStatus(autumn, !autumn.isLoading)
let currentPlan: PlanType = "free"
if (status.api_enterprise?.status !== null) {
currentPlan = "enterprise"
} else if (status.api_scale?.status !== null) {
currentPlan = "scale"
} else if (status.api_pro?.status !== null) {
currentPlan = "pro"
}
const hasPaidPlan = currentPlan !== "free"
// Get token usage from autumn customer features
const tokensFeature = autumn.customer?.features?.api_tokens
const tokensUsed = tokensFeature?.usage ?? 0
const tokensLimit = tokensFeature?.included_usage ?? 0
const tokensResetAt = tokensFeature?.next_reset_at
// Get search queries usage from autumn customer features
const searchesFeature = autumn.customer?.features?.api_search_queries
const searchesUsed = searchesFeature?.usage ?? 0
const searchesLimit = searchesFeature?.included_usage ?? 0
const isLoading = autumn.isLoading || isCheckingStatus
const tokensPercent = calculateUsagePercent(tokensUsed, tokensLimit)
const searchesPercent = calculateUsagePercent(searchesUsed, searchesLimit)
const daysRemaining = getDaysRemaining(tokensResetAt)
return {
tokensUsed,
tokensLimit,
tokensPercent,
searchesUsed,
searchesLimit,
searchesPercent,
currentPlan,
hasPaidPlan,
isLoading,
daysRemaining,
}
}

View file

@ -0,0 +1,49 @@
/**
* Convert tokens to credits
* 100,000 tokens = 1 credit
* Uses floor rounding (2.9 credits shows as 2, only shows 3 when it hits 3.0)
*/
export function tokensToCredits(tokens: number): number {
return Math.floor(tokens / 100_000)
}
/**
* Format a number with K/M suffix for display
* @example formatUsageNumber(1500000) => "1.5M"
* @example formatUsageNumber(50000) => "50K"
*/
export function formatUsageNumber(value: number): string {
if (value >= 1_000_000) {
const millions = value / 1_000_000
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`
}
if (value >= 1_000) {
const thousands = value / 1_000
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`
}
return value.toString()
}
/**
* Calculate usage percentage, clamped between 0 and 100
*/
export function calculateUsagePercent(used: number, limit: number): number {
if (limit <= 0) return 0
return Math.min(Math.max((used / limit) * 100, 0), 100)
}
/**
* Get the number of days remaining until a reset date
* @param resetAt - Unix timestamp in milliseconds (from Autumn billing)
* @returns number of days, or null if no date provided
*/
export function getDaysRemaining(
resetAt: number | null | undefined,
): number | null {
if (!resetAt) return null
const end = new Date(resetAt)
const now = new Date()
const diffMs = end.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
return Math.max(0, diffDays)
}

View file

@ -14,7 +14,7 @@ export const fetchSubscriptionStatus = (
) =>
useQuery({
queryFn: async () => {
const allPlans = ["consumer_pro"]
const allPlans = ["api_pro", "api_scale", "api_enterprise"]
const statusMap: Record<
string,
{ allowed: boolean; status: string | null }
@ -84,15 +84,13 @@ export const fetchConnectionsFeature = (
})
// Product checks
export const fetchConsumerProProduct = (
autumn: ReturnType<typeof useCustomer>,
) =>
export const fetchApiProProduct = (autumn: ReturnType<typeof useCustomer>) =>
useQuery({
queryFn: async () => {
const res = autumn.check({ productId: "consumer_pro" })
const res = autumn.check({ productId: "api_pro" })
return res.data
},
queryKey: ["autumn-product", "consumer_pro"],
queryKey: ["autumn-product", "api_pro"],
staleTime: 30 * 1000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
})

View file

@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"dependencies": {
"@pixi/react": "^8.0.3",
"@radix-ui/react-accordion": "^1.2.11",