From 9db1b6fc16131e7eaef650f1ab0741767db57c53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 25 Jun 2026 11:11:31 +0000 Subject: [PATCH] Fix critical auth and billing regressions Co-authored-by: Cole Collins --- app/api/analyses/[id]/analyze/route.ts | 61 ++-- app/api/analyses/[id]/run/route.ts | 81 ++++- app/api/app-idea-chat/route.ts | 19 +- app/api/build-app/route.ts | 101 ++++-- app/api/code-completion/route.ts | 29 +- app/api/generate-scaffold/route.ts | 54 ++- app/api/pattern-analyzer/route.ts | 27 +- .../[id]/milestones/[milestoneId]/route.ts | 9 +- app/api/projects/[id]/route.ts | 2 +- app/api/setup/init-db/route.ts | 35 ++ app/api/stripe/webhook/route.ts | 4 + lib/credits.ts | 334 ++++++++++++------ lib/queries.ts | 70 +++- .../009_credit_transaction_idempotency.sql | 7 + 14 files changed, 599 insertions(+), 234 deletions(-) create mode 100644 migrations/009_credit_transaction_idempotency.sql diff --git a/app/api/analyses/[id]/analyze/route.ts b/app/api/analyses/[id]/analyze/route.ts index 65e9bec..351937f 100644 --- a/app/api/analyses/[id]/analyze/route.ts +++ b/app/api/analyses/[id]/analyze/route.ts @@ -1,6 +1,8 @@ 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' +import { getAnalysisById } from '@/lib/queries' const model = 'openai/gpt-4-turbo' @@ -20,30 +22,45 @@ interface AppSuggestion { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let creditMetadata: 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?.id) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + if (!analysisId || !Array.isArray(selectedRepos)) { + return NextResponse.json({ error: 'analysisId and selectedRepos are required' }, { status: 400 }) + } + + const analysis = await getAnalysisById(analysisId, user.id) + if (!analysis) { + return NextResponse.json({ error: 'Analysis not found' }, { status: 404 }) } - const currentBalance = await getCreditBalance(userId) - if (currentBalance < CREDITS.ANALYSIS_COST) { + creditMetadata = { + analysisId, + selectedRepos: selectedRepos.map((r) => r.name), + } + const deductResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', creditMetadata) + if (!deductResult.success) { return NextResponse.json( { error: 'Insufficient credits', required: CREDITS.ANALYSIS_COST, - available: currentBalance, - message: 'Upgrade to Pro to get unlimited analyses with 3,000 monthly credits.', + message: deductResult.error ?? '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 = {} @@ -92,20 +109,10 @@ Return as JSON array of app suggestions. Focus on practical, buildable applicati } } catch (e) { console.error('Failed to parse AI response:', e) + throw new Error('Failed to parse AI response') } - - // 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 } - ) + if (suggestions.length === 0) { + throw new Error('AI returned no app suggestions') } const newBalance = deductResult.transaction?.balance_after || 0 @@ -120,6 +127,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', { + ...creditMetadata, + failure: error instanceof Error ? error.message : String(error), + }).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..f3137e2 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, + deleteBlueprintsByIds, getBlueprintsByAnalysis, getSubscriptionByGithubId, upsertSubscription, @@ -23,6 +23,7 @@ import { getAnthropicModel } from '@/lib/anthropic-model' import { isOnFreeTier } from '@/lib/pro-access' import { PLANS } from '@/lib/stripe' import { generateGapsFromBlueprint, generateTemplatesFromBlueprints } from '@/lib/gap-generation' +import { CREDITS, deductCredits, refundCredits } from '@/lib/credits' // Schema for AI-generated app blueprints const complexityEnum = z.preprocess((val) => { @@ -141,9 +142,24 @@ export async function POST( const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { + let chargedUserId: string | null = null + let ownerUserId: string | null = null + let oldBlueprintsDeleted = false + const createdBlueprintIds: string[] = [] const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + const refundAnalysisCharge = async (reason: string) => { + if (!chargedUserId) return + const userId = chargedUserId + chargedUserId = null + await refundCredits(userId, CREDITS.ANALYSIS_COST, 'Analysis run failed', { + analysisId: id, + failure: reason, + }).catch((refundError) => { + console.error('[analysis] Failed to refund credits:', refundError) + }) + } try { const accessToken = await getCurrentAccessToken() @@ -164,6 +180,7 @@ export async function POST( controller.close() return } + ownerUserId = user.id let sub = await getSubscriptionByGithubId(user.github_id).catch(() => null) if (!sub) { @@ -198,9 +215,21 @@ export async function POST( return } + const creditResult = await deductCredits(user.id, CREDITS.ANALYSIS_COST, 'analysis', { analysisId: id }) + if (!creditResult.success) { + send({ + status: 'failed', + error: creditResult.error ?? `Analysis requires ${CREDITS.ANALYSIS_COST} credits.`, + }) + controller.close() + return + } + chargedUserId = user.id + + const existingBlueprintIds = (await getBlueprintsByAnalysis(id, user.id)).map((bp) => bp.id) + // Update status to scanning await updateAnalysisStatus(id, 'scanning') - await deleteBlueprintsByAnalysis(id) send({ status: 'scanning', progress: 10 }) // Fetch file trees from GitHub for each repository @@ -263,6 +292,7 @@ export async function POST( const msg = `No source files found to analyze. ${details} Add repos with application code or check your GitHub access token.` send({ status: 'failed', error: msg }) await updateAnalysisStatus(id, 'failed', { error_message: msg }) + await refundAnalysisCharge(msg) controller.close() return } @@ -420,6 +450,7 @@ 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 }) + await refundAnalysisCharge(msg) controller.close() return } @@ -430,22 +461,31 @@ 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, - }) + 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) + } + } catch (error) { + await deleteBlueprintsByIds(createdBlueprintIds, user.id) + throw error } + + await deleteBlueprintsByIds(existingBlueprintIds, user.id) + oldBlueprintsDeleted = true } // Update to complete @@ -462,12 +502,19 @@ For each app blueprint: } await generateTemplatesFromBlueprints(finalBlueprints) + chargedUserId = null send({ status: 'complete', progress: 100, blueprints: finalBlueprints }) controller.close() } catch (error) { console.error('Analysis error:', error) const errorDetail = error instanceof Error ? error.message : 'Unknown error' + if (ownerUserId && !oldBlueprintsDeleted) { + await deleteBlueprintsByIds(createdBlueprintIds, ownerUserId).catch((cleanupError) => { + console.error('Failed to clean up partial blueprints:', cleanupError) + }) + } + await refundAnalysisCharge(errorDetail) 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..4c0b6e7 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,6 +59,9 @@ function parseAppIdeaChatResponse(raw: string): AppIdeaChatResponse { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let chargedAnalysisId: string | undefined + try { if (!isAiConfigured()) { return NextResponse.json({ error: aiConfigErrorMessage() }, { status: 503 }) @@ -96,6 +99,8 @@ export async function POST(request: NextRequest) { if (!creditResult.success) { return NextResponse.json({ error: creditResult.error || 'Insufficient credits' }, { status: 402 }) } + chargedUserId = user.id + chargedAnalysisId = analysisId let codebaseContext = '' if (analysisId) { @@ -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,14 @@ 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: chargedAnalysisId, + failure: errorMsg, + }).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..5dbb000 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 ?? res.statusText}`) } } @@ -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 ?? res.statusText}`) } } @@ -198,15 +199,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 chargeMetadata: Record = {} const send = (data: object) => { controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) } + const close = () => { + if (!closed) { + closed = true + controller.close() + } + } + const refundCharge = async (message: string) => { + if (!chargedUserId) return + const userId = chargedUserId + chargedUserId = null + await refundCredits(userId, CREDITS.BUILD_APP_COST, 'Build app failed', { + ...chargeMetadata, + failure: message, + }).catch((refundError) => { + console.error('[build-app] Failed to refund credits:', refundError) + }) + } try { const user = await getCurrentUser() if (!user) { send({ step: 'error', message: 'Sign in before building an app.' }) - controller.close() + close() return } @@ -216,7 +237,7 @@ 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 } @@ -225,11 +246,27 @@ export async function POST(request: NextRequest) { if (!repoName?.trim()) { send({ step: 'error', message: 'Repository name is required.' }) - controller.close() + close() return } const cleanRepoName = repoName.trim().replace(/\s+/g, '-').toLowerCase() + chargeMetadata = { + platform, + repoName: cleanRepoName, + blueprintName: blueprint.name, + } + + const deductResult = await deductCredits(user.id, CREDITS.BUILD_APP_COST, 'build_app', chargeMetadata) + if (!deductResult.success) { + send({ + step: 'error', + message: deductResult.error ?? `Build This App requires ${CREDITS.BUILD_APP_COST} credits.`, + }) + close() + return + } + chargedUserId = user.id // Step 1 — determine files to generate const filesToGenerate = getFilesToGenerate(blueprint) @@ -264,11 +301,10 @@ 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() + const message = `Could not create repository: ${e instanceof Error ? e.message : String(e)}. Make sure you are connected to ${platform === 'github' ? 'GitHub' : 'GitLab'}.` + await refundCharge(message) + send({ step: 'error', message }) + close() return } @@ -283,16 +319,31 @@ export async function POST(request: NextRequest) { let content: string try { content = await generateSingleFile(blueprint, path, purpose, user.id) + if (!content.trim()) { + throw new Error(`Generated empty content for ${path}`) + } } 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 message = `Failed to generate ${path}: ${e instanceof Error ? e.message : String(e)}` + await refundCharge(message) + send({ step: 'error', message }) + 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) { + const message = e instanceof Error ? e.message : String(e) + await refundCharge(message) + send({ step: 'error', message }) + close() + return } pushed++ @@ -312,15 +363,17 @@ export async function POST(request: NextRequest) { repoUrl, filesCreated: pushed, }) + chargedUserId = null } catch (e) { console.error('[build-app] unhandled error:', e) + await refundCharge(e instanceof Error ? e.message : String(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/code-completion/route.ts b/app/api/code-completion/route.ts index f7ede13..8f895d1 100644 --- a/app/api/code-completion/route.ts +++ b/app/api/code-completion/route.ts @@ -5,6 +5,7 @@ import { batchGenerateCompletions, } from '@/lib/code-completion' import { getDb } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth' const LANG_EXTENSIONS: Record = { python: ['.py'], @@ -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 rf.extension = ANY(${extensions}::text[]) + AND rf.ai_summary IS NOT NULL + AND r.user_id = ${userId} + 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: 'Not authenticated' }, { 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: 'Not authenticated' }, { 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..ca9e0dc 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: 'Insufficient credits', + required: CREDITS.SCAFFOLD_COST, + message: deductResult.error ?? 'Upgrade to Pro to get 3,000 monthly credits.', + }, + { status: 402 }, + ) } + chargedUserId = user.id const raw = await generateWithGateway({ feature: 'scaffold', @@ -120,26 +122,22 @@ 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, + failure: error instanceof Error ? error.message : String(error), + }).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..0804280 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,6 +42,9 @@ export interface PatternAnalyzerResult { } export async function POST(request: NextRequest) { + let chargedUserId: string | null = null + let chargedAnalysisId: string | null = null + try { const user = await getCurrentUser() if (!user) { @@ -61,11 +64,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 +75,13 @@ 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 + chargedAnalysisId = analysisId + // 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,14 @@ 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: chargedAnalysisId, + failure: error instanceof Error ? error.message : String(error), + }).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..e595843 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(id, user.id, milestoneId, 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, user.id, milestoneId) + 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/projects/[id]/route.ts b/app/api/projects/[id]/route.ts index 863bbf3..248c732 100644 --- a/app/api/projects/[id]/route.ts +++ b/app/api/projects/[id]/route.ts @@ -11,7 +11,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: try { const [project, milestones] = await Promise.all([ getProjectById(id, user.id), - getMilestonesByProject(id), + getMilestonesByProject(id, user.id), ]) if (!project) return NextResponse.json({ error: 'Not found' }, { status: 404 }) return NextResponse.json({ project, milestones }) diff --git a/app/api/setup/init-db/route.ts b/app/api/setup/init-db/route.ts index de1874a..94e8d4a 100644 --- a/app/api/setup/init-db/route.ts +++ b/app/api/setup/init-db/route.ts @@ -169,6 +169,41 @@ async function run() { await sql`CREATE INDEX IF NOT EXISTS idx_analyses_user_id ON analyses(user_id)` await sql`CREATE INDEX IF NOT EXISTS idx_app_blueprints_analysis_id ON app_blueprints(analysis_id)` await sql`CREATE INDEX IF NOT EXISTS idx_app_blueprints_user_id ON app_blueprints(user_id)` + await sql` + CREATE TABLE IF NOT EXISTS user_credits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES user_auth(id) ON DELETE CASCADE, + current_balance BIGINT NOT NULL DEFAULT 0, + total_granted BIGINT NOT NULL DEFAULT 0, + total_used BIGINT NOT NULL DEFAULT 0, + last_renewal_date TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) + ) + ` + await sql` + CREATE TABLE IF NOT EXISTS credit_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES user_auth(id) ON DELETE CASCADE, + amount BIGINT NOT NULL, + transaction_type VARCHAR(50) NOT NULL, + reason TEXT, + metadata JSONB DEFAULT '{}', + balance_after BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ` + await sql`ALTER TABLE credit_transactions ADD COLUMN IF NOT EXISTS idempotency_key TEXT` + await sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_transactions_idempotency_key + ON credit_transactions (idempotency_key) + WHERE idempotency_key IS NOT NULL + ` + await sql`CREATE INDEX IF NOT EXISTS idx_user_credits_user_id ON user_credits(user_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id)` + await sql`CREATE INDEX IF NOT EXISTS idx_credit_transactions_type ON credit_transactions(transaction_type)` + await sql`CREATE INDEX IF NOT EXISTS idx_credit_transactions_created ON credit_transactions(created_at)` await sql` CREATE TABLE IF NOT EXISTS missing_file_gaps ( diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 9b80351..d752b9f 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: `stripe:${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: `stripe:${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..624c769 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,27 +56,30 @@ 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() - - // Try to get existing + + await sql` + INSERT INTO user_credits (user_id, current_balance, total_granted, total_used) + VALUES (${userId}, 0, 0, 0) + ON CONFLICT (user_id) DO NOTHING + ` + 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 * - ` - - return result[0] as UserCredit + + throw new Error('Failed to initialize user credits') } // Get current credit balance @@ -100,32 +104,60 @@ 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) + + if (idempotencyKey) { + const existing = await sql` + SELECT * FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + ` + if (existing[0]) return existing[0] as CreditTransaction + } + + 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 inserted AS ( + INSERT INTO credit_transactions ( + user_id, amount, transaction_type, reason, metadata, balance_after, idempotency_key + ) + VALUES ( + ${userId}, ${amount}, 'grant', ${reason}, ${JSON.stringify(metadata)}::jsonb, 0, ${idempotencyKey} + ) + ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING + RETURNING id + ), + 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 EXISTS (SELECT 1 FROM inserted) + RETURNING current_balance ) - RETURNING * + UPDATE credit_transactions ct + SET balance_after = updated.current_balance + FROM updated + WHERE ct.id IN (SELECT id FROM inserted) + RETURNING ct.* ` - + + if (!transaction[0] && idempotencyKey) { + const existing = await sql` + SELECT * FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + ` + if (existing[0]) return existing[0] as CreditTransaction + } + + if (!transaction[0]) { + throw new Error('Failed to grant credits') + } + return transaction[0] as CreditTransaction } @@ -137,10 +169,57 @@ export async function renewMonthlyCredits( metadata: CreditMetadata = {} ): Promise { const sql = getDb() + const idempotencyKey = getIdempotencyKey(metadata) + + if (idempotencyKey) { + const existing = await sql` + SELECT * FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + ` + if (existing[0]) return existing[0] as CreditTransaction + } + const userCredits = await getOrCreateUserCredits(userId) const topUpAmount = Math.max(0, monthlyAllowance - userCredits.current_balance) if (topUpAmount === 0) { + if (idempotencyKey) { + const transaction = await sql` + WITH inserted AS ( + INSERT INTO credit_transactions ( + user_id, amount, transaction_type, reason, metadata, balance_after, idempotency_key + ) + VALUES ( + ${userId}, 0, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, 0, ${idempotencyKey} + ) + ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING + RETURNING id + ), + updated AS ( + UPDATE user_credits + SET last_renewal_date = CURRENT_TIMESTAMP + WHERE user_id = ${userId} + AND EXISTS (SELECT 1 FROM inserted) + RETURNING current_balance + ) + UPDATE credit_transactions ct + SET balance_after = updated.current_balance + FROM updated + WHERE ct.id IN (SELECT id FROM inserted) + RETURNING ct.* + ` + + if (transaction[0]) return transaction[0] as CreditTransaction + + const existing = await sql` + SELECT * FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + ` + if (existing[0]) return existing[0] as CreditTransaction + } + await sql` UPDATE user_credits SET last_renewal_date = CURRENT_TIMESTAMP @@ -149,27 +228,47 @@ export async function renewMonthlyCredits( 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 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 inserted AS ( + INSERT INTO credit_transactions ( + user_id, amount, transaction_type, reason, metadata, balance_after, idempotency_key + ) + VALUES ( + ${userId}, ${topUpAmount}, 'renewal', ${reason}, ${JSON.stringify(metadata)}::jsonb, 0, ${idempotencyKey} + ) + ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING + RETURNING id + ), + updated AS ( + UPDATE user_credits + SET + current_balance = current_balance + ${topUpAmount}, + total_granted = total_granted + ${topUpAmount}, + last_renewal_date = CURRENT_TIMESTAMP + WHERE user_id = ${userId} + AND EXISTS (SELECT 1 FROM inserted) + RETURNING current_balance ) - RETURNING * + UPDATE credit_transactions ct + SET balance_after = updated.current_balance + FROM updated + WHERE ct.id IN (SELECT id FROM inserted) + RETURNING ct.* ` + if (!transaction[0] && idempotencyKey) { + const existing = await sql` + SELECT * FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + ` + if (existing[0]) return existing[0] as CreditTransaction + } + + if (!transaction[0]) { + throw new Error('Failed to renew credits') + } + return transaction[0] as CreditTransaction } @@ -181,45 +280,44 @@ 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 * ` - - return { - success: true, - transaction: transaction[0] as CreditTransaction, + + if (transaction[0]) { + return { + success: true, + transaction: transaction[0] as CreditTransaction, + } + } + + const currentBalance = await getCreditBalance(userId) + if (currentBalance < amount) { + return { + success: false, + error: `Insufficient credits. Required: ${amount}, Available: ${currentBalance}`, + } } + + return { success: false, error: 'Failed to deduct credits' } } // Refund credits @@ -230,29 +328,57 @@ 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 + const idempotencyKey = getIdempotencyKey(metadata) + + if (idempotencyKey) { + const existing = await sql` + SELECT * FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + ` + if (existing[0]) return existing[0] as CreditTransaction + } + + await getOrCreateUserCredits(userId) + const transaction = await sql` - INSERT INTO credit_transactions ( - user_id, amount, transaction_type, reason, metadata, balance_after - ) - VALUES ( - ${userId}, ${amount}, 'refund', ${reason}, ${JSON.stringify(metadata)}::jsonb, ${newBalance} + WITH inserted AS ( + INSERT INTO credit_transactions ( + user_id, amount, transaction_type, reason, metadata, balance_after, idempotency_key + ) + VALUES ( + ${userId}, ${amount}, 'refund', ${reason}, ${JSON.stringify(metadata)}::jsonb, 0, ${idempotencyKey} + ) + ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING + RETURNING id + ), + updated AS ( + UPDATE user_credits + SET current_balance = current_balance + ${amount} + WHERE user_id = ${userId} + AND EXISTS (SELECT 1 FROM inserted) + RETURNING current_balance ) - RETURNING * + UPDATE credit_transactions ct + SET balance_after = updated.current_balance + FROM updated + WHERE ct.id IN (SELECT id FROM inserted) + RETURNING ct.* ` - + + if (!transaction[0] && idempotencyKey) { + const existing = await sql` + SELECT * FROM credit_transactions + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + ` + if (existing[0]) return existing[0] as CreditTransaction + } + + if (!transaction[0]) { + throw new Error('Failed to refund credits') + } + return transaction[0] as CreditTransaction } diff --git a/lib/queries.ts b/lib/queries.ts index 7dd365e..f3fc2f9 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -380,6 +380,16 @@ export async function deleteBlueprintsByAnalysis(analysisId: string): Promise { + if (ids.length === 0) return + const sql = getDb() + await sql` + DELETE FROM app_blueprints + WHERE id = ANY(${ids}::uuid[]) + AND user_id = ${userId} + ` +} + export async function updateUserBilling(userId: string, data: UserBillingUpdate): Promise { const sql = getDb() await sql` @@ -1019,13 +1029,21 @@ export async function deleteProject(id: string, userId: string): Promise { // ── Milestone queries ───────────────────────────────────────────────────────── -export async function getMilestonesByProject(projectId: string): Promise { +export async function getMilestonesByProject(projectId: string, userId?: string): Promise { const sql = getDb() - const rows = await sql` - SELECT * FROM project_milestones - WHERE project_id = ${projectId} - ORDER BY phase, sort_order, created_at - ` + const rows = userId + ? await sql` + SELECT m.* FROM project_milestones m + JOIN projects p ON p.id = m.project_id + WHERE m.project_id = ${projectId} + AND p.user_id = ${userId} + ORDER BY m.phase, m.sort_order, m.created_at + ` + : await sql` + SELECT * FROM project_milestones + WHERE project_id = ${projectId} + ORDER BY phase, sort_order, created_at + ` return rows as ProjectMilestone[] } @@ -1044,27 +1062,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, + userId: string, + id: 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(projectId: string, userId: string, id: 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_credit_transaction_idempotency.sql b/migrations/009_credit_transaction_idempotency.sql new file mode 100644 index 0000000..03d4076 --- /dev/null +++ b/migrations/009_credit_transaction_idempotency.sql @@ -0,0 +1,7 @@ +-- Ensure retried external events cannot apply the same credit mutation twice. +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;