diff --git a/.kilo/skills/kilo-mcp/SKILL.md b/.kilo/skills/kilo-mcp/SKILL.md index 28ece9816de..3612574bd03 100644 --- a/.kilo/skills/kilo-mcp/SKILL.md +++ b/.kilo/skills/kilo-mcp/SKILL.md @@ -66,6 +66,7 @@ exist as an org-scoped mirror under `organizations`, for example - `codeIndexing` - `codeReviews` - `codingPlans` +- `computeBilling` - `credits` - `debug` - `discord` diff --git a/apps/web/src/app/api/internal/organizations/compute-balance-alert/route.test.ts b/apps/web/src/app/api/internal/organizations/compute-balance-alert/route.test.ts new file mode 100644 index 00000000000..aa0ab630ac2 --- /dev/null +++ b/apps/web/src/app/api/internal/organizations/compute-balance-alert/route.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from '@jest/globals'; +import { after, NextRequest } from 'next/server'; +import { INTERNAL_API_SECRET } from '@kilocode/web-shared/lib/config.server'; +import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper'; +import { createTestOrganization } from '@kilocode/web-shared/tests/helpers/organization.helper'; +import { sendBalanceAlertEmail } from '@kilocode/web-shared/lib/email'; +import { dispatchLowBalancePush } from '@kilocode/web-shared/lib/notifications-worker-client'; +import { POST } from './route'; + +jest.mock('next/server', () => ({ ...jest.requireActual('next/server'), after: jest.fn() })); + +jest.mock('@kilocode/web-shared/lib/email', () => ({ + ...jest.requireActual('@kilocode/web-shared/lib/email'), + sendBalanceAlertEmail: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('@kilocode/web-shared/lib/notifications-worker-client', () => ({ + dispatchLowBalancePush: jest.fn().mockResolvedValue(undefined), +})); + +function request(body: unknown, secret = INTERNAL_API_SECRET) { + return new NextRequest('http://localhost/api/internal/organizations/compute-balance-alert', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Internal-Secret': secret }, + body: JSON.stringify(body), + }); +} + +describe('internal organization compute balance alert', () => { + it('requires service authentication and a valid organization ID', async () => { + expect((await POST(request({ organizationId: crypto.randomUUID() }, 'wrong'))).status).toBe( + 401 + ); + expect((await POST(request({ organizationId: 'invalid' }))).status).toBe(400); + expect(sendBalanceAlertEmail).not.toHaveBeenCalled(); + }); + + it('uses the existing email and push delivery with configured recipients', async () => { + const user = await insertTestUser(); + const org = await createTestOrganization('Compute alert recipients', user.id, 1_000_000, { + minimum_balance: 18, + minimum_balance_alert_email: [user.google_user_email], + }); + expect((await POST(request({ organizationId: org.id }))).status).toBe(200); + expect(sendBalanceAlertEmail).not.toHaveBeenCalled(); + const task = jest.mocked(after).mock.calls[0]?.[0]; + expect(typeof task).toBe('function'); + if (typeof task !== 'function') throw new Error('Missing deferred alert'); + await task(); + expect(sendBalanceAlertEmail).toHaveBeenCalledWith({ + organizationId: org.id, + minimum_balance: 18, + to: [user.google_user_email], + }); + expect(dispatchLowBalancePush).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: org.id, + recipientUserIds: [user.id], + minimumBalanceUsd: 18, + }) + ); + }); +}); diff --git a/apps/web/src/app/api/internal/organizations/compute-balance-alert/route.ts b/apps/web/src/app/api/internal/organizations/compute-balance-alert/route.ts new file mode 100644 index 00000000000..11e6f6ca644 --- /dev/null +++ b/apps/web/src/app/api/internal/organizations/compute-balance-alert/route.ts @@ -0,0 +1,33 @@ +import { after, NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; +import { eq } from 'drizzle-orm'; +import { organizations } from '@kilocode/db/schema'; +import { db } from '@kilocode/web-shared/lib/drizzle'; +import { sendOrganizationLowBalanceAlert } from '@kilocode/web-shared/lib/organizations/organization-usage'; +import { toMicrodollars } from '@kilocode/web-shared/lib/microdollars'; +import { authorizeInternalApiRequest } from '@/lib/internal-api-auth'; + +const bodySchema = z.object({ organizationId: z.uuid() }).strict(); + +export async function POST(request: NextRequest) { + const unauthorized = authorizeInternalApiRequest(request); + if (unauthorized) return unauthorized; + const body = bodySchema.safeParse(await request.json().catch(() => null)); + if (!body.success) return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + const [organization] = await db + .select({ settings: organizations.settings }) + .from(organizations) + .where(eq(organizations.id, body.data.organizationId)) + .limit(1); + const minimum = organization?.settings?.minimum_balance; + if (minimum) { + after(() => + sendOrganizationLowBalanceAlert( + body.data.organizationId, + toMicrodollars(minimum), + organization.settings?.minimum_balance_alert_email ?? [] + ) + ); + } + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.test.ts b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.test.ts index f2116788aba..0f0565176ef 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.test.ts +++ b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.test.ts @@ -39,15 +39,17 @@ describe('CloudAgentBillingError', () => { presentation: personal, }); expect(html).toContain('role="alert"'); - expect(html).toContain('Your account needs more credits to start Cloud Agent compute.'); - expect(html).toContain('Available: $1.25 · Need more than $2.00'); - expect(html).toContain('Your prompt did not start.'); + expect(html).toContain( + 'Your account needs more credits for Cloud Agent compute. Add credits, then send a new message.' + ); + expect(html).toContain('Available: $1.25 · Need at least $2.00'); + expect(html).not.toContain('Your prompt did not start.'); expect(html).toContain('Add credits'); }); it.each([ ['remainingMicrodollars', 'Available: $1.00'], - ['minimumRequiredMicrodollars', 'Need more than $2.00'], + ['minimumRequiredMicrodollars', 'Need at least $2.00'], ] as const)('renders %s independently', (key, text) => { const html = renderBillingError({ failure: { @@ -71,7 +73,7 @@ describe('CloudAgentBillingError', () => { ], [ 'BILLING_UNAVAILABLE', - 'Cloud Agent cannot verify compute billing right now. Your prompt has not started and you have not been charged.', + 'Cloud Agent cannot verify compute billing right now. Try again shortly.', ], ] as const)('renders exact %s core copy without an action', (code, copy) => { const html = renderBillingError({ @@ -100,6 +102,6 @@ describe('CloudAgentBillingError', () => { remainingMicrodollars: 5_000_000, minimumRequiredMicrodollars: 5_000_000, }) - ).toBe('Available: $5.00 · Need more than $5.00'); + ).toBe('Available: $5.00 · Need at least $5.00'); }); }); diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.tsx index d441294c1ff..b85afdc98fa 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentBillingError.tsx @@ -30,7 +30,7 @@ export function billingBalanceCopy(failure: CustomerBillingFailure): string | nu const required = failure.minimumRequiredMicrodollars === undefined ? undefined - : `Need more than ${formatBillingMoney(failure.minimumRequiredMicrodollars)}`; + : `Need at least ${formatBillingMoney(failure.minimumRequiredMicrodollars)}`; return [available, required].filter(Boolean).join(' · ') || null; } @@ -41,10 +41,12 @@ export function CloudAgentBillingError({ failure, presentation }: Props) { const balanceCopy = billingBalanceCopy(failure); const content = failure.code === 'INSUFFICIENT_CREDITS' - ? `${payerName} needs more credits to start Cloud Agent compute.` + ? failure.payer.type === 'org' + ? `${payerName} cannot cover Cloud Agent compute. Add credits or ask an owner or admin to check your daily spending limit, then send a new message.` + : `${payerName} needs more credits for Cloud Agent compute. Add credits, then send a new message.` : failure.code === 'COMPUTE_STOPPING' ? 'Cloud Agent is saving and stopping compute. Your prompt has not started. Try again after shutdown completes.' - : 'Cloud Agent cannot verify compute billing right now. Your prompt has not started and you have not been charged.'; + : 'Cloud Agent cannot verify compute billing right now. Try again shortly.'; return (