Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/src/app/(app)/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -159,6 +160,11 @@ export default async function ProfilePage({ searchParams }: AppPageProps) {
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-sm font-medium">Add Credits</p>
<DevAddCreditsButton />
</div>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-sm font-medium">Consume Credits</p>
<DevConsumeCreditsButton />
Expand Down
71 changes: 71 additions & 0 deletions apps/web/src/app/api/dev/add-credits/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 });
}
}
91 changes: 91 additions & 0 deletions apps/web/src/components/dev/DevAddCreditsButton.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<Input
type="number"
step="0.01"
min="0"
placeholder="Amount in dollars"
value={amount}
onChange={e => {
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}
/>
<Button
type="button"
variant="secondary"
size="md"
className="flex items-center"
onClick={handleAdd}
disabled={isLoading || !amount}
>
<Plus className="mr-2 h-4 w-4" />
{isLoading ? 'Adding...' : 'Add'}
</Button>
</div>
{amountError && (
<p id="dev-add-amount-error" className="text-destructive text-xs">
{amountError}
</p>
)}
</div>
);
}