diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..5f9456e 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 { deductCredits, refundCredits, CREDITS } from '@/lib/credits' +import { getCurrentUser } from '@/lib/auth' const model = 'openai/gpt-4-turbo' @@ -20,30 +21,40 @@ interface AppSuggestion { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let refundMetadata: Record = {} + try { - const { analysisId, selectedRepos, userId } = (await request.json()) as { + const { analysisId, selectedRepos } = (await request.json()) as { analysisId: string selectedRepos: SelectedRepository[] - userId: string } - // Check credit balance before proceeding - if (!userId) { - return NextResponse.json({ error: 'User ID required' }, { status: 401 }) + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: 'Sign in before analyzing repositories.' }, { status: 401 }) + } + + if (!analysisId || !Array.isArray(selectedRepos)) { + return NextResponse.json({ error: 'Analysis ID and selected repositories are required' }, { status: 400 }) } - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { + refundMetadata = { + analysisId, + selectedRepos: selectedRepos.map((repo) => repo.name), + } + const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', refundMetadata) + if (!deductResult.success) { return NextResponse.json( { - error: 'Insufficient credits', + error: deductResult.error ?? 'Insufficient credits', required: CREDITS.ANALYSIS_COST, - available: currentBalance, 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 +105,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 +117,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, 'legacy analysis failed', refundMetadata).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/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..2cc1a0b 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' @@ -69,7 +70,14 @@ function getFilesToGenerate(blueprint: BuildAppRequest['blueprint']): Array<{ pa files.push({ path: '.env.example', purpose: 'All required environment variables with placeholder values' }) files.push({ path: '.gitignore', purpose: 'Gitignore file appropriate for this stack' }) - return files + const seen = new Set() + return files.filter((file) => { + const normalizedPath = file.path.trim() + if (!normalizedPath || seen.has(normalizedPath)) return false + seen.add(normalizedPath) + file.path = normalizedPath + return true + }) } async function createGitHubRepo( @@ -88,7 +96,7 @@ async function createGitHubRepo( body: JSON.stringify({ name: repoName, description, - private: false, + private: true, auto_init: false, }), }) @@ -128,7 +136,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 ?? res.statusText}`) } } @@ -190,7 +198,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 ?? res.statusText}`) } } @@ -202,11 +210,13 @@ export async function POST(request: NextRequest) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + let chargedUserId: string | null = null + let refundMetadata: Record = {} + try { const user = await getCurrentUser() if (!user) { send({ step: 'error', message: 'Sign in before building an app.' }) - controller.close() return } @@ -216,20 +226,37 @@ export async function POST(request: NextRequest) { } if (!hasProAccess(user, sub)) { send({ step: 'error', message: 'Build This App is available on paid plans. Upgrade to create and push a generated repo.' }) - controller.close() return } 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 before building an app.' }) + return + } if (!repoName?.trim()) { send({ step: 'error', message: 'Repository name is required.' }) - controller.close() + return + } + if (!blueprint?.name || !Array.isArray(blueprint.existing_files) || !Array.isArray(blueprint.missing_files)) { + send({ step: 'error', message: 'Blueprint details are required.' }) return } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + refundMetadata = { platform, repoName: cleanRepoName, blueprintName: blueprint.name } + + const chargeResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', refundMetadata) + if (!chargeResult.success) { + send({ + step: 'error', + message: chargeResult.error ?? `Build This App requires ${CREDITS.BUILD_APP_COST} credits.`, + }) + return + } + chargedUserId = user.id // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) @@ -264,12 +291,7 @@ export async function POST(request: NextRequest) { 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 + throw new Error(`Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.`) } send({ step: 'repo_created', message: 'Repository created. Generating and pushing files…', repoUrl }) @@ -279,13 +301,9 @@ export async function POST(request: NextRequest) { const total = filesToGenerate.length 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) + if (!content.trim()) { + throw new Error(`Generated empty content for ${path}`) } // Push to platform @@ -312,13 +330,18 @@ export async function POST(request: NextRequest) { repoUrl, filesCreated: pushed, }) + chargedUserId = null } catch (e) { console.error('[build-app] unhandled error:', e) - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`, - ), - ) + if (chargedUserId) { + await refundCredits(chargedUserId, CREDITS.BUILD_APP_COST, 'build app failed', refundMetadata).catch((refundError) => + console.error('[build-app] Failed to refund credits:', refundError), + ) + } + send({ + step: 'error', + message: e instanceof Error ? e.message : 'An unexpected error occurred.', + }) } finally { controller.close() } diff --git a/app/api/generate-scaffold/route.ts b/app/api/generate-scaffold/route.ts index 312c934..133f127 100644 --- a/app/api/generate-scaffold/route.ts +++ b/app/api/generate-scaffold/route.ts @@ -1,17 +1,20 @@ 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 refundMetadata: Record = {} + 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() const user = await getCurrentUser() if (!user) { return NextResponse.json({ error: 'Sign in with GitHub to generate scaffolds.' }, { status: 401 }) @@ -32,20 +35,19 @@ 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 }, - ) - } + refundMetadata = { appName, technologies } + const deductResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', refundMetadata) + if (!deductResult.success) { + return NextResponse.json( + { + error: deductResult.error ?? 'Insufficient credits', + required: CREDITS.SCAFFOLD_COST, + message: 'Upgrade to Pro to get unlimited scaffold generation with 3,000 monthly credits.', + }, + { status: 402 }, + ) } + chargedUserId = user.id const raw = await generateWithGateway({ feature: 'scaffold', @@ -120,26 +122,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', refundMetadata).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/lib/credits.ts b/lib/credits.ts index 34c0da9..8bb79af 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -181,41 +181,36 @@ 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, transaction: transaction[0] as CreditTransaction, @@ -230,29 +225,25 @@ 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 * ` - + return transaction[0] as CreditTransaction }