diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 3b185e961..afed03947 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -34,6 +34,7 @@ import { useChatState } from './hooks/use-chat-state' import { useChatStreaming } from './hooks/use-chat-streaming' import { useChatUI } from './hooks/use-chat-ui' import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query' +import { useSubscriptionQuery } from './hooks/use-subscription-query' import { useClipboard } from './hooks/use-clipboard' import { useEvent } from './hooks/use-event' import { useGravityAd } from './hooks/use-gravity-ad' @@ -55,6 +56,7 @@ import { getClaudeOAuthStatus } from './utils/claude-oauth' import { showClipboardMessage } from './utils/clipboard' import { readClipboardImage } from './utils/clipboard-image' import { getInputModeConfig } from './utils/input-modes' +import { getAlwaysUseALaCarte } from './utils/settings' import { type ChatKeyboardState, createDefaultChatKeyboardState, @@ -1245,6 +1247,30 @@ export const Chat = ({ refetchInterval: 60 * 1000, // Refetch every 60 seconds }) + // Fetch subscription data + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 60 * 1000, + }) + + // Auto-show subscription limit banner when rate limit becomes active + const subscriptionLimitShownRef = useRef(false) + const subscriptionRateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined + useEffect(() => { + const isLimited = subscriptionRateLimit?.limited === true + if (isLimited && !subscriptionLimitShownRef.current) { + subscriptionLimitShownRef.current = true + // Skip showing the banner if user prefers to always fall back to a-la-carte + if (!getAlwaysUseALaCarte()) { + useChatStore.getState().setInputMode('subscriptionLimit') + } + } else if (!isLimited) { + subscriptionLimitShownRef.current = false + if (useChatStore.getState().inputMode === 'subscriptionLimit') { + useChatStore.getState().setInputMode('default') + } + } + }, [subscriptionRateLimit?.limited]) + const inputBoxTitle = useMemo(() => { const segments: string[] = [] diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index a7d63828e..897f1b5bb 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -380,6 +380,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ clearInput(params) }, }), + defineCommand({ + name: 'subscribe', + aliases: ['strong'], + handler: (params) => { + open(WEBSITE_URL + '/pricing') + clearInput(params) + }, + }), defineCommand({ name: 'buy-credits', handler: (params) => { diff --git a/cli/src/components/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx index a16c93437..bb876b88f 100644 --- a/cli/src/components/bottom-status-line.tsx +++ b/cli/src/components/bottom-status-line.tsx @@ -16,7 +16,7 @@ interface BottomStatusLineProps { /** * Bottom status line component - shows below the input box - * Currently displays Claude subscription status when connected + * Displays Claude subscription status and/or Codebuff Strong status */ export const BottomStatusLine: React.FC = ({ isClaudeConnected, @@ -25,28 +25,28 @@ export const BottomStatusLine: React.FC = ({ }) => { const theme = useTheme() - // Don't render if there's nothing to show - if (!isClaudeConnected) { - return null - } - // Use the more restrictive of the two quotas (5-hour window is usually the limiting factor) - const displayRemaining = claudeQuota + const claudeDisplayRemaining = claudeQuota ? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining) : null - // Check if quota is exhausted (0%) - const isExhausted = displayRemaining !== null && displayRemaining <= 0 + // Check if Claude quota is exhausted (0%) + const isClaudeExhausted = claudeDisplayRemaining !== null && claudeDisplayRemaining <= 0 - // Get the reset time for the limiting quota window - const resetTime = claudeQuota + // Get the reset time for the limiting Claude quota window + const claudeResetTime = claudeQuota ? claudeQuota.fiveHourRemaining <= claudeQuota.sevenDayRemaining ? claudeQuota.fiveHourResetsAt : claudeQuota.sevenDayResetsAt : null - // Determine dot color: red if exhausted, green if active, muted otherwise - const dotColor = isExhausted + // Only show when Claude is connected + if (!isClaudeConnected) { + return null + } + + // Determine dot color for Claude: red if exhausted, green if active, muted otherwise + const claudeDotColor = isClaudeExhausted ? theme.error : isClaudeActive ? theme.success @@ -59,23 +59,42 @@ export const BottomStatusLine: React.FC = ({ flexDirection: 'row', justifyContent: 'flex-end', paddingRight: 1, + gap: 2, }} > - - - Claude subscription - {isExhausted && resetTime ? ( - {` · resets in ${formatResetTime(resetTime)}`} - ) : displayRemaining !== null ? ( - - ) : null} - + {/* Show Claude subscription when connected and not depleted */} + {!isClaudeExhausted && ( + + + Claude subscription + {claudeDisplayRemaining !== null ? ( + + ) : null} + + )} + + {/* Show Claude as depleted when exhausted */} + {isClaudeExhausted && ( + + + Claude + {claudeResetTime && ( + {` · resets in ${formatResetTime(claudeResetTime)}`} + )} + + )} ) } diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index ac8daba0b..71beafa5e 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -6,6 +6,7 @@ import { FeedbackContainer } from './feedback-container' import { InputModeBanner } from './input-mode-banner' import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { OutOfCreditsBanner } from './out-of-credits-banner' +import { SubscriptionLimitBanner } from './subscription-limit-banner' import { PublishContainer } from './publish-container' import { SuggestionMenu, type SuggestionItem } from './suggestion-menu' import { useAskUserBridge } from '../hooks/use-ask-user-bridge' @@ -187,6 +188,11 @@ export const ChatInputBar = ({ return } + // Subscription limit mode: replace entire input with subscription limit banner + if (inputMode === 'subscriptionLimit') { + return + } + // Handle input changes with special mode entry detection const handleInputChange = (value: InputValue) => { // Detect entering bash mode: user typed exactly '!' when in default mode diff --git a/cli/src/components/input-mode-banner.tsx b/cli/src/components/input-mode-banner.tsx index e73b74f8a..1a69ff03d 100644 --- a/cli/src/components/input-mode-banner.tsx +++ b/cli/src/components/input-mode-banner.tsx @@ -4,6 +4,7 @@ import { ClaudeConnectBanner } from './claude-connect-banner' import { HelpBanner } from './help-banner' import { PendingAttachmentsBanner } from './pending-attachments-banner' import { ReferralBanner } from './referral-banner' +import { SubscriptionLimitBanner } from './subscription-limit-banner' import { UsageBanner } from './usage-banner' import { useChatStore } from '../state/chat-store' @@ -26,6 +27,7 @@ const BANNER_REGISTRY: Record< referral: () => , help: () => , 'connect:claude': () => , + subscriptionLimit: () => , } /** diff --git a/cli/src/components/message-footer.tsx b/cli/src/components/message-footer.tsx index 13c2b3e9c..57138ed7d 100644 --- a/cli/src/components/message-footer.tsx +++ b/cli/src/components/message-footer.tsx @@ -1,3 +1,4 @@ +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' import React, { useCallback, useMemo } from 'react' @@ -5,6 +6,7 @@ import React, { useCallback, useMemo } from 'react' import { CopyButton } from './copy-button' import { ElapsedTimer } from './elapsed-timer' import { FeedbackIconButton } from './feedback-icon-button' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' import { useFeedbackStore, @@ -157,19 +159,7 @@ export const MessageFooter: React.FC = ({ if (typeof credits === 'number' && credits > 0) { footerItems.push({ key: 'credits', - node: ( - - {pluralize(credits, 'credit')} - - ), + node: , }) } if (shouldRenderFeedbackButton) { @@ -222,3 +212,46 @@ export const MessageFooter: React.FC = ({ ) } + +const CreditsOrSubscriptionIndicator: React.FC<{ credits: number }> = ({ credits }) => { + const theme = useTheme() + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: false, + refetchOnActivity: false, + pauseWhenIdle: false, + }) + + const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null + const rateLimit = activeSubscription?.rateLimit + + const blockPercentRemaining = useMemo(() => { + if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null + return Math.round(((rateLimit.blockLimit - rateLimit.blockUsed) / rateLimit.blockLimit) * 100) + }, [rateLimit]) + + const showSubscriptionIndicator = + activeSubscription && !rateLimit?.limited && blockPercentRemaining != null && blockPercentRemaining > 0 + + if (showSubscriptionIndicator) { + const label = blockPercentRemaining < 20 + ? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)` + : `✓ ${SUBSCRIPTION_DISPLAY_NAME}` + return ( + + {label} + + ) + } + + return ( + + {pluralize(credits, 'credit')} + + ) +} diff --git a/cli/src/components/progress-bar.tsx b/cli/src/components/progress-bar.tsx index acc11fac9..e9e18353d 100644 --- a/cli/src/components/progress-bar.tsx +++ b/cli/src/components/progress-bar.tsx @@ -72,7 +72,7 @@ export const ProgressBar: React.FC = ({ {label && {label} } {filled} - {empty} + {emptyWidth > 0 && {empty}} {showPercentage && ( {Math.round(clampedValue)}% )} diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx new file mode 100644 index 000000000..74590ebb0 --- /dev/null +++ b/cli/src/components/subscription-limit-banner.tsx @@ -0,0 +1,174 @@ +import open from 'open' +import React from 'react' + +import { Button } from './button' +import { ProgressBar } from './progress-bar' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' +import { useTheme } from '../hooks/use-theme' +import { useUsageQuery } from '../hooks/use-usage-query' +import { WEBSITE_URL } from '../login/constants' +import { useChatStore } from '../state/chat-store' +import { + getAlwaysUseALaCarte, + setAlwaysUseALaCarte, +} from '../utils/settings' +import { formatResetTime } from '../utils/time-format' +import { BORDER_CHARS } from '../utils/ui-constants' + +export const SubscriptionLimitBanner = () => { + const setInputMode = useChatStore((state) => state.setInputMode) + const theme = useTheme() + + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 15 * 1000, + }) + + const { data: usageData } = useUsageQuery({ + enabled: true, + refetchInterval: 30 * 1000, + }) + + const rateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined + const remainingBalance = usageData?.remainingBalance ?? 0 + const hasAlaCarteCredits = remainingBalance > 0 + + const [alwaysALaCarte, setAlwaysALaCarteState] = React.useState( + () => getAlwaysUseALaCarte(), + ) + + const handleToggleAlwaysALaCarte = () => { + const newValue = !alwaysALaCarte + setAlwaysALaCarteState(newValue) + setAlwaysUseALaCarte(newValue) + } + + if (!subscriptionData || !rateLimit?.limited) { + return null + } + + const { reason, weeklyPercentUsed, weeklyResetsAt: weeklyResetsAtStr, blockResetsAt: blockResetsAtStr } = rateLimit + const isWeeklyLimit = reason === 'weekly_limit' + const isBlockExhausted = reason === 'block_exhausted' + const weeklyRemaining = 100 - weeklyPercentUsed + const weeklyResetsAt = weeklyResetsAtStr ? new Date(weeklyResetsAtStr) : null + const blockResetsAt = blockResetsAtStr ? new Date(blockResetsAtStr) : null + + const handleContinueWithCredits = () => { + setInputMode('default') + } + + const handleBuyCredits = () => { + open(WEBSITE_URL + '/usage') + } + + const handleWait = () => { + setInputMode('default') + } + + const borderColor = isWeeklyLimit ? theme.error : theme.warning + + return ( + + + {isWeeklyLimit ? ( + <> + + 🛑 Weekly limit reached + + + You've used all {rateLimit.weeklyLimit.toLocaleString()} credits for this week. + + {weeklyResetsAt && ( + + Weekly usage resets in {formatResetTime(weeklyResetsAt)} + + )} + + ) : isBlockExhausted ? ( + <> + + ⏱️ 5 hour limit reached + + {blockResetsAt && ( + + New block starts in {formatResetTime(blockResetsAt)} + + )} + + ) : ( + + Subscription limit reached + + )} + + + Weekly: + + {weeklyPercentUsed}% used + + + {hasAlaCarteCredits && ( + + )} + + + {hasAlaCarteCredits ? ( + <> + + {isWeeklyLimit ? ( + + ) : ( + + )} + + ) : ( + <> + No a-la-carte credits available. + + + + )} + + + + ) +} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 7283fc657..b08bab5b3 100644 --- a/cli/src/components/usage-banner.tsx +++ b/cli/src/components/usage-banner.tsx @@ -1,17 +1,20 @@ import { isClaudeOAuthValid } from '@codebuff/sdk' +import { TextAttributes } from '@opentui/core' import open from 'open' -import React, { useEffect } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { BottomBanner } from './bottom-banner' import { Button } from './button' import { ProgressBar } from './progress-bar' import { getActivityQueryData } from '../hooks/use-activity-query' import { useClaudeQuotaQuery } from '../hooks/use-claude-quota-query' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' import { useTheme } from '../hooks/use-theme' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' +import { getAlwaysUseALaCarte, setAlwaysUseALaCarte } from '../utils/settings' import { useChatStore } from '../state/chat-store' -import { formatResetTime } from '../utils/time-format' +import { formatResetTime, formatResetTimeLong } from '../utils/time-format' import { getBannerColorLevel, generateLoadingBannerText, @@ -31,13 +34,13 @@ const formatRenewalDate = (dateStr: string | null): string => { const isToday = resetDate.toDateString() === today.toDateString() return isToday ? resetDate.toLocaleString('en-US', { - hour: 'numeric', - minute: '2-digit', - }) + hour: 'numeric', + minute: '2-digit', + }) : resetDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) + month: 'short', + day: 'numeric', + }) } export const UsageBanner = ({ showTime }: { showTime: number }) => { @@ -53,6 +56,11 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { refetchInterval: 30 * 1000, // Refresh every 30 seconds when banner is open }) + // Fetch subscription data + const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionQuery({ + refetchInterval: 30 * 1000, + }) + const { data: apiData, isLoading, @@ -99,12 +107,24 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { const adCredits = activeData.balanceBreakdown?.ad const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null + const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null + const { rateLimit, subscription: subscriptionInfo, displayName } = activeSubscription ?? {} + return ( setInputMode('default')} > + {activeSubscription && ( + + )} + {/* Codebuff credits section - structured layout */} @@ -177,3 +197,87 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { ) } + +interface SubscriptionUsageSectionProps { + displayName?: string + subscriptionInfo?: { tier: number } + rateLimit?: { + blockLimit?: number + blockUsed?: number + blockResetsAt?: string + weeklyPercentUsed: number + weeklyResetsAt: string + } + isLoading: boolean +} + +const SubscriptionUsageSection: React.FC = ({ + displayName, + subscriptionInfo, + rateLimit, + isLoading, +}) => { + const theme = useTheme() + const [useALaCarte, setUseALaCarte] = useState(() => getAlwaysUseALaCarte()) + + const handleToggleALaCarte = () => { + const newValue = !useALaCarte + setUseALaCarte(newValue) + setAlwaysUseALaCarte(newValue) + } + + const blockPercent = useMemo(() => { + if (rateLimit?.blockLimit == null || rateLimit.blockUsed == null) return 100 + return Math.max(0, 100 - Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)) + }, [rateLimit?.blockLimit, rateLimit?.blockUsed]) + + const weeklyPercent = rateLimit ? 100 - rateLimit.weeklyPercentUsed : 100 + + return ( + + + + 💪 {displayName ?? 'Strong'} subscription + + {subscriptionInfo?.tier && ( + ${subscriptionInfo.tier}/mo + )} + + {isLoading ? ( + Loading subscription data... + ) : rateLimit ? ( + + + 5-hour limit + {`${blockPercent}%`.padStart(4)} + + + {rateLimit.blockResetsAt + ? ` resets in ${formatResetTime(new Date(rateLimit.blockResetsAt))}` + : ''} + + + + Weekly limit + {`${weeklyPercent}%`.padStart(4)} + + + {' '}resets in {formatResetTimeLong(rateLimit.weeklyResetsAt)} + + + + ) : null} + + When limit reached: + + {useALaCarte ? 'spend credits' : 'pause'} + + + + + ) +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 098771f42..3d8e46dad 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -73,6 +73,12 @@ export const SLASH_COMMANDS: SlashCommand[] = [ description: 'View credits and subscription quota', aliases: ['credits'], }, + { + id: 'subscribe', + label: 'subscribe', + description: 'Subscribe to Codebuff Strong', + aliases: ['strong'], + }, { id: 'buy-credits', label: 'buy-credits', diff --git a/cli/src/hooks/use-subscription-query.ts b/cli/src/hooks/use-subscription-query.ts new file mode 100644 index 000000000..75ea01166 --- /dev/null +++ b/cli/src/hooks/use-subscription-query.ts @@ -0,0 +1,70 @@ +import { useActivityQuery } from './use-activity-query' +import { getAuthToken } from '../utils/auth' +import { getApiClient } from '../utils/codebuff-api' +import { logger as defaultLogger } from '../utils/logger' + +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + +export type { SubscriptionResponse } + +export const subscriptionQueryKeys = { + all: ['subscription'] as const, + current: () => [...subscriptionQueryKeys.all, 'current'] as const, +} + +export async function fetchSubscriptionData( + logger: Logger = defaultLogger, +): Promise { + const client = getApiClient() + const response = await client.get( + '/api/user/subscription', + { includeCookie: true }, + ) + + if (!response.ok) { + logger.debug( + { status: response.status }, + 'Failed to fetch subscription data', + ) + throw new Error(`Failed to fetch subscription: ${response.status}`) + } + + return response.data! +} + +export interface UseSubscriptionQueryDeps { + logger?: Logger + enabled?: boolean + refetchInterval?: number | false + refetchOnActivity?: boolean + pauseWhenIdle?: boolean + idleThreshold?: number +} + +export function useSubscriptionQuery(deps: UseSubscriptionQueryDeps = {}) { + const { + logger = defaultLogger, + enabled = true, + refetchInterval = 60 * 1000, + refetchOnActivity = true, + pauseWhenIdle = true, + idleThreshold = 30_000, + } = deps + + const authToken = getAuthToken() + + return useActivityQuery({ + queryKey: subscriptionQueryKeys.current(), + queryFn: () => fetchSubscriptionData(logger), + enabled: enabled && !!authToken, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + refetchOnMount: true, + refetchInterval, + refetchOnActivity, + pauseWhenIdle, + idleThreshold, + }) +} diff --git a/cli/src/hooks/use-user-details-query.ts b/cli/src/hooks/use-user-details-query.ts index 4c3f335ae..fa5f7524c 100644 --- a/cli/src/hooks/use-user-details-query.ts +++ b/cli/src/hooks/use-user-details-query.ts @@ -37,12 +37,13 @@ export async function fetchUserDetails({ logger = defaultLogger, apiClient: providedApiClient, }: FetchUserDetailsParams): Promise | null> { - const apiClient = - providedApiClient ?? - (() => { - setApiClientAuthToken(authToken) - return getApiClient() - })() + let apiClient: CodebuffApiClient + if (providedApiClient) { + apiClient = providedApiClient + } else { + setApiClientAuthToken(authToken) + apiClient = getApiClient() + } const response = await apiClient.me(fields) diff --git a/cli/src/index.tsx b/cli/src/index.tsx index fcef730c7..3fd6affed 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -24,8 +24,9 @@ import { runPlainLogin } from './login/plain-login' import { initializeApp } from './init/init-app' import { getProjectRoot, setProjectRoot } from './project-files' import { initAnalytics, trackEvent } from './utils/analytics' -import { getAuthTokenDetails } from './utils/auth' +import { getAuthToken, getAuthTokenDetails } from './utils/auth' import { resetCodebuffClient } from './utils/codebuff-client' +import { setApiClientAuthToken } from './utils/codebuff-api' import { getCliEnv } from './utils/env' import { initializeAgentRegistry } from './utils/local-agent-registry' import { clearLogFile, logger } from './utils/logger' @@ -181,6 +182,9 @@ async function main(): Promise { await initializeApp({ cwd }) + // Set the auth token for the API client + setApiClientAuthToken(getAuthToken()) + // Handle login command before rendering the app if (isLoginCommand) { await runPlainLogin() diff --git a/cli/src/utils/fetch-usage.ts b/cli/src/utils/fetch-usage.ts index 8102cf85b..070687630 100644 --- a/cli/src/utils/fetch-usage.ts +++ b/cli/src/utils/fetch-usage.ts @@ -1,5 +1,5 @@ import { getAuthToken } from './auth' -import { getApiClient, setApiClientAuthToken } from './codebuff-api' +import { getApiClient } from './codebuff-api' import { logger } from './logger' import { useChatStore } from '../state/chat-store' @@ -42,11 +42,7 @@ export async function fetchAndUpdateUsage( } const apiClient = - providedApiClient ?? - (() => { - setApiClientAuthToken(authToken) - return getApiClient() - })() + providedApiClient ?? getApiClient() try { const response = await apiClient.usage() diff --git a/cli/src/utils/input-modes.ts b/cli/src/utils/input-modes.ts index be2196223..a8fc12259 100644 --- a/cli/src/utils/input-modes.ts +++ b/cli/src/utils/input-modes.ts @@ -13,6 +13,7 @@ export type InputMode = | 'help' | 'connect:claude' | 'outOfCredits' + | 'subscriptionLimit' // Theme color keys that are valid color values (must match ChatTheme keys) export type ThemeColorKey = @@ -114,6 +115,14 @@ export const INPUT_MODE_CONFIGS: Record = { showAgentModeToggle: false, disableSlashSuggestions: true, }, + subscriptionLimit: { + icon: null, + color: 'warning', + placeholder: '', + widthAdjustment: 0, + showAgentModeToggle: false, + disableSlashSuggestions: true, + }, } export function getInputModeConfig(mode: InputMode): InputModeConfig { diff --git a/cli/src/utils/settings.ts b/cli/src/utils/settings.ts index 14a9f20fd..e6ea66c9c 100644 --- a/cli/src/utils/settings.ts +++ b/cli/src/utils/settings.ts @@ -20,6 +20,7 @@ const DEFAULT_SETTINGS: Settings = { export interface Settings { mode?: AgentMode adsEnabled?: boolean + alwaysUseALaCarte?: boolean } /** @@ -92,6 +93,11 @@ const validateSettings = (parsed: unknown): Settings => { settings.adsEnabled = obj.adsEnabled } + // Validate alwaysUseALaCarte + if (typeof obj.alwaysUseALaCarte === 'boolean') { + settings.alwaysUseALaCarte = obj.alwaysUseALaCarte + } + return settings } @@ -134,3 +140,18 @@ export const loadModePreference = (): AgentMode => { export const saveModePreference = (mode: AgentMode): void => { saveSettings({ mode }) } + +/** + * Load the "always use a-la-carte" preference + */ +export const getAlwaysUseALaCarte = (): boolean => { + const settings = loadSettings() + return settings.alwaysUseALaCarte ?? false +} + +/** + * Save the "always use a-la-carte" preference + */ +export const setAlwaysUseALaCarte = (value: boolean): void => { + saveSettings({ alwaysUseALaCarte: value }) +} diff --git a/cli/src/utils/time-format.ts b/cli/src/utils/time-format.ts index af178fde8..e7b472360 100644 --- a/cli/src/utils/time-format.ts +++ b/cli/src/utils/time-format.ts @@ -1,20 +1,21 @@ +import { formatTimeUntil } from '@codebuff/common/util/dates' + /** - * Format time until reset in human-readable form + * Format time until reset in human-readable form. * @param resetDate - The date when the quota/resource resets * @returns Human-readable string like "2h 30m" or "45m" */ export const formatResetTime = (resetDate: Date | null): string => { if (!resetDate) return '' - const now = new Date() - const diffMs = resetDate.getTime() - now.getTime() - if (diffMs <= 0) return 'now' - - const diffMins = Math.floor(diffMs / (1000 * 60)) - const diffHours = Math.floor(diffMins / 60) - const remainingMins = diffMins % 60 + return formatTimeUntil(resetDate, { fallback: 'now' }) +} - if (diffHours > 0) { - return `${diffHours}h ${remainingMins}m` - } - return `${diffMins}m` +/** + * Format time until reset in human-readable form, including days. + * @param resetDate - The date when the quota/resource resets + * @returns Human-readable string like "4d 7h" or "2h 30m" + */ +export const formatResetTimeLong = (resetDate: Date | string | null): string => { + if (!resetDate) return '' + return formatTimeUntil(resetDate, { fallback: 'now' }) } diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts new file mode 100644 index 000000000..498e25c39 --- /dev/null +++ b/common/src/types/subscription.ts @@ -0,0 +1,63 @@ +/** + * Core subscription information for an active subscription. + */ +export interface SubscriptionInfo { + status: string + billingPeriodEnd: string + cancelAtPeriodEnd: boolean + canceledAt: string | null + tier: number + scheduledTier?: number | null +} + +/** + * Rate limit information for subscription usage. + */ +export interface SubscriptionRateLimit { + limited: boolean + reason?: 'block_exhausted' | 'weekly_limit' + canStartNewBlock: boolean + blockUsed?: number + blockLimit?: number + blockResetsAt?: string + weeklyUsed: number + weeklyLimit: number + weeklyResetsAt: string + weeklyPercentUsed: number +} + +/** + * Subscription limits configuration. + */ +export interface SubscriptionLimits { + creditsPerBlock: number + blockDurationHours: number + weeklyCreditsLimit: number +} + +/** + * Response when user has no active subscription. + */ +export interface NoSubscriptionResponse { + hasSubscription: false +} + +/** + * Response when user has an active subscription. + * All fields are required - no invalid states possible. + */ +export interface ActiveSubscriptionResponse { + hasSubscription: true + displayName: string + subscription: SubscriptionInfo + rateLimit: SubscriptionRateLimit + limits: SubscriptionLimits + billingPortalUrl?: string +} + +/** + * Discriminated union for subscription API response. + * Use `hasSubscription` to narrow the type. + */ +export type SubscriptionResponse = NoSubscriptionResponse | ActiveSubscriptionResponse + diff --git a/common/src/util/dates.ts b/common/src/util/dates.ts index 6c75b68c1..57096e324 100644 --- a/common/src/util/dates.ts +++ b/common/src/util/dates.ts @@ -15,3 +15,67 @@ export const getNextQuotaReset = (referenceDate: Date | null): Date => { } return nextMonth } + +export interface FormatTimeUntilOptions { + /** + * What to return when the date is in the past or invalid. + * @default 'now' + */ + fallback?: string + /** + * Whether to include the smaller unit (hours in "Xd Yh", minutes in "Xh Ym"). + * @default true + */ + includeSubUnit?: boolean +} + +/** + * Format the time until a future date in a human-readable string. + * + * @param date - The target date (Date object or ISO string) + * @param options - Formatting options + * @returns Human-readable string like "4d 7h", "2h 30m", or "45m" + * + * @example + * // Date 2 days and 5 hours in the future + * formatTimeUntil(futureDate) // "2d 5h" + * formatTimeUntil(futureDate, { includeSubUnit: false }) // "2d" + * + * // Date 3 hours and 20 minutes in the future + * formatTimeUntil(futureDate) // "3h 20m" + * + * // Date in the past + * formatTimeUntil(pastDate) // "now" + * formatTimeUntil(pastDate, { fallback: '0h' }) // "0h" + */ +export const formatTimeUntil = ( + date: Date | string | null, + options: FormatTimeUntilOptions = {}, +): string => { + const { fallback = 'now', includeSubUnit = true } = options + + if (!date) return fallback + + const target = typeof date === 'string' ? new Date(date) : date + const diffMs = target.getTime() - Date.now() + + if (isNaN(diffMs) || diffMs <= 0) return fallback + + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + const remainingHours = diffHours % 24 + const remainingMins = diffMins % 60 + + if (diffDays > 0) { + return includeSubUnit && remainingHours > 0 + ? `${diffDays}d ${remainingHours}h` + : `${diffDays}d` + } + if (diffHours > 0) { + return includeSubUnit && remainingMins > 0 + ? `${diffHours}h ${remainingMins}m` + : `${diffHours}h` + } + return `${diffMins}m` +} diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index cda205d00..546cd335f 100644 --- a/packages/billing/src/subscription-webhooks.ts +++ b/packages/billing/src/subscription-webhooks.ts @@ -146,7 +146,6 @@ export async function handleSubscriptionInvoicePaid(params: { ), billing_period_end: new Date(stripeSub.current_period_end * 1000), cancel_at_period_end: stripeSub.cancel_at_period_end, - updated_at: new Date(), }, }) @@ -197,7 +196,6 @@ export async function handleSubscriptionInvoicePaymentFailed(params: { .update(schema.subscription) .set({ status: 'past_due', - updated_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) @@ -220,6 +218,12 @@ export async function handleSubscriptionInvoicePaymentFailed(params: { /** * Syncs plan details and cancellation intent from Stripe. + * + * Note: Downgrade scheduling is handled by subscription_schedule webhooks. + * When a user downgrades via Customer Portal with "Wait until end of billing + * period", Stripe creates a subscription schedule rather than immediately + * changing the subscription price. The handleSubscriptionScheduleCreatedOrUpdated + * handler sets scheduled_tier based on the schedule's phases. */ export async function handleSubscriptionUpdated(params: { stripeSubscription: Stripe.Subscription @@ -259,22 +263,20 @@ export async function handleSubscriptionUpdated(params: { const status = mapStripeStatus(stripeSubscription.status) - // Check existing tier to detect downgrades. During a downgrade the old - // higher tier is kept in `scheduled_tier` so limits remain until renewal. + // Check existing tier to detect upgrades for block grant expiration. const existingSub = await db .select({ tier: schema.subscription.tier, - scheduled_tier: schema.subscription.scheduled_tier, }) .from(schema.subscription) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) .limit(1) const existingTier = existingSub[0]?.tier - const isDowngrade = existingTier != null && existingTier > tier // Upsert — webhook ordering is not guaranteed by Stripe, so this event // may arrive before invoice.paid creates the row. + // Note: We don't modify scheduled_tier here; that's managed by schedule webhooks. await db .insert(schema.subscription) .values({ @@ -296,11 +298,8 @@ export async function handleSubscriptionUpdated(params: { target: schema.subscription.stripe_subscription_id, set: { user_id: userId, - // Downgrade: preserve current tier & stripe_price_id, schedule the - // new tier for the next billing period. - ...(isDowngrade - ? { scheduled_tier: tier } - : { tier, stripe_price_id: priceId, scheduled_tier: null }), + tier, + stripe_price_id: priceId, status, cancel_at_period_end: stripeSubscription.cancel_at_period_end, billing_period_start: new Date( @@ -309,7 +308,6 @@ export async function handleSubscriptionUpdated(params: { billing_period_end: new Date( stripeSubscription.current_period_end * 1000, ), - updated_at: new Date(), }, }) @@ -325,12 +323,9 @@ export async function handleSubscriptionUpdated(params: { { subscriptionId, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, - isDowngrade, isUpgrade, }, - isDowngrade - ? 'Processed subscription update — downgrade scheduled for next billing period' - : 'Processed subscription update', + 'Processed subscription update', ) } @@ -358,7 +353,6 @@ export async function handleSubscriptionDeleted(params: { status: 'canceled', scheduled_tier: null, canceled_at: new Date(), - updated_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) @@ -375,3 +369,179 @@ export async function handleSubscriptionDeleted(params: { logger.info({ subscriptionId }, 'Subscription canceled') } + +// --------------------------------------------------------------------------- +// subscription_schedule.created / subscription_schedule.updated +// --------------------------------------------------------------------------- + +/** + * Handles subscription schedule creation or updates. + * + * When a user schedules a downgrade via Stripe Customer Portal (with "Wait + * until end of billing period"), Stripe creates a subscription schedule with + * multiple phases. Phase 0 is the current state, phase 1+ contains the + * scheduled changes. + * + * This handler extracts the scheduled tier from the next phase and stores it + * in our database so we can show the pending change to the user and apply + * appropriate limits at renewal. + */ +export async function handleSubscriptionScheduleCreatedOrUpdated(params: { + schedule: Stripe.SubscriptionSchedule + logger: Logger +}): Promise { + const { schedule, logger } = params + + // Only process active schedules + if (schedule.status !== 'active') { + logger.debug( + { scheduleId: schedule.id, status: schedule.status }, + 'Ignoring non-active subscription schedule', + ) + return + } + + // Get the linked subscription ID + const subscriptionId = schedule.subscription + ? getStripeId(schedule.subscription) + : null + + if (!subscriptionId) { + logger.warn( + { scheduleId: schedule.id }, + 'Subscription schedule has no linked subscription — skipping', + ) + return + } + + // Stripe subscription schedules use "phases" to represent timeline segments: + // - Phase 0: The current subscription state (e.g., $200/month) + // - Phase 1: The scheduled future state (e.g., $100/month after renewal) + // We need at least 2 phases to have a pending change; 1 phase means no scheduled change. + if (!schedule.phases || schedule.phases.length < 2) { + logger.debug( + { scheduleId: schedule.id, subscriptionId, phases: schedule.phases?.length }, + 'Subscription schedule has fewer than 2 phases — no scheduled change', + ) + return + } + + // Extract the scheduled tier from phase 1 (the upcoming change) + const nextPhase = schedule.phases[1] + const scheduledPriceId = nextPhase?.items?.[0]?.price + const priceId = typeof scheduledPriceId === 'string' + ? scheduledPriceId + : scheduledPriceId?.toString() + + if (!priceId) { + logger.warn( + { scheduleId: schedule.id, subscriptionId }, + 'Subscription schedule next phase has no price — skipping', + ) + return + } + + const scheduledTier = getTierFromPriceId(priceId) + if (!scheduledTier) { + logger.debug( + { scheduleId: schedule.id, subscriptionId, priceId }, + 'Scheduled price ID does not match a Strong tier — skipping', + ) + return + } + + // Update the subscription with the scheduled tier + const result = await db + .update(schema.subscription) + .set({ + scheduled_tier: scheduledTier, + }) + .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + .returning({ tier: schema.subscription.tier }) + + if (result.length === 0) { + logger.warn( + { scheduleId: schedule.id, subscriptionId, scheduledTier }, + 'No subscription found to update with scheduled tier — may arrive before subscription created', + ) + return + } + + const currentTier = result[0]?.tier + + logger.info( + { + scheduleId: schedule.id, + subscriptionId, + currentTier, + scheduledTier, + scheduledStartDate: nextPhase.start_date + ? new Date(nextPhase.start_date * 1000).toISOString() + : null, + }, + 'Set scheduled tier from subscription schedule', + ) +} + +// --------------------------------------------------------------------------- +// subscription_schedule.released / subscription_schedule.canceled +// --------------------------------------------------------------------------- + +/** + * Handles subscription schedule release or cancellation. + * + * When a schedule is released (completes and detaches from the subscription) + * or canceled (user cancels the pending change), we clear the scheduled_tier. + * + * Note: When a schedule "releases" after applying its final phase, the + * subscription itself gets updated, which triggers invoice.paid at renewal. + * That handler already clears scheduled_tier, but this provides a safety net. + */ +export async function handleSubscriptionScheduleReleasedOrCanceled(params: { + schedule: Stripe.SubscriptionSchedule + logger: Logger +}): Promise { + const { schedule, logger } = params + + // When a schedule is released, the subscription field becomes null and + // the subscription ID moves to released_subscription. When canceled, + // the subscription field is retained. Check both fields. + const subscriptionId = schedule.subscription + ? getStripeId(schedule.subscription) + : schedule.released_subscription + ? getStripeId(schedule.released_subscription) + : null + + if (!subscriptionId) { + logger.debug( + { scheduleId: schedule.id }, + 'Released/canceled schedule has no subscription — skipping', + ) + return + } + + const result = await db + .update(schema.subscription) + .set({ + scheduled_tier: null, + }) + .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + .returning({ tier: schema.subscription.tier }) + + if (result.length === 0) { + logger.debug( + { scheduleId: schedule.id, subscriptionId }, + 'No subscription found when clearing scheduled tier — may already be deleted', + ) + return + } + + logger.info( + { + scheduleId: schedule.id, + subscriptionId, + status: schedule.status, + }, + 'Cleared scheduled tier after subscription schedule released/canceled', + ) +} diff --git a/packages/billing/src/subscription.ts b/packages/billing/src/subscription.ts index d83c998b8..279c7f524 100644 --- a/packages/billing/src/subscription.ts +++ b/packages/billing/src/subscription.ts @@ -63,12 +63,25 @@ export interface WeeklyLimitError { resetsAt: Date } -export type BlockGrantResult = BlockGrant | WeeklyLimitError +export interface BlockExhaustedError { + error: 'block_exhausted' + blockUsed: number + blockLimit: number + resetsAt: Date +} + +export type BlockGrantResult = BlockGrant | WeeklyLimitError | BlockExhaustedError export function isWeeklyLimitError( result: BlockGrantResult, ): result is WeeklyLimitError { - return 'error' in result + return 'error' in result && result.error === 'weekly_limit_reached' +} + +export function isBlockExhaustedError( + result: BlockGrantResult, +): result is BlockExhaustedError { + return 'error' in result && result.error === 'block_exhausted' } export interface RateLimitStatus { @@ -251,7 +264,7 @@ export async function ensureActiveBlockGrantCallback(params: { const { conn, userId, subscription, logger, now = new Date() } = params const subscriptionId = subscription.stripe_subscription_id - // 1. Check for an existing active block grant + // 1. Check for an existing non-expired block grant (regardless of balance) const existingGrants = await conn .select() .from(schema.creditLedger) @@ -260,7 +273,6 @@ export async function ensureActiveBlockGrantCallback(params: { eq(schema.creditLedger.user_id, userId), eq(schema.creditLedger.type, 'subscription'), gt(schema.creditLedger.expires_at, now), - gt(schema.creditLedger.balance, 0), ), ) .orderBy(desc(schema.creditLedger.expires_at)) @@ -268,12 +280,24 @@ export async function ensureActiveBlockGrantCallback(params: { if (existingGrants.length > 0) { const g = existingGrants[0] + + // Block exists with credits remaining - return it + if (g.balance > 0) { + return { + grantId: g.operation_id, + credits: g.balance, + expiresAt: g.expires_at!, + isNew: false, + } satisfies BlockGrant + } + + // Block exists but is exhausted - don't create a new one until it expires return { - grantId: g.operation_id, - credits: g.balance, - expiresAt: g.expires_at!, - isNew: false, - } satisfies BlockGrant + error: 'block_exhausted', + blockUsed: g.principal, + blockLimit: g.principal, + resetsAt: g.expires_at!, + } satisfies BlockExhaustedError } // 2. Resolve limits @@ -398,6 +422,24 @@ export async function ensureActiveBlockGrant(params: { return result } +/** + * Combined function that gets the active subscription and ensures a block grant exists. + * Returns the block grant result if the user has an active subscription, null otherwise. + */ +export async function ensureSubscriberBlockGrant(params: { + userId: string + logger: Logger +}): Promise { + const { userId, logger } = params + + const subscription = await getActiveSubscription({ userId, logger }) + if (!subscription) { + return null + } + + return ensureActiveBlockGrant({ userId, subscription, logger }) +} + // --------------------------------------------------------------------------- // Rate limiting // --------------------------------------------------------------------------- diff --git a/packages/billing/src/usage-service.ts b/packages/billing/src/usage-service.ts index 80b6f41fe..df47cf628 100644 --- a/packages/billing/src/usage-service.ts +++ b/packages/billing/src/usage-service.ts @@ -14,19 +14,17 @@ import { getActiveSubscription } from './subscription' import type { CreditBalance } from './balance-calculator' import type { Logger } from '@codebuff/common/types/contracts/logger' -export interface SubscriptionInfo { - status: string - billingPeriodEnd: string - cancelAtPeriodEnd: boolean -} - export interface UserUsageData { usageThisCycle: number balance: CreditBalance nextQuotaReset: string autoTopupTriggered?: boolean autoTopupEnabled?: boolean - subscription?: SubscriptionInfo + subscription?: { + status: string + billingPeriodEnd: string + cancelAtPeriodEnd: boolean + } } export interface OrganizationUsageData { @@ -88,7 +86,7 @@ export async function getUserUsageData(params: { }) // Check for active subscription - let subscription: SubscriptionInfo | undefined + let subscription: UserUsageData['subscription'] const activeSub = await getActiveSubscription({ userId, logger }) if (activeSub) { subscription = { diff --git a/packages/internal/src/db/migrations/0039_automatic_updated_at.sql b/packages/internal/src/db/migrations/0039_automatic_updated_at.sql new file mode 100644 index 000000000..ac3863f39 --- /dev/null +++ b/packages/internal/src/db/migrations/0039_automatic_updated_at.sql @@ -0,0 +1,24 @@ +-- Create a reusable function that sets updated_at to NOW() +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +--> statement-breakpoint + +-- Add trigger to subscription table +CREATE TRIGGER trigger_subscription_updated_at + BEFORE UPDATE ON "subscription" + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +--> statement-breakpoint + +-- Add trigger to limit_override table +CREATE TRIGGER trigger_limit_override_updated_at + BEFORE UPDATE ON "limit_override" + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); diff --git a/web/src/app/api/stripe/cancel-subscription/route.ts b/web/src/app/api/stripe/cancel-subscription/route.ts index d7075802c..af1aa779b 100644 --- a/web/src/app/api/stripe/cancel-subscription/route.ts +++ b/web/src/app/api/stripe/cancel-subscription/route.ts @@ -44,7 +44,7 @@ export async function POST() { try { await db .update(schema.subscription) - .set({ cancel_at_period_end: true, scheduled_tier: null, updated_at: new Date() }) + .set({ cancel_at_period_end: true, scheduled_tier: null }) .where( eq( schema.subscription.stripe_subscription_id, diff --git a/web/src/app/api/stripe/change-subscription-tier/route.ts b/web/src/app/api/stripe/change-subscription-tier/route.ts index ac5b9f245..cef5e70b0 100644 --- a/web/src/app/api/stripe/change-subscription-tier/route.ts +++ b/web/src/app/api/stripe/change-subscription-tier/route.ts @@ -122,7 +122,7 @@ export async function POST(req: NextRequest) { if (isCancelDowngrade) { await db .update(schema.subscription) - .set({ scheduled_tier: null, updated_at: new Date() }) + .set({ scheduled_tier: null }) .where( eq( schema.subscription.stripe_subscription_id, @@ -137,7 +137,6 @@ export async function POST(req: NextRequest) { tier, stripe_price_id: newPriceId, scheduled_tier: null, - updated_at: new Date(), }) .where( eq( @@ -158,7 +157,6 @@ export async function POST(req: NextRequest) { .update(schema.subscription) .set({ scheduled_tier: tier, - updated_at: new Date(), }) .where( eq( diff --git a/web/src/app/api/stripe/create-subscription/route.ts b/web/src/app/api/stripe/create-subscription/route.ts index 202228e70..3ae329c5d 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -75,7 +75,7 @@ export async function POST(req: NextRequest) { line_items: [{ price: priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile?tab=usage&subscription_success=true`, - cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/strong?canceled=true`, + cancel_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing?canceled=true`, metadata: { userId, type: 'strong_subscription', diff --git a/web/src/app/api/stripe/webhook/route.ts b/web/src/app/api/stripe/webhook/route.ts index 372bfd0a0..3e7d629b4 100644 --- a/web/src/app/api/stripe/webhook/route.ts +++ b/web/src/app/api/stripe/webhook/route.ts @@ -6,6 +6,8 @@ import { handleSubscriptionInvoicePaymentFailed, handleSubscriptionUpdated, handleSubscriptionDeleted, + handleSubscriptionScheduleCreatedOrUpdated, + handleSubscriptionScheduleReleasedOrCanceled, } from '@codebuff/billing' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -390,6 +392,25 @@ const webhookHandler = async (req: NextRequest): Promise => { } break } + case 'subscription_schedule.created': + case 'subscription_schedule.updated': { + const schedule = event.data.object as Stripe.SubscriptionSchedule + // Skip organization schedules (if they have org metadata) + if (!schedule.metadata?.organization_id) { + await handleSubscriptionScheduleCreatedOrUpdated({ schedule, logger }) + } + break + } + case 'subscription_schedule.completed': + case 'subscription_schedule.released': + case 'subscription_schedule.canceled': { + const schedule = event.data.object as Stripe.SubscriptionSchedule + // Skip organization schedules (if they have org metadata) + if (!schedule.metadata?.organization_id) { + await handleSubscriptionScheduleReleasedOrCanceled({ schedule, logger }) + } + break + } case 'charge.dispute.created': { const dispute = event.data.object as Stripe.Dispute diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index c8d53b8db..63844f7f2 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -4,12 +4,19 @@ import { getSubscriptionLimits, } from '@codebuff/billing' import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' import { logger } from '@/util/logger' +import type { + NoSubscriptionResponse, + ActiveSubscriptionResponse, +} from '@codebuff/common/types/subscription' + export async function GET() { const session = await getServerSession(authOptions) if (!session?.user?.id) { @@ -19,16 +26,31 @@ export async function GET() { const userId = session.user.id const subscription = await getActiveSubscription({ userId, logger }) - if (!subscription) { - return NextResponse.json({ hasSubscription: false }) + if (!subscription || !subscription.tier) { + const response: NoSubscriptionResponse = { hasSubscription: false } + return NextResponse.json(response) } - const [rateLimit, limits] = await Promise.all([ + const stripeCustomerId = session.user.stripe_customer_id + + const [rateLimit, limits, billingPortalUrl] = await Promise.all([ checkRateLimit({ userId, subscription, logger }), getSubscriptionLimits({ userId, logger, tier: subscription.tier }), + stripeCustomerId + ? stripeServer.billingPortal.sessions + .create({ + customer: stripeCustomerId, + return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/profile`, + }) + .then((portalSession) => portalSession.url) + .catch((error) => { + logger.warn({ userId, error }, 'Failed to create billing portal session') + return undefined + }) + : Promise.resolve(undefined), ]) - return NextResponse.json({ + const response: ActiveSubscriptionResponse = { hasSubscription: true, displayName: SUBSCRIPTION_DISPLAY_NAME, subscription: { @@ -52,5 +74,7 @@ export async function GET() { weeklyPercentUsed: rateLimit.weeklyPercentUsed, }, limits, - }) + billingPortalUrl, + } + return NextResponse.json(response) } diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index ac8dde87f..11801d225 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -17,6 +17,8 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' + +import type { BlockGrantResult } from '@codebuff/billing/subscription' import type { NextRequest } from 'next/server' import type { ChatCompletionRequestBody } from '@/llm-api/types' @@ -78,6 +80,7 @@ export async function postChatCompletions(params: { getAgentRunFromId: GetAgentRunFromIdFn fetch: typeof globalThis.fetch insertMessageBigquery: InsertMessageBigqueryFn + ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise }) { const { req, @@ -88,6 +91,7 @@ export async function postChatCompletions(params: { getAgentRunFromId, fetch, insertMessageBigquery, + ensureSubscriberBlockGrant, } = params let { logger } = params @@ -182,6 +186,19 @@ export async function postChatCompletions(params: { logger, }) + // For subscribers, ensure a block grant exists before checking balance. + // This is done here because block grants should only start when the user begins working. + if (ensureSubscriberBlockGrant) { + try { + await ensureSubscriberBlockGrant({ userId, logger }) + } catch (error) { + logger.error( + { error: getErrorObject(error), userId }, + 'Error ensuring subscription block grant', + ) + } + } + // Check user credits const { balance: { totalRemaining }, diff --git a/web/src/app/api/v1/chat/completions/route.ts b/web/src/app/api/v1/chat/completions/route.ts index 7b49e8232..44db2feae 100644 --- a/web/src/app/api/v1/chat/completions/route.ts +++ b/web/src/app/api/v1/chat/completions/route.ts @@ -1,4 +1,5 @@ import { insertMessageBigquery } from '@codebuff/bigquery' +import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription' import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' @@ -21,5 +22,6 @@ export async function POST(req: NextRequest) { getAgentRunFromId, fetch, insertMessageBigquery, + ensureSubscriberBlockGrant, }) } diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index e71b4a86c..96b3068ce 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -1,12 +1,252 @@ 'use client' import { DEFAULT_FREE_CREDITS_GRANT } from '@codebuff/common/old-constants' -import { Gift, Shield, Link2, Zap, Terminal } from 'lucide-react' +import { + SUBSCRIPTION_TIERS, + SUBSCRIPTION_DISPLAY_NAME, + type SubscriptionTierPrice, +} from '@codebuff/common/constants/subscription-plans' +import { env } from '@codebuff/common/env' +import { loadStripe } from '@stripe/stripe-js' +import { motion } from 'framer-motion' +import { Gift, Shield, Loader2 } from 'lucide-react' +import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' +import { useState } from 'react' import { BlockColor } from '@/components/ui/decorative-blocks' import { SECTION_THEMES } from '@/components/ui/landing/constants' import { FeatureSection } from '@/components/ui/landing/feature' +import { toast } from '@/components/ui/use-toast' +import { cn } from '@/lib/utils' + +const USAGE_MULTIPLIER: Record = { + 100: '1×', + 200: '3×', + 500: '8×', +} + +function SubscribeButton({ + className, + tier, +}: { + className?: string + tier?: number +}) { + const { status } = useSession() + const router = useRouter() + const [isLoading, setIsLoading] = useState(false) + + const handleSubscribe = async () => { + if (status !== 'authenticated') { + router.push('/login?callbackUrl=/pricing') + return + } + + setIsLoading(true) + try { + const res = await fetch('/api/stripe/create-subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tier }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || 'Failed to start checkout') + } + const { sessionId } = await res.json() + const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + if (!stripe) throw new Error('Stripe failed to load') + const { error } = await stripe.redirectToCheckout({ sessionId }) + if (error) throw new Error(error.message) + } catch (err) { + toast({ + title: 'Error', + description: + err instanceof Error ? err.message : 'Something went wrong', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + return ( + + ) +} + +function StrongHeroSection() { + return ( +
+ {/* Subtle radial glow behind content */} +
+ + {/* Animated gradient blobs */} + + + {/* Giant background text */} + + + {/* Foreground content */} +
+
+ + codebuff + + + + The strongest coding agent + + + + Deep thinking. Multi-agent orchestration. Ship faster. + +
+ + {/* Pricing cards grid */} + + {Object.entries(SUBSCRIPTION_TIERS).map(([key, tier]) => { + const price = Number(key) as SubscriptionTierPrice + const isHighlighted = price === 200 + + return ( +
+
+ + ${tier.monthlyPrice} + + /mo +
+ +

+ {USAGE_MULTIPLIER[price]} usage +

+ + +
+ ) + })} +
+ + + Cancel anytime · Tax not included · Usage amounts subject to change + +
+
+ ) +} function CreditVisual() { return ( @@ -46,7 +286,7 @@ function CreditVisual() { {DEFAULT_FREE_CREDITS_GRANT} credits is typically enough for {' '} - a few hours of intense coding on a new project + a few hours of coding on a new project
) @@ -62,78 +302,6 @@ function PricingCard() { ) } -function ClaudeSubscriptionIllustration() { - return ( -
-
- {/* Connection visual */} -
- {/* Claude card */} -
-
Claude
-
Pro / Max
-
- - {/* Connection arrow */} -
-
- -
-
- - {/* Codebuff card */} -
-
Codebuff
-
CLI
-
-
- - {/* Benefits grid */} -
-
-
- -
-
-
- Save on credits -
-
- Use your subscription for Claude model requests -
-
-
- -
-
- -
-
-
- Simple CLI setup -
-
- Connect with one command -
-
-
-
- - {/* Code snippet */} -
-
$ codebuff
-
- {'>'} /connect:claude -
-
- ✓ Connected to Claude subscription -
-
-
-
- ) -} - function TeamPlanIllustration() { return (
@@ -222,8 +390,10 @@ export default function PricingClient() { return ( <> + + Simple, Usage-Based Pricing} + title={Usage-Based Pricing} description="Get 500 free credits monthly, then pay just 1¢ per credit. Credits are consumed based on task complexity — simple queries cost less, complex changes more. You'll see how many credits each task consumes." backdropColor={SECTION_THEMES.competition.background} decorativeColors={[BlockColor.GenerativeGreen, BlockColor.AcidMatrix]} @@ -235,36 +405,7 @@ export default function PricingClient() { learnMoreLink={status === 'authenticated' ? '/usage' : '/login'} /> - Connect Your Claude Subscription} - description="Already have a Claude Pro or Max subscription? Connect it to Codebuff and use your existing subscription for Claude model requests. Note: Using your Claude Pro/Max subscription in Codebuff is not officially supported by Anthropic." - backdropColor={BlockColor.DarkForestGreen} - decorativeColors={[BlockColor.CRTAmber, BlockColor.BetweenGreen]} - textColor="text-white" - tagline="BRING YOUR OWN SUBSCRIPTION" - highlightText="Use your Claude Pro or Max subscription" - illustration={} - learnMoreText="View Documentation" - learnMoreLink="/docs" - imagePosition="left" - /> - Working with others} - description="Collaborate with your team more closely using Codebuff by pooling credits and seeing usage analytics." - backdropColor={BlockColor.CRTAmber} - decorativeColors={[ - BlockColor.DarkForestGreen, - BlockColor.GenerativeGreen, - ]} - textColor="text-black" - tagline="SCALE UP YOUR TEAM" - highlightText="Pooled resources and usage analytics" - illustration={} - learnMoreText="Contact Sales" - learnMoreLink="mailto:founders@codebuff.com" - imagePosition="left" - /> ) } diff --git a/web/src/app/profile/components/subscription-section.tsx b/web/src/app/profile/components/subscription-section.tsx new file mode 100644 index 000000000..421865068 --- /dev/null +++ b/web/src/app/profile/components/subscription-section.tsx @@ -0,0 +1,208 @@ +'use client' + +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import { env } from '@codebuff/common/env' +import { useQuery } from '@tanstack/react-query' +import { + AlertTriangle, + ExternalLink, + Loader2, +} from 'lucide-react' +import Link from 'next/link' +import { useSession } from 'next-auth/react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { cn } from '@/lib/utils' + +import { formatTimeUntil } from '@codebuff/common/util/dates' + +import type { + SubscriptionResponse, + ActiveSubscriptionResponse, +} from '@codebuff/common/types/subscription' + +const formatDaysHours = (dateStr: string): string => + formatTimeUntil(dateStr, { fallback: '0h' }) + +function ProgressBar({ percentAvailable, label }: { percentAvailable: number; label: string }) { + const percent = Math.min(100, Math.max(0, Math.round(percentAvailable))) + const colorClass = percent <= 0 ? 'bg-red-500' : percent <= 25 ? 'bg-yellow-500' : 'bg-green-500' + return ( +
+
+
+ ) +} + +function SubscriptionActive({ data, email }: { data: ActiveSubscriptionResponse; email: string }) { + const { subscription, rateLimit } = data + const isCanceling = subscription.cancelAtPeriodEnd + const fallbackPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` + const billingPortalUrl = data.billingPortalUrl ?? fallbackPortalUrl + + return ( + + +
+ + 💪 + {SUBSCRIPTION_DISPLAY_NAME} + + ${subscription.tier}/mo + + {isCanceling && ( + + Canceling + + )} + {subscription.scheduledTier != null && ( + + Renewing at ${subscription.scheduledTier}/mo + + )} + + + Manage + + +
+
+ +
+
+ + 5-hour limit + {rateLimit.blockResetsAt && ( + + resets in {formatDaysHours(rateLimit.blockResetsAt)} + + )} + + + {rateLimit.blockLimit && rateLimit.blockUsed != null + ? `${Math.round(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100)}%` + : '100%'} remaining + +
+ +
+ +
+
+ + Weekly limit + + resets in {formatDaysHours(rateLimit.weeklyResetsAt)} + + + + {100 - rateLimit.weeklyPercentUsed}% remaining + +
+ +
+ + {rateLimit.limited && ( +
+ +

+ {rateLimit.reason === 'weekly_limit' + ? `Weekly limit reached. Resets in ${formatDaysHours(rateLimit.weeklyResetsAt)}. You can still use a-la-carte credits.` + : `Session exhausted. New session in ${rateLimit.blockResetsAt ? formatDaysHours(rateLimit.blockResetsAt) : 'soon'}. You can still use a-la-carte credits.`} +

+
+ )} +
+
+ ) +} + +function SubscriptionCta() { + return ( + + +
+
+ 💪 +
+
+

+ Upgrade to {SUBSCRIPTION_DISPLAY_NAME} +

+

+ From $100/mo · Save credits with 5-hour work sessions included +

+
+
+ + + +
+
+ ) +} + +export function SubscriptionSection() { + const { data: session, status } = useSession() + + const { data, isLoading } = useQuery({ + queryKey: ['subscription'], + queryFn: async () => { + const res = await fetch('/api/user/subscription') + if (!res.ok) throw new Error('Failed to fetch subscription') + return res.json() + }, + enabled: status === 'authenticated', + refetchInterval: 60_000, + }) + + if (status !== 'authenticated') return null + if (isLoading) { + return ( + + +
+ + Loading subscription... +
+
+
+ ) + } + + if (!data || !data.hasSubscription) { + return + } + + const email = session?.user?.email || '' + + return +} diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 48f90d1a7..b9469132b 100644 --- a/web/src/app/profile/components/usage-display.tsx +++ b/web/src/app/profile/components/usage-display.tsx @@ -10,7 +10,6 @@ import { CreditCard, Star, Megaphone, - Zap, } from 'lucide-react' import React from 'react' @@ -54,6 +53,14 @@ const grantTypeInfo: Record< label: 'Monthly Free', description: 'Your monthly allowance', }, + subscription: { + bg: 'bg-indigo-500', + text: 'text-indigo-600 dark:text-indigo-400', + gradient: 'from-indigo-500/70 to-indigo-600/70', + icon: , + label: 'Strong', + description: 'Credits from your Strong subscription', + }, referral: { bg: 'bg-green-500', text: 'text-green-600 dark:text-green-400', @@ -86,14 +93,6 @@ const grantTypeInfo: Record< label: 'Ad Credits', description: 'Earned from viewing ads', }, - subscription: { - bg: 'bg-teal-500', - text: 'text-teal-600 dark:text-teal-400', - gradient: 'from-teal-500/70 to-teal-600/70', - icon: , - label: 'Subscription', - description: 'Credits from your subscription', - }, } interface CreditLeafProps { @@ -242,8 +241,8 @@ export const UsageDisplay = ({ // Calculate used credits per type (excluding organization) const usedCredits: Record = { free: 0, - referral: 0, subscription: 0, + referral: 0, purchase: 0, admin: 0, ad: 0, diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index eaa8beab8..9f62d0134 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -6,6 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useSession } from 'next-auth/react' import { useState } from 'react' +import { SubscriptionSection } from './subscription-section' import { UsageDisplay } from './usage-display' import { CreditManagementSection } from '@/components/credits/CreditManagementSection' @@ -127,6 +128,7 @@ export function UsageSection() { Track your credit usage and purchase additional credits as needed.

+ {status === 'authenticated' && } {isUsageError && ( diff --git a/web/src/components/ui/landing/feature/highlight-text.tsx b/web/src/components/ui/landing/feature/highlight-text.tsx index 0d70424aa..923f6e9bf 100644 --- a/web/src/components/ui/landing/feature/highlight-text.tsx +++ b/web/src/components/ui/landing/feature/highlight-text.tsx @@ -5,9 +5,10 @@ import { cn } from '@/lib/utils' interface HighlightTextProps { text: string isLight?: boolean + icon?: string } -export function HighlightText({ text, isLight }: HighlightTextProps) { +export function HighlightText({ text, isLight, icon = '⚡' }: HighlightTextProps) { return ( -
+
{icon}
{text}
) diff --git a/web/src/components/ui/landing/feature/index.tsx b/web/src/components/ui/landing/feature/index.tsx index da18d774d..9b276b342 100644 --- a/web/src/components/ui/landing/feature/index.tsx +++ b/web/src/components/ui/landing/feature/index.tsx @@ -58,6 +58,7 @@ interface FeatureSectionProps { tagline: string decorativeColors?: BlockColor[] highlightText: string + highlightIcon?: string illustration: ReactNode learnMoreText?: string learnMoreLink: string @@ -86,6 +87,7 @@ export function FeatureSection({ tagline, decorativeColors = [BlockColor.GenerativeGreen, BlockColor.DarkForestGreen], highlightText, + highlightIcon, illustration, learnMoreText = 'Learn More', learnMoreLink, @@ -106,7 +108,7 @@ export function FeatureSection({
- +

{description}