diff --git a/.env.example b/.env.example index 467269d..1818c99 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,4 @@ STRIPE_WEBHOOK_SECRET= VERCEL_ORG_ID= VERCEL_PROJECT_ID= VERCEL_TOKEN= +YAHOO_FINANCE_API_URL= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d916c53..bf17bbc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -180,6 +180,16 @@ jobs: vercel env add APPWRITE_COLLECTION_ID_AI_KEYS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_AI_KEYS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }} vercel env add APPWRITE_COLLECTION_ID_TRIAL_SESSIONS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_TRIAL_SESSIONS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }} vercel env add APPWRITE_COLLECTION_ID_SUBSCRIPTIONS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_SUBSCRIPTIONS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }} + if [ -n "${{ vars.FINNHUB_BASE_URL }}" ]; then + vercel env add FINNHUB_BASE_URL $ENV_TYPE "" --value "${{ vars.FINNHUB_BASE_URL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }} + fi + vercel env add FINNHUB_API_KEY $ENV_TYPE "" --value "${{ secrets.FINNHUB_API_KEY }}" --yes --force --sensitive --token=${{ secrets.VERCEL_TOKEN }} + if [ -n "${{ vars.OLLAMA_MODEL }}" ]; then + vercel env add OLLAMA_MODEL $ENV_TYPE "" --value "${{ vars.OLLAMA_MODEL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }} + fi + if [ -n "${{ vars.YAHOO_FINANCE_API_URL }}" ]; then + vercel env add YAHOO_FINANCE_API_URL $ENV_TYPE "" --value "${{ vars.YAHOO_FINANCE_API_URL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }} + fi if [ -n "${{ vars.STRIPE_PRICE_ADS_FREE }}" ]; then vercel env add STRIPE_PRICE_ADS_FREE $ENV_TYPE "" --value "${{ vars.STRIPE_PRICE_ADS_FREE }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }} fi @@ -216,6 +226,10 @@ jobs: APPWRITE_COLLECTION_ID_AI_KEYS: ${{ secrets.APPWRITE_COLLECTION_ID_AI_KEYS }} APPWRITE_COLLECTION_ID_TRIAL_SESSIONS: ${{ secrets.APPWRITE_COLLECTION_ID_TRIAL_SESSIONS }} APPWRITE_COLLECTION_ID_SUBSCRIPTIONS: ${{ secrets.APPWRITE_COLLECTION_ID_SUBSCRIPTIONS }} + FINNHUB_BASE_URL: ${{ vars.FINNHUB_BASE_URL }} + FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }} + OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }} + YAHOO_FINANCE_API_URL: ${{ vars.YAHOO_FINANCE_API_URL }} STRIPE_PRICE_ADS_FREE: ${{ vars.STRIPE_PRICE_ADS_FREE }} STRIPE_PRICE_LOCAL: ${{ vars.STRIPE_PRICE_LOCAL }} STRIPE_PRICE_BYOK: ${{ vars.STRIPE_PRICE_BYOK }} diff --git a/app/(main)/home-page-client.tsx b/app/(main)/home-page-client.tsx index 17ffa51..142dd29 100644 --- a/app/(main)/home-page-client.tsx +++ b/app/(main)/home-page-client.tsx @@ -258,7 +258,10 @@ export function HomePageClient() { `/api/market/symbol/${selectedSymbol}` ); if (!symbolResponse.ok) { - throw new Error("Failed to fetch symbol data"); + const body = (await symbolResponse.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(body.error ?? "Failed to fetch symbol data"); } const symbolResult = await symbolResponse.json(); setSymbolData(symbolResult.data); @@ -531,6 +534,7 @@ export function HomePageClient() { loading={aiPredictionLoading} locked={!hasAIAccess} error={aiPredictionError} + pricingTier={effectiveTier} /> > )} @@ -569,6 +573,7 @@ export function HomePageClient() { loading={stockOfTheDayLoading} locked={!hasAIAccess} error={stockOfTheDayError} + pricingTier={effectiveTier} /> diff --git a/app/(main)/stock-of-the-day/page.tsx b/app/(main)/stock-of-the-day/page.tsx index 4c111bf..7da8ffa 100644 --- a/app/(main)/stock-of-the-day/page.tsx +++ b/app/(main)/stock-of-the-day/page.tsx @@ -102,6 +102,7 @@ export default function StockOfTheDayPage() { loading={loading} locked={!hasAIAccess} error={loadError} + pricingTier={pricingTier} /> ); diff --git a/app/api/market/ai-prediction/[symbol]/route.ts b/app/api/market/ai-prediction/[symbol]/route.ts index 674c5e6..b0f359e 100644 --- a/app/api/market/ai-prediction/[symbol]/route.ts +++ b/app/api/market/ai-prediction/[symbol]/route.ts @@ -3,13 +3,7 @@ import { aiMarketInsightsService } from "@/services/ai-market-insights.service"; import { logger } from "@/lib/logger"; import { getAuthenticatedUser } from "@/lib/server-auth"; import { subscriptionService } from "@/services/subscription.service"; -import { appwriteAIKeyStoreService } from "@/services/appwrite-ai-key-store.service"; -import type { AIProvider } from "@/types"; -import type { BYOKProvider } from "@/services/api-key-manager.service"; - -function isBYOKProvider(value: string): value is BYOKProvider { - return ["OPENAI", "GEMINI", "MISTRAL", "DEEPSEEK"].includes(value); -} +import { resolveMarketRouteLLMConfig } from "@/lib/resolve-market-ai-llm-config"; export async function GET( request: NextRequest, @@ -43,54 +37,22 @@ export async function GET( const requestedProviderRaw = request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? ""; - const model = process.env.AI_MODEL; - - let llmConfig: - | { - provider: AIProvider; - apiKey?: string; - model?: string; - } - | undefined; - if (tier === "LOCAL") { - llmConfig = { - provider: "OLLAMA", - model: process.env.OLLAMA_MODEL ?? model, - }; - } else if (tier === "BYOK") { - const requestedProvider = isBYOKProvider(requestedProviderRaw) - ? requestedProviderRaw - : null; - const provider = - requestedProvider ?? - (await appwriteAIKeyStoreService.getPreferredProvider(auth.id)); - if (!provider) { - return NextResponse.json( - { - success: false, - error: - "No BYOK API key found. Save a provider key in profile first.", - }, - { status: 403 } - ); - } - const apiKey = await appwriteAIKeyStoreService.getDecryptedKey( - auth.id, - provider - ); - if (!apiKey) { - return NextResponse.json( - { - success: false, - error: `No API key stored for provider ${provider}. Change provider in profile or save a key for ${provider}.`, - }, - { status: 403 } - ); - } - llmConfig = { provider, apiKey, model }; + const resolved = await resolveMarketRouteLLMConfig({ + tier, + userId: auth.id, + requestedProviderRaw, + }); + if (!resolved.ok) { + logger.warn("AI prediction: no LLM credentials; using heuristic only", { + userId: auth.id, + tier, + symbol, + detail: resolved.error, + }); } + const llmConfig = resolved.ok ? resolved.llmConfig : undefined; const data = await aiMarketInsightsService.generatePrediction( symbol, llmConfig diff --git a/app/api/market/stock-of-the-day/route.ts b/app/api/market/stock-of-the-day/route.ts index db3e400..4fe2ba9 100644 --- a/app/api/market/stock-of-the-day/route.ts +++ b/app/api/market/stock-of-the-day/route.ts @@ -3,13 +3,7 @@ import { aiMarketInsightsService } from "@/services/ai-market-insights.service"; import { logger } from "@/lib/logger"; import { getAuthenticatedUser } from "@/lib/server-auth"; import { subscriptionService } from "@/services/subscription.service"; -import { appwriteAIKeyStoreService } from "@/services/appwrite-ai-key-store.service"; -import type { AIProvider } from "@/types"; -import type { BYOKProvider } from "@/services/api-key-manager.service"; - -function isBYOKProvider(value: string): value is BYOKProvider { - return ["OPENAI", "GEMINI", "MISTRAL", "DEEPSEEK"].includes(value); -} +import { resolveMarketRouteLLMConfig } from "@/lib/resolve-market-ai-llm-config"; export async function GET(request: NextRequest) { try { @@ -31,54 +25,24 @@ export async function GET(request: NextRequest) { const requestedProviderRaw = request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? ""; - const model = process.env.AI_MODEL; - let llmConfig: - | { - provider: AIProvider; - apiKey?: string; - model?: string; + const resolved = await resolveMarketRouteLLMConfig({ + tier, + userId: auth.id, + requestedProviderRaw, + }); + if (!resolved.ok) { + logger.warn( + "Stock of the day: no LLM credentials; using heuristic only", + { + userId: auth.id, + tier, + detail: resolved.error, } - | undefined; - - if (tier === "LOCAL") { - llmConfig = { - provider: "OLLAMA", - model: process.env.OLLAMA_MODEL ?? model, - }; - } else if (tier === "BYOK") { - const requestedProvider = isBYOKProvider(requestedProviderRaw) - ? requestedProviderRaw - : null; - const provider = - requestedProvider ?? - (await appwriteAIKeyStoreService.getPreferredProvider(auth.id)); - if (!provider) { - return NextResponse.json( - { - success: false, - error: - "No BYOK API key found. Save a provider key in profile first.", - }, - { status: 403 } - ); - } - const apiKey = await appwriteAIKeyStoreService.getDecryptedKey( - auth.id, - provider ); - if (!apiKey) { - return NextResponse.json( - { - success: false, - error: `No API key stored for provider ${provider}. Change provider in profile or save a key for ${provider}.`, - }, - { status: 403 } - ); - } - llmConfig = { provider, apiKey, model }; } + const llmConfig = resolved.ok ? resolved.llmConfig : undefined; const data = await aiMarketInsightsService.getStockOfTheDay(llmConfig); return NextResponse.json({ diff --git a/app/layout.tsx b/app/layout.tsx index a8eb17d..207447e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import { Ibarra_Real_Nova, Merriweather } from "next/font/google"; -import Script from "next/script"; import "./globals.css"; import { Providers } from "./providers"; @@ -37,13 +36,16 @@ export default function RootLayout({
{gtmId ? ( - +})(window,document,'script','dataLayer','${gtmId}');`, + }} + /> ) : null}- AI prediction is available only for AI subscriptions. + {getAiSubscriptionGateMessage(pricingTier ?? undefined)}
@@ -102,8 +106,7 @@ export function StockOfTheDayPanel({ {locked && (- AI section locked. Enable any AI subscription to reveal - today's pick. + {getAiSubscriptionGateMessage(pricingTier ?? undefined)}
{ + const allowedForTier = PROVIDER_OPTIONS.filter((p) => + p.allowedTiers.includes(tier) + ); + if (allowedForTier.length === 0) { + if (typeof window !== "undefined") { + localStorage.removeItem("explanations_provider"); + } + setSelectedExplanationProvider((prev) => + prev === "OPENAI" ? prev : "OPENAI" + ); + return; + } + const currentOk = allowedForTier.some( + (p) => p.id === selectedExplanationProvider + ); + if (currentOk) return; + const next = allowedForTier[0].id; + setSelectedExplanationProvider(next); + if (typeof window !== "undefined") { + localStorage.setItem("explanations_provider", next); + } + }, [tier, selectedExplanationProvider]); + useEffect(() => { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); @@ -331,6 +355,13 @@ export function UserProfileMenu() {+ AI explanations (Ollama, cloud keys, or Ditectrev AI) unlock + after you subscribe to an AI plan — Ads-free and Free tiers do + not include server-side AI. +
+ )}