diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx index e0b8b48be8..29385f828d 100644 --- a/apps/web/src/app/(app)/profile/page.tsx +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -6,6 +6,7 @@ import ProfileExpiringCredits from '@/components/profile/ProfileExpiringCredits' import { getCustomerInfo } from '@/lib/customerInfo'; import { DevNukeAccountButton } from '@/components/dev/DevNukeAccountButton'; import { DevConsumeCreditsButton } from '@/components/dev/DevConsumeCreditsButton'; +import { DevAddCreditsButton } from '@/components/dev/DevAddCreditsButton'; import { getUserFromAuthOrRedirect } from '@/lib/user/server'; import { getOAuthDisplayNames } from '@/lib/user'; import { getExtensionUrl } from '@/components/auth/getExtensionUrl'; @@ -159,6 +160,11 @@ export default async function ProfilePage({ searchParams }: AppPageProps) {
+
+

Add Credits

+ +
+

Consume Credits

diff --git a/apps/web/src/app/api/dev/add-credits/route.ts b/apps/web/src/app/api/dev/add-credits/route.ts new file mode 100644 index 0000000000..ad8dd7e166 --- /dev/null +++ b/apps/web/src/app/api/dev/add-credits/route.ts @@ -0,0 +1,71 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { eq, sql } from 'drizzle-orm'; +import { getUserFromAuth } from '@/lib/user/server'; +import { forceImmediateExpirationRecomputation } from '@/lib/balanceCache'; +import { captureException } from '@sentry/nextjs'; +import { db } from '@/lib/drizzle'; +import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; + +export async function POST(request: NextRequest): Promise { + if (process.env.NODE_ENV !== 'development') { + return NextResponse.json( + { error: 'This endpoint is only available in development mode' }, + { status: 403 } + ); + } + + const { user, authFailedResponse } = await getUserFromAuth({ + adminOnly: false, + }); + + if (authFailedResponse) return authFailedResponse; + + try { + const body = await request.json(); + const { dollarAmount } = body; + + if (typeof dollarAmount !== 'number' || dollarAmount <= 0) { + return NextResponse.json({ error: 'Invalid dollar amount' }, { status: 400 }); + } + + const kiloUserId = user.id; + const amountMicrodollars = Math.ceil(dollarAmount * 1_000_000); + + const newTransactionId = crypto.randomUUID(); + + await db.insert(credit_transactions).values({ + id: newTransactionId, + kilo_user_id: kiloUserId, + is_free: true, + amount_microdollars: amountMicrodollars, + description: 'Dev tool: added credits', + credit_category: 'dev-tools', + original_baseline_microdollars_used: user.microdollars_used, + created_by_kilo_user_id: kiloUserId, + }); + + await db + .update(kilocode_users) + .set({ + total_microdollars_acquired: sql`${kilocode_users.total_microdollars_acquired} + ${amountMicrodollars}`, + }) + .where(eq(kilocode_users.id, kiloUserId)); + + await forceImmediateExpirationRecomputation(kiloUserId); + + console.log( + `Added ${dollarAmount} dollars (${amountMicrodollars} microdollars) for user ${kiloUserId}, transaction ${newTransactionId}` + ); + + return NextResponse.json({ success: true, credit_transaction_id: newTransactionId }); + } catch (error) { + console.error('Error adding credits:', error); + captureException(error, { + tags: { source: 'dev_add_credits_api' }, + extra: { userId: user.id }, + level: 'error', + }); + return NextResponse.json({ error: 'Failed to add credits' }, { status: 500 }); + } +} diff --git a/apps/web/src/components/dev/DevAddCreditsButton.tsx b/apps/web/src/components/dev/DevAddCreditsButton.tsx new file mode 100644 index 0000000000..2dd513b1c0 --- /dev/null +++ b/apps/web/src/components/dev/DevAddCreditsButton.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/Button'; +import { Input } from '@/components/ui/input'; +import { Plus } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; + +export function DevAddCreditsButton() { + const [amount, setAmount] = useState(''); + const [amountError, setAmountError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + if (process.env.NODE_ENV !== 'development') return null; + + const handleAdd = async () => { + const dollarAmount = parseFloat(amount); + if (isNaN(dollarAmount) || dollarAmount <= 0) { + setAmountError('Enter an amount greater than 0.'); + return; + } + setAmountError(null); + + setIsLoading(true); + try { + const response = await fetch('/api/dev/add-credits', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ dollarAmount }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to add credits'); + } + + setAmount(''); + + router.refresh(); + } catch (error) { + console.error('Error adding credits:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to add credits. Please try again.' + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ { + setAmount(e.target.value); + if (amountError) setAmountError(null); + }} + className="max-w-[200px]" + disabled={isLoading} + aria-invalid={amountError !== null} + aria-describedby={amountError ? 'dev-add-amount-error' : undefined} + /> + +
+ {amountError && ( +

+ {amountError} +

+ )} +
+ ); +}