Skip to content
Merged
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
151 changes: 151 additions & 0 deletions apps/web/src/lib/creditTransactions.page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, test, expect } from '@jest/globals';
import { insertTestUser } from '../tests/helpers/user.helper';
import { createTestOrganization } from '../tests/helpers/organization.helper';

import {
getCreditTransactionsForOrganization,
getCreditTransactionsForOrganizationPage,
} from '@/lib/creditTransactions';
import { db, pool } from './drizzle';
import { credit_transactions } from '@kilocode/db/schema';

function whereClause(text: string): string {
const match = text.match(/\bwhere\s+(.+?)\s+order by\s/);
return match ? match[1] : '';
}

describe('getCreditTransactionsForOrganizationPage', () => {
test('pages 26 transactions into 25 entries and matches the summary for the excluded set', async () => {
const user = await insertTestUser();
const org = await createTestOrganization('page org', user.id, 0);

const purchases = Array.from({ length: 26 }, () => ({
kilo_user_id: user.id,
organization_id: org.id,
is_free: false,
amount_microdollars: 1_000_000,
description: 'purchase',
}));
await db.insert(credit_transactions).values(purchases);

// kpo:consumption rows must be absent from both the page and the summary.
await db.insert(credit_transactions).values([
{
kilo_user_id: user.id,
organization_id: org.id,
is_free: true,
amount_microdollars: 5_000_000,
credit_category: 'kpo:consumption:models',
description: 'consumption',
},
{
kilo_user_id: user.id,
organization_id: org.id,
is_free: true,
amount_microdollars: 5_000_000,
credit_category: 'kpo:consumption:models',
description: 'consumption',
},
]);

const page = await getCreditTransactionsForOrganizationPage(org.id);

expect(page.entries).toHaveLength(25);
expect(page.hasMore).toBe(true);
expect(page.nextCursor).toBe(`${page.entries[24]!.created_at}|${page.entries[24]!.id}`);
expect(page.entries.every(entry => !entry.credit_category?.startsWith('kpo:consumption'))).toBe(
true
);

expect(page.summary).toEqual({
total_promotional_musd: 0,
total_purchased_musd: 26_000_000,
credit_transaction_count: 26,
});
});

test('returns empty entries, hasMore false, and zero summary for an empty organization', async () => {
const user = await insertTestUser();
const org = await createTestOrganization('empty page org', user.id, 0);

const page = await getCreditTransactionsForOrganizationPage(org.id);

expect(page.entries).toEqual([]);
expect(page.hasMore).toBe(false);
expect(page.nextCursor).toBeNull();
expect(page.summary).toEqual({
total_promotional_musd: 0,
total_purchased_musd: 0,
credit_transaction_count: 0,
});
});

test('page SQL keeps the old where clause and adds id ordering plus limit+1', async () => {
const user = await insertTestUser();
const org = await createTestOrganization('sql page org', user.id, 0);

const querySpy = jest.spyOn(pool, 'query');

await getCreditTransactionsForOrganization(org.id);
await getCreditTransactionsForOrganizationPage(org.id);

const captured = (querySpy.mock.calls as unknown as unknown[][]).map(call => {
const first = call[0];
const text =
typeof first === 'string' ? first : ((first as { text?: string } | null)?.text ?? '');
return { text, params: (call[1] ?? []) as unknown[] };
});

const oldQuery = captured.find(call => call.text.includes('from "credit_transactions"'));
const pageQuery = captured.find(call => call.text.includes('"id" desc'));

expect(oldQuery).toBeDefined();
expect(pageQuery).toBeDefined();

expect(whereClause(pageQuery!.text)).toBe(whereClause(oldQuery!.text));

expect(pageQuery!.text).toContain('"created_at" desc');
expect(pageQuery!.text.indexOf('"created_at" desc')).toBeLessThan(
pageQuery!.text.indexOf('"id" desc')
);
expect(oldQuery!.text).not.toContain('"id" desc');

expect(pageQuery!.params).toContain(26);

querySpy.mockRestore();
});

// An OFFSET cursor breaks here: a row inserted at the head between the two
// requests shifts every later page, so page 2 repeats a page-1 row.
test('keeps page 2 disjoint from page 1 when a new transaction lands between requests', async () => {
const user = await insertTestUser();
const org = await createTestOrganization('stable page org', user.id, 0);

await db.insert(credit_transactions).values(
Array.from({ length: 30 }, (_, index) => ({
kilo_user_id: user.id,
organization_id: org.id,
is_free: false,
amount_microdollars: 1_000_000,
description: `purchase ${index}`,
}))
);

const first = await getCreditTransactionsForOrganizationPage(org.id);
expect(first.hasMore).toBe(true);

await db.insert(credit_transactions).values({
kilo_user_id: user.id,
organization_id: org.id,
is_free: false,
amount_microdollars: 9_000_000,
description: 'inserted between pages',
});

const second = await getCreditTransactionsForOrganizationPage(org.id, first.nextCursor);

const firstIds = new Set(first.entries.map(entry => entry.id));
expect(second.entries.some(entry => firstIds.has(entry.id))).toBe(false);
expect(second.entries).toHaveLength(5);
});
});
124 changes: 123 additions & 1 deletion apps/web/src/lib/creditTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { db, readDb, sql } from './drizzle';
import type { Organization } from '@kilocode/db/schema';
import { credit_transactions, kilo_pass_issuance_items, kilocode_users } from '@kilocode/db/schema';

type CreditSummary = {
export type CreditSummary = {
total_promotional_musd: number;
total_purchased_musd: number;
credit_transaction_count: number;
Expand Down Expand Up @@ -35,6 +35,33 @@ export async function getCreditTransactionsSummaryByUserId(
};
}

export async function getCreditTransactionsSummaryForOrganization(
organizationId: Organization['id']
): Promise<CreditSummary> {
const { rows } = await db.execute(
sql`
select
coalesce(sum(amount_microdollars) filter (where is_free),0) :: bigint total_promotional_musd,
coalesce(sum(amount_microdollars) filter (where not is_free),0) :: bigint total_purchased_musd,
count(*) as credit_transaction_count
from public.credit_transactions
where organization_id = ${organizationId}
and (credit_category is null or credit_category not like 'kpo:consumption:%')
`
);
const result = rows[0] as {
total_promotional_musd: bigint;
total_purchased_musd: bigint;
credit_transaction_count: bigint;
};

return {
total_promotional_musd: Number(result.total_promotional_musd),
total_purchased_musd: Number(result.total_purchased_musd),
credit_transaction_count: Number(result.credit_transaction_count),
};
}

export type CreditInfo = {
balance: number;
isDepleted: boolean;
Expand Down Expand Up @@ -66,6 +93,7 @@ export async function summarizeUserPayments(kiloUserId: string, fromDb: typeof d
)[0];
}

// old form: array capped at 100, no cursor; remove when every client pages.
export async function getCreditTransactionsForOrganization(organizationId: Organization['id']) {
return db
.select({
Expand Down Expand Up @@ -100,6 +128,100 @@ export async function getCreditTransactionsForOrganization(organizationId: Organ
.limit(100);
}

const CREDIT_TRANSACTIONS_PAGE_SIZE = 25;

type OrganizationCreditTransaction = Awaited<
ReturnType<typeof getCreditTransactionsForOrganization>
>[number];

export type CreditTransactionsPage = {
entries: OrganizationCreditTransaction[];
nextCursor: string | null;
hasMore: boolean;
summary: CreditSummary;
};

/**
* Opaque keyset cursor: the ordering key of the last row a page returned,
* as `<created_at>|<id>`. An OFFSET cursor is not stable here — the ledger
* grows at the head, so a row inserted between two requests shifts every
* later page and page 2 repeats a row page 1 already showed.
*
* `created_at` is read in `mode: 'string'`, so the value keeps the full
* Postgres microsecond precision a JS `Date` would round away.
*/
function encodeLedgerCursor(row: { created_at: string; id: string }): string {
return `${row.created_at}|${row.id}`;
}

function decodeLedgerCursor(cursor: string): { createdAt: string; id: string } | null {
const separator = cursor.indexOf('|');
if (separator <= 0 || separator === cursor.length - 1) {
return null;
}
return { createdAt: cursor.slice(0, separator), id: cursor.slice(separator + 1) };
}

export async function getCreditTransactionsForOrganizationPage(
organizationId: Organization['id'],
cursor?: string | null
): Promise<CreditTransactionsPage> {
// A malformed cursor reads as "start from the top" rather than throwing: the
// value is opaque to the client and a stale one must not break the screen.
const decoded = cursor ? decodeLedgerCursor(cursor) : null;
const [transactions, summary] = await Promise.all([
db
.select({
id: credit_transactions.id,
kilo_user_id: credit_transactions.kilo_user_id,
amount_microdollars: credit_transactions.amount_microdollars,
expiration_baseline_microdollars_used:
credit_transactions.expiration_baseline_microdollars_used,
original_baseline_microdollars_used:
credit_transactions.original_baseline_microdollars_used,
is_free: credit_transactions.is_free,
description: credit_transactions.description,
original_transaction_id: credit_transactions.original_transaction_id,
stripe_payment_id: credit_transactions.stripe_payment_id,
coinbase_credit_block_id: credit_transactions.coinbase_credit_block_id,
credit_category: credit_transactions.credit_category,
expiry_date: credit_transactions.expiry_date,
created_at: credit_transactions.created_at,
organization_id: credit_transactions.organization_id,
check_category_uniqueness: credit_transactions.check_category_uniqueness,
})
.from(credit_transactions)
.where(
and(
eq(credit_transactions.organization_id, organizationId),
or(
isNull(credit_transactions.credit_category),
notLike(credit_transactions.credit_category, 'kpo:consumption:%')
),
// Row-value comparison in the same (created_at desc, id desc) order,
// so later pages stay disjoint from the ones already shown.
decoded
? sql`(${credit_transactions.created_at}, ${credit_transactions.id}) < (${decoded.createdAt}::timestamptz, ${decoded.id}::uuid)`
: undefined
)
)
.orderBy(desc(credit_transactions.created_at), desc(credit_transactions.id))
.limit(CREDIT_TRANSACTIONS_PAGE_SIZE + 1),
getCreditTransactionsSummaryForOrganization(organizationId),
]);

const hasMore = transactions.length > CREDIT_TRANSACTIONS_PAGE_SIZE;
const entries = transactions.slice(0, CREDIT_TRANSACTIONS_PAGE_SIZE);
const lastEntry = entries.at(-1);

return {
entries,
nextCursor: hasMore && lastEntry ? encodeLedgerCursor(lastEntry) : null,
hasMore,
summary,
};
}

export async function getAdminCreditTransactionsForOrganization(
organizationId: Organization['id']
) {
Expand Down
Loading