From 9b3581e8a61ce3b2fbe2681c61cb5418b54f17c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Jul 2026 11:10:27 +0000 Subject: [PATCH] Fix critical auth billing and data-loss regressions Co-authored-by: Cole Collins --- app/api/analyses/[id]/analyze/route.ts | 59 ++++-- app/api/analyses/[id]/run/route.ts | 60 ++++-- app/api/app-idea-chat/route.ts | 38 ++-- app/api/build-app/route.ts | 104 +++++---- app/api/code-completion/route.ts | 29 ++- app/api/generate-scaffold/route.ts | 57 +++-- app/api/pattern-analyzer/route.ts | 26 ++- .../[id]/milestones/[milestoneId]/route.ts | 9 +- app/api/stripe/webhook/route.ts | 4 + lib/credits.ts | 200 +++++++++--------- lib/queries.ts | 96 ++++++++- migrations/003_user_credits_system.sql | 4 + .../009_credit_transaction_idempotency.sql | 6 + scripts/critical-regression-check.mjs | 115 ++++++++++ 14 files changed, 561 insertions(+), 246 deletions(-) create mode 100644 migrations/009_credit_transaction_idempotency.sql create mode 100644 scripts/critical-regression-check.mjs diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..b921d24 100644 --- a/app/api/analyses/[id]/analyze/route.ts +++ b/app/api/analyses/[id]/analyze/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { generateText } from 'ai' -import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' +import { getCreditBalance, deductCredits, refundCredits, CREDITS } from '@/lib/credits' +import { getCurrentUser } from '@/lib/auth' const model = 'openai/gpt-4-turbo' @@ -20,19 +21,26 @@ interface AppSuggestion { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let analysisIdForRefund: string | null = null + try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { analysisId, selectedRepos } = (await request.json()) as { analysisId: string selectedRepos: SelectedRepository[] - userId: string } + analysisIdForRefund = analysisId - // Check credit balance before proceeding - if (!userId) { - return NextResponse.json({ error: 'User ID required' }, { status: 401 }) + if (!Array.isArray(selectedRepos) || selectedRepos.length === 0) { + return NextResponse.json({ error: 'selectedRepos is required' }, { status: 400 }) } - const currentBalance = await getCreditBalance(userId) + const currentBalance = await getCreditBalance(user.id) if (currentBalance < CREDITS.ANALYSIS_COST) { return NextResponse.json( { @@ -45,6 +53,24 @@ export async function POST(request: NextRequest) { ) } + const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', { + analysisId, + selectedRepos: selectedRepos.map((r) => r.name), + }) + + if (!deductResult.success) { + return NextResponse.json( + { + error: 'Insufficient credits', + required: CREDITS.ANALYSIS_COST, + available: await getCreditBalance(user.id), + message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.', + }, + { status: 402 }, + ) + } + chargedUserId = user.id + // Get all repo files from database const filesByRepo: Record = {} @@ -94,20 +120,6 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati console.error('Failed to parse AI response:', e) } - // Deduct credits for successful analysis - const deductResult = await deductCredits(userId, CREDITS.ANALYSIS_COST, 'analysis', { - analysisId, - selectedRepos: selectedRepos.map((r) => r.name), - }) - - if (!deductResult.success) { - console.error('Failed to deduct credits:', deductResult.error) - return NextResponse.json( - { error: 'Failed to process analysis' }, - { status: 500 } - ) - } - const newBalance = deductResult.transaction?.balance_after || 0 return NextResponse.json({ @@ -120,6 +132,11 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati }) } catch (error) { console.error('Analysis error:', error) + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.ANALYSIS_COST, 'Analysis failed', { + analysisId: analysisIdForRefund, + }).catch((refundError) => console.error('Failed to refund analysis credits:', refundError)) + } return NextResponse.json({ error: 'Failed to analyze repositories' }, { status: 500 }) } } diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index 11a4675..6ea1060 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -13,11 +13,13 @@ import { updateAnalysisStatus, createRepoFile, createBlueprint, - deleteBlueprintsByAnalysis, + deleteBlueprintsByAnalysisExcept, + deleteBlueprintsByIds, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, - incrementAnalysisUsage, + reserveAnalysisUsage, + releaseAnalysisUsage, } from '@/lib/queries' import { getAnthropicModel } from '@/lib/anthropic-model' import { isOnFreeTier } from '@/lib/pro-access' @@ -144,6 +146,7 @@ export async function POST( const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + let reservedUsageGithubId: number | null = null try { const accessToken = await getCurrentAccessToken() @@ -159,7 +162,7 @@ export async function POST( } const user = await getCurrentUser() - if (!user) { + if (!user?.id) { send({ error: 'Sign in with GitHub before running an analysis.' }) controller.close() return @@ -200,7 +203,6 @@ export async function POST( // Update status to scanning await updateAnalysisStatus(id, 'scanning') - await deleteBlueprintsByAnalysis(id) send({ status: 'scanning', progress: 10 }) // Fetch file trees from GitHub for each repository @@ -269,6 +271,19 @@ export async function POST( send({ status: 'scanning', progress: 40 }) + if (isOnFreeTier(user, sub) && sub) { + const limit = PLANS.free.analyses_per_month + const reserved = await reserveAnalysisUsage(user.github_id, limit) + if (!reserved) { + const msg = `You've reached your free plan limit of ${limit} analyses per month. Upgrade to Pro for unlimited analyses.` + send({ error: msg, status: 'failed' }) + await updateAnalysisStatus(id, 'failed', { error_message: msg }) + controller.close() + return + } + reservedUsageGithubId = user.github_id + } + // Update to analyzing await updateAnalysisStatus(id, 'analyzing', { total_files: allFiles.length }) send({ status: 'analyzing', progress: 50 }) @@ -420,18 +435,23 @@ For each app blueprint: console.error('[analysis] No valid blueprints.', { stop_reason: aiResponse.stop_reason, rawInput: JSON.stringify(rawInput).slice(0, 500) }) send({ status: 'failed', error: msg }) await updateAnalysisStatus(id, 'failed', { error_message: msg }) + if (reservedUsageGithubId !== null) { + await releaseAnalysisUsage(reservedUsageGithubId) + reservedUsageGithubId = null + } controller.close() return } - // Save blueprints to database - { - const rankedBlueprints = blueprintsFromAI - .map((bp) => normalizeBlueprint(bp)) - .sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a)) + // Insert replacements before deleting old blueprints so failures never wipe prior results. + const rankedBlueprints = blueprintsFromAI + .map((bp) => normalizeBlueprint(bp)) + .sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a)) + const createdBlueprintIds: string[] = [] + try { for (const bp of rankedBlueprints) { - await createBlueprint({ + const created = await createBlueprint({ analysis_id: id, user_id: user.id, name: bp.name.slice(0, 255), @@ -445,15 +465,19 @@ For each app blueprint: technologies: bp.technologies, ai_explanation: bp.explanation, }) + createdBlueprintIds.push(created.id) } + } catch (insertError) { + await deleteBlueprintsByIds(id, user.id, createdBlueprintIds).catch((cleanupError) => + console.error('[analysis] Failed to clean up partial replacement blueprints:', cleanupError), + ) + throw insertError } + await deleteBlueprintsByAnalysisExcept(id, user.id, createdBlueprintIds) // Update to complete await updateAnalysisStatus(id, 'complete', { analyzed_files: allFiles.length }) - - await incrementAnalysisUsage(user.github_id).catch((e) => - console.error('[analysis] Failed to increment usage:', e) - ) + reservedUsageGithubId = null // Get final blueprints and hydrate recurring-value surfaces from them. const finalBlueprints = await getBlueprintsByAnalysis(id, user.id) @@ -468,6 +492,14 @@ For each app blueprint: } catch (error) { console.error('Analysis error:', error) const errorDetail = error instanceof Error ? error.message : 'Unknown error' + try { + const githubId = (typeof reservedUsageGithubId === 'number') ? reservedUsageGithubId : null + if (githubId !== null) { + await releaseAnalysisUsage(githubId) + } + } catch (usageErr) { + console.error('Failed to release analysis usage after error:', usageErr) + } try { await updateAnalysisStatus(id, 'failed', { error_message: errorDetail }) } catch (dbErr) { diff --git a/app/api/app-idea-chat/route.ts b/app/api/app-idea-chat/route.ts index 64b439e..3ec269e 100644 --- a/app/api/app-idea-chat/route.ts +++ b/app/api/app-idea-chat/route.ts @@ -6,7 +6,7 @@ import { getFilesByRepository, } from '@/lib/queries' import { getCurrentUser } from '@/lib/auth' -import { deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, refundCredits, CREDITS } from '@/lib/credits' import type { AppIdeaChatResponse, ChatMessage } from '@/lib/app-idea-chat-types' import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway' @@ -59,13 +59,16 @@ function parseAppIdeaChatResponse(raw: string): AppIdeaChatResponse { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let analysisIdForRefund: string | undefined + try { if (!isAiConfigured()) { return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 }) } const user = await getCurrentUser() - if (!user) { + if (!user?.id) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) } @@ -75,6 +78,7 @@ export async function POST(request: NextRequest) { history?: ChatMessage[] } const analysisId = normalizeAnalysisId(rawAnalysisId) + analysisIdForRefund = analysisId if (!message?.trim()) { return NextResponse.json({ error: 'Message is required' }, { status: 400 }) @@ -87,16 +91,6 @@ export async function POST(request: NextRequest) { ) } - const creditResult = await deductCredits( - user.id, - CREDITS.PATTERN_ANALYZER_COST, - 'pattern_analyzer', - { analysisId }, - ) - if (!creditResult.success) { - return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) - } - let codebaseContext = '' if (analysisId) { try { @@ -145,6 +139,17 @@ ${reusableFiles.length > 0 ? reusableFiles.map((file) => `- ${file}`).join('\n') } } + const creditResult = await deductCredits( + user.id, + CREDITS.PATTERN_ANALYZER_COST, + 'pattern_analyzer', + { analysisId }, + ) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) + } + chargedUserId = user.id + const conversationHistory = normalizeConversationHistory(history).slice(-6) const systemPrompt = `You are RepoFuse's VibeCoding app assembler. Help developers describe what they want to build, then turn their connected GitHub/GitLab repository knowledge into buildable app plans that reuse as much existing code, file structure, and patterns as possible. @@ -198,11 +203,11 @@ Always respond with valid JSON only (no markdown fences): try { parsed = parseAppIdeaChatResponse(raw) } catch { - return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + throw new Error('Failed to parse AI response') } if (!parsed.reply?.trim()) { - return NextResponse.json({ error: 'Empty AI response' }, { status: 500 }) + throw new Error('Empty AI response') } return NextResponse.json({ @@ -216,6 +221,11 @@ Always respond with valid JSON only (no markdown fences): if (error instanceof Error) { console.error('[v0] Stack:', error.stack) } + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.PATTERN_ANALYZER_COST, 'App Idea Chat failed', { + analysisId: analysisIdForRefund, + }).catch((refundError) => console.error('[v0] Failed to refund app idea chat credits:', refundError)) + } return NextResponse.json({ error: errorMsg }, { status: 500 }) } } diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..75fba4c 100644 --- a/app/api/build-app/route.ts +++ b/app/api/build-app/route.ts @@ -3,6 +3,7 @@ import { generateWithGateway } from '@/lib/ai-gateway' import { getCurrentUser } from '@/lib/auth' import { getSubscriptionByGithubId, upsertSubscription, type AppBlueprint } from '@/lib/queries' import { hasProAccess } from '@/lib/pro-access' +import { CREDITS, deductCredits, refundCredits } from '@/lib/credits' type Platform = 'github' | 'gitlab' @@ -56,20 +57,20 @@ Just the file content itself, ready to save.` /** Build the list of all files to generate */ function getFilesToGenerate(blueprint: BuildAppRequest['blueprint']): Array<{ path: string; purpose: string }> { - const files: Array<{ path: string; purpose: string }> = [] + const files = new Map() // Missing files from the blueprint for (const f of blueprint.missing_files) { - files.push({ path: f.name, purpose: f.purpose }) + files.set(f.name, f.purpose) } // Standard project files - files.push({ path: 'README.md', purpose: 'Comprehensive setup, usage, and API documentation' }) - files.push({ path: 'package.json', purpose: 'Project dependencies and scripts for the tech stack' }) - files.push({ path: '.env.example', purpose: 'All required environment variables with placeholder values' }) - files.push({ path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' }) + files.set('README.md', 'Comprehensive setup, usage, and API documentation') + files.set('package.json', 'Project dependencies and scripts for the tech stack') + files.set('.env.example', 'All required environment variables with placeholder values') + files.set('.gitignore', 'Gitignore file appropriate for this stack') - return files + return Array.from(files, ([path, purpose]) => ({ path, purpose })) } async function createGitHubRepo( @@ -88,7 +89,7 @@ async function createGitHubRepo( body: JSON.stringify({ name: repoName, description, - private: false, + private: true, auto_init: false, }), }) @@ -128,7 +129,7 @@ async function pushFileToGitHub( if (!res.ok) { const err = (await res.json()) as { message?: string } - console.warn(`[build-app] Failed to push ${path}: ${err.message}`) + throw new Error(`Failed to push ${path}: ${err.message ?? 'GitHub rejected the file'}`) } } @@ -190,7 +191,7 @@ async function pushFileToGitLab( if (!res.ok) { const err = (await res.json()) as { message?: string } - console.warn(`[build-app] Failed to push ${path} to GitLab: ${err.message}`) + throw new Error(`Failed to push ${path} to GitLab: ${err.message ?? 'GitLab rejected the file'}`) } } @@ -201,10 +202,12 @@ export async function POST(request: NextRequest) { const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + let chargedUserId: string | null = null + let buildCompleted = false try { const user = await getCurrentUser() - if (!user) { + if (!user?.id) { send({ step: 'error', message: 'Sign in before building an app.' }) controller.close() return @@ -223,6 +226,12 @@ export async function POST(request: NextRequest) { const body = (await request.json()) as BuildAppRequest const { platform, repoName, blueprint } = body + if (platform !== 'github' && platform !== 'gitlab') { + send({ step: 'error', message: 'Choose GitHub or GitLab as the build destination.' }) + controller.close() + return + } + if (!repoName?.trim()) { send({ step: 'error', message: 'Repository name is required.' }) controller.close() @@ -233,6 +242,19 @@ export async function POST(request: NextRequest) { // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) + const chargeResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', { + platform, + repoName: cleanRepoName, + blueprintName: blueprint.name, + files: filesToGenerate.map((f) => f.path), + }) + if (!chargeResult.success) { + send({ step: 'error', message: chargeResult.error ?? 'Insufficient credits to build this app.' }) + controller.close() + return + } + chargedUserId = user.id + send({ step: 'generating', message: `Generating ${filesToGenerate.length} files with Claude…`, @@ -245,31 +267,22 @@ export async function POST(request: NextRequest) { let gitlabProjectId: number | null = null let gitlabBranch = 'main' - try { - if (platform === 'github') { - repoUrl = await createGitHubRepo( - accessToken, - user.github_username, - cleanRepoName, - blueprint.description ?? blueprint.name, - ) - } else { - const project = await createGitLabProject( - accessToken, - cleanRepoName, - blueprint.description ?? blueprint.name, - ) - repoUrl = project.web_url - gitlabProjectId = project.id - gitlabBranch = project.default_branch || 'main' - } - } catch (e) { - send({ - step: 'error', - message: `Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`, - }) - controller.close() - return + if (platform === 'github') { + repoUrl = await createGitHubRepo( + accessToken, + user.github_username, + cleanRepoName, + blueprint.description ?? blueprint.name, + ) + } else { + const project = await createGitLabProject( + accessToken, + cleanRepoName, + blueprint.description ?? blueprint.name, + ) + repoUrl = project.web_url + gitlabProjectId = project.id + gitlabBranch = project.default_branch || 'main' } send({ step: 'repo_created', message: 'Repository created. Generating and pushing files…', repoUrl }) @@ -280,13 +293,7 @@ export async function POST(request: NextRequest) { for (const { path, purpose } of filesToGenerate) { // Generate this file - let content: string - try { - content = await generateSingleFile(blueprint, path, purpose, user.id) - } catch (e) { - console.warn(`[build-app] Failed to generate ${path}:`, e) - content = `# Error generating ${path}\n# ${e instanceof Error ? e.message : String(e)}\n` - } + const content = await generateSingleFile(blueprint, path, purpose, user.id) // Push to platform if (platform === 'github') { @@ -312,11 +319,20 @@ export async function POST(request: NextRequest) { repoUrl, filesCreated: pushed, }) + buildCompleted = true } catch (e) { console.error('[build-app] unhandled error:', e) + if (chargedUserId && !buildCompleted) { + await refundCredits(chargedUserId, CREDITS.BUILD_APP_COST, 'Build This App failed', { + error: e instanceof Error ? e.message : String(e), + }).catch((refundError) => console.error('[build-app] Failed to refund credits:', refundError)) + } controller.enqueue( encoder.encode( - `data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`, + `data: ${JSON.stringify({ + step: 'error', + message: e instanceof Error ? e.message : 'An unexpected error occurred.', + })}\n\n`, ), ) } finally { diff --git a/app/api/code-completion/route.ts b/app/api/code-completion/route.ts index f7ede13..51fc3ac 100644 --- a/app/api/code-completion/route.ts +++ b/app/api/code-completion/route.ts @@ -4,6 +4,7 @@ import { CodeSnippet, batchGenerateCompletions, } from '@/lib/code-completion' +import { getCurrentUser } from '@/lib/auth' import { getDb } from '@/lib/db' const LANG_EXTENSIONS: Record = { @@ -24,18 +25,20 @@ const LANG_EXTENSIONS: Record = { shell: ['.sh', '.bash'], } -async function fetchSnippetsFromDb(language: string): Promise { +async function fetchSnippetsFromDb(language: string, userId: string): Promise { try { const sql = getDb() const extensions = LANG_EXTENSIONS[language.toLowerCase()] ?? [] if (extensions.length === 0) return [] const rows = await sql` - SELECT id, path, name, extension, ai_summary, reusability_score, exports, imports - FROM repo_files - WHERE extension = ANY(${extensions}::text[]) - AND ai_summary IS NOT NULL - ORDER BY reusability_score DESC + SELECT rf.id, rf.path, rf.name, rf.extension, rf.ai_summary, rf.reusability_score, rf.exports, rf.imports + FROM repo_files rf + JOIN repositories r ON r.id = rf.repository_id + WHERE r.user_id = ${userId} + AND rf.extension = ANY(${extensions}::text[]) + AND rf.ai_summary IS NOT NULL + ORDER BY rf.reusability_score DESC LIMIT 20 ` @@ -63,6 +66,11 @@ async function fetchSnippetsFromDb(language: string): Promise { export async function POST(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const { incompleteCode, codebaseSnippets = [], language = 'python' } = body @@ -75,7 +83,7 @@ export async function POST(request: NextRequest) { const snippets: CodeSnippet[] = codebaseSnippets.length > 0 ? codebaseSnippets - : await fetchSnippetsFromDb(language) + : await fetchSnippetsFromDb(language, user.id) const result = await generateRelevanceGuidedCompletion( incompleteCode, @@ -120,6 +128,11 @@ export async function POST(request: NextRequest) { */ export async function PUT(request: NextRequest) { try { + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const body = await request.json() const { codeSnippets = [], codebaseSnippets = [], language = 'python' } = body @@ -132,7 +145,7 @@ export async function PUT(request: NextRequest) { const snippets: CodeSnippet[] = codebaseSnippets.length > 0 ? codebaseSnippets - : await fetchSnippetsFromDb(language) + : await fetchSnippetsFromDb(language, user.id) const results = await batchGenerateCompletions( codeSnippets, diff --git a/app/api/generate-scaffold/route.ts b/app/api/generate-scaffold/route.ts index 312c934..29d42f9 100644 --- a/app/api/generate-scaffold/route.ts +++ b/app/api/generate-scaffold/route.ts @@ -1,19 +1,24 @@ import { NextRequest, NextResponse } from 'next/server' import { aiConfigErrorMessage, generateWithGateway, isAiConfigured } from '@/lib/ai-gateway' -import { getCreditBalance, deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, refundCredits, CREDITS } from '@/lib/credits' import { getCurrentUser } from '@/lib/auth' import { getSubscriptionByGithubId, upsertSubscription } from '@/lib/queries' import { hasProAccess } from '@/lib/pro-access' export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let appNameForRefund: string | null = null + try { if (!isAiConfigured()) { return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 }) } - const { appName, description, technologies, existingFiles, missingFiles, userId } = await request.json() + const { appName, description, technologies, existingFiles, missingFiles } = await request.json() + appNameForRefund = appName ?? null + const user = await getCurrentUser() - if (!user) { + if (!user?.id) { return NextResponse.json({ error: 'Sign in with GitHub to generate scaffolds.' }, { status: 401 }) } @@ -32,20 +37,21 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) } - if (userId) { - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.SCAFFOLD_COST) { - return NextResponse.json( - { - error: 'Insufficient credits', - required: CREDITS.SCAFFOLD_COST, - available: currentBalance, - message: 'Upgrade to Pro to get unlimited scaffold generation with 3,000 monthly credits.', - }, - { status: 402 }, - ) - } + const deductResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', { + appName, + technologies, + }) + if (!deductResult.success) { + return NextResponse.json( + { + error: 'Insufficient credits', + required: CREDITS.SCAFFOLD_COST, + message: deductResult.error, + }, + { status: 402 }, + ) } + chargedUserId = user.id const raw = await generateWithGateway({ feature: 'scaffold', @@ -120,26 +126,19 @@ Example structure: throw new Error(`Failed to parse scaffold: ${e instanceof Error ? e.message : 'Invalid JSON'}`) } - let creditsUsed = 0 - if (userId) { - const deductResult = await deductCredits(userId, CREDITS.SCAFFOLD_COST, 'scaffold', { - appName, - technologies, - }) - - if (deductResult.success) { - creditsUsed = CREDITS.SCAFFOLD_COST - } - } - return NextResponse.json({ success: true, scaffold, appName, - creditsUsed, + creditsUsed: CREDITS.SCAFFOLD_COST, }) } catch (error) { console.error('[scaffold] Generation error:', error) + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.SCAFFOLD_COST, 'Scaffold generation failed', { + appName: appNameForRefund, + }).catch((refundError) => console.error('[scaffold] Failed to refund credits:', refundError)) + } return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to generate scaffold' }, { status: 500 }, diff --git a/app/api/pattern-analyzer/route.ts b/app/api/pattern-analyzer/route.ts index 10447f4..14572bb 100644 --- a/app/api/pattern-analyzer/route.ts +++ b/app/api/pattern-analyzer/route.ts @@ -8,7 +8,7 @@ import { } from '@/lib/queries' import { getAnthropicModel } from '@/lib/anthropic-model' import { getCurrentUser } from '@/lib/auth' -import { deductCredits, CREDITS } from '@/lib/credits' +import { deductCredits, refundCredits, CREDITS } from '@/lib/credits' let __anthropicClient: Anthropic | null = null function getAnthropic(): Anthropic { @@ -42,13 +42,17 @@ export interface PatternAnalyzerResult { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let analysisIdForRefund: string | null = null + try { const user = await getCurrentUser() - if (!user) { + if (!user?.id) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) } const { analysisId } = (await request.json()) as { analysisId: string } + analysisIdForRefund = analysisId if (!analysisId) { return NextResponse.json({ error: 'analysisId is required' }, { status: 400 }) @@ -61,11 +65,6 @@ export async function POST(request: NextRequest) { ) } - const creditResult = await deductCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'pattern_analyzer', { analysisId }) - if (!creditResult.success) { - return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) - } - const analysis = await getAnalysisById(analysisId, user.id) if (!analysis) { return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }) @@ -77,6 +76,12 @@ export async function POST(request: NextRequest) { ) } + const creditResult = await deductCredits(user.id, CREDITS.PATTERN_ANALYZER_COST, 'pattern_analyzer', { analysisId }) + if (!creditResult.success) { + return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) + } + chargedUserId = user.id + // Gather repo files and blueprints const [repositories, blueprints] = await Promise.all([ getRepositoriesForAnalysis(analysisId, user.id), @@ -185,7 +190,7 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s try { parsed = JSON.parse(jsonText) } catch { - return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 }) + throw new Error('Failed to parse AI response') } const result: PatternAnalyzerResult = { @@ -198,6 +203,11 @@ Respond ONLY with a valid JSON object (no markdown fences) matching this exact s return NextResponse.json(result) } catch (error) { console.error('[pattern-analyzer] error:', error) + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.PATTERN_ANALYZER_COST, 'Pattern Analyzer failed', { + analysisId: analysisIdForRefund, + }).catch((refundError) => console.error('[pattern-analyzer] Failed to refund credits:', refundError)) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/app/api/projects/[id]/milestones/[milestoneId]/route.ts b/app/api/projects/[id]/milestones/[milestoneId]/route.ts index 0b04cb1..c675a2d 100644 --- a/app/api/projects/[id]/milestones/[milestoneId]/route.ts +++ b/app/api/projects/[id]/milestones/[milestoneId]/route.ts @@ -9,10 +9,10 @@ export async function PATCH( const user = await getCurrentUser() if (!user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const { milestoneId } = await params try { + const { id, milestoneId } = await params const { completed } = await request.json() - const milestone = await toggleMilestone(milestoneId, Boolean(completed)) + const milestone = await toggleMilestone(id, milestoneId, user.id, Boolean(completed)) if (!milestone) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ milestone }) } catch (err) { @@ -28,9 +28,10 @@ export async function DELETE( const user = await getCurrentUser() if (!user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const { milestoneId } = await params + const { id, milestoneId } = await params try { - await deleteMilestone(milestoneId) + const deleted = await deleteMilestone(id, milestoneId, user.id) + if (!deleted) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ ok: true }) } catch (err) { console.error('[milestones/id] DELETE error:', err) diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 9b80351..a790829 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -188,6 +188,8 @@ export async function POST(request: NextRequest) { if (user) { const amount = getCreditGrantForPrice(priceId, CREDITS.INITIAL_GRANT) await grantCredits(user.id, amount, 'Subscription signup credit grant', { + idempotency_key: event.id, + stripe_event_id: event.id, stripe_customer_id: customerId, stripe_subscription_id: subscription.id, }) @@ -303,6 +305,8 @@ export async function POST(request: NextRequest) { if (user && billingReason !== 'subscription_create') { const amount = getCreditGrantForPrice(priceId, CREDITS.MONTHLY_GRANT) await renewMonthlyCredits(user.id, amount, 'Monthly subscription renewal', { + idempotency_key: event.id, + stripe_event_id: event.id, invoice_id: invoice.id, stripe_customer_id: customerId, stripe_subscription_id: subscription.id, diff --git a/lib/credits.ts b/lib/credits.ts index 34c0da9..5babf85 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -32,6 +32,7 @@ export interface CreditTransaction { reason: string | null metadata: CreditMetadata balance_after: number + idempotency_key: string | null created_at: string } @@ -55,6 +56,11 @@ function toCount(value: string | number | null | undefined): number { return Number.parseInt(value ?? '0', 10) } +function getIdempotencyKey(metadata: CreditMetadata): string | null { + const value = metadata.idempotency_key + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + // Initialize or get user credits export async function getOrCreateUserCredits(userId: string): Promise { const sql = getDb() @@ -100,30 +106,39 @@ export async function grantCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - - // Get or create user credits - const userCredits = await getOrCreateUserCredits(userId) - const newBalance = userCredits.current_balance + amount - - // Update balance - await sql` - UPDATE user_credits - SET - current_balance = ${newBalance}, - total_granted = total_granted + ${amount}, - last_renewal_date = CURRENT_TIMESTAMP - WHERE user_id = ${userId} - ` - - // Record transaction + const idempotencyKey = getIdempotencyKey(metadata) + + await getOrCreateUserCredits(userId) + const transaction = await sql` - INSERT INTO credit_transactions ( - user_id, amount, transaction_type, reason, metadata, balance_after - ) - VALUES ( - ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} + WITH existing AS ( + SELECT * + FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + AND ${idempotencyKey} IS NOT NULL + LIMIT 1 + ), + updated AS ( + UPDATE user_credits + SET + current_balance = current_balance + ${amount}, + total_granted = total_granted + ${amount}, + last_renewal_date = CURRENT_TIMESTAMP + WHERE user_id = ${userId} + AND NOT EXISTS (SELECT 1 FROM existing) + RETURNING current_balance + ), + inserted AS ( + INSERT INTO credit_transactions ( + user_id, amount, transaction_type, reason, metadata, balance_after, idempotency_key + ) + SELECT ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance, ${idempotencyKey} + FROM updated + RETURNING * ) - RETURNING * + SELECT * FROM inserted + UNION ALL + SELECT * FROM existing ` return transaction[0] as CreditTransaction @@ -137,40 +152,48 @@ export async function renewMonthlyCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - const userCredits = await getOrCreateUserCredits(userId) - const topUpAmount = Math.max(0, monthlyAllowance - userCredits.current_balance) - - if (topUpAmount === 0) { - await sql` - UPDATE user_credits - SET last_renewal_date = CURRENT_TIMESTAMP - WHERE user_id = ${userId} - ` - return null - } - - const newBalance = userCredits.current_balance + topUpAmount - - await sql` - UPDATE user_credits - SET - current_balance = ${newBalance}, - total_granted = total_granted + ${topUpAmount}, - last_renewal_date = CURRENT_TIMESTAMP - WHERE user_id = ${userId} - ` + const idempotencyKey = getIdempotencyKey(metadata) + await getOrCreateUserCredits(userId) const transaction = await sql` - INSERT INTO credit_transactions ( - user_id, amount, transaction_type, reason, metadata, balance_after - ) - VALUES ( - ${userId}, ${topUpAmount}, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} + WITH existing AS ( + SELECT * + FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + AND ${idempotencyKey} IS NOT NULL + LIMIT 1 + ), + current_credits AS ( + SELECT current_balance, GREATEST(${monthlyAllowance} - current_balance, 0) AS top_up_amount + FROM user_credits + WHERE user_id = ${userId} + AND NOT EXISTS (SELECT 1 FROM existing) + ), + updated AS ( + UPDATE user_credits + SET + current_balance = user_credits.current_balance + current_credits.top_up_amount, + total_granted = total_granted + current_credits.top_up_amount, + last_renewal_date = CURRENT_TIMESTAMP + FROM current_credits + WHERE user_credits.user_id = ${userId} + RETURNING user_credits.current_balance, current_credits.top_up_amount + ), + inserted AS ( + INSERT INTO credit_transactions ( + user_id, amount, transaction_type, reason, metadata, balance_after, idempotency_key + ) + SELECT ${userId}, top_up_amount, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance, ${idempotencyKey} + FROM updated + WHERE top_up_amount > 0 + RETURNING * ) - RETURNING * + SELECT * FROM inserted + UNION ALL + SELECT * FROM existing ` - return transaction[0] as CreditTransaction + return (transaction[0] as CreditTransaction | undefined) ?? null } // Deduct credits (for analysis, scaffold, build_app, or pattern_analyzer) @@ -181,40 +204,33 @@ export async function deductCredits( metadata: CreditMetadata = {} ): Promise<{ success: boolean; transaction?: CreditTransaction; error?: string }> { const sql = getDb() - - // Get current balance - const userCredits = await getOrCreateUserCredits(userId) - const currentBalance = userCredits.current_balance - - // Check if sufficient balance - if (currentBalance < amount) { - return { - success: false, - error: `Insufficient credits. Required: ${amount}, Available: ${currentBalance}`, - } - } - - const newBalance = currentBalance - amount - - // Update balance - await sql` - UPDATE user_credits - SET - current_balance = ${newBalance}, - total_used = total_used + ${amount} - WHERE user_id = ${userId} - ` - - // Record transaction + await getOrCreateUserCredits(userId) + const transaction = await sql` + WITH updated AS ( + UPDATE user_credits + SET + current_balance = current_balance - ${amount}, + total_used = total_used + ${amount} + WHERE user_id = ${userId} + AND current_balance >= ${amount} + RETURNING current_balance + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${-amount}, ${type}, ${`${type} deduction`}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, ${-amount}, ${type}, ${`${type} deduction`}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated RETURNING * ` + + if (transaction.length === 0) { + const currentBalance = await getCreditBalance(userId) + return { + success: false, + error: `Insufficient credits. Required: ${amount}, Available: ${currentBalance}`, + } + } return { success: true, @@ -230,26 +246,20 @@ export async function refundCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() - - // Get or create user credits - const userCredits = await getOrCreateUserCredits(userId) - const newBalance = userCredits.current_balance + amount - - // Update balance - await sql` - UPDATE user_credits - SET current_balance = ${newBalance} - WHERE user_id = ${userId} - ` - - // Record transaction + await getOrCreateUserCredits(userId) + const transaction = await sql` + WITH updated AS ( + UPDATE user_credits + SET current_balance = current_balance + ${amount} + WHERE user_id = ${userId} + RETURNING current_balance + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${amount}, 'refund', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, ${amount}, 'refund', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated RETURNING * ` diff --git a/lib/queries.ts b/lib/queries.ts index 7dd365e..f6a5a47 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -170,6 +170,28 @@ export async function incrementAnalysisUsage(githubId: number): Promise { ` } +export async function reserveAnalysisUsage(githubId: number, monthlyLimit: number): Promise { + const sql = getDb() + const result = await sql` + UPDATE subscriptions + SET analyses_used_this_month = analyses_used_this_month + 1, updated_at = CURRENT_TIMESTAMP + WHERE github_id = ${githubId} + AND analyses_used_this_month < ${monthlyLimit} + RETURNING id + ` + return result.length > 0 +} + +export async function releaseAnalysisUsage(githubId: number): Promise { + const sql = getDb() + await sql` + UPDATE subscriptions + SET analyses_used_this_month = GREATEST(analyses_used_this_month - 1, 0), + updated_at = CURRENT_TIMESTAMP + WHERE github_id = ${githubId} + ` +} + export async function resetMonthlyUsage(githubId: number): Promise { const sql = getDb() await sql` @@ -380,6 +402,40 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { + if (keepIds.length === 0) { + throw new Error('Cannot replace blueprints without replacement rows') + } + + const sql = getDb() + await sql` + DELETE FROM app_blueprints + WHERE analysis_id = ${analysisId} + AND user_id = ${userId} + AND NOT (id = ANY(${keepIds}::uuid[])) + ` +} + +export async function deleteBlueprintsByIds( + analysisId: string, + userId: string, + blueprintIds: string[], +): Promise { + if (blueprintIds.length === 0) return + + const sql = getDb() + await sql` + DELETE FROM app_blueprints + WHERE analysis_id = ${analysisId} + AND user_id = ${userId} + AND id = ANY(${blueprintIds}::uuid[]) + ` +} + export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise { const sql = getDb() await sql` @@ -1044,27 +1100,49 @@ export async function createMilestone(data: { return result[0] as ProjectMilestone } -export async function toggleMilestone(id: string, completed: boolean): Promise { +export async function toggleMilestone( + projectId: string, + id: string, + userId: string, + completed: boolean, +): Promise { const sql = getDb() const result = completed ? await sql` - UPDATE project_milestones + UPDATE project_milestones AS m SET completed = true, completed_at = NOW() - WHERE id = ${id} - RETURNING * + FROM projects AS p + WHERE m.id = ${id} + AND m.project_id = ${projectId} + AND p.id = m.project_id + AND p.user_id = ${userId} + RETURNING m.* ` : await sql` - UPDATE project_milestones + UPDATE project_milestones AS m SET completed = false, completed_at = NULL - WHERE id = ${id} - RETURNING * + FROM projects AS p + WHERE m.id = ${id} + AND m.project_id = ${projectId} + AND p.id = m.project_id + AND p.user_id = ${userId} + RETURNING m.* ` return (result[0] as ProjectMilestone) || null } -export async function deleteMilestone(id: string): Promise { +export async function deleteMilestone(projectId: string, id: string, userId: string): Promise { const sql = getDb() - await sql`DELETE FROM project_milestones WHERE id = ${id}` + const result = await sql` + DELETE FROM project_milestones AS m + USING projects AS p + WHERE m.id = ${id} + AND m.project_id = ${projectId} + AND p.id = m.project_id + AND p.user_id = ${userId} + RETURNING m.id + ` + return result.length > 0 } export async function seedDefaultMilestones(projectId: string): Promise { diff --git a/migrations/003_user_credits_system.sql b/migrations/003_user_credits_system.sql index ae2c747..d676097 100644 --- a/migrations/003_user_credits_system.sql +++ b/migrations/003_user_credits_system.sql @@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS credit_transactions ( reason TEXT, metadata JSONB DEFAULT '{}', balance_after BIGINT NOT NULL, + idempotency_key TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -28,6 +29,9 @@ CREATE INDEX IF NOT EXISTS idx_user_credits_user_id ON user_credits(user_id); CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id); CREATE INDEX IF NOT EXISTS idx_credit_transactions_type ON credit_transactions(transaction_type); CREATE INDEX IF NOT EXISTS idx_credit_transactions_created ON credit_transactions(created_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_transactions_idempotency_key + ON credit_transactions(idempotency_key) + WHERE idempotency_key IS NOT NULL; -- Add trigger to update updated_at on user_credits CREATE OR REPLACE FUNCTION update_user_credits_timestamp() diff --git a/migrations/009_credit_transaction_idempotency.sql b/migrations/009_credit_transaction_idempotency.sql new file mode 100644 index 0000000..b37584c --- /dev/null +++ b/migrations/009_credit_transaction_idempotency.sql @@ -0,0 +1,6 @@ +ALTER TABLE credit_transactions + ADD COLUMN IF NOT EXISTS idempotency_key TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_transactions_idempotency_key + ON credit_transactions(idempotency_key) + WHERE idempotency_key IS NOT NULL; diff --git a/scripts/critical-regression-check.mjs b/scripts/critical-regression-check.mjs new file mode 100644 index 0000000..9ef8c9a --- /dev/null +++ b/scripts/critical-regression-check.mjs @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.cwd() + +function read(path) { + return readFileSync(join(root, path), 'utf8') +} + +function assertIncludes(path, needle, message) { + const text = read(path) + if (!text.includes(needle)) { + throw new Error(`${message}\nMissing in ${path}: ${needle}`) + } +} + +function assertNotIncludes(path, needle, message) { + const text = read(path) + if (text.includes(needle)) { + throw new Error(`${message}\nUnexpected in ${path}: ${needle}`) + } +} + +assertIncludes( + 'app/api/code-completion/route.ts', + "import { getCurrentUser } from '@/lib/auth'", + 'code-completion must require an authenticated user', +) +assertIncludes( + 'app/api/code-completion/route.ts', + 'WHERE r.user_id = ${userId}', + 'code-completion fallback snippets must be scoped to the authenticated user', +) + +assertIncludes( + 'app/api/analyses/[id]/analyze/route.ts', + 'const user = await getCurrentUser()', + 'legacy analysis must authenticate before billing or AI work', +) +assertNotIncludes( + 'app/api/analyses/[id]/analyze/route.ts', + 'userId: string', + 'legacy analysis must not trust a client-supplied userId', +) + +assertIncludes( + 'lib/queries.ts', + 'AND p.user_id = ${userId}', + 'milestone mutations must be scoped through project ownership', +) +assertIncludes( + 'app/api/projects/[id]/milestones/[milestoneId]/route.ts', + 'toggleMilestone(id, milestoneId, user.id', + 'milestone route must pass project id and authenticated user id', +) + +assertNotIncludes( + 'app/api/analyses/[id]/run/route.ts', + 'deleteBlueprintsByAnalysis(id)', + 'analysis reruns must not delete existing blueprints before replacement rows are created', +) +assertIncludes( + 'app/api/analyses/[id]/run/route.ts', + 'deleteBlueprintsByAnalysisExcept(id, user.id, createdBlueprintIds)', + 'analysis reruns must delete old blueprints only after replacements are inserted', +) +assertIncludes( + 'app/api/analyses/[id]/run/route.ts', + 'reserveAnalysisUsage(user.github_id, limit)', + 'free-tier analysis usage must be reserved atomically before AI work', +) +assertIncludes( + 'app/api/analyses/[id]/run/route.ts', + 'releaseAnalysisUsage(reservedUsageGithubId)', + 'failed free-tier analysis runs must release reserved usage', +) + +assertIncludes( + 'app/api/build-app/route.ts', + 'private: true', + 'Build This App must create GitHub repos private by default', +) +assertNotIncludes( + 'app/api/build-app/route.ts', + '# Error generating', + 'Build This App must not push placeholder files after generation failures', +) +assertIncludes( + 'app/api/build-app/route.ts', + "throw new Error(`Failed to push ${path}:", + 'Build This App must fail the stream when GitHub pushes fail', +) +assertIncludes( + 'app/api/build-app/route.ts', + 'refundCredits(chargedUserId, CREDITS.BUILD_APP_COST', + 'Build This App must refund incomplete builds', +) + +assertIncludes( + 'lib/credits.ts', + 'AND current_balance >= ${amount}', + 'credit deductions must be atomic and balance-guarded', +) +assertIncludes( + 'lib/credits.ts', + 'idempotency_key', + 'credit grants and renewals must support Stripe webhook idempotency', +) +assertIncludes( + 'migrations/009_credit_transaction_idempotency.sql', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_transactions_idempotency_key', + 'credit idempotency migration must enforce a unique key', +) + +console.log('critical regression invariants passed')