diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8366358e5..6f5ce2951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,13 @@ jobs: echo "No regular tests found in .agents" fi elif [ "${{ matrix.package }}" = "web" ]; then - bun run test --runInBand + # Use bun test directly to pick up bunfig.toml preloads for Request global + TEST_FILES=$(find src -name '*.test.ts' ! -name '*.integration.test.ts' ! -path 'src/__tests__/e2e/*' 2>/dev/null | sort | tr '\n' ' ') + if [ -n "$TEST_FILES" ]; then + bun test $TEST_FILES + else + echo "No tests found in web" + fi else # Run all non-integration tests in a single bun test invocation # This avoids xargs exit code issues with orphaned child processes diff --git a/bunfig.toml b/bunfig.toml index 87aea9fae..7068677e5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -7,4 +7,4 @@ linkWorkspacePackages = true [test] # Exclude test repositories, integration tests, and Playwright e2e tests from test execution by default exclude = ["evals/test-repos/**", "**/*.integration.test.*", "web/src/__tests__/e2e/**"] -preload = ["./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts"] +preload = ["./sdk/test/setup-env.ts", "./test/setup-bigquery-mocks.ts", "./web/test/setup-globals.ts"] diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 9e373227c..77674e0af 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -35,6 +35,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' @@ -57,6 +58,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 { type ChatKeyboardState, createDefaultChatKeyboardState, @@ -161,6 +163,11 @@ export const Chat = ({ const { statusMessage } = useClipboard() const { ad } = useGravityAd() + // Fetch subscription data early - needed for session credits tracking + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 60 * 1000, + }) + // Set initial mode from CLI flag on mount useEffect(() => { if (initialMode) { @@ -425,6 +432,7 @@ export const Chat = ({ resumeQueue, continueChat, continueChatId, + subscriptionData, }) sendMessageRef.current = sendMessage @@ -1278,6 +1286,26 @@ export const Chat = ({ refetchInterval: 60 * 1000, // Refetch every 60 seconds }) + // Auto-show subscription limit banner when rate limit becomes active + const subscriptionLimitShownRef = useRef(false) + const subscriptionRateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined + const fallbackToALaCarte = subscriptionData?.fallbackToALaCarte ?? false + 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 (!fallbackToALaCarte) { + useChatStore.getState().setInputMode('subscriptionLimit') + } + } else if (!isLimited) { + subscriptionLimitShownRef.current = false + if (useChatStore.getState().inputMode === 'subscriptionLimit') { + useChatStore.getState().setInputMode('default') + } + } + }, [subscriptionRateLimit?.limited, fallbackToALaCarte]) + const inputBoxTitle = useMemo(() => { const segments: string[] = [] diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index 9fa76b32c..1d990c7ae 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -382,6 +382,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/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..678611302 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,11 @@ 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 { + getBlockPercentRemaining, + isCoveredBySubscription, +} from '../utils/subscription' import { useTheme } from '../hooks/use-theme' import { useFeedbackStore, @@ -157,19 +163,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 +216,42 @@ 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 blockPercentRemaining = useMemo( + () => getBlockPercentRemaining(subscriptionData), + [subscriptionData], + ) + + const showSubscriptionIndicator = isCoveredBySubscription(subscriptionData) + + if (showSubscriptionIndicator) { + const label = (blockPercentRemaining ?? 0) < 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..4f9a16686 --- /dev/null +++ b/cli/src/components/subscription-limit-banner.tsx @@ -0,0 +1,181 @@ +import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' +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 { useUpdatePreference } from '../hooks/use-update-preference' +import { useUsageQuery } from '../hooks/use-usage-query' +import { WEBSITE_URL } from '../login/constants' +import { useChatStore } from '../state/chat-store' +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 + + // Determine if user can upgrade (not on highest tier) + const maxTier = Math.max(...Object.keys(SUBSCRIPTION_TIERS).map(Number)) + const currentTier = subscriptionData?.hasSubscription ? subscriptionData.subscription.tier : 0 + const canUpgrade = currentTier < maxTier + + const fallbackToALaCarte = subscriptionData?.fallbackToALaCarte ?? false + const updatePreference = useUpdatePreference() + + const handleToggleFallbackToALaCarte = () => { + updatePreference.mutate({ fallbackToALaCarte: !fallbackToALaCarte }) + } + + 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 handleUpgrade = () => { + open(WEBSITE_URL + '/pricing') + } + + 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 session starts in {formatResetTime(blockResetsAt)} + + )} + + ) : ( + + Subscription limit reached + + )} + + + Weekly: + + {weeklyPercentUsed}% used + + + {hasAlaCarteCredits && ( + + )} + + + {hasAlaCarteCredits ? ( + <> + + {canUpgrade ? ( + + ) : ( + + )} + + ) : ( + <> + No a-la-carte credits available. + {canUpgrade ? ( + + ) : ( + + )} + + + )} + + + + ) +} diff --git a/cli/src/components/usage-banner.tsx b/cli/src/components/usage-banner.tsx index 1a86a231e..7f58741d6 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 } 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 { useUpdatePreference } from '../hooks/use-update-preference' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { WEBSITE_URL } from '../login/constants' 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,25 @@ 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 +198,85 @@ 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 + fallbackToALaCarte: boolean +} + +const SubscriptionUsageSection: React.FC = ({ + displayName, + subscriptionInfo, + rateLimit, + isLoading, + fallbackToALaCarte, +}) => { + const theme = useTheme() + const updatePreference = useUpdatePreference() + + const handleToggleFallbackToALaCarte = () => { + updatePreference.mutate({ fallbackToALaCarte: !fallbackToALaCarte }) + } + + 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: + + {fallbackToALaCarte ? 'spend credits' : 'pause'} + + + + + ) +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 02e2ddd57..8c7deb050 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-send-message.ts b/cli/src/hooks/use-send-message.ts index 4411c79e8..9cc0b6cf0 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -38,6 +38,9 @@ import type { SendMessageFn } from '../types/contracts/send-message' import type { AgentMode } from '../utils/constants' import type { SendMessageTimerEvent } from '../utils/send-message-timer' import type { AgentDefinition, MessageContent, RunState } from '@codebuff/sdk' +import { isCoveredBySubscription } from '../utils/subscription' + +import type { SubscriptionResponse } from './use-subscription-query' interface UseSendMessageOptions { inputRef: React.MutableRefObject @@ -59,6 +62,7 @@ interface UseSendMessageOptions { resumeQueue?: () => void continueChat: boolean continueChatId?: string + subscriptionData?: SubscriptionResponse | null } // Choose the agent definition by explicit selection or mode-based fallback. @@ -109,6 +113,7 @@ export const useSendMessage = ({ resumeQueue, continueChat, continueChatId, + subscriptionData, }: UseSendMessageOptions): { sendMessage: SendMessageFn clearMessages: () => void @@ -431,7 +436,11 @@ export const useSendMessage = ({ setIsRetrying, onTotalCost: (cost: number) => { actualCredits = cost - addSessionCredits(cost) + // Only add to session credits if not covered by subscription + // (subscription credits are shown separately in the UI) + if (!isCoveredBySubscription(subscriptionData)) { + addSessionCredits(cost) + } }, }) 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-update-preference.ts b/cli/src/hooks/use-update-preference.ts new file mode 100644 index 000000000..7c72f304b --- /dev/null +++ b/cli/src/hooks/use-update-preference.ts @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react' + +import { + getActivityQueryData, + invalidateActivityQuery, + setActivityQueryData, +} from './use-activity-query' +import { subscriptionQueryKeys } from './use-subscription-query' +import { showClipboardMessage } from '../utils/clipboard' +import { getApiClient } from '../utils/codebuff-api' +import { logger } from '../utils/logger' + +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + +interface UpdatePreferenceParams { + fallbackToALaCarte?: boolean +} + +export function useUpdatePreference() { + const [isPending, setIsPending] = useState(false) + + const mutate = useCallback(async (params: UpdatePreferenceParams) => { + const queryKey = subscriptionQueryKeys.current() + + // Snapshot the previous value for rollback + const previousData = getActivityQueryData(queryKey) + + // Optimistically update to the new value + if (previousData && params.fallbackToALaCarte !== undefined) { + setActivityQueryData(queryKey, { + ...previousData, + fallbackToALaCarte: params.fallbackToALaCarte, + }) + } + + setIsPending(true) + + try { + const client = getApiClient() + const response = await client.patch<{ success: boolean; error?: string }>( + '/api/user/preferences', + params as Record, + { includeCookie: true }, + ) + + if (!response.ok) { + const errorMessage = response.error || 'Failed to update preference' + throw new Error(errorMessage) + } + + // Invalidate to refetch fresh data from server + invalidateActivityQuery(queryKey) + } catch (err) { + // Rollback to previous value on error + if (previousData) { + setActivityQueryData(queryKey, previousData) + } + logger.error({ err }, 'Failed to update preference') + showClipboardMessage('Failed to update preference', { durationMs: 3000 }) + } finally { + setIsPending(false) + } + }, []) + + return { mutate, isPending } +} 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..7ce71e2d6 100644 --- a/cli/src/utils/settings.ts +++ b/cli/src/utils/settings.ts @@ -20,6 +20,10 @@ const DEFAULT_SETTINGS: Settings = { export interface Settings { mode?: AgentMode adsEnabled?: boolean + /** @deprecated Use server-side fallbackToALaCarte setting instead */ + alwaysUseALaCarte?: boolean + /** @deprecated Use server-side fallbackToALaCarte setting instead */ + fallbackToALaCarte?: boolean } /** @@ -92,6 +96,16 @@ const validateSettings = (parsed: unknown): Settings => { settings.adsEnabled = obj.adsEnabled } + // Validate alwaysUseALaCarte (legacy) + if (typeof obj.alwaysUseALaCarte === 'boolean') { + settings.alwaysUseALaCarte = obj.alwaysUseALaCarte + } + + // Validate fallbackToALaCarte (legacy) + if (typeof obj.fallbackToALaCarte === 'boolean') { + settings.fallbackToALaCarte = obj.fallbackToALaCarte + } + return settings } @@ -134,3 +148,4 @@ export const loadModePreference = (): AgentMode => { export const saveModePreference = (mode: AgentMode): void => { saveSettings({ mode }) } + diff --git a/cli/src/utils/subscription.ts b/cli/src/utils/subscription.ts new file mode 100644 index 000000000..5bbdc5ae9 --- /dev/null +++ b/cli/src/utils/subscription.ts @@ -0,0 +1,31 @@ +import type { SubscriptionResponse } from '../hooks/use-subscription-query' + +/** + * Calculates the percentage of subscription block credits remaining. + * Returns null if the subscription data is incomplete. + */ +export function getBlockPercentRemaining( + subscriptionData: SubscriptionResponse | null | undefined, +): number | null { + if (!subscriptionData?.hasSubscription) return null + const rateLimit = subscriptionData.rateLimit + if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null + return Math.round( + ((rateLimit.blockLimit - rateLimit.blockUsed) / rateLimit.blockLimit) * 100, + ) +} + +/** + * Determines if a request is covered by subscription based on subscription data. + * Returns true if the user has an active subscription that's not rate-limited + * and has remaining block credits. + */ +export function isCoveredBySubscription( + subscriptionData: SubscriptionResponse | null | undefined, +): boolean { + if (!subscriptionData?.hasSubscription) return false + const rateLimit = subscriptionData.rateLimit + if (rateLimit?.limited) return false + const blockPercentRemaining = getBlockPercentRemaining(subscriptionData) + return blockPercentRemaining != null && blockPercentRemaining > 0 +} 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/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts index 23309e2f4..5f9e3ec8e 100644 --- a/common/src/constants/subscription-plans.ts +++ b/common/src/constants/subscription-plans.ts @@ -10,21 +10,21 @@ export interface TierConfig { export const SUBSCRIPTION_TIERS = { 100: { monthlyPrice: 100, - creditsPerBlock: 400, + creditsPerBlock: 350, blockDurationHours: 5, - weeklyCreditsLimit: 4000, + weeklyCreditsLimit: 3500, }, 200: { monthlyPrice: 200, - creditsPerBlock: 1200, + creditsPerBlock: 1050, blockDurationHours: 5, - weeklyCreditsLimit: 12000, + weeklyCreditsLimit: 10500, }, 500: { monthlyPrice: 500, - creditsPerBlock: 3200, + creditsPerBlock: 2800, blockDurationHours: 5, - weeklyCreditsLimit: 32000, + weeklyCreditsLimit: 28000, }, } as const satisfies Record diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts new file mode 100644 index 000000000..714bdf24e --- /dev/null +++ b/common/src/types/subscription.ts @@ -0,0 +1,67 @@ +/** + * Core subscription information for an active subscription. + */ +export interface SubscriptionInfo { + id: string + 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 + /** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */ + fallbackToALaCarte: boolean +} + +/** + * 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 + + /** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */ + fallbackToALaCarte: boolean +} + +/** + * 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/__tests__/balance-calculator.test.ts b/packages/billing/src/__tests__/balance-calculator.test.ts index 616a7e421..d0bdcbe8a 100644 --- a/packages/billing/src/__tests__/balance-calculator.test.ts +++ b/packages/billing/src/__tests__/balance-calculator.test.ts @@ -139,6 +139,199 @@ function createDbMockForUnion(options: { } } +describe('Balance Calculator - calculateUsageAndBalance', () => { + afterEach(() => { + clearMockedModules() + }) + + describe('isPersonalContext behavior', () => { + it('should exclude subscription credits when isPersonalContext is true', async () => { + const now = new Date() + const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago + + const grants = [ + createMockGrant({ + operation_id: 'free-grant', + balance: 500, + principal: 1000, + priority: 20, + type: 'purchase', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'subscription-grant', + balance: 2000, + principal: 5000, + priority: 10, + type: 'subscription', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + // Mock the database to return our test grants + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => grants, + }), + }), + }), + }, + })) + + // Mock analytics to prevent actual tracking + await mockModule('@codebuff/common/analytics', () => ({ + trackEvent: () => {}, + })) + + const { calculateUsageAndBalance } = await import( + '@codebuff/billing/balance-calculator' + ) + + const result = await calculateUsageAndBalance({ + userId: 'user-123', + quotaResetDate, + now, + isPersonalContext: true, + logger, + }) + + // Should only include purchase credits (500), not subscription (2000) + expect(result.balance.totalRemaining).toBe(500) + expect(result.balance.breakdown.purchase).toBe(500) + expect(result.balance.breakdown.subscription).toBe(0) + + // Usage should only include purchase usage (1000 - 500 = 500), not subscription (5000 - 2000 = 3000) + expect(result.usageThisCycle).toBe(500) + }) + + it('should include subscription credits when isPersonalContext is false', async () => { + const now = new Date() + const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago + + const grants = [ + createMockGrant({ + operation_id: 'free-grant', + balance: 500, + principal: 1000, + priority: 20, + type: 'purchase', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'subscription-grant', + balance: 2000, + principal: 5000, + priority: 10, + type: 'subscription', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => grants, + }), + }), + }), + }, + })) + + await mockModule('@codebuff/common/analytics', () => ({ + trackEvent: () => {}, + })) + + const { calculateUsageAndBalance } = await import( + '@codebuff/billing/balance-calculator' + ) + + const result = await calculateUsageAndBalance({ + userId: 'user-123', + quotaResetDate, + now, + isPersonalContext: false, + logger, + }) + + // Should include both purchase (500) and subscription (2000) credits + expect(result.balance.totalRemaining).toBe(2500) + expect(result.balance.breakdown.purchase).toBe(500) + expect(result.balance.breakdown.subscription).toBe(2000) + + // Usage should include both: (1000 - 500) + (5000 - 2000) = 3500 + expect(result.usageThisCycle).toBe(3500) + }) + + it('should exclude organization credits when isPersonalContext is true', async () => { + const now = new Date() + const quotaResetDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const grants = [ + createMockGrant({ + operation_id: 'free-grant', + balance: 500, + principal: 1000, + priority: 20, + type: 'purchase', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'org-grant', + balance: 3000, + principal: 5000, + priority: 5, + type: 'organization', + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + await mockModule('@codebuff/internal/db', () => ({ + default: { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => grants, + }), + }), + }), + }, + })) + + await mockModule('@codebuff/common/analytics', () => ({ + trackEvent: () => {}, + })) + + const { calculateUsageAndBalance } = await import( + '@codebuff/billing/balance-calculator' + ) + + const result = await calculateUsageAndBalance({ + userId: 'user-123', + quotaResetDate, + now, + isPersonalContext: true, + logger, + }) + + // Should only include purchase credits (500), not organization (3000) + expect(result.balance.totalRemaining).toBe(500) + expect(result.balance.breakdown.purchase).toBe(500) + expect(result.balance.breakdown.organization).toBe(0) + }) + }) +}) + describe('Balance Calculator - Grant Ordering for Consumption', () => { // NOTE: This test suite uses a complex mock (createDbMockForUnion) to simulate the // behavior of the UNION query in `getOrderedActiveGrantsForConsumption`. diff --git a/packages/billing/src/__tests__/subscription.test.ts b/packages/billing/src/__tests__/subscription.test.ts index b563eaf94..1c5a75fbb 100644 --- a/packages/billing/src/__tests__/subscription.test.ts +++ b/packages/billing/src/__tests__/subscription.test.ts @@ -412,11 +412,11 @@ describe('subscription', () => { const subscription = createMockSubscription() it('should report weekly_limit when usage reaches limit', async () => { - // tier 200 → weeklyCreditsLimit: 12000 + const weeklyLimit = SUBSCRIPTION_TIERS[200].weeklyCreditsLimit const { conn } = createSequentialMock({ selectResults: [ - [], // no limit overrides - [{ total: 12000 }], // weekly usage at limit + [], // no limit overrides + [{ total: weeklyLimit }], // weekly usage at limit ], }) @@ -430,8 +430,8 @@ describe('subscription', () => { expect(result.limited).toBe(true) expect(result.reason).toBe('weekly_limit') expect(result.canStartNewBlock).toBe(false) - expect(result.weeklyUsed).toBe(12000) - expect(result.weeklyLimit).toBe(SUBSCRIPTION_TIERS[200].weeklyCreditsLimit) + expect(result.weeklyUsed).toBe(weeklyLimit) + expect(result.weeklyLimit).toBe(weeklyLimit) }) it('should allow new block when no active block exists', async () => { @@ -528,12 +528,12 @@ describe('subscription', () => { }) it('should return weekly limit error when limit is reached', async () => { - // tier 200 → weeklyCreditsLimit: 12000 + const weeklyLimit = SUBSCRIPTION_TIERS[200].weeklyCreditsLimit const { conn } = createSequentialMock({ selectResults: [ - [], // no existing grants - [], // no limit overrides - [{ total: 12000 }], // weekly limit reached + [], // no existing grants + [], // no limit overrides + [{ total: weeklyLimit }], // weekly limit reached ], }) @@ -547,8 +547,8 @@ describe('subscription', () => { expect(isWeeklyLimitError(result)).toBe(true) const error = result as WeeklyLimitError expect(error.error).toBe('weekly_limit_reached') - expect(error.used).toBe(12000) - expect(error.limit).toBe(SUBSCRIPTION_TIERS[200].weeklyCreditsLimit) + expect(error.used).toBe(weeklyLimit) + expect(error.limit).toBe(weeklyLimit) }) it('should create new block grant when none exists', async () => { @@ -583,14 +583,15 @@ describe('subscription', () => { }) it('should cap block credits to weekly remaining', async () => { - // tier 200: creditsPerBlock=1200, weeklyCreditsLimit=12000 - // weekly used=11500 → remaining=500, block capped to 500 + const weeklyLimit = SUBSCRIPTION_TIERS[200].weeklyCreditsLimit + const expectedRemaining = 500 + const weeklyUsed = weeklyLimit - expectedRemaining const now = new Date('2025-01-15T10:00:00Z') const { conn, captures } = createSequentialMock({ selectResults: [ - [], // no existing grants - [], // no limit overrides - [{ total: 11500 }], // 500 remaining + [], // no existing grants + [], // no limit overrides + [{ total: weeklyUsed }], // expectedRemaining credits remaining ], insertResults: [ [{ operation_id: 'capped-block' }], @@ -607,9 +608,9 @@ describe('subscription', () => { expect(isWeeklyLimitError(result)).toBe(false) const grant = result as BlockGrant - expect(grant.credits).toBe(500) - expect(captures.insertValues[0].principal).toBe(500) - expect(captures.insertValues[0].balance).toBe(500) + expect(grant.credits).toBe(expectedRemaining) + expect(captures.insertValues[0].principal).toBe(expectedRemaining) + expect(captures.insertValues[0].balance).toBe(expectedRemaining) }) it('should throw when insert returns no grant (duplicate operation)', async () => { diff --git a/packages/billing/src/balance-calculator.ts b/packages/billing/src/balance-calculator.ts index 9b46e5faf..165c2030a 100644 --- a/packages/billing/src/balance-calculator.ts +++ b/packages/billing/src/balance-calculator.ts @@ -326,8 +326,9 @@ export async function calculateUsageAndBalance( for (const grant of grants) { const grantType = grant.type as GrantType - // Skip organization credits for personal context - if (isPersonalContext && grantType === 'organization') { + // Skip organization and subscription credits for personal context + // Subscription credits are shown separately in the CLI with progress bars + if (isPersonalContext && (grantType === 'organization' || grantType === 'subscription')) { continue } diff --git a/packages/billing/src/subscription-webhooks.ts b/packages/billing/src/subscription-webhooks.ts index cda205d00..ea923f372 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', ) } @@ -352,15 +347,30 @@ export async function handleSubscriptionDeleted(params: { const user = await getUserByStripeCustomerId(customerId) const userId = user?.id ?? null - await db + const result = await db .update(schema.subscription) .set({ status: 'canceled', scheduled_tier: null, canceled_at: new Date(), - updated_at: new Date(), }) .where(eq(schema.subscription.stripe_subscription_id, subscriptionId)) + .returning({ id: schema.subscription.stripe_subscription_id }) + + if (result.length === 0) { + logger.warn( + { subscriptionId, customerId }, + 'No subscription found to cancel — may not exist in our database', + ) + // Still track the event for observability + trackEvent({ + event: AnalyticsEvent.SUBSCRIPTION_CANCELED, + userId: userId ?? 'system', + properties: { subscriptionId, notFoundInDb: true }, + logger, + }) + return + } if (userId) { await expireActiveBlockGrants({ userId, subscriptionId, logger }) @@ -375,3 +385,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?.id + + 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/packages/internal/src/db/migrations/0040_empty_phil_sheldon.sql b/packages/internal/src/db/migrations/0040_empty_phil_sheldon.sql new file mode 100644 index 000000000..66111f5a0 --- /dev/null +++ b/packages/internal/src/db/migrations/0040_empty_phil_sheldon.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "fallback_to_a_la_carte" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0040_snapshot.json b/packages/internal/src/db/migrations/meta/0040_snapshot.json new file mode 100644 index 000000000..74a942dbf --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0040_snapshot.json @@ -0,0 +1,3078 @@ +{ + "id": "20f36987-146d-4bca-ab34-2f0201235556", + "prevId": "c08ced84-4b3d-4bd3-8934-aa9531d889ca", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 8d6ca418d..7fd42149f 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -278,8 +278,15 @@ { "idx": 39, "version": "7", - "when": 1769482939158, - "tag": "0039_quiet_franklin_storm", + "when": 1770252529987, + "tag": "0039_bumpy_vertigo", + "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1770252805234, + "tag": "0040_empty_phil_sheldon", "breakpoints": true } ] diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 3d3f9e024..694437f00 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -88,6 +88,7 @@ export const user = pgTable('user', { auto_topup_threshold: integer('auto_topup_threshold'), auto_topup_amount: integer('auto_topup_amount'), banned: boolean('banned').notNull().default(false), + fallback_to_a_la_carte: boolean('fallback_to_a_la_carte').notNull().default(false), }) export const account = pgTable( diff --git a/web/bunfig.toml b/web/bunfig.toml new file mode 100644 index 000000000..78f557a45 --- /dev/null +++ b/web/bunfig.toml @@ -0,0 +1,3 @@ +[test] +# Preload web globals (Request, Response, Headers, fetch) for Next.js server modules +preload = ["./test/setup-globals.ts", "../sdk/test/setup-env.ts", "../test/setup-bigquery-mocks.ts"] diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts b/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts new file mode 100644 index 000000000..5e6c3a3bc --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/__tests__/org-billing-portal.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +import { postOrgBillingPortal } from '../_post' + +import type { + CreateBillingPortalSessionFn, + GetMembershipFn, + GetSessionFn, + OrgMembership, + Session, +} from '../_post' + +const createMockLogger = (errorFn = mock(() => {})): Logger => ({ + error: errorFn, + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), +}) + +const createMockGetSession = (session: Session): GetSessionFn => + mock(() => Promise.resolve(session)) + +const createMockGetMembership = ( + result: OrgMembership | null +): GetMembershipFn => mock(() => Promise.resolve(result)) + +const createMockCreateBillingPortalSession = ( + result: { url: string } | Error = { url: 'https://billing.stripe.com/session/test_123' } +): CreateBillingPortalSessionFn => { + if (result instanceof Error) { + return mock(() => Promise.reject(result)) + } + return mock(() => Promise.resolve(result)) +} + +const defaultOrg = { + id: 'org-123', + name: 'Test Org', + slug: 'test-org', + stripe_customer_id: 'cus_org_123', +} + +const buildReturnUrl = (orgSlug: string) => `https://codebuff.com/orgs/${orgSlug}/settings` + +describe('/api/orgs/[orgId]/billing/portal POST endpoint', () => { + const orgId = 'org-123' + + describe('Feature flag', () => { + test('returns 503 when org billing is disabled', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: false, + buildReturnUrl, + }) + + expect(response.status).toBe(503) + const body = await response.json() + expect(body).toEqual({ error: 'Organization billing is temporarily disabled' }) + }) + }) + + describe('Authentication', () => { + test('returns 401 when session is null', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession(null), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user is null', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: null }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user.id is missing', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: {} as any }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + }) + + describe('Organization membership', () => { + test('returns 404 when user is not a member of the organization', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body).toEqual({ error: 'Organization not found' }) + }) + + test('calls getMembership with correct parameters', async () => { + const mockGetMembership = createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }) + + await postOrgBillingPortal({ + orgId: 'org-456', + getSession: createMockGetSession({ user: { id: 'user-789' } }), + getMembership: mockGetMembership, + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(mockGetMembership).toHaveBeenCalledTimes(1) + expect(mockGetMembership).toHaveBeenCalledWith({ + orgId: 'org-456', + userId: 'user-789', + }) + }) + }) + + describe('Permissions', () => { + test('returns 403 when user is a member (not owner or admin)', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'member', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body).toEqual({ error: 'Insufficient permissions' }) + }) + + test('allows owner to access billing portal', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + }) + + test('allows admin to access billing portal', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'admin', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + }) + }) + + describe('Stripe customer validation', () => { + test('returns 400 when organization has no stripe_customer_id', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: { ...defaultOrg, stripe_customer_id: null }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found for organization' }) + }) + }) + + describe('Successful portal session creation', () => { + test('returns 200 with portal URL on success', async () => { + const expectedUrl = 'https://billing.stripe.com/session/org_abc123' + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession({ url: expectedUrl }), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ url: expectedUrl }) + }) + + test('calls createBillingPortalSession with correct parameters', async () => { + const mockCreateSession = createMockCreateBillingPortalSession() + + await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'admin', + organization: { + ...defaultOrg, + slug: 'my-org', + stripe_customer_id: 'cus_my_org_456', + }, + }), + createBillingPortalSession: mockCreateSession, + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl: (slug) => `https://example.com/orgs/${slug}/billing`, + }) + + expect(mockCreateSession).toHaveBeenCalledTimes(1) + expect(mockCreateSession).toHaveBeenCalledWith({ + customer: 'cus_my_org_456', + return_url: 'https://example.com/orgs/my-org/billing', + }) + }) + }) + + describe('Error handling', () => { + test('returns 500 when Stripe API throws an error', async () => { + const response = await postOrgBillingPortal({ + orgId, + getSession: createMockGetSession({ user: { id: 'user-123' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession( + new Error('Stripe API error') + ), + logger: createMockLogger(), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Failed to create billing portal session' }) + }) + + test('logs error when Stripe API fails', async () => { + const mockLoggerError = mock(() => {}) + const testError = new Error('Stripe connection failed') + + await postOrgBillingPortal({ + orgId: 'org-error-test', + getSession: createMockGetSession({ user: { id: 'user-error' } }), + getMembership: createMockGetMembership({ + role: 'owner', + organization: defaultOrg, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(testError), + logger: createMockLogger(mockLoggerError), + orgBillingEnabled: true, + buildReturnUrl, + }) + + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledWith( + { userId: 'user-error', orgId: 'org-error-test', error: testError }, + 'Failed to create org billing portal session' + ) + }) + }) +}) diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts b/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts new file mode 100644 index 000000000..8a222b44d --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/_post.ts @@ -0,0 +1,116 @@ +import { NextResponse } from 'next/server' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type OrgMemberRole = 'owner' | 'admin' | 'member' + +export type Organization = { + id: string + name: string + slug: string + stripe_customer_id: string | null +} + +export type OrgMembership = { + role: OrgMemberRole + organization: Organization +} + +export type SessionUser = { + id: string +} + +export type Session = { + user?: SessionUser | null +} | null + +export type GetSessionFn = () => Promise + +export type GetMembershipFn = (params: { + orgId: string + userId: string +}) => Promise + +export type CreateBillingPortalSessionFn = (params: { + customer: string + return_url: string +}) => Promise<{ url: string }> + +export type PostOrgBillingPortalParams = { + orgId: string + getSession: GetSessionFn + getMembership: GetMembershipFn + createBillingPortalSession: CreateBillingPortalSessionFn + logger: Logger + orgBillingEnabled: boolean + buildReturnUrl: (orgSlug: string) => string +} + +export async function postOrgBillingPortal(params: PostOrgBillingPortalParams) { + const { + orgId, + getSession, + getMembership, + createBillingPortalSession, + logger, + orgBillingEnabled, + buildReturnUrl, + } = params + + if (!orgBillingEnabled) { + return NextResponse.json( + { error: 'Organization billing is temporarily disabled' }, + { status: 503 } + ) + } + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const membership = await getMembership({ orgId, userId }) + + if (!membership) { + return NextResponse.json( + { error: 'Organization not found' }, + { status: 404 } + ) + } + + const { role, organization } = membership + + if (role !== 'owner' && role !== 'admin') { + return NextResponse.json( + { error: 'Insufficient permissions' }, + { status: 403 } + ) + } + + if (!organization.stripe_customer_id) { + return NextResponse.json( + { error: 'No Stripe customer ID found for organization' }, + { status: 400 } + ) + } + + try { + const portalSession = await createBillingPortalSession({ + customer: organization.stripe_customer_id, + return_url: buildReturnUrl(organization.slug), + }) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId, orgId, error }, + 'Failed to create org billing portal session' + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 } + ) + } +} diff --git a/web/src/app/api/orgs/[orgId]/billing/portal/route.ts b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts new file mode 100644 index 000000000..84fc75aba --- /dev/null +++ b/web/src/app/api/orgs/[orgId]/billing/portal/route.ts @@ -0,0 +1,61 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { eq, and } from 'drizzle-orm' +import { getServerSession } from 'next-auth' + +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { ORG_BILLING_ENABLED } from '@/lib/billing-config' +import { logger } from '@/util/logger' + +import { postOrgBillingPortal } from './_post' + +import type { GetMembershipFn } from './_post' + +interface RouteParams { + params: Promise<{ + orgId: string + }> +} + +const getMembership: GetMembershipFn = async ({ orgId, userId }) => { + const membership = await db + .select({ + role: schema.orgMember.role, + organization: schema.org, + }) + .from(schema.orgMember) + .innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id)) + .where( + and( + eq(schema.orgMember.org_id, orgId), + eq(schema.orgMember.user_id, userId), + ), + ) + .limit(1) + + if (membership.length === 0) { + return null + } + + return membership[0] +} + +export async function POST(req: NextRequest, { params }: RouteParams) { + const { orgId } = await params + + return postOrgBillingPortal({ + orgId, + getSession: () => getServerSession(authOptions), + getMembership, + createBillingPortalSession: (params) => + stripeServer.billingPortal.sessions.create(params), + logger, + orgBillingEnabled: ORG_BILLING_ENABLED, + buildReturnUrl: (orgSlug) => + `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${orgSlug}/settings`, + }) +} diff --git a/web/src/app/api/orgs/[orgId]/billing/status/route.ts b/web/src/app/api/orgs/[orgId]/billing/status/route.ts index 6bf6509d7..057db56ea 100644 --- a/web/src/app/api/orgs/[orgId]/billing/status/route.ts +++ b/web/src/app/api/orgs/[orgId]/billing/status/route.ts @@ -1,6 +1,5 @@ import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' -import { env } from '@codebuff/internal/env' import { stripeServer } from '@codebuff/internal/util/stripe' import { eq, and, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -74,32 +73,21 @@ export async function GET(req: NextRequest, { params }: RouteParams) { // Get subscription details if it exists let subscriptionDetails = null - let billingPortalUrl = null - if (organization.stripe_customer_id) { + if (organization.stripe_customer_id && organization.stripe_subscription_id) { try { - // Create billing portal session - const portalSession = await stripeServer.billingPortal.sessions.create({ - customer: organization.stripe_customer_id, - return_url: `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/orgs/${organization.slug}/settings`, - }) - billingPortalUrl = portalSession.url - - // Get subscription details if subscription exists - if (organization.stripe_subscription_id) { - const subscription = await stripeServer.subscriptions.retrieve( - organization.stripe_subscription_id, - ) - - subscriptionDetails = { - status: subscription.status, - current_period_start: subscription.current_period_start, - current_period_end: subscription.current_period_end, - cancel_at_period_end: subscription.cancel_at_period_end, - } + const subscription = await stripeServer.subscriptions.retrieve( + organization.stripe_subscription_id, + ) + + subscriptionDetails = { + status: subscription.status, + current_period_start: subscription.current_period_start, + current_period_end: subscription.current_period_end, + cancel_at_period_end: subscription.cancel_at_period_end, } } catch (error) { - logger.warn({ orgId, error }, 'Failed to get Stripe billing details') + logger.warn({ orgId, error }, 'Failed to get Stripe subscription details') } } @@ -112,7 +100,6 @@ export async function GET(req: NextRequest, { params }: RouteParams) { totalMonthlyCost: seatCount * pricePerSeat, hasActiveSubscription: !!organization.stripe_subscription_id, subscriptionDetails, - billingPortalUrl, organization: { id: organization.id, name: organization.name, 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 0ec89b670..f23f5635e 100644 --- a/web/src/app/api/stripe/create-subscription/route.ts +++ b/web/src/app/api/stripe/create-subscription/route.ts @@ -72,13 +72,12 @@ export async function POST(req: NextRequest) { const checkoutSession = await stripeServer.checkout.sessions.create({ customer: user.stripe_customer_id, mode: 'subscription', - invoice_creation: { enabled: true }, tax_id_collection: { enabled: true }, // optional (EU B2B) customer_update: { name: "auto", address: "auto" }, 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 78e7a561c..8c3406214 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' @@ -392,6 +394,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/billing-portal/__tests__/billing-portal.test.ts b/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts new file mode 100644 index 000000000..0fa874438 --- /dev/null +++ b/web/src/app/api/user/billing-portal/__tests__/billing-portal.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, mock, test } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +import { postBillingPortal } from '../_post' + +import type { CreateBillingPortalSessionFn, GetSessionFn, Session } from '../_post' + +const createMockLogger = (errorFn = mock(() => {})): Logger => ({ + error: errorFn, + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), +}) + +const createMockGetSession = (session: Session): GetSessionFn => mock(() => Promise.resolve(session)) + +const createMockCreateBillingPortalSession = ( + result: { url: string } | Error = { url: 'https://billing.stripe.com/session/test_123' } +): CreateBillingPortalSessionFn => { + if (result instanceof Error) { + return mock(() => Promise.reject(result)) + } + return mock(() => Promise.resolve(result)) +} + +describe('/api/user/billing-portal POST endpoint', () => { + const returnUrl = 'https://codebuff.com/profile' + + describe('Authentication', () => { + test('returns 401 when session is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession(null), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ user: null }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + test('returns 401 when session.user.id is missing', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ user: { stripe_customer_id: 'cus_123' } as any }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(401) + const body = await response.json() + expect(body).toEqual({ error: 'Unauthorized' }) + }) + }) + + describe('Stripe customer validation', () => { + test('returns 400 when stripe_customer_id is null', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: null }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found' }) + }) + + test('returns 400 when stripe_customer_id is undefined', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'No Stripe customer ID found' }) + }) + }) + + describe('Successful portal session creation', () => { + test('returns 200 with portal URL on success', async () => { + const expectedUrl = 'https://billing.stripe.com/session/abc123' + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession({ url: expectedUrl }), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body).toEqual({ url: expectedUrl }) + }) + + test('calls createBillingPortalSession with correct parameters', async () => { + const mockCreateSession = createMockCreateBillingPortalSession() + await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_456' }, + }), + createBillingPortalSession: mockCreateSession, + logger: createMockLogger(), + returnUrl: 'https://example.com/return', + }) + + expect(mockCreateSession).toHaveBeenCalledTimes(1) + expect(mockCreateSession).toHaveBeenCalledWith({ + customer: 'cus_test_456', + return_url: 'https://example.com/return', + }) + }) + }) + + describe('Error handling', () => { + test('returns 500 when Stripe API throws an error', async () => { + const response = await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession( + new Error('Stripe API error') + ), + logger: createMockLogger(), + returnUrl, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Failed to create billing portal session' }) + }) + + test('logs error when Stripe API fails', async () => { + const mockLoggerError = mock(() => {}) + const testError = new Error('Stripe connection failed') + + await postBillingPortal({ + getSession: createMockGetSession({ + user: { id: 'user-123', stripe_customer_id: 'cus_test_123' }, + }), + createBillingPortalSession: createMockCreateBillingPortalSession(testError), + logger: createMockLogger(mockLoggerError), + returnUrl, + }) + + expect(mockLoggerError).toHaveBeenCalledTimes(1) + expect(mockLoggerError).toHaveBeenCalledWith( + { userId: 'user-123', error: testError }, + 'Failed to create billing portal session' + ) + }) + }) +}) diff --git a/web/src/app/api/user/billing-portal/_post.ts b/web/src/app/api/user/billing-portal/_post.ts new file mode 100644 index 000000000..3dfb7ebad --- /dev/null +++ b/web/src/app/api/user/billing-portal/_post.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type SessionUser = { + id: string + stripe_customer_id?: string | null +} + +export type Session = { + user?: SessionUser | null +} | null + +export type GetSessionFn = () => Promise + +export type BillingPortalFlowData = { + type: 'subscription_update' + subscription_update: { + subscription: string + } +} + +export type CreateBillingPortalSessionParams = { + customer: string + return_url: string + flow_data?: BillingPortalFlowData +} + +export type CreateBillingPortalSessionFn = ( + params: CreateBillingPortalSessionParams +) => Promise<{ url: string }> + +export type PostBillingPortalParams = { + getSession: GetSessionFn + createBillingPortalSession: CreateBillingPortalSessionFn + logger: Logger + returnUrl: string + flowData?: BillingPortalFlowData +} + +export async function postBillingPortal(params: PostBillingPortalParams) { + const { getSession, createBillingPortalSession, logger, returnUrl, flowData } = params + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const stripeCustomerId = session.user.stripe_customer_id + if (!stripeCustomerId) { + return NextResponse.json( + { error: 'No Stripe customer ID found' }, + { status: 400 } + ) + } + + try { + const portalParams: CreateBillingPortalSessionParams = { + customer: stripeCustomerId, + return_url: returnUrl, + } + + if (flowData) { + portalParams.flow_data = flowData + } + + const portalSession = await createBillingPortalSession(portalParams) + + return NextResponse.json({ url: portalSession.url }) + } catch (error) { + logger.error( + { userId: session.user.id, error }, + 'Failed to create billing portal session' + ) + return NextResponse.json( + { error: 'Failed to create billing portal session' }, + { status: 500 } + ) + } +} diff --git a/web/src/app/api/user/billing-portal/route.ts b/web/src/app/api/user/billing-portal/route.ts new file mode 100644 index 000000000..69091e415 --- /dev/null +++ b/web/src/app/api/user/billing-portal/route.ts @@ -0,0 +1,38 @@ +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { getServerSession } from 'next-auth' + +import type { NextRequest } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +import { postBillingPortal } from './_post' + +import type { BillingPortalFlowData } from './_post' + +export async function POST(req: NextRequest) { + // Parse optional subscriptionId from request body for deep-linking to subscription update + let flowData: BillingPortalFlowData | undefined + const body = await req.json().catch(() => null) + if (body?.subscriptionId) { + flowData = { + type: 'subscription_update', + subscription_update: { + subscription: body.subscriptionId, + }, + } + } + + // Determine return URL - use provided returnUrl or default to /pricing + const returnUrl = body?.returnUrl || `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/pricing` + + return postBillingPortal({ + getSession: () => getServerSession(authOptions), + createBillingPortalSession: (params) => + stripeServer.billingPortal.sessions.create(params), + logger, + returnUrl, + flowData, + }) +} diff --git a/web/src/app/api/user/preferences/route.ts b/web/src/app/api/user/preferences/route.ts new file mode 100644 index 000000000..43478d81c --- /dev/null +++ b/web/src/app/api/user/preferences/route.ts @@ -0,0 +1,90 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { z } from 'zod' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +const updatePreferencesSchema = z.object({ + fallbackToALaCarte: z.boolean().optional(), +}) + +export async function PATCH(request: Request) { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const parsed = updatePreferencesSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const { fallbackToALaCarte } = parsed.data + + // Build the update object with only provided fields + const updates: Partial<{ fallback_to_a_la_carte: boolean }> = {} + + if (fallbackToALaCarte !== undefined) { + updates.fallback_to_a_la_carte = fallbackToALaCarte + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) + } + + try { + await db + .update(schema.user) + .set(updates) + .where(eq(schema.user.id, userId)) + + logger.info({ userId, updates }, 'User preferences updated') + + return NextResponse.json({ success: true, ...parsed.data }) + } catch (error) { + logger.error({ error, userId }, 'Error updating user preferences') + return NextResponse.json( + { error: 'Failed to update preferences' }, + { status: 500 }, + ) + } +} + +export async function GET() { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, session.user.id), + columns: { fallback_to_a_la_carte: true }, + }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + return NextResponse.json({ + fallbackToALaCarte: user.fallback_to_a_la_carte, + }) +} diff --git a/web/src/app/api/user/subscription/route.ts b/web/src/app/api/user/subscription/route.ts index c8d53b8db..ada3158e5 100644 --- a/web/src/app/api/user/subscription/route.ts +++ b/web/src/app/api/user/subscription/route.ts @@ -4,12 +4,20 @@ import { getSubscriptionLimits, } from '@codebuff/billing' import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' 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) { @@ -17,10 +25,21 @@ export async function GET() { } const userId = session.user.id - const subscription = await getActiveSubscription({ userId, logger }) - if (!subscription) { - return NextResponse.json({ hasSubscription: false }) + // Fetch user preference for always use a-la-carte + const [subscription, userPrefs] = await Promise.all([ + getActiveSubscription({ userId, logger }), + db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { fallback_to_a_la_carte: true }, + }), + ]) + + const fallbackToALaCarte = userPrefs?.fallback_to_a_la_carte ?? false + + if (!subscription || !subscription.tier) { + const response: NoSubscriptionResponse = { hasSubscription: false, fallbackToALaCarte } + return NextResponse.json(response) } const [rateLimit, limits] = await Promise.all([ @@ -28,10 +47,11 @@ export async function GET() { getSubscriptionLimits({ userId, logger, tier: subscription.tier }), ]) - return NextResponse.json({ + const response: ActiveSubscriptionResponse = { hasSubscription: true, displayName: SUBSCRIPTION_DISPLAY_NAME, subscription: { + id: subscription.stripe_subscription_id, status: subscription.status, billingPeriodEnd: subscription.billing_period_end.toISOString(), cancelAtPeriodEnd: subscription.cancel_at_period_end, @@ -52,5 +72,7 @@ export async function GET() { weeklyPercentUsed: rateLimit.weeklyPercentUsed, }, limits, - }) + fallbackToALaCarte, + } + return NextResponse.json(response) } diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 40c763fd4..f3ab9a365 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -14,6 +14,8 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' +import type { BlockGrantResult } from '@codebuff/billing/subscription' +import type { GetUserPreferencesFn } from '../_post' describe('/api/v1/chat/completions POST endpoint', () => { const mockUserData: Record< @@ -497,4 +499,265 @@ describe('/api/v1/chat/completions POST endpoint', () => { expect(body.choices[0].message.content).toBe('test response') }) }) + + describe('Subscription limit enforcement', () => { + const createValidRequest = () => + new NextRequest('http://localhost:3000/api/v1/chat/completions', { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-123', + client_id: 'test-client-id-123', + client_request_id: 'test-client-session-id-123', + }, + }), + }) + + it('returns 429 when weekly limit reached and fallback disabled', async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => weeklyLimitError) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(429) + const body = await response.json() + expect(body.error).toBe('rate_limit_exceeded') + expect(body.message).toContain('weekly limit reached') + expect(body.message).toContain('Enable "Continue with credits"') + }) + + it('returns 429 when block exhausted and fallback disabled', async () => { + const blockExhaustedError: BlockGrantResult = { + error: 'block_exhausted', + blockUsed: 350, + blockLimit: 350, + resetsAt: new Date(Date.now() + 4 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => blockExhaustedError) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(429) + const body = await response.json() + expect(body.error).toBe('rate_limit_exceeded') + expect(body.message).toContain('5-hour session limit reached') + expect(body.message).toContain('Enable "Continue with credits"') + }) + + it('continues when weekly limit reached but fallback is enabled', async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => weeklyLimitError) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: true, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(200) + expect(mockLogger.info).toHaveBeenCalled() + }) + + it('continues when block grant is created successfully', async () => { + const blockGrant: BlockGrantResult = { + grantId: 'block-123', + credits: 350, + expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), + isNew: true, + } + const mockEnsureSubscriberBlockGrant = mock(async () => blockGrant) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(200) + // getUserPreferences should not be called when block grant succeeds + expect(mockGetUserPreferences).not.toHaveBeenCalled() + }) + + it('continues when ensureSubscriberBlockGrant throws an error (fail open)', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => { + throw new Error('Database connection failed') + }) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + // Should continue processing (fail open) + expect(response.status).toBe(200) + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('continues when user is not a subscriber (null result)', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => null) + const mockGetUserPreferences: GetUserPreferencesFn = mock(async () => ({ + fallbackToALaCarte: false, + })) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + getUserPreferences: mockGetUserPreferences, + }) + + expect(response.status).toBe(200) + // getUserPreferences should not be called for non-subscribers + expect(mockGetUserPreferences).not.toHaveBeenCalled() + }) + + it('defaults to allowing fallback when getUserPreferences is not provided', async () => { + const weeklyLimitError: BlockGrantResult = { + error: 'weekly_limit_reached', + used: 3500, + limit: 3500, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), + } + const mockEnsureSubscriberBlockGrant = mock(async () => weeklyLimitError) + + const response = await postChatCompletions({ + req: createValidRequest(), + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + // Note: getUserPreferences is NOT provided + }) + + // Should continue processing (default to allowing a-la-carte) + expect(response.status).toBe(200) + }) + + it('does not call ensureSubscriberBlockGrant before validation passes', async () => { + const mockEnsureSubscriberBlockGrant = mock(async () => null) + + // Request with invalid run_id + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: { Authorization: 'Bearer test-api-key-123' }, + body: JSON.stringify({ + model: 'test/test-model', + stream: false, + codebuff_metadata: { + run_id: 'run-nonexistent', + }, + }), + }, + ) + + const response = await postChatCompletions({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, + }) + + // Should return 400 for invalid run_id + expect(response.status).toBe(400) + // ensureSubscriberBlockGrant should NOT have been called + expect(mockEnsureSubscriberBlockGrant).not.toHaveBeenCalled() + }) + }) }) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index ac8dde87f..62c3a7eb3 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -17,6 +17,19 @@ import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' + +import type { + BlockGrantResult, +} from '@codebuff/billing/subscription' +import { + isWeeklyLimitError, + isBlockExhaustedError, +} from '@codebuff/billing/subscription' + +export type GetUserPreferencesFn = (params: { + userId: string + logger: Logger +}) => Promise<{ fallbackToALaCarte: boolean }> import type { NextRequest } from 'next/server' import type { ChatCompletionRequestBody } from '@/llm-api/types' @@ -78,6 +91,8 @@ export async function postChatCompletions(params: { getAgentRunFromId: GetAgentRunFromIdFn fetch: typeof globalThis.fetch insertMessageBigquery: InsertMessageBigqueryFn + ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise + getUserPreferences?: GetUserPreferencesFn }) { const { req, @@ -88,6 +103,8 @@ export async function postChatCompletions(params: { getAgentRunFromId, fetch, insertMessageBigquery, + ensureSubscriberBlockGrant, + getUserPreferences, } = params let { logger } = params @@ -264,6 +281,59 @@ export async function postChatCompletions(params: { ) } + // For subscribers, ensure a block grant exists before processing the request. + // This is done AFTER validation so malformed requests don't start a new 5-hour block. + if (ensureSubscriberBlockGrant) { + try { + const blockGrantResult = await ensureSubscriberBlockGrant({ userId, logger }) + + // Check if user hit subscription limit and should be rate-limited + if (blockGrantResult && (isWeeklyLimitError(blockGrantResult) || isBlockExhaustedError(blockGrantResult))) { + // Fetch user's preference for falling back to a-la-carte credits + const preferences = getUserPreferences + ? await getUserPreferences({ userId, logger }) + : { fallbackToALaCarte: true } // Default to allowing a-la-carte if no preference function + + if (!preferences.fallbackToALaCarte) { + const resetTime = blockGrantResult.resetsAt + const resetCountdown = formatQuotaResetCountdown(resetTime.toISOString()) + const limitType = isWeeklyLimitError(blockGrantResult) ? 'weekly' : '5-hour session' + + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_INSUFFICIENT_CREDITS, + userId, + properties: { + reason: 'subscription_limit_no_fallback', + limitType, + fallbackToALaCarte: false, + }, + logger, + }) + + return NextResponse.json( + { + error: 'rate_limit_exceeded', + message: `Subscription ${limitType} limit reached. Your limit resets ${resetCountdown}. Enable "Continue with credits" in the CLI to use a-la-carte credits.`, + }, + { status: 429 }, + ) + } + // If fallbackToALaCarte is true, continue to use a-la-carte credits + logger.info( + { userId, limitType: isWeeklyLimitError(blockGrantResult) ? 'weekly' : 'session' }, + 'Subscriber hit limit, falling back to a-la-carte credits', + ) + } + } catch (error) { + logger.error( + { error: getErrorObject(error), userId }, + 'Error ensuring subscription block grant', + ) + // Fail open: if we can't check the subscription status, allow the request to proceed + // This is intentional - we prefer to allow requests rather than block legitimate users + } + } + const openrouterApiKey = req.headers.get(BYOK_OPENROUTER_HEADER) // Handle streaming vs non-streaming diff --git a/web/src/app/api/v1/chat/completions/route.ts b/web/src/app/api/v1/chat/completions/route.ts index 7b49e8232..a6a4ace37 100644 --- a/web/src/app/api/v1/chat/completions/route.ts +++ b/web/src/app/api/v1/chat/completions/route.ts @@ -1,15 +1,30 @@ import { insertMessageBigquery } from '@codebuff/bigquery' +import { ensureSubscriberBlockGrant } from '@codebuff/billing/subscription' import { getUserUsageData } from '@codebuff/billing/usage-service' import { trackEvent } from '@codebuff/common/analytics' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' import { postChatCompletions } from './_post' +import type { GetUserPreferencesFn } from './_post' import type { NextRequest } from 'next/server' import { getAgentRunFromId } from '@/db/agent-run' import { getUserInfoFromApiKey } from '@/db/user' import { logger, loggerWithContext } from '@/util/logger' +const getUserPreferences: GetUserPreferencesFn = async ({ userId }) => { + const userPrefs = await db.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { fallback_to_a_la_carte: true }, + }) + return { + fallbackToALaCarte: userPrefs?.fallback_to_a_la_carte ?? false, + } +} + export async function POST(req: NextRequest) { return postChatCompletions({ req, @@ -21,5 +36,7 @@ export async function POST(req: NextRequest) { getAgentRunFromId, fetch, insertMessageBigquery, + ensureSubscriberBlockGrant, + getUserPreferences, }) } diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index e628ad150..67d17fe6b 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -1,12 +1,365 @@ '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 { useQuery, useMutation } from '@tanstack/react-query' import { BlockColor } from '@/components/ui/decorative-blocks' +import { Section } from '@/components/ui/section' 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' + +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + +const USAGE_MULTIPLIER: Record = { + 100: '1×', + 200: '3×', + 500: '8×', +} + +type ButtonAction = 'subscribe' | 'current' | 'upgrade' | 'downgrade' + +function getButtonAction(tierPrice: number, currentTier: number | null): ButtonAction { + if (currentTier === null) return 'subscribe' + if (tierPrice === currentTier) return 'current' + if (tierPrice > currentTier) return 'upgrade' + return 'downgrade' +} + +function getButtonLabel(action: ButtonAction): string { + switch (action) { + case 'current': + return 'Current Plan' + case 'upgrade': + return 'Upgrade' + case 'downgrade': + return 'Downgrade' + default: + return 'Subscribe' + } +} + +function SubscribeButton({ + className, + tier, + currentTier, + subscriptionId, + isHighlighted, +}: { + className?: string + tier: number + currentTier: number | null + subscriptionId: string | null + isHighlighted: boolean +}) { + const { status } = useSession() + const router = useRouter() + const [isLoading, setIsLoading] = useState(false) + + const action = getButtonAction(tier, currentTier) + const isCurrent = action === 'current' + + // Mutation to open billing portal for upgrades/downgrades + const billingPortalMutation = useMutation({ + mutationFn: async () => { + const res = await fetch('/api/user/billing-portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subscriptionId }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || 'Failed to open billing portal') + } + return res.json() + }, + onSuccess: (data: { url: string }) => { + window.location.href = data.url + }, + onError: (err: Error) => { + toast({ + title: 'Error', + description: err.message, + variant: 'destructive', + }) + }, + }) + + const handleClick = async () => { + if (status !== 'authenticated') { + router.push('/login?callbackUrl=/pricing') + return + } + + if (isCurrent) return + + // If user has a subscription, redirect to billing portal for confirmation + if (currentTier !== null && subscriptionId) { + billingPortalMutation.mutate() + return + } + + // Otherwise, create new subscription + 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) + } + } + + const isLoadingState = isLoading || billingPortalMutation.isPending + + return ( + + ) +} + +function PricingCardsGrid() { + const { status } = useSession() + + const { data: subscriptionData } = 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', + staleTime: 30_000, + }) + + const currentTier = subscriptionData?.hasSubscription + ? subscriptionData.subscription.tier + : null + + const subscriptionId = subscriptionData?.hasSubscription + ? subscriptionData.subscription.id + : null + + return ( + +
+ {Object.entries(SUBSCRIPTION_TIERS).map(([key, tier]) => { + const price = Number(key) as SubscriptionTierPrice + const isCurrentPlan = currentTier === price + const isHighlighted = currentTier === null ? price === 200 : isCurrentPlan + + return ( +
+ {isCurrentPlan && ( +
+ + Your Plan + +
+ )} +
+ + ${tier.monthlyPrice} + + + /mo + +
+ +

+ {USAGE_MULTIPLIER[price]} usage +

+ + +
+ ) + })} +
+
+ ) +} + +function StrongHeroSection() { + return ( +
+ {/* Subtle radial glow behind content */} +
+ + {/* Animated gradient blobs */} + + + {/* Giant background text */} + + + {/* Foreground content */} +
+
+ + Access the strongest coding agent + + + + Subscribe for higher usage limits + + + {/* Pricing cards grid with decorative blocks */} + + + + Cancel anytime · Applicable taxes not shown · Usage subject to change + +
+
+
+ ) +} function CreditVisual() { return ( @@ -46,7 +399,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,72 +415,83 @@ function PricingCard() { ) } -function ClaudeSubscriptionIllustration() { +function TeamPlanIllustration() { return ( -
-
- {/* Connection visual */} -
- {/* Claude card */} -
-
Claude
-
Pro / Max
+
+ {/* Team plan */} +
+
+

Team

+
+ + $19 + + + /user/month +
+
- {/* Connection arrow */} -
-
- -
-
+
    +
  • + + + Team management dashboard + +
  • +
  • + + Pooled credit usage +
  • +
  • + + + Pay-as-you-go at 1¢ per credit + +
  • +
- {/* Codebuff card */} -
-
Codebuff
-
CLI
-
+ +
- {/* Benefits grid */} -
-
-
- -
-
-
- Save on credits -
-
- Use your subscription for Claude model requests -
-
-
- -
-
- -
-
-
- Simple CLI setup -
-
- Connect with one command -
-
+ {/* Enterprise plan */} +
+
+

Enterprise

+
+ Custom Pricing
- {/* Code snippet */} -
-
$ codebuff
-
- {'>'} /connect:claude -
-
- ✓ Connected to Claude subscription -
+
    +
  • + + Everything in Team +
  • +
  • + + Dedicated support +
  • +
  • + + Custom integrations +
  • +
+ +
@@ -139,8 +503,13 @@ export default function PricingClient() { return ( <> + + + {/* Visual divider between hero and feature section */} +
+ 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]} @@ -151,21 +520,6 @@ export default function PricingClient() { learnMoreText={status === 'authenticated' ? 'My Usage' : 'Get Started'} 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" - /> - ) } 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..e748439c9 --- /dev/null +++ b/web/src/app/profile/components/subscription-section.tsx @@ -0,0 +1,233 @@ +'use client' + +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + AlertTriangle, + 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 { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { toast } from '@/components/ui/use-toast' +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' }) + +const clampPercent = (n: number): number => Math.min(100, Math.max(0, Math.round(n))) + +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 }: { data: ActiveSubscriptionResponse }) { + const { subscription, rateLimit, fallbackToALaCarte } = data + const isCanceling = subscription.cancelAtPeriodEnd + const queryClient = useQueryClient() + + const updatePreferenceMutation = useMutation({ + mutationFn: async (newValue: boolean) => { + const res = await fetch('/api/user/preferences', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fallbackToALaCarte: newValue }), + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to update preference' })) + throw new Error(error.error || 'Failed to update preference') + } + return newValue + }, + onSuccess: (newValue) => { + queryClient.setQueryData(['subscription'], (old: SubscriptionResponse | undefined) => + old ? { ...old, fallbackToALaCarte: newValue } : old + ) + }, + onError: (err: Error) => { + toast({ + title: 'Error', + description: err.message, + variant: 'destructive', + }) + }, + onSettled: () => { + // Refetch to ensure consistency with server + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + }, + }) + + const blockRemainingPercent = + rateLimit.blockLimit != null && rateLimit.blockUsed != null && rateLimit.blockLimit > 0 + ? clampPercent(100 - (rateLimit.blockUsed / rateLimit.blockLimit) * 100) + : 100 + const weeklyRemainingPercent = clampPercent(100 - rateLimit.weeklyPercentUsed) + + return ( + + + + 💪 + {SUBSCRIPTION_DISPLAY_NAME} + + ${subscription.tier}/mo + + {isCanceling && ( + + Canceling + + )} + {subscription.scheduledTier != null && ( + + Renewing at ${subscription.scheduledTier}/mo + + )} + + + + {rateLimit.limited && ( +
+ +

+ {rateLimit.reason === 'weekly_limit' + ? `Weekly limit reached. Resets in ${formatDaysHours(rateLimit.weeklyResetsAt)}. ${fallbackToALaCarte ? 'Automatically using your credits.' : 'Your credits will not be used.'}` + : `Session exhausted. New session in ${rateLimit.blockResetsAt ? formatDaysHours(rateLimit.blockResetsAt) : 'soon'}. ${fallbackToALaCarte ? 'Automatically using your credits.' : 'Your credits will not be used.'}`} +

+
+ )} + +
+
+ 5-hour limit + +
+ {blockRemainingPercent}% remaining + {rateLimit.blockResetsAt && ( + <> + · + Resets in {formatDaysHours(rateLimit.blockResetsAt)} + + )} +
+
+ +
+ Weekly limit + +
+ {weeklyRemainingPercent}% remaining + · + Resets in {formatDaysHours(rateLimit.weeklyResetsAt)} +
+
+
+ +
+ updatePreferenceMutation.mutate(checked)} + disabled={updatePreferenceMutation.isPending} + /> + +
+
+
+ ) +} + +function SubscriptionCta() { + return ( + + +
+
+ 💪 +
+
+

+ Upgrade to {SUBSCRIPTION_DISPLAY_NAME} +

+

+ From $100/mo · Subscribe to save on credits +

+
+
+ +
+
+ ) +} + +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 + } + + return +} diff --git a/web/src/app/profile/components/usage-display.tsx b/web/src/app/profile/components/usage-display.tsx index 548eaddbd..6358982db 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', @@ -94,14 +101,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 { @@ -296,7 +295,7 @@ export const UsageDisplay = ({ ) return ( - + Credit Balance @@ -396,7 +395,7 @@ export const UsageDisplay = ({ } export const UsageDisplaySkeleton = () => ( - +
diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index eaa8beab8..01edf4383 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -3,19 +3,21 @@ import { env } from '@codebuff/common/env' import { loadStripe } from '@stripe/stripe-js' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { ExternalLink, Loader2 } from 'lucide-react' 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' +import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { CreditConfetti } from '@/components/ui/credit-confetti' import { toast } from '@/components/ui/use-toast' const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { const { data: session } = useSession() - const email = encodeURIComponent(session?.user?.email || '') const queryClient = useQueryClient() const [showConfetti, setShowConfetti] = useState(false) const [purchasedAmount, setPurchasedAmount] = useState(0) @@ -83,7 +85,6 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { isPurchasePending={buyCreditsMutation.isPending} showAutoTopup={true} isLoading={isLoading} - billingPortalUrl={`${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${email}`} />
@@ -119,14 +120,65 @@ export function UsageSection() { const isUsageOrProfileLoading = isLoadingUsage || (status === 'authenticated' && !usageData) + const email = session?.user?.email || '' + const fallbackPortalUrl = email + ? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` + : env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL + + const billingPortalMutation = useMutation({ + mutationFn: async () => { + const res = await fetch('/api/user/billing-portal', { + method: 'POST', + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) + throw new Error(error.error || 'Failed to open billing portal') + } + const data = await res.json() + return data.url as string + }, + onSuccess: (url) => { + window.open(url, '_blank', 'noopener,noreferrer') + }, + onError: () => { + // Fall back to the prefilled email portal URL on error + window.open(fallbackPortalUrl, '_blank', 'noopener,noreferrer') + toast({ + title: 'Note', + description: 'Opened billing portal - you may need to sign in.', + }) + }, + }) + return (
- {' '} -
+

Track your credit usage and purchase additional credits as needed.

+ {status === 'authenticated' && ( + + )}
+ {status === 'authenticated' && } {isUsageError && ( diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index 72a8ff322..16cc3ae38 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -1,7 +1,7 @@ 'use client' import { CreditCard, Shield, Users, Key, Menu } from 'lucide-react' -import { useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { useSession } from 'next-auth/react' import { useState, useEffect, Suspense } from 'react' @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' +import { toast } from '@/components/ui/use-toast' const sections = [ { @@ -82,6 +83,7 @@ function ProfileSidebar({ function ProfilePageContent() { const { status } = useSession() + const router = useRouter() const searchParams = useSearchParams() ?? new URLSearchParams() const [activeSection, setActiveSection] = useState('usage') const [open, setOpen] = useState(false) @@ -93,6 +95,19 @@ function ProfilePageContent() { } }, [searchParams]) + // Check for subscription success + useEffect(() => { + if (searchParams.get('subscription_success') === 'true') { + toast({ + title: 'Welcome to Codebuff Strong! 🎉', + description: + 'Thanks for subscribing! Your subscription is now active.', + }) + // Clean up the URL while preserving the tab + router.replace('/profile?tab=usage', { scroll: false }) + } + }, [searchParams, router]) + const ActiveComponent = sections.find((s) => s.id === activeSection)?.component || UsageSection const activeTitle = diff --git a/web/src/components/credits/CreditManagementSection.tsx b/web/src/components/credits/CreditManagementSection.tsx index 9c3ba003f..98c64cdb3 100644 --- a/web/src/components/credits/CreditManagementSection.tsx +++ b/web/src/components/credits/CreditManagementSection.tsx @@ -13,7 +13,6 @@ export interface CreditManagementSectionProps { organizationId?: string isOrganization?: boolean // Keep for backward compatibility isLoading?: boolean - billingPortalUrl?: string } export { CreditManagementSkeleton } @@ -27,7 +26,6 @@ export function CreditManagementSection({ organizationId, isOrganization = false, isLoading = false, - billingPortalUrl, }: CreditManagementSectionProps) { // Determine if we're in organization context const isOrgContext = context === 'organization' || isOrganization @@ -39,34 +37,18 @@ export function CreditManagementSection({ return (
-
-

Buy Credits

- {billingPortalUrl && ( - - Billing Portal → - - )} -
+

Buy Credits

- {showAutoTopup && ( - <> -
- {isOrgContext && organizationId ? ( - - ) : ( - - )} - - )} + {showAutoTopup && + (isOrgContext && organizationId ? ( + + ) : ( + + ))}
) diff --git a/web/src/components/organization/billing-status.tsx b/web/src/components/organization/billing-status.tsx index 9f338a0fb..78c76fc37 100644 --- a/web/src/components/organization/billing-status.tsx +++ b/web/src/components/organization/billing-status.tsx @@ -1,13 +1,14 @@ 'use client' import { pluralize } from '@codebuff/common/util/string' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation } from '@tanstack/react-query' import { CreditCard, Users, ExternalLink, AlertTriangle, CheckCircle, + Loader2, } from 'lucide-react' @@ -15,6 +16,7 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' +import { toast } from '@/components/ui/use-toast' import { useIsMobile } from '@/hooks/use-mobile' import { cn } from '@/lib/utils' @@ -29,7 +31,6 @@ interface BillingStatus { current_period_end: number cancel_at_period_end: boolean } - billingPortalUrl?: string organization: { id: string name: string @@ -58,6 +59,30 @@ export function BillingStatus({ }: BillingStatusProps) { const isMobile = useIsMobile() + const billingPortalMutation = useMutation({ + mutationFn: async () => { + const res = await fetch(`/api/orgs/${organizationId}/billing/portal`, { + method: 'POST', + }) + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Failed to open billing portal' })) + throw new Error(error.error || 'Failed to open billing portal') + } + const data = await res.json() + return data.url as string + }, + onSuccess: (url) => { + window.open(url, '_blank', 'noopener,noreferrer') + }, + onError: (err: Error) => { + toast({ + title: 'Error', + description: err.message || 'Failed to open billing portal', + variant: 'destructive', + }) + }, + }) + const { data: billingStatus, isLoading, @@ -233,23 +258,26 @@ export function BillingStatus({
{/* Billing Portal Link */} - {billingStatus.billingPortalUrl && ( + {billingStatus.organization && (
)} 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} diff --git a/web/test/setup-globals.ts b/web/test/setup-globals.ts new file mode 100644 index 000000000..72be9fd91 --- /dev/null +++ b/web/test/setup-globals.ts @@ -0,0 +1,29 @@ +/** + * Polyfill web globals for Bun tests that import Next.js server modules. + * + * Next.js's `next/server` module (NextRequest, NextResponse) expects the + * standard web globals (Request, Response, Headers, fetch) to exist. + * Bun provides these in its runtime, but they may not be available at + * module load time during tests. + * + * This preload script ensures these globals are set up before any test + * modules are imported. + */ + +// Bun has built-in support for web APIs, but we need to ensure they're +// available on globalThis for Next.js server modules +if (typeof globalThis.Request === 'undefined') { + globalThis.Request = Request +} + +if (typeof globalThis.Response === 'undefined') { + globalThis.Response = Response +} + +if (typeof globalThis.Headers === 'undefined') { + globalThis.Headers = Headers +} + +if (typeof globalThis.fetch === 'undefined') { + globalThis.fetch = fetch +}