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
117 changes: 115 additions & 2 deletions apps/web/src/lib/ai-gateway/processUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ import { join } from 'node:path';
import { createReadStream } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { db } from '@/lib/drizzle';
import { microdollar_usage, microdollar_usage_metadata } from '@kilocode/db/schema';
import { eq, getTableColumns } from 'drizzle-orm';
import {
microdollar_usage,
microdollar_usage_daily,
microdollar_usage_metadata,
} from '@kilocode/db/schema';
import { and, eq, getTableColumns, isNull, sql } from 'drizzle-orm';
import { findUserById } from '../user';
import { Readable } from 'node:stream';
import { getFraudDetectionHeaders, toMicrodollars } from '../utils';
Expand Down Expand Up @@ -740,6 +744,115 @@ describe('logMicrodollarUsage', () => {
const updatedUser = await findUserById('test-insert-org-user');
expect(updatedUser?.microdollars_used).toBe(4000); // unchanged
});

test('insertUsageRecord populates microdollar_usage_daily for personal usage', async () => {
const user = await insertTestUser({
id: 'test-daily-personal-user',
microdollars_used: 0,
google_user_email: 'daily-personal@example.com',
});

await insertUsageWithOverrides({
kilo_user_id: user.id,
cost: 1500,
});

const dailyRows = await db
.select()
.from(microdollar_usage_daily)
.where(
and(
eq(microdollar_usage_daily.kilo_user_id, user.id),
isNull(microdollar_usage_daily.organization_id)
)
);

expect(dailyRows).toHaveLength(1);
expect(dailyRows[0].total_cost_microdollars).toBe(1500);
expect(dailyRows[0].organization_id).toBeNull();
});

test('insertUsageRecord increments microdollar_usage_daily on subsequent inserts on the same day', async () => {
const user = await insertTestUser({
id: 'test-daily-increment-user',
microdollars_used: 0,
google_user_email: 'daily-increment@example.com',
});

await insertUsageWithOverrides({ kilo_user_id: user.id, cost: 1000 });
await insertUsageWithOverrides({ kilo_user_id: user.id, cost: 2500 });
await insertUsageWithOverrides({ kilo_user_id: user.id, cost: 700 });

const [row] = await db
.select({
total: sql<number>`coalesce(sum(${microdollar_usage_daily.total_cost_microdollars}), 0)::int`,
})
.from(microdollar_usage_daily)
.where(
and(
eq(microdollar_usage_daily.kilo_user_id, user.id),
isNull(microdollar_usage_daily.organization_id)
)
);

expect(row.total).toBe(4200);
});

test('insertUsageRecord writes org-scoped rollup separately from personal rollup', async () => {
const user = await insertTestUser({
id: 'test-daily-org-scope-user',
microdollars_used: 0,
google_user_email: 'daily-org-scope@example.com',
});
const orgId = '11111111-1111-1111-1111-111111111111';

await insertUsageWithOverrides({ kilo_user_id: user.id, cost: 500 });
await insertUsageWithOverrides({
kilo_user_id: user.id,
organization_id: orgId,
cost: 9000,
});

const personalRows = await db
.select()
.from(microdollar_usage_daily)
.where(
and(
eq(microdollar_usage_daily.kilo_user_id, user.id),
isNull(microdollar_usage_daily.organization_id)
)
);
expect(personalRows).toHaveLength(1);
expect(personalRows[0].total_cost_microdollars).toBe(500);

const orgRows = await db
.select()
.from(microdollar_usage_daily)
.where(
and(
eq(microdollar_usage_daily.kilo_user_id, user.id),
eq(microdollar_usage_daily.organization_id, orgId)
)
);
expect(orgRows).toHaveLength(1);
expect(orgRows[0].total_cost_microdollars).toBe(9000);
});

test('insertUsageRecord skips microdollar_usage_daily for zero-cost rows', async () => {
const user = await insertTestUser({
id: 'test-daily-zero-cost-user',
microdollars_used: 0,
google_user_email: 'daily-zero@example.com',
});

await insertUsageWithOverrides({ kilo_user_id: user.id, cost: 0 });

const dailyRows = await db
.select()
.from(microdollar_usage_daily)
.where(eq(microdollar_usage_daily.kilo_user_id, user.id));
expect(dailyRows).toHaveLength(0);
});
});

describe('stripNulBytesInPlace', () => {
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/ai-gateway/processUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,14 @@ async function insertUsageAndMetadataWithBalanceUpdate(
coreUsageFields: MicrodollarUsage,
metadataFields: UsageMetaData
): Promise<BalanceUpdateResult> {
// Pick the matching partial unique index for the daily-rollup upsert. The
// microdollar_usage_daily table has two partial unique indexes; the upsert
// must target the one corresponding to this row's scope.
const dailyConflictTarget =
coreUsageFields.organization_id === null
? sql`(kilo_user_id, usage_date) WHERE organization_id IS NULL`
: sql`(kilo_user_id, organization_id, usage_date) WHERE organization_id IS NOT NULL`;

// Use a single SQL statement with CTEs to insert usage, upsert all lookup values, metadata, and update user balance in one roundtrip
// This ensures atomicity: microdollar_usage insert and kilocode_users.microdollars_used update happen together
const result = await db.execute<{
Expand Down Expand Up @@ -544,6 +552,22 @@ async function insertUsageAndMetadataWithBalanceUpdate(
(SELECT mode_id FROM mode_cte),
(SELECT auto_model_id FROM auto_model_cte)
)
, microdollar_usage_daily_upsert AS (
INSERT INTO microdollar_usage_daily (
kilo_user_id, organization_id, usage_date, total_cost_microdollars
)
SELECT
${coreUsageFields.kilo_user_id},
${coreUsageFields.organization_id}::uuid,
date_trunc('day', ${coreUsageFields.created_at}::timestamptz)::date,
${coreUsageFields.cost}::bigint
WHERE ${coreUsageFields.cost} <> 0
ON CONFLICT ${dailyConflictTarget}
DO UPDATE SET
total_cost_microdollars =
microdollar_usage_daily.total_cost_microdollars + EXCLUDED.total_cost_microdollars,
updated_at = NOW()
)
UPDATE kilocode_users
SET microdollars_used = microdollars_used + ${coreUsageFields.cost}
WHERE id = ${coreUsageFields.kilo_user_id}
Expand Down
43 changes: 42 additions & 1 deletion apps/web/src/tests/helpers/microdollar-usage.helper.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { MicrodollarUsage } from '@kilocode/db/schema';
import { microdollar_usage, type MicrodollarUsage } from '@kilocode/db/schema';
import {
toInsertableDbUsageRecord,
insertUsageRecord,
type UsageContextInfo,
} from '@/lib/ai-gateway/processUsage';
import { db } from '@/lib/drizzle';
import { sql } from 'drizzle-orm';
import { EmptyFraudDetectionHeaders } from '@/lib/utils';
import type {
CoreUsageWithMetaData,
Expand Down Expand Up @@ -131,3 +133,42 @@ export function createOrganizationUsage(
const { core } = defineMicrodollarUsage();
return { ...core, kilo_user_id, cost, organization_id };
}

/**
* Insert raw microdollar_usage rows AND bump the matching microdollar_usage_daily
* counters in a single statement, mirroring the dual-write that production
* performs in insertUsageAndMetadataWithBalanceUpdate.
*
* Use this in tests that exercise queries against microdollar_usage_daily
* (e.g. kiloPass.getAverageMonthlyUsageLast3Months). For tests that only
* read microdollar_usage directly, plain db.insert(microdollar_usage) is fine.
*/
export async function insertMicrodollarUsageWithDailyRollup(
rows: (typeof microdollar_usage.$inferInsert)[]
): Promise<void> {
if (rows.length === 0) return;
await db.transaction(async tx => {
await tx.insert(microdollar_usage).values(rows);
for (const row of rows) {
if (!row.cost || row.cost === 0) continue;
Comment thread
RSO marked this conversation as resolved.
const dailyConflictTarget = row.organization_id
? sql`(kilo_user_id, organization_id, usage_date) WHERE organization_id IS NOT NULL`
: sql`(kilo_user_id, usage_date) WHERE organization_id IS NULL`;
await tx.execute(sql`
INSERT INTO microdollar_usage_daily (
kilo_user_id, organization_id, usage_date, total_cost_microdollars
)
SELECT
${row.kilo_user_id},
${row.organization_id ?? null}::uuid,
date_trunc('day', ${row.created_at ?? sql`NOW()`}::timestamptz)::date,
${row.cost}::bigint
ON CONFLICT ${dailyConflictTarget}
DO UPDATE SET
total_cost_microdollars =
microdollar_usage_daily.total_cost_microdollars + EXCLUDED.total_cost_microdollars,
updated_at = NOW()
`);
}
});
}
11 changes: 11 additions & 0 deletions packages/db/src/migrations/0131_known_banshee.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE "microdollar_usage_daily" (
"id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL,
"kilo_user_id" text NOT NULL,
"organization_id" uuid,
"usage_date" date NOT NULL,
"total_cost_microdollars" bigint DEFAULT 0 NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "idx_microdollar_usage_daily_personal" ON "microdollar_usage_daily" USING btree ("kilo_user_id","usage_date") WHERE "microdollar_usage_daily"."organization_id" is null;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_microdollar_usage_daily_org" ON "microdollar_usage_daily" USING btree ("kilo_user_id","organization_id","usage_date") WHERE "microdollar_usage_daily"."organization_id" is not null;
Comment thread
RSO marked this conversation as resolved.
Loading