diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..b152344 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,6 +21,9 @@ interface AppSuggestion { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let chargeMetadata: Record = {} + try { const { analysisId, selectedRepos, userId } = (await request.json()) as { analysisId: string @@ -27,23 +31,30 @@ export async function POST(request: NextRequest) { userId: string } - // Check credit balance before proceeding - if (!userId) { - return NextResponse.json({ error: 'User ID required' }, { status: 401 }) + const user = await getCurrentUser() + if (!user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if (userId && userId !== user.id) { + return NextResponse.json({ error: 'Cannot charge credits for a different user.' }, { status: 403 }) } - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { + chargeMetadata = { + analysisId, + selectedRepos: selectedRepos.map((r) => r.name), + } + const creditResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', chargeMetadata) + if (!creditResult.success) { return NextResponse.json( { - error: 'Insufficient credits', + error: creditResult.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,21 +105,8 @@ 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 + const newBalance = creditResult.transaction?.balance_after || 0 + chargedUserId = null return NextResponse.json({ analysisId, @@ -120,6 +118,14 @@ 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', { + ...chargeMetadata, + error: error instanceof Error ? error.message : String(error), + }).catch((refundError) => { + console.error('Failed to refund credits after legacy analysis failure:', 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..2981126 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -13,7 +13,7 @@ import { updateAnalysisStatus, createRepoFile, createBlueprint, - deleteBlueprintsByAnalysis, + deleteBlueprintByIdForAnalysis, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, @@ -190,6 +190,7 @@ export async function POST( controller.close() return } + const previousBlueprints = await getBlueprintsByAnalysis(id, user.id) const repositories = await getRepositoriesForAnalysis(id, user.id) if (repositories.length === 0) { @@ -200,7 +201,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 @@ -393,7 +393,12 @@ For each app blueprint: // Check if response was truncated (hit max_tokens) if (aiResponse.stop_reason === 'max_tokens') { - console.warn('[analysis] AI response truncated (max_tokens). Tool output may be incomplete.') + const msg = 'AI response was cut short (output too large). Try running with fewer repositories selected.' + console.warn('[analysis] AI response truncated (max_tokens). Refusing to replace existing blueprints.') + send({ status: 'failed', error: msg }) + await updateAnalysisStatus(id, 'failed', { error_message: msg }) + controller.close() + return } // Extract structured output from tool use response @@ -411,12 +416,9 @@ For each app blueprint: const blueprintsFromAI = parseBlueprints(rawInput) if (blueprintsFromAI.length === 0) { - const wasMaxTokens = aiResponse.stop_reason === 'max_tokens' - const msg = wasMaxTokens - ? 'AI response was cut short (output too large). Try running with fewer repositories selected.' - : rawInput - ? 'AI returned empty results. Try running the analysis again — this can happen intermittently.' - : 'Model did not return usable blueprints (missing tool output). Check ANTHROPIC_API_KEY and model availability.' + const msg = rawInput + ? 'AI returned empty results. Try running the analysis again — this can happen intermittently.' + : 'Model did not return usable blueprints (missing tool output). Check ANTHROPIC_API_KEY and model availability.' 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 }) @@ -430,21 +432,34 @@ For each app blueprint: .map((bp) => normalizeBlueprint(bp)) .sort((a, b) => getOpportunityScore(b) - getOpportunityScore(a)) - for (const bp of rankedBlueprints) { - await createBlueprint({ - analysis_id: id, - user_id: user.id, - name: bp.name.slice(0, 255), - description: bp.description, - app_type: bp.app_type?.slice(0, 100) ?? null, - complexity: bp.complexity, - reuse_percentage: bp.reuse_percentage, - existing_files: bp.existing_files, - missing_files: bp.missing_files, - estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length), - technologies: bp.technologies, - ai_explanation: bp.explanation, - }) + const createdBlueprintIds: string[] = [] + try { + for (const bp of rankedBlueprints) { + const created = await createBlueprint({ + analysis_id: id, + user_id: user.id, + name: bp.name.slice(0, 255), + description: bp.description, + app_type: bp.app_type?.slice(0, 100) ?? null, + complexity: bp.complexity, + reuse_percentage: bp.reuse_percentage, + existing_files: bp.existing_files, + missing_files: bp.missing_files, + estimated_effort: getEffortEstimate(bp.complexity, bp.missing_files.length), + technologies: bp.technologies, + ai_explanation: bp.explanation, + }) + createdBlueprintIds.push(created.id) + } + + for (const blueprint of previousBlueprints) { + await deleteBlueprintByIdForAnalysis(blueprint.id, id) + } + } catch (error) { + await Promise.allSettled( + createdBlueprintIds.map((blueprintId) => deleteBlueprintByIdForAnalysis(blueprintId, id)), + ) + throw error } } diff --git a/app/api/build-app/route.ts b/app/api/build-app/route.ts index 45c0852..fceb4b5 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,26 @@ 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() + const addFile = (path: string, purpose: string) => { + const cleanPath = path.trim().replace(/^\/+/, '') + if (cleanPath && !files.has(cleanPath)) { + files.set(cleanPath, { path: cleanPath, purpose }) + } + } // Missing files from the blueprint for (const f of blueprint.missing_files) { - files.push({ path: f.name, purpose: f.purpose }) + addFile(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' }) + addFile('README.md', 'Comprehensive setup, usage, and API documentation') + addFile('package.json', 'Project dependencies and scripts for the tech stack') + addFile('.env.example', 'All required environment variables with placeholder values') + addFile('.gitignore', 'Gitignore file appropriate for this stack') - return files + return Array.from(files.values()) } async function createGitHubRepo( @@ -88,7 +95,7 @@ async function createGitHubRepo( body: JSON.stringify({ name: repoName, description, - private: false, + private: true, auto_init: false, }), }) @@ -110,8 +117,9 @@ async function pushFileToGitHub( content: string, ): Promise { const encoded = Buffer.from(content).toString('base64') + const encodedPath = path.split('/').map(encodeURIComponent).join('/') const res = await fetch( - `https://api.github.com/repos/${username}/${repoName}/contents/${path}`, + `https://api.github.com/repos/${username}/${repoName}/contents/${encodedPath}`, { method: 'PUT', headers: { @@ -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}`) } } @@ -198,15 +206,35 @@ export async function POST(request: NextRequest) { const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { + let closed = false + let chargedUserId: string | null = null + let chargedMetadata: Record = {} const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + const close = () => { + if (!closed) { + closed = true + controller.close() + } + } + const refundBuildCredits = async (reason: string, error: unknown) => { + if (!chargedUserId) return + const userId = chargedUserId + chargedUserId = null + await refundCredits(userId, CREDITS.BUILD_APP_COST, reason, { + ...chargedMetadata, + error: error instanceof Error ? error.message : String(error), + }).catch((refundError) => { + console.error('[build-app] Failed to refund credits after build failure:', refundError) + }) + } try { const user = await getCurrentUser() if (!user) { send({ step: 'error', message: 'Sign in before building an app.' }) - controller.close() + close() return } @@ -216,23 +244,42 @@ 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() + 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.' }) + close() + return + } + if (!repoName?.trim()) { send({ step: 'error', message: 'Repository name is required.' }) - controller.close() + close() return } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + chargedMetadata = { + platform, + repoName: cleanRepoName, + blueprintName: blueprint.name, + } // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) + const creditResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', chargedMetadata) + if (!creditResult.success) { + send({ step: 'error', message: creditResult.error ?? 'Insufficient credits to build this app.' }) + close() + return + } + chargedUserId = user.id + send({ step: 'generating', message: `Generating ${filesToGenerate.length} files with Claude…`, @@ -264,11 +311,12 @@ export async function POST(request: NextRequest) { gitlabBranch = project.default_branch || 'main' } } catch (e) { + await refundBuildCredits('Build app repository creation failed', 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() + close() return } @@ -284,15 +332,34 @@ export async function POST(request: NextRequest) { 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` + console.error(`[build-app] Failed to generate ${path}:`, e) + await refundBuildCredits('Build app file generation failed', e) + send({ + step: 'error', + message: `Failed to generate ${path}: ${e instanceof Error ? e.message : String(e)}`, + repoUrl, + }) + close() + return } // Push to platform - if (platform === 'github') { - await pushFileToGitHub(accessToken, user.github_username, cleanRepoName, path, content) - } else if (gitlabProjectId !== null) { - await pushFileToGitLab(accessToken, gitlabProjectId, gitlabBranch, path, content) + try { + if (platform === 'github') { + await pushFileToGitHub(accessToken, user.github_username, cleanRepoName, path, content) + } else if (gitlabProjectId !== null) { + await pushFileToGitLab(accessToken, gitlabProjectId, gitlabBranch, path, content) + } + } catch (e) { + console.error(`[build-app] Failed to push ${path}:`, e) + await refundBuildCredits('Build app file push failed', e) + send({ + step: 'error', + message: e instanceof Error ? e.message : `Failed to push ${path}`, + repoUrl, + }) + close() + return } pushed++ @@ -312,15 +379,17 @@ export async function POST(request: NextRequest) { repoUrl, filesCreated: pushed, }) + chargedUserId = null } catch (e) { console.error('[build-app] unhandled error:', e) + await refundBuildCredits('Build app unexpected failure', e) controller.enqueue( encoder.encode( `data: ${JSON.stringify({ step: 'error', message: 'An unexpected error occurred.' })}\n\n`, ), ) } finally { - controller.close() + close() } }, }) diff --git a/app/api/generate-scaffold/route.ts b/app/api/generate-scaffold/route.ts index 312c934..6f86b9a 100644 --- a/app/api/generate-scaffold/route.ts +++ b/app/api/generate-scaffold/route.ts @@ -1,11 +1,14 @@ 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 chargeMetadata: Record = {} + try { if (!isAiConfigured()) { return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 }) @@ -28,24 +31,27 @@ export async function POST(request: NextRequest) { ) } + if (userId && userId !== user.id) { + return NextResponse.json({ error: 'Cannot charge credits for a different user.' }, { status: 403 }) + } + if (!appName || !description || !technologies) { 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 }, - ) - } + chargeMetadata = { appName, technologies } + const deductResult = await deductCredits(user.id, CREDITS.SCAFFOLD_COST, 'scaffold', chargeMetadata) + 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 +126,24 @@ 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 - } - } + chargedUserId = null 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', { + ...chargeMetadata, + error: error instanceof Error ? error.message : String(error), + }).catch((refundError) => { + console.error('[scaffold] Failed to refund credits after generation failure:', refundError) + }) + } return NextResponse.json( { error: error instanceof Error ? error.message : 'Failed to generate scaffold' }, { status: 500 }, diff --git a/app/api/projects/[id]/milestones/[milestoneId]/route.ts b/app/api/projects/[id]/milestones/[milestoneId]/route.ts index 0b04cb1..f75fd26 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 + const { id, milestoneId } = await params try { const { completed } = await request.json() - const milestone = await toggleMilestone(milestoneId, Boolean(completed)) + const milestone = await toggleMilestone(milestoneId, id, 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(milestoneId, id, 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..9f0e4e7 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -6,6 +6,8 @@ import { getUserByGithubId, updateUserBilling, upsertSubscription, + claimStripeWebhookEvent, + releaseStripeWebhookEvent, } from '@/lib/queries' import { getStripe, getWebhookSecret, getPriceIdForPlan } from '@/lib/stripe' @@ -164,7 +166,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ received: true, ignored: event.type }) } + let claimedEvent = false try { + claimedEvent = await claimStripeWebhookEvent(event.id, event.type) + if (!claimedEvent) { + return NextResponse.json({ received: true, duplicate: event.id }) + } + switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session @@ -181,7 +189,7 @@ export async function POST(request: NextRequest) { console.error('[stripe/webhook] Could not map checkout session to a GitHub user', { sessionId: session.id, }) - break + throw new Error(`Could not map checkout session ${session.id} to a GitHub user`) } const { user, priceId } = await persistSubscriptionState({ githubId, customerId, subscription }) @@ -204,7 +212,7 @@ export async function POST(request: NextRequest) { console.error('[stripe/webhook] Could not map subscription to a GitHub user', { subscriptionId: subscription.id, }) - break + throw new Error(`Could not map subscription ${subscription.id} to a GitHub user`) } await persistSubscriptionState({ githubId, customerId, subscription }) @@ -220,7 +228,7 @@ export async function POST(request: NextRequest) { console.error('[stripe/webhook] Could not map deleted subscription to a GitHub user', { subscriptionId: subscription.id, }) - break + throw new Error(`Could not map deleted subscription ${subscription.id} to a GitHub user`) } const savedSubscription = await upsertSubscription({ @@ -296,18 +304,20 @@ export async function POST(request: NextRequest) { if (subscription) { const githubId = await resolveGithubId(stripe, subscription, customerId) - if (githubId) { - const { user, priceId } = await persistSubscriptionState({ githubId, customerId, subscription }) - const billingReason = (invoice as unknown as { billing_reason?: string | null }).billing_reason - - if (user && billingReason !== 'subscription_create') { - const amount = getCreditGrantForPrice(priceId, CREDITS.MONTHLY_GRANT) - await renewMonthlyCredits(user.id, amount, 'Monthly subscription renewal', { - invoice_id: invoice.id, - stripe_customer_id: customerId, - stripe_subscription_id: subscription.id, - }) - } + if (!githubId) { + throw new Error(`Could not map invoice ${invoice.id} subscription ${subscription.id} to a GitHub user`) + } + + const { user, priceId } = await persistSubscriptionState({ githubId, customerId, subscription }) + const billingReason = (invoice as unknown as { billing_reason?: string | null }).billing_reason + + if (user && billingReason !== 'subscription_create') { + const amount = getCreditGrantForPrice(priceId, CREDITS.MONTHLY_GRANT) + await renewMonthlyCredits(user.id, amount, 'Monthly subscription renewal', { + invoice_id: invoice.id, + stripe_customer_id: customerId, + stripe_subscription_id: subscription.id, + }) } } break @@ -315,6 +325,11 @@ export async function POST(request: NextRequest) { } } catch (err) { console.error('[stripe/webhook] Handler error:', { eventId: event.id, type: event.type, err }) + if (claimedEvent) { + await releaseStripeWebhookEvent(event.id).catch((releaseError) => { + console.error('[stripe/webhook] Failed to release failed event claim:', releaseError) + }) + } return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }) } diff --git a/lib/credits.ts b/lib/credits.ts index 34c0da9..22fa036 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -58,23 +58,20 @@ function toCount(value: string | number | null | undefined): number { // Initialize or get user credits export async function getOrCreateUserCredits(userId: string): Promise { const sql = getDb() - - // Try to get existing - const existing = await sql` - SELECT * FROM user_credits WHERE user_id = ${userId} - ` - - if (existing.length > 0) { - return existing[0] as UserCredit - } - - // Create new + const result = await sql` - INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) - VALUES (${userId}, 0, 0, 0) - RETURNING * + WITH inserted AS ( + INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) + VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO NOTHING + RETURNING * + ) + SELECT * FROM inserted + UNION ALL + SELECT * FROM user_credits WHERE user_id = ${userId} + LIMIT 1 ` - + return result[0] as UserCredit } @@ -100,29 +97,28 @@ 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} + INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) + VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO NOTHING ` - - // Record transaction + const transaction = await sql` + WITH 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} + RETURNING current_balance + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated RETURNING * ` @@ -137,40 +133,42 @@ 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} + INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) + VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO NOTHING ` const transaction = await sql` + WITH locked AS ( + SELECT + current_balance, + GREATEST(0, ${monthlyAllowance} - current_balance) AS top_up + FROM user_credits + WHERE user_id = ${userId} + FOR UPDATE + ), + updated AS ( + UPDATE user_credits u + SET + current_balance = u.current_balance + locked.top_up, + total_granted = u.total_granted + locked.top_up, + last_renewal_date = CURRENT_TIMESTAMP + FROM locked + WHERE u.user_id = ${userId} + RETURNING u.current_balance, locked.top_up + ) INSERT INTO credit_transactions ( user_id, amount, transaction_type, reason, metadata, balance_after ) - VALUES ( - ${userId}, ${topUpAmount}, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} - ) + SELECT ${userId}, top_up, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, current_balance + FROM updated + WHERE top_up > 0 RETURNING * ` - return transaction[0] as CreditTransaction + return (transaction[0] as CreditTransaction | undefined) ?? null } // Deduct credits (for analysis, scaffold, build_app, or pattern_analyzer) @@ -181,41 +179,39 @@ 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} + INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) + VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO NOTHING ` - - // Record transaction + 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,26 +226,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} + INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) + VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO NOTHING ` - - // Record transaction + 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..9319761 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -380,6 +380,11 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { + const sql = getDb() + await sql`DELETE FROM app_blueprints WHERE id = ${id} AND analysis_id = ${analysisId}` +} + export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise { const sql = getDb() await sql` @@ -394,6 +399,29 @@ export async function updateUserBilling(userId: string, data: UserBillingUpdate) ` } +export async function claimStripeWebhookEvent(eventId: string, eventType: string): Promise { + const sql = getDb() + await sql` + CREATE TABLE IF NOT EXISTS stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + ` + const result = await sql` + INSERT INTO stripe_webhook_events (event_id, event_type) + VALUES (${eventId}, ${eventType}) + ON CONFLICT (event_id) DO NOTHING + RETURNING event_id + ` + return result.length > 0 +} + +export async function releaseStripeWebhookEvent(eventId: string): Promise { + const sql = getDb() + await sql`DELETE FROM stripe_webhook_events WHERE event_id = ${eventId}` +} + export async function createBlueprint(data: { analysis_id: string user_id: string @@ -1044,27 +1072,49 @@ export async function createMilestone(data: { return result[0] as ProjectMilestone } -export async function toggleMilestone(id: string, completed: boolean): Promise { +export async function toggleMilestone( + id: string, + projectId: string, + userId: string, + completed: boolean, +): Promise { const sql = getDb() const result = completed ? await sql` - UPDATE project_milestones + UPDATE project_milestones m SET completed = true, completed_at = NOW() - WHERE id = ${id} - RETURNING * + FROM projects 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 m SET completed = false, completed_at = NULL - WHERE id = ${id} - RETURNING * + FROM projects 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(id: string, projectId: string, userId: string): Promise { const sql = getDb() - await sql`DELETE FROM project_milestones WHERE id = ${id}` + const result = await sql` + DELETE FROM project_milestones m + USING projects 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/009_stripe_webhook_events.sql b/migrations/009_stripe_webhook_events.sql new file mode 100644 index 0000000..64d29f3 --- /dev/null +++ b/migrations/009_stripe_webhook_events.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);