diff --git a/.prettierignore b/.prettierignore index 94639fe72f..2edd982031 100644 --- a/.prettierignore +++ b/.prettierignore @@ -34,6 +34,7 @@ pnpm-lock.yaml # Kilo agent runtime artifacts .kilocode/ +.kilo # Misc .DS_Store diff --git a/jest.config.ts b/jest.config.ts index 8271083d4b..d9fa86435a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -48,6 +48,7 @@ const config: Config = { '/kiloclaw/', '/packages/encryption/', '/packages/worker-utils/', + '/src/scripts/', '/.worktrees/', ], modulePathIgnorePatterns: ['/.worktrees/'], diff --git a/package.json b/package.json index e4d2f5152a..5accbda8e7 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "csv-parse": "^6.2.1", "date-fns": "^4.1.0", "dayjs": "^1.11.20", "discord-api-types": "^0.38.42", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52ac834c9d..4c2d67d251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + csv-parse: + specifier: ^6.2.1 + version: 6.2.1 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -8777,6 +8780,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + csv-parse@6.2.1: + resolution: {integrity: sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==} + cwd@0.10.0: resolution: {integrity: sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA==} engines: {node: '>=0.8'} @@ -21692,7 +21698,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -22889,6 +22895,8 @@ snapshots: csstype@3.2.3: {} + csv-parse@6.2.1: {} + cwd@0.10.0: dependencies: find-pkg: 0.1.2 diff --git a/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts new file mode 100644 index 0000000000..e773ea18cd --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts @@ -0,0 +1,305 @@ +/** + * Integration test for the expire-free-credits revert script. + * + * 1. Inserts test users + credits + * 2. Runs the expire script with --execute (produces a mutations file) + * 3. Runs the revert script with --execute using that mutations file + * 4. Asserts DB state is back to the original state + * + * Usage: + * pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.test.ts + */ + +import '../lib/load-env'; + +import { execSync } from 'node:child_process'; +import { readdirSync, writeFileSync, unlinkSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { db, closeAllDrizzleConnections } from '@/lib/drizzle'; +import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; +import { inArray } from 'drizzle-orm'; +import { defineTestUser } from '@/tests/helpers/user.helper'; + +// ── Test user IDs ──────────────────────────────────────────────────────────── + +const TEST_PREFIX = `revert-test-${Date.now()}`; +const USER_PARTIALLY_SPENT = `${TEST_PREFIX}-partially-spent`; +const USER_UNSPENT = `${TEST_PREFIX}-unspent`; +const USER_FULLY_SPENT = `${TEST_PREFIX}-fully-spent`; +const USER_EXISTING_EXPIRY = `${TEST_PREFIX}-existing-expiry`; + +const ALL_USER_IDS = [USER_PARTIALLY_SPENT, USER_UNSPENT, USER_FULLY_SPENT, USER_EXISTING_EXPIRY]; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const MICRODOLLARS = 1_000_000; + +function makeUser(id: string, spent: number, acquired: number) { + return defineTestUser({ + id, + google_user_email: `${id}@test.local`, + stripe_customer_id: `stripe-${id}`, + microdollars_used: spent * MICRODOLLARS, + total_microdollars_acquired: acquired * MICRODOLLARS, + }); +} + +function makeCredit( + userId: string, + amount: number, + opts: { + category?: string; + description?: string | null; + expiryDate?: string | null; + } = {} +) { + return { + kilo_user_id: userId, + amount_microdollars: amount * MICRODOLLARS, + is_free: true, + credit_category: opts.category ?? 'automatic-welcome-credits', + description: + opts.description ?? + 'Free credits for new users, obtained by stych approval, card validation, or maybe some other method', + expiry_date: opts.expiryDate ?? null, + organization_id: null, + original_baseline_microdollars_used: 0, + check_category_uniqueness: false, + }; +} + +type Snapshot = { + credits: Map; + users: Map; +}; + +async function takeSnapshot(): Promise { + const credits = await db + .select() + .from(credit_transactions) + .where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS)); + + const users = await db + .select() + .from(kilocode_users) + .where(inArray(kilocode_users.id, ALL_USER_IDS)); + + return { + credits: new Map( + credits.map(c => [ + c.id, + { + expiry_date: c.expiry_date, + expiration_baseline: c.expiration_baseline_microdollars_used, + }, + ]) + ), + users: new Map( + users.map(u => [u.id, { next_credit_expiration_at: u.next_credit_expiration_at }]) + ), + }; +} + +const EXPECTED_LOCAL_DB_URL = 'postgres://postgres:postgres@localhost:5432/postgres'; + +function assertLocalDatabase() { + const dbUrl = process.env.POSTGRES_SCRIPT_URL ?? process.env.POSTGRES_URL ?? ''; + if (dbUrl !== EXPECTED_LOCAL_DB_URL) { + console.error(`ABORT: Expected local database URL but got: ${dbUrl}`); + console.error(`Expected: ${EXPECTED_LOCAL_DB_URL}`); + process.exit(1); + } +} + +let testCsvPath = ''; + +function generateTestCsv(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'expire-test-')); + const csvPath = path.join(dir, 'input.csv'); + + const csvContent = [ + 'CREDIT_CATEGORY,SHOULD_EXPIRE,DESCRIPTION,RECORDS,USERS,BLOCKED_USERS,FIRST_ISSUED_AT,LAST_ISSUED_AT,AMOUNT_GRANTED_USD,PCT,EXPIRE_IN_DAYS,REVIEWED_BY', + 'automatic-welcome-credits,TRUE,"Free credits for new users, obtained by stych approval, card validation, or maybe some other method",0,0,0,,,,,30,test', + 'referral-redeeming-bonus,TRUE,,0,0,0,,,,,30,test', + 'card-validation-upgrade,TRUE,Upgrade credits for passing card validation after having already passed Stytch validation.,0,0,0,,,,,30,test', + 'card-validation-no-stytch,TRUE,Free credits for passing card validation without prior Stytch validation.,0,0,0,,,,,30,test', + 'stytch-validation,TRUE,Free credits for passing Stytch fraud detection.,0,0,0,,,,,30,test', + ].join('\n'); + + writeFileSync(csvPath, csvContent); + return csvPath; +} + +let insertedCreditIds: string[] = []; + +async function setup() { + testCsvPath = generateTestCsv(); + console.log(` Generated test CSV: ${testCsvPath}\n`); + console.log('Setting up test data...\n'); + + const EARLIER_EXPIRY = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); + + await db.insert(kilocode_users).values([ + makeUser(USER_PARTIALLY_SPENT, 5, 10), + makeUser(USER_UNSPENT, 0, 10), + makeUser(USER_FULLY_SPENT, 10, 10), + { + ...makeUser(USER_EXISTING_EXPIRY, 5, 15), + next_credit_expiration_at: EARLIER_EXPIRY, + }, + ]); + + const credits = await db + .insert(credit_transactions) + .values([ + // Partially spent: $10 credit, $5 spent → $5 would expire + makeCredit(USER_PARTIALLY_SPENT, 10), + + // Unspent: $10 credit, $0 spent → $10 would expire + makeCredit(USER_UNSPENT, 10), + + // Fully spent: $10 credit, $10 spent → $0 would expire + makeCredit(USER_FULLY_SPENT, 10), + + // Existing expiry user: has an independent expiring credit + a new one + makeCredit(USER_EXISTING_EXPIRY, 5, { expiryDate: EARLIER_EXPIRY }), + makeCredit(USER_EXISTING_EXPIRY, 10), + ]) + .returning({ id: credit_transactions.id }); + + insertedCreditIds = credits.map(c => c.id); + console.log(` Inserted ${ALL_USER_IDS.length} users and ${insertedCreditIds.length} credits\n`); +} + +async function cleanup() { + console.log('\nCleaning up test data...'); + await db + .delete(credit_transactions) + .where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS)); + await db.delete(kilocode_users).where(inArray(kilocode_users.id, ALL_USER_IDS)); + if (testCsvPath) { + try { + unlinkSync(testCsvPath); + } catch { + // ignore — temp file cleanup is best-effort + } + } + console.log(' Done.\n'); +} + +function findLatestMutationsFile(): string { + const outputDir = path.join(__dirname, 'output'); + const files = readdirSync(outputDir) + .filter(f => f.includes('.mutations.jsonl')) + .sort(); + if (files.length === 0) throw new Error('No mutations file found'); + return path.join(outputDir, files[files.length - 1]); +} + +type AssertionResult = { name: string; passed: boolean; detail?: string }; + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + try { + assertLocalDatabase(); + await setup(); + + // 1. Take snapshot of original state + const before = await takeSnapshot(); + + // 2. Run the expire script + console.log('Running expire-free-credits script with --execute...\n'); + const expireOutput = execSync( + `pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=${testCsvPath} --execute --yes --batch-size=1`, + { cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 } + ); + console.log(expireOutput); + + // 3. Verify something actually changed + const afterExpire = await takeSnapshot(); + let creditsChanged = 0; + for (const [id, snap] of afterExpire.credits) { + const orig = before.credits.get(id); + if (orig && orig.expiry_date !== snap.expiry_date) creditsChanged++; + } + console.log(`Credits modified by expire script: ${creditsChanged}\n`); + + // 4. Run the revert script + const mutationsFile = findLatestMutationsFile(); + console.log(`Running revert script with mutations file: ${mutationsFile}\n`); + const revertOutput = execSync( + `pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts ${mutationsFile} --execute --yes`, + { cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 } + ); + console.log(revertOutput); + + // 5. Take snapshot of reverted state and compare to original + const afterRevert = await takeSnapshot(); + + const results: AssertionResult[] = []; + + // Check all credits are back to original + for (const [id, orig] of before.credits) { + const reverted = afterRevert.credits.get(id); + results.push({ + name: `Credit ${id.slice(0, 8)}: expiry_date restored`, + passed: orig.expiry_date === (reverted?.expiry_date ?? null), + detail: `before=${orig.expiry_date}, after=${reverted?.expiry_date}`, + }); + results.push({ + name: `Credit ${id.slice(0, 8)}: baseline restored`, + passed: orig.expiration_baseline === (reverted?.expiration_baseline ?? null), + detail: `before=${orig.expiration_baseline}, after=${reverted?.expiration_baseline}`, + }); + } + + // Check user next_credit_expiration_at is correct + // For USER_EXISTING_EXPIRY: should still point to the independent credit's expiry + // For others: should be null (no remaining expiring credits) + for (const userId of ALL_USER_IDS) { + const orig = before.users.get(userId); + const reverted = afterRevert.users.get(userId); + results.push({ + name: `User ${userId.split('-').pop()}: next_credit_expiration_at restored`, + passed: orig?.next_credit_expiration_at === reverted?.next_credit_expiration_at, + detail: `before=${orig?.next_credit_expiration_at}, after=${reverted?.next_credit_expiration_at}`, + }); + } + + // Verify at least some credits were actually changed and reverted + results.push({ + name: 'Sanity: some credits were modified by expire script', + passed: creditsChanged > 0, + detail: `${creditsChanged} credits changed`, + }); + + let passed = 0; + let failed = 0; + for (const r of results) { + const icon = r.passed ? 'PASS' : 'FAIL'; + console.log(` [${icon}] ${r.name}`); + if (!r.passed) { + console.log(` ${r.detail}`); + failed++; + } else { + passed++; + } + } + + console.log(`\n${passed} passed, ${failed} failed out of ${results.length} assertions`); + + if (failed > 0) { + process.exitCode = 1; + } + } finally { + await cleanup(); + await closeAllDrizzleConnections(); + } +} + +void main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/scripts/d2026-03-18_expire-free-credits-revert.ts b/src/scripts/d2026-03-18_expire-free-credits-revert.ts new file mode 100644 index 0000000000..2b4d020e66 --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.ts @@ -0,0 +1,174 @@ +/** + * Reverts changes made by the expire-free-credits script using its mutations log. + * + * For each credit_transaction mutation, restores expiry_date and + * expiration_baseline_microdollars_used to their previous values. + * + * For each kilocode_user mutation, recomputes next_credit_expiration_at from + * the user's remaining expiring credits (since other expirations may have been + * added independently). + * + * Usage: + * pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts + * pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts --execute + */ + +import '../lib/load-env'; + +import { createReadStream } from 'node:fs'; +import { createInterface } from 'node:readline'; +import pLimit from 'p-limit'; +import { db, closeAllDrizzleConnections } from '@/lib/drizzle'; +import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; +import { and, eq, isNull, isNotNull } from 'drizzle-orm'; + +type CreditMutation = { + type: 'credit_transaction'; + id: string; + user_id: string; + old: { + expiry_date: string | null; + expiration_baseline_microdollars_used: number | null; + }; + new: { + expiry_date: string; + expiration_baseline_microdollars_used: number; + }; +}; + +type UserMutation = { + type: 'kilocode_user'; + id: string; + old: { + next_credit_expiration_at: string | null; + }; + new: { + next_credit_expiration_at_input: string; + }; +}; + +type Mutation = CreditMutation | UserMutation; + +async function main() { + const args = process.argv.slice(2); + const mutationsFile = args.find(a => !a.startsWith('--')); + const execute = args.includes('--execute'); + const yes = args.includes('--yes') || args.includes('-y'); + + if (!mutationsFile) { + console.error( + 'Usage: pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts [--execute] [--yes]' + ); + process.exit(1); + } + + console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); + console.log(`Mutations file: ${mutationsFile}`); + + const dbUrl = process.env.POSTGRES_SCRIPT_URL ?? process.env.POSTGRES_URL ?? '(unknown)'; + const dbHost = (() => { + try { + return new URL(dbUrl).hostname; + } catch { + return dbUrl; + } + })(); + console.log(`Database: ${dbHost}\n`); + + // Parse mutations + const creditMutations: CreditMutation[] = []; + const userIds = new Set(); + + const rl = createInterface({ input: createReadStream(mutationsFile) }); + for await (const line of rl) { + const mutation: Mutation = JSON.parse(line); + if (mutation.type === 'credit_transaction') { + creditMutations.push(mutation); + } else if (mutation.type === 'kilocode_user') { + userIds.add(mutation.id); + } + } + + console.log(`Credit transactions to revert: ${creditMutations.length}`); + console.log(`Users to recompute next_credit_expiration_at: ${userIds.size}\n`); + + if (!execute) { + console.log('Run with --execute to apply changes.'); + return; + } + + if (!yes) { + const confirmRl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise(resolve => { + confirmRl.question('Proceed? (y/N) ', resolve); + }); + confirmRl.close(); + if (answer.trim().toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + console.log(); + } + + // Revert credit transactions using their logged old values + let reverted = 0; + for (const mutation of creditMutations) { + await db + .update(credit_transactions) + .set({ + expiry_date: mutation.old.expiry_date, + expiration_baseline_microdollars_used: mutation.old.expiration_baseline_microdollars_used, + }) + .where(eq(credit_transactions.id, mutation.id)); + + reverted++; + if (reverted % 500 === 0 || reverted === creditMutations.length) { + console.log(` Reverted ${reverted}/${creditMutations.length} credit transactions`); + } + } + + // Recompute next_credit_expiration_at for each affected user + const limit = pLimit(50); + const userIdArray = [...userIds]; + let usersProcessed = 0; + + const results = await Promise.allSettled( + userIdArray.map(userId => + limit(async () => { + // Find the earliest remaining expiry_date across the user's credits + const [earliest] = await db + .select({ expiry_date: credit_transactions.expiry_date }) + .from(credit_transactions) + .where( + and( + eq(credit_transactions.kilo_user_id, userId), + isNotNull(credit_transactions.expiry_date), + isNull(credit_transactions.organization_id) + ) + ) + .orderBy(credit_transactions.expiry_date) + .limit(1); + + await db + .update(kilocode_users) + .set({ next_credit_expiration_at: earliest?.expiry_date ?? null }) + .where(eq(kilocode_users.id, userId)); + }) + ) + ); + + for (const r of results) { + if (r.status === 'fulfilled') usersProcessed++; + else console.error(` Error: ${r.reason}`); + } + + console.log(`\nUsers updated: ${usersProcessed}/${userIds.size}`); + console.log('Done.'); +} + +void main() + .catch(err => { + console.error('Fatal error:', err); + process.exit(1); + }) + .finally(() => closeAllDrizzleConnections()); diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts new file mode 100644 index 0000000000..28d51ffb2d --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -0,0 +1,786 @@ +/** + * Integration test for the expire-free-credits script. + * + * Prepopulates the local DB with known state, shells out to run the actual + * script with --execute, then asserts DB state matches expectations. + * + * Usage: + * pnpm script src/scripts/d2026-03-18_expire-free-credits.test.ts + */ + +import '../lib/load-env'; + +import { execSync } from 'node:child_process'; +import { writeFileSync, unlinkSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { db, closeAllDrizzleConnections } from '@/lib/drizzle'; +import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; +import { inArray } from 'drizzle-orm'; +import { defineTestUser } from '@/tests/helpers/user.helper'; + +// ── Test user IDs (prefixed to avoid collisions) ──────────────────────────── + +const TEST_PREFIX = `expire-test-${Date.now()}`; +const USER_FULLY_SPENT = `${TEST_PREFIX}-fully-spent`; +const USER_PARTIALLY_SPENT = `${TEST_PREFIX}-partially-spent`; +const USER_UNSPENT = `${TEST_PREFIX}-unspent`; +const USER_EMPTY_DESC_CATEGORY = `${TEST_PREFIX}-empty-desc`; +const USER_NON_FREE = `${TEST_PREFIX}-non-free`; +const USER_ORG_SCOPED = `${TEST_PREFIX}-org-scoped`; +const USER_ALREADY_EXPIRING = `${TEST_PREFIX}-already-expiring`; +const USER_WRONG_DESC = `${TEST_PREFIX}-wrong-desc`; +const USER_MIXED = `${TEST_PREFIX}-mixed`; +const USER_MULTI_MATCH = `${TEST_PREFIX}-multi-match`; +const USER_ZERO_AMOUNT = `${TEST_PREFIX}-zero-amount`; +const USER_EXISTING_EXPIRATION = `${TEST_PREFIX}-existing-expiration`; +const USER_MULTI_BLOCK = `${TEST_PREFIX}-multi-block`; +const USER_BUY_USE_FREE = `${TEST_PREFIX}-buy-use-free`; +const USER_FREE_USE_BUY = `${TEST_PREFIX}-free-use-buy`; +const USER_ORB_DOUBLE_DEDUCT = `${TEST_PREFIX}-orb-double-deduct`; +const USER_ORB_EXISTING_EXPIRY = `${TEST_PREFIX}-orb-existing-expiry`; +const USER_FALSE_OVERRIDE = `${TEST_PREFIX}-false-override`; +const USER_MIXED_EXPIRY_HEADROOM = `${TEST_PREFIX}-mixed-expiry-headroom`; + +const ALL_USER_IDS = [ + USER_FULLY_SPENT, + USER_PARTIALLY_SPENT, + USER_UNSPENT, + USER_EMPTY_DESC_CATEGORY, + USER_NON_FREE, + USER_ORG_SCOPED, + USER_ALREADY_EXPIRING, + USER_WRONG_DESC, + USER_MIXED, + USER_MULTI_MATCH, + USER_ZERO_AMOUNT, + USER_EXISTING_EXPIRATION, + USER_MULTI_BLOCK, + USER_BUY_USE_FREE, + USER_FREE_USE_BUY, + USER_ORB_DOUBLE_DEDUCT, + USER_ORB_EXISTING_EXPIRY, + USER_FALSE_OVERRIDE, + USER_MIXED_EXPIRY_HEADROOM, +]; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const MICRODOLLARS = 1_000_000; // $1 + +function makeUser(id: string, spent: number, acquired: number) { + return defineTestUser({ + id, + google_user_email: `${id}@test.local`, + stripe_customer_id: `stripe-${id}`, + microdollars_used: spent * MICRODOLLARS, + total_microdollars_acquired: acquired * MICRODOLLARS, + }); +} + +function makeCredit( + userId: string, + amount: number, + opts: { + category?: string; + description?: string | null; + isFree?: boolean; + expiryDate?: string | null; + organizationId?: string | null; + originalBaseline?: number; + } = {} +) { + return { + kilo_user_id: userId, + amount_microdollars: amount * MICRODOLLARS, + is_free: opts.isFree ?? true, + credit_category: opts.category ?? 'automatic-welcome-credits', + description: + opts.description ?? + 'Free credits for new users, obtained by stych approval, card validation, or maybe some other method', + expiry_date: opts.expiryDate ?? null, + organization_id: opts.organizationId ?? null, + original_baseline_microdollars_used: (opts.originalBaseline ?? 0) * MICRODOLLARS, + check_category_uniqueness: false, + }; +} + +const EXPECTED_LOCAL_DB_URL = 'postgres://postgres:postgres@localhost:5432/postgres'; + +function assertLocalDatabase() { + const dbUrl = process.env.POSTGRES_SCRIPT_URL ?? process.env.POSTGRES_URL ?? ''; + if (dbUrl !== EXPECTED_LOCAL_DB_URL) { + console.error(`ABORT: Expected local database URL but got: ${dbUrl}`); + console.error(`Expected: ${EXPECTED_LOCAL_DB_URL}`); + process.exit(1); + } +} + +let testCsvPath = ''; + +function generateTestCsv(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'expire-test-')); + const csvPath = path.join(dir, 'input.csv'); + + const csvContent = [ + 'CREDIT_CATEGORY,SHOULD_EXPIRE,DESCRIPTION,RECORDS,USERS,BLOCKED_USERS,FIRST_ISSUED_AT,LAST_ISSUED_AT,AMOUNT_GRANTED_USD,PCT,EXPIRE_IN_DAYS,REVIEWED_BY', + 'automatic-welcome-credits,TRUE,"Free credits for new users, obtained by stych approval, card validation, or maybe some other method",0,0,0,,,,,30,test', + 'referral-redeeming-bonus,TRUE,,0,0,0,,,,,30,test', + 'card-validation-upgrade,TRUE,Upgrade credits for passing card validation after having already passed Stytch validation.,0,0,0,,,,,30,test', + 'card-validation-no-stytch,TRUE,Free credits for passing card validation without prior Stytch validation.,0,0,0,,,,,30,test', + 'stytch-validation,TRUE,Free credits for passing Stytch fraud detection.,0,0,0,,,,,30,test', + 'referral-redeeming-bonus,FALSE,Specific desc marked false in CSV,0,0,0,,,,,30,test', + 'custom,TRUE,long-expiry-test,0,0,0,,,,,180,test', + ].join('\n'); + + writeFileSync(csvPath, csvContent); + return csvPath; +} + +let insertedCreditIds: string[] = []; + +async function setup() { + testCsvPath = generateTestCsv(); + console.log(` Generated test CSV: ${testCsvPath}\n`); + + console.log('Setting up test data...\n'); + + const EARLIER_EXPIRY = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); + + // Insert users + await db.insert(kilocode_users).values([ + makeUser(USER_FULLY_SPENT, 10, 10), + makeUser(USER_PARTIALLY_SPENT, 5, 10), + makeUser(USER_UNSPENT, 0, 10), + makeUser(USER_EMPTY_DESC_CATEGORY, 3, 10), + makeUser(USER_NON_FREE, 5, 10), + makeUser(USER_ORG_SCOPED, 5, 10), + makeUser(USER_ALREADY_EXPIRING, 5, 10), + makeUser(USER_WRONG_DESC, 5, 10), + makeUser(USER_MIXED, 5, 20), + makeUser(USER_MULTI_MATCH, 5, 20), + makeUser(USER_ZERO_AMOUNT, 5, 5), + { + ...makeUser(USER_EXISTING_EXPIRATION, 5, 10), + next_credit_expiration_at: EARLIER_EXPIRY, + }, + makeUser(USER_MULTI_BLOCK, 7, 15), + // Both have $10 used, $20 acquired ($10 paid + $10 free), balance = $10 + makeUser(USER_BUY_USE_FREE, 10, 20), + makeUser(USER_FREE_USE_BUY, 10, 20), + // Orb double-deduction: user got $5 free, spent it all via Orb (which reduced + // total_acquired by $5), balance is now $0. Without the floor-at-zero fix, + // expiring the $5 credit would push balance to -$5. + makeUser(USER_ORB_DOUBLE_DEDUCT, 0, 0), + // Orb double-deduction with existing expiring credit: user got $5 free (already + // has expiry from a previous run) + $5 new free credit. Orb adjusted -$5. + // acquired=5, used=0, balance=$5. Existing $5 expires fully → $0. + // New $5 should NOT push to -$5. + { + ...makeUser(USER_ORB_EXISTING_EXPIRY, 0, 5), + next_credit_expiration_at: EARLIER_EXPIRY, + }, + // Specific FALSE overrides catch-all TRUE: referral-redeeming-bonus has a + // catch-all TRUE row, but the specific description below is marked FALSE. + makeUser(USER_FALSE_OVERRIDE, 0, 10), + // Mixed expiry headroom: Orb clawed back spend, so balance=$5 but has $10 in + // free credits. Two $5 credits with different EXPIRE_IN_DAYS. Both would fully + // expire ($5 each), but only $5 headroom. The earlier-expiring (30d) should be + // preferred. acquired=$5, used=$0, balance=$5. + makeUser(USER_MIXED_EXPIRY_HEADROOM, 0, 5), + ]); + + // Insert credits + const credits = await db + .insert(credit_transactions) + .values([ + // 1. Fully spent user: $10 matching promo, $10 spent → nothing should expire + makeCredit(USER_FULLY_SPENT, 10), + + // 2. Partially spent user: $10 matching promo, $5 spent → $5 would expire + makeCredit(USER_PARTIALLY_SPENT, 10), + + // 3. Unspent user: $10 matching promo, $0 spent → $10 would expire + makeCredit(USER_UNSPENT, 10), + + // 4. Empty-desc category (referral): should match any description + makeCredit(USER_EMPTY_DESC_CATEGORY, 10, { + category: 'referral-redeeming-bonus', + description: 'Referral bonus for redeeming code some-uuid-here', + }), + + // 5. Non-free credit: should NOT be touched (is_free=false) + makeCredit(USER_NON_FREE, 10, { isFree: false }), + + // 6. Org-scoped credit: should NOT be touched + makeCredit(USER_ORG_SCOPED, 10, { + organizationId: '00000000-0000-0000-0000-000000000001', + }), + + // 7. Already-expiring credit: should NOT be touched (already has expiry_date) + makeCredit(USER_ALREADY_EXPIRING, 10, { + expiryDate: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), + }), + + // 8. Wrong description: right category, wrong description → NOT touched + makeCredit(USER_WRONG_DESC, 10, { + category: 'automatic-welcome-credits', + description: 'Something completely different', + }), + + // 9. Mixed credits: one matching + one non-matching on same user + makeCredit(USER_MIXED, 10), // matching + makeCredit(USER_MIXED, 10, { + category: 'some-unrelated-category', + description: 'Not in the spreadsheet', + }), // non-matching + + // 10. Multiple matching credits on same user + makeCredit(USER_MULTI_MATCH, 10), // matching + makeCredit(USER_MULTI_MATCH, 10, { + category: 'card-validation-upgrade', + description: + 'Upgrade credits for passing card validation after having already passed Stytch validation.', + }), // also matching + + // 11. Zero-amount credit: $0 matching → should still get expiry set + makeCredit(USER_ZERO_AMOUNT, 0), + + // 12. User with existing next_credit_expiration_at (10 days out) + matching credit + // LEAST should preserve the earlier (10-day) date + makeCredit(USER_EXISTING_EXPIRATION, 10), + + // 13. Multiple free credit blocks: 3 x $5, user spent $7 + // All 3 should get expiry. At expiration: first fully used ($0 expires), + // second partially used ($3 expires), third unused ($5 expires) = $8 total + makeCredit(USER_MULTI_BLOCK, 5), + makeCredit(USER_MULTI_BLOCK, 5, { + category: 'card-validation-no-stytch', + description: 'Free credits for passing card validation without prior Stytch validation.', + }), + makeCredit(USER_MULTI_BLOCK, 5, { + category: 'stytch-validation', + description: 'Free credits for passing Stytch fraud detection.', + }), + + // 14. Buy $10, use $10, get $10 free → original_baseline=10 (spent $10 before free credit) + // The paid $10 is non-expiring, non-free + makeCredit(USER_BUY_USE_FREE, 10, { isFree: false }), + makeCredit(USER_BUY_USE_FREE, 10, { originalBaseline: 10 }), + + // 15. Get $10 free, use $10, buy $10 → original_baseline=0 (spent $0 before free credit) + makeCredit(USER_FREE_USE_BUY, 10), + makeCredit(USER_FREE_USE_BUY, 10, { isFree: false }), + + // 16. Orb double-deduction: $5 free credit, Orb already clawed back (balance=0) + makeCredit(USER_ORB_DOUBLE_DEDUCT, 5, { + category: 'stytch-validation', + description: 'Free credits for passing Stytch fraud detection.', + }), + + // 17. Orb double-deduction with existing expiry: + // $5 free credit already has expiry (simulates previous script run) + makeCredit(USER_ORB_EXISTING_EXPIRY, 5, { + category: 'stytch-validation', + description: 'Free credits for passing Stytch fraud detection.', + expiryDate: EARLIER_EXPIRY, + }), + // $5 new free credit (no expiry yet, will be tagged by this script) + makeCredit(USER_ORB_EXISTING_EXPIRY, 5), + + // 18. Specific FALSE overrides catch-all TRUE: + // referral-redeeming-bonus has a catch-all TRUE row in the CSV, + // but this specific description is marked FALSE → should NOT be expired. + makeCredit(USER_FALSE_OVERRIDE, 10, { + category: 'referral-redeeming-bonus', + description: 'Specific desc marked false in CSV', + }), + + // 19. Mixed expiry headroom: two $5 credits, only one fits within $5 headroom. + // The 30-day credit (automatic-welcome-credits) should be expired. + // The 180-day credit (custom/long-expiry-test) should be skipped. + makeCredit(USER_MIXED_EXPIRY_HEADROOM, 5, { + category: 'custom', + description: 'long-expiry-test', + }), + makeCredit(USER_MIXED_EXPIRY_HEADROOM, 5), + ]) + .returning({ id: credit_transactions.id }); + + insertedCreditIds = credits.map(c => c.id); + console.log(` Inserted ${ALL_USER_IDS.length} users and ${insertedCreditIds.length} credits\n`); +} + +async function cleanup() { + console.log('\nCleaning up test data...'); + await db + .delete(credit_transactions) + .where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS)); + await db.delete(kilocode_users).where(inArray(kilocode_users.id, ALL_USER_IDS)); + if (testCsvPath) { + try { + unlinkSync(testCsvPath); + } catch { + // ignore — temp file cleanup is best-effort + } + } + console.log(' Done.\n'); +} + +// ── Assertions ────────────────────────────────────────────────────────────── + +type AssertionResult = { name: string; passed: boolean; detail?: string }; + +async function runAssertions(): Promise { + const results: AssertionResult[] = []; + + // Fetch all credits for our test users + const allCredits = await db + .select() + .from(credit_transactions) + .where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS)); + + const creditsFor = (userId: string) => allCredits.filter(c => c.kilo_user_id === userId); + + // Fetch all users + const allUsers = await db + .select() + .from(kilocode_users) + .where(inArray(kilocode_users.id, ALL_USER_IDS)); + + const userById = (id: string) => allUsers.find(u => u.id === id)!; + + // --- 1. Fully spent user: expiry_date should be set + { + const credits = creditsFor(USER_FULLY_SPENT).filter(c => c.expiry_date != null); + results.push({ + name: 'Fully spent user: expiry_date set', + passed: credits.length === 1, + detail: `Expected 1 credit with expiry_date, got ${credits.length}`, + }); + } + + // --- 2. Fully spent user: next_credit_expiration_at set + { + const user = userById(USER_FULLY_SPENT); + results.push({ + name: 'Fully spent user: next_credit_expiration_at set', + passed: user.next_credit_expiration_at != null, + detail: `Got ${user.next_credit_expiration_at}`, + }); + } + + // --- 3. Partially spent user: expiry_date set + { + const credits = creditsFor(USER_PARTIALLY_SPENT).filter(c => c.expiry_date != null); + results.push({ + name: 'Partially spent user: expiry_date set', + passed: credits.length === 1, + detail: `Expected 1 credit with expiry_date, got ${credits.length}`, + }); + } + + // --- 4. Partially spent user: expiration_baseline set from original + { + const credit = creditsFor(USER_PARTIALLY_SPENT).find(c => c.expiry_date != null); + results.push({ + name: 'Partially spent user: expiration_baseline set to 0', + passed: credit?.expiration_baseline_microdollars_used === 0, + detail: `Got ${credit?.expiration_baseline_microdollars_used}`, + }); + } + + // --- 5. Unspent user: expiry_date set + { + const credits = creditsFor(USER_UNSPENT).filter(c => c.expiry_date != null); + results.push({ + name: 'Unspent user: expiry_date set', + passed: credits.length === 1, + detail: `Expected 1 credit with expiry_date, got ${credits.length}`, + }); + } + + // --- 6. Empty-desc category (referral): expiry_date set + { + const credits = creditsFor(USER_EMPTY_DESC_CATEGORY).filter(c => c.expiry_date != null); + results.push({ + name: 'Referral (any-description match): expiry_date set', + passed: credits.length === 1, + detail: `Expected 1 credit with expiry_date, got ${credits.length}`, + }); + } + + // --- 7. Non-free credit: NOT touched + { + const credits = creditsFor(USER_NON_FREE).filter(c => c.expiry_date != null); + results.push({ + name: 'Non-free credit: NOT touched', + passed: credits.length === 0, + detail: `Expected 0 credits with expiry_date, got ${credits.length}`, + }); + } + + // --- 8. Org-scoped credit: NOT touched + { + const credits = creditsFor(USER_ORG_SCOPED); + const untouched = credits.every(c => c.expiry_date == null); + results.push({ + name: 'Org-scoped credit: NOT touched', + passed: untouched, + detail: `Credits: ${credits.map(c => ({ id: c.id, expiry: c.expiry_date }))}`, + }); + } + + // --- 9. Already-expiring credit: NOT modified + { + const credit = creditsFor(USER_ALREADY_EXPIRING).find(c => c.expiry_date != null); + const originalExpiry = new Date(credit!.expiry_date!).getTime(); + // Should still be ~60 days out, not 30 + const fiftyDaysFromNow = Date.now() + 50 * 24 * 60 * 60 * 1000; + results.push({ + name: 'Already-expiring credit: original expiry preserved', + passed: originalExpiry > fiftyDaysFromNow, + detail: `Expiry: ${credit?.expiry_date}`, + }); + } + + // --- 10. Expiry date is ~30 days from now + { + const credit = creditsFor(USER_PARTIALLY_SPENT).find(c => c.expiry_date != null); + if (credit?.expiry_date) { + const expiryMs = new Date(credit.expiry_date).getTime(); + const expectedMs = Date.now() + 30 * 24 * 60 * 60 * 1000; + const diffHours = Math.abs(expiryMs - expectedMs) / (1000 * 60 * 60); + results.push({ + name: 'Expiry date is ~30 days from now', + passed: diffHours < 24, // within 24 hours tolerance + detail: `Diff: ${diffHours.toFixed(1)} hours from expected`, + }); + } else { + results.push({ + name: 'Expiry date is ~30 days from now', + passed: false, + detail: 'No credit with expiry found', + }); + } + } + + // --- 11. Wrong description: NOT touched + { + const credits = creditsFor(USER_WRONG_DESC).filter(c => c.expiry_date != null); + results.push({ + name: 'Wrong description: NOT touched', + passed: credits.length === 0, + detail: `Expected 0 credits with expiry_date, got ${credits.length}`, + }); + } + + // --- 12. Mixed credits: only matching one gets expiry + { + const credits = creditsFor(USER_MIXED); + const withExpiry = credits.filter(c => c.expiry_date != null); + const withoutExpiry = credits.filter(c => c.expiry_date == null); + results.push({ + name: 'Mixed credits: only matching credit gets expiry', + passed: withExpiry.length === 1 && withoutExpiry.length === 1, + detail: `Expected 1 with expiry + 1 without, got ${withExpiry.length} + ${withoutExpiry.length}`, + }); + } + + // --- 13. Mixed credits: the non-matching one is untouched + { + const credits = creditsFor(USER_MIXED); + const nonMatching = credits.find(c => c.credit_category === 'some-unrelated-category'); + results.push({ + name: 'Mixed credits: non-matching credit untouched', + passed: nonMatching?.expiry_date == null, + detail: `Non-matching credit expiry: ${nonMatching?.expiry_date}`, + }); + } + + // --- 14. Multiple matching credits: both get expiry + { + const credits = creditsFor(USER_MULTI_MATCH).filter(c => c.expiry_date != null); + results.push({ + name: 'Multiple matching credits: both get expiry', + passed: credits.length === 2, + detail: `Expected 2 credits with expiry_date, got ${credits.length}`, + }); + } + + // --- 15. Zero-amount credit: expiry set + { + const credits = creditsFor(USER_ZERO_AMOUNT).filter(c => c.expiry_date != null); + results.push({ + name: 'Zero-amount credit: expiry set', + passed: credits.length === 1, + detail: `Expected 1 credit with expiry_date, got ${credits.length}`, + }); + } + + // --- 16. Existing next_credit_expiration_at: LEAST preserves earlier date + { + const user = userById(USER_EXISTING_EXPIRATION); + if (user.next_credit_expiration_at) { + const expiryMs = new Date(user.next_credit_expiration_at).getTime(); + // Should be ~10 days out (the earlier one), not ~30 days + const twentyDaysFromNow = Date.now() + 20 * 24 * 60 * 60 * 1000; + results.push({ + name: 'Existing expiration: LEAST preserves earlier date', + passed: expiryMs < twentyDaysFromNow, + detail: `next_credit_expiration_at: ${user.next_credit_expiration_at}`, + }); + } else { + results.push({ + name: 'Existing expiration: LEAST preserves earlier date', + passed: false, + detail: 'next_credit_expiration_at is null', + }); + } + } + + // --- 17. Multi-block: all 3 credits get expiry set + { + const credits = creditsFor(USER_MULTI_BLOCK).filter(c => c.expiry_date != null); + results.push({ + name: 'Multi-block: all 3 credits get expiry set', + passed: credits.length === 3, + detail: `Expected 3 credits with expiry_date, got ${credits.length}`, + }); + } + + // --- 18. Multi-block: all baselines set to 0 (from original_baseline) + { + const credits = creditsFor(USER_MULTI_BLOCK).filter(c => c.expiry_date != null); + const allBaselinesZero = credits.every(c => c.expiration_baseline_microdollars_used === 0); + results.push({ + name: 'Multi-block: all baselines set to 0', + passed: allBaselinesZero, + detail: `Baselines: ${credits.map(c => c.expiration_baseline_microdollars_used)}`, + }); + } + + // --- Helper: simulate expiration and return total expired amount + const { computeExpiration } = await import('@/lib/creditExpiration'); + + function simulateExpiration(userId: string): number { + const credits = creditsFor(userId).filter(c => c.expiry_date != null); + const user = userById(userId); + if (credits.length === 0) return 0; + + const expiringTxns = credits.map(c => ({ + id: c.id, + amount_microdollars: c.amount_microdollars, + expiration_baseline_microdollars_used: c.expiration_baseline_microdollars_used, + expiry_date: c.expiry_date, + description: c.description, + is_free: c.is_free, + })); + + const expiryDate = new Date(credits[0].expiry_date!); + const { newTransactions } = computeExpiration( + expiringTxns, + { id: user.id, microdollars_used: user.microdollars_used }, + expiryDate, + user.id + ); + + return newTransactions.reduce((sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), 0); + } + + // --- 19. Multi-block: verify projected expiration + // User has 3 x $5 = $15, spent $7 → $8 should expire + { + const totalExpired = simulateExpiration(USER_MULTI_BLOCK); + const expectedExpired = 8 * MICRODOLLARS; + results.push({ + name: 'Multi-block: projected expiration is $8 (3x$5 - $7 spent)', + passed: totalExpired === expectedExpired, + detail: `Expected ${expectedExpired}, got ${totalExpired}`, + }); + } + + // --- 20. Buy $10, use $10, get $10 free → balance $10 today, $0 after expiry + // Free credit has original_baseline=10 (user already spent $10 when it was granted) + // So the free $10 is NOT covered by usage → all $10 expires + { + const user = userById(USER_BUY_USE_FREE); + const balanceNow = user.total_microdollars_acquired - user.microdollars_used; + const totalExpired = simulateExpiration(USER_BUY_USE_FREE); + const balanceAfter = balanceNow - totalExpired; + + results.push({ + name: 'Buy-use-free: balance is $10 today', + passed: balanceNow === 10 * MICRODOLLARS, + detail: `Expected ${10 * MICRODOLLARS}, got ${balanceNow}`, + }); + results.push({ + name: 'Buy-use-free: $10 expires (free credit unused)', + passed: totalExpired === 10 * MICRODOLLARS, + detail: `Expected ${10 * MICRODOLLARS}, got ${totalExpired}`, + }); + results.push({ + name: 'Buy-use-free: balance is $0 after expiry', + passed: balanceAfter === 0, + detail: `Expected 0, got ${balanceAfter}`, + }); + } + + // --- 21. Get $10 free, use $10, buy $10 → balance $10 today, $10 after expiry + // Free credit has original_baseline=0 (user had $0 spent when it was granted) + // So the free $10 IS fully covered by usage → $0 expires + { + const user = userById(USER_FREE_USE_BUY); + const balanceNow = user.total_microdollars_acquired - user.microdollars_used; + const totalExpired = simulateExpiration(USER_FREE_USE_BUY); + const balanceAfter = balanceNow - totalExpired; + + results.push({ + name: 'Free-use-buy: balance is $10 today', + passed: balanceNow === 10 * MICRODOLLARS, + detail: `Expected ${10 * MICRODOLLARS}, got ${balanceNow}`, + }); + results.push({ + name: 'Free-use-buy: $0 expires (free credit fully used)', + passed: totalExpired === 0, + detail: `Expected 0, got ${totalExpired}`, + }); + results.push({ + name: 'Free-use-buy: balance is $10 after expiry', + passed: balanceAfter === 10 * MICRODOLLARS, + detail: `Expected ${10 * MICRODOLLARS}, got ${balanceAfter}`, + }); + } + + // --- 22. Orb double-deduction: credit skipped, balance stays at $0 + // User got $5 free, Orb clawed it back (acquired=0, used=0, balance=$0). + // Without the fix, expiring the $5 credit would push to -$5. + // With the fix, expiry is NOT set on the credit (skipped). + { + const user = userById(USER_ORB_DOUBLE_DEDUCT); + const balanceNow = user.total_microdollars_acquired - user.microdollars_used; + + results.push({ + name: 'Orb double-deduct: balance is $0 today', + passed: balanceNow === 0, + detail: `Expected 0, got ${balanceNow}`, + }); + // Credit should NOT have expiry_date set (skipped to prevent negative balance) + const credit = creditsFor(USER_ORB_DOUBLE_DEDUCT).find( + c => c.credit_category === 'stytch-validation' + ); + results.push({ + name: 'Orb double-deduct: credit skipped (no expiry set)', + passed: credit?.expiry_date == null, + detail: `expiry_date: ${credit?.expiry_date}`, + }); + } + + // --- 23. Orb double-deduction with existing expiring credit: + // User has $5 balance. Existing $5 credit (with expiry) already covers it. + // New $5 credit should NOT get expiry — would push to -$5. + { + const user = userById(USER_ORB_EXISTING_EXPIRY); + const balanceNow = user.total_microdollars_acquired - user.microdollars_used; + + results.push({ + name: 'Orb existing-expiry: balance is $5 today', + passed: balanceNow === 5 * MICRODOLLARS, + detail: `Expected ${5 * MICRODOLLARS}, got ${balanceNow}`, + }); + // The new credit (automatic-welcome-credits) should NOT have expiry set + const newCredit = creditsFor(USER_ORB_EXISTING_EXPIRY).find( + c => c.credit_category === 'automatic-welcome-credits' + ); + results.push({ + name: 'Orb existing-expiry: new credit skipped (no expiry set)', + passed: newCredit?.expiry_date == null, + detail: `expiry_date: ${newCredit?.expiry_date}`, + }); + } + + // --- 24. Specific FALSE overrides catch-all TRUE: + // referral-redeeming-bonus has catch-all TRUE, but the specific description + // is marked FALSE → credit should NOT have expiry_date set. + { + const credits = creditsFor(USER_FALSE_OVERRIDE).filter(c => c.expiry_date != null); + results.push({ + name: 'Specific FALSE overrides catch-all TRUE: NOT touched', + passed: credits.length === 0, + detail: `Expected 0 credits with expiry_date, got ${credits.length}`, + }); + } + + // --- 25. Mixed expiry headroom: two $5 credits with different EXPIRE_IN_DAYS, + // only $5 headroom. The earlier-expiring credit (30d, automatic-welcome-credits) + // should be expired; the later-expiring one (180d, custom/long-expiry-test) + // should be skipped. + { + const credits = creditsFor(USER_MIXED_EXPIRY_HEADROOM); + const earlyExpiry = credits.find(c => c.credit_category === 'automatic-welcome-credits'); + const lateExpiry = credits.find(c => c.credit_category === 'custom'); + results.push({ + name: 'Mixed expiry headroom: earlier-expiring credit gets expiry', + passed: earlyExpiry?.expiry_date != null, + detail: `expiry_date: ${earlyExpiry?.expiry_date}`, + }); + results.push({ + name: 'Mixed expiry headroom: later-expiring credit skipped', + passed: lateExpiry?.expiry_date == null, + detail: `expiry_date: ${lateExpiry?.expiry_date}`, + }); + } + + return results; +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + try { + assertLocalDatabase(); + await setup(); + + console.log('Running expire-free-credits script with --execute...\n'); + const output = execSync( + `pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=${testCsvPath} --execute --yes --batch-size=1`, + { + cwd: process.cwd(), + encoding: 'utf-8', + env: { ...process.env }, + timeout: 120_000, + } + ); + console.log(output); + + console.log('Running assertions...\n'); + const results = await runAssertions(); + + let passed = 0; + let failed = 0; + for (const r of results) { + const icon = r.passed ? 'PASS' : 'FAIL'; + console.log(` [${icon}] ${r.name}`); + if (!r.passed) { + console.log(` ${r.detail}`); + failed++; + } else { + passed++; + } + } + + console.log(`\n${passed} passed, ${failed} failed out of ${results.length} assertions`); + + if (failed > 0) { + process.exitCode = 1; + } + } finally { + await cleanup(); + await closeAllDrizzleConnections(); + } +} + +void main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts new file mode 100644 index 0000000000..ebc3ed8ece --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -0,0 +1,685 @@ +/** + * Adds expiry dates to free, non-expiring credit transactions so they expire + * after a configurable number of days (default 30) from when the script is run. + * + * The set of credits to expire is defined by a CSV file passed via --input. + * The input.csv is generated by downloading the following Google Sheet as CSV: + * https://docs.google.com/spreadsheets/d/1G8EAUD39Hn3C01qNnjvWSEQpG3te0HIgiSi0yMD-AZk/edit?gid=458053126#gid=458053126 + * Each CSV row specifies a (CREDIT_CATEGORY, DESCRIPTION) pair and whether it + * should expire, along with an optional EXPIRE_IN_DAYS override. An empty + * DESCRIPTION matches any description for that category. + * + * The script queries by credit_category first (much faster than scanning all + * users), then processes each affected user. + * + * For each affected user the script: + * 1. Fetches all personal credit transactions (excluding org-scoped). + * 2. Simulates what would expire on the per-credit expiry date using + * computeExpiration(). + * 3. Writes a JSONL log line with the user's current/projected balance and + * per-credit projected expired amounts. + * 4. In --execute mode, sets expiry_date and expiration_baseline on the + * affected transactions and updates the user's next_credit_expiration_at. + * + * Usage: + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=path/to/credits.csv + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=path/to/credits.csv --execute + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=path/to/credits.csv --batch-size=1000 + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=path/to/credits.csv --concurrency=20 + */ + +import '../lib/load-env'; + +import { createWriteStream, readFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { createInterface } from 'node:readline'; +import { parse as csvParse } from 'csv-parse/sync'; +import pLimit from 'p-limit'; +import { db, closeAllDrizzleConnections } from '@/lib/drizzle'; +import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; +import { and, eq, gt, isNull, sql, inArray } from 'drizzle-orm'; +import { computeExpiration, type ExpiringTransaction } from '@/lib/creditExpiration'; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const DEFAULT_EXPIRE_IN_DAYS = 30; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type CsvRow = { + category: string; + shouldExpire: boolean; + description: string | null; + expireInDays: number; +}; + +type PairKey = string; +const pairKey = (cat: string, desc: string | null): PairKey => + desc ? `${cat} | ${desc}` : `${cat} | (any)`; + +type RowLookup = { + shouldExpire: boolean; + expireInDays: number; + expiryDate: Date; + expiryDateIso: string; +}; + +// ── CSV parsing ────────────────────────────────────────────────────────────── + +function parseCsv(inputPath: string): { + rows: CsvRow[]; + lookup: Map; + categoriesToQuery: string[]; +} { + const content = readFileSync(inputPath, 'utf-8'); + const rawRows = csvParse(content, { + columns: true, + skip_empty_lines: true, + trim: true, + }) as Record[]; + + // Validate required columns + const requiredColumns = ['CREDIT_CATEGORY', 'SHOULD_EXPIRE', 'DESCRIPTION', 'EXPIRE_IN_DAYS']; + if (rawRows.length === 0) { + throw new Error(`CSV file "${inputPath}" contains no data rows`); + } + const firstRow = rawRows[0]; + for (const col of requiredColumns) { + if (!(col in firstRow)) { + throw new Error(`CSV is missing required column: ${col}`); + } + } + + const rows: CsvRow[] = []; + const lookup = new Map(); + const seenPairs = new Set(); + + for (const raw of rawRows) { + const category = raw['CREDIT_CATEGORY'] ?? ''; + const shouldExpire = (raw['SHOULD_EXPIRE'] ?? '').toUpperCase() === 'TRUE'; + const rawDescription = raw['DESCRIPTION'] ?? ''; + const description = rawDescription === '' ? null : rawDescription; + const rawExpireInDays = raw['EXPIRE_IN_DAYS'] ?? ''; + let expireInDays: number; + if (rawExpireInDays === '') { + expireInDays = DEFAULT_EXPIRE_IN_DAYS; + } else { + const parsed = parseInt(rawExpireInDays, 10); + if (isNaN(parsed) || parsed <= 0) { + throw new Error( + `Invalid EXPIRE_IN_DAYS value "${rawExpireInDays}" for category "${category}"` + ); + } + expireInDays = parsed; + } + + const key = pairKey(category, description); + if (seenPairs.has(key)) { + console.warn( + ` Warning: duplicate (category, description) pair in CSV: "${key}" — using first occurrence` + ); + continue; + } + seenPairs.add(key); + + const row: CsvRow = { category, shouldExpire, description, expireInDays }; + rows.push(row); + + // Pre-compute expiry date: now + expireInDays, truncated to midnight UTC + const expiryDate = new Date(Date.now() + expireInDays * 24 * 60 * 60 * 1000); + expiryDate.setUTCHours(0, 0, 0, 0); + const expiryDateIso = expiryDate.toISOString(); + + lookup.set(key, { shouldExpire, expireInDays, expiryDate, expiryDateIso }); + } + + // Distinct categories that have at least one shouldExpire=true row + const categoriesToQuery = [...new Set(rows.filter(r => r.shouldExpire).map(r => r.category))]; + + return { rows, lookup, categoriesToQuery }; +} + +// ── Credit resolution ──────────────────────────────────────────────────────── + +function resolveCredit( + lookup: Map, + category: string, + description: string | null +): RowLookup | null { + // Try specific match first + const specific = lookup.get(pairKey(category, description)); + if (specific) return specific; + // Fall back to catch-all (null description) + const catchAll = lookup.get(pairKey(category, null)); + if (catchAll) return catchAll; + return null; +} + +/** Resolve a credit that was already filtered to shouldExpire=true. Throws if no match. */ +function resolveCreditOrThrow( + lookup: Map, + category: string, + description: string | null +): RowLookup { + const resolved = resolveCredit(lookup, category, description); + if (!resolved) { + throw new Error( + `No CSV row found for (${category}, ${description ?? '(any)'}). This should not happen — credit passed the shouldExpire filter.` + ); + } + return resolved; +} + +// ── Arg parsing ────────────────────────────────────────────────────────────── + +function parseArgs(): { + execute: boolean; + yes: boolean; + batchSize: number; + concurrency: number; + input: string; +} { + const args = process.argv.slice(2); + let execute = false; + let yes = false; + let batchSize = 10_000; + let concurrency = 50; + let input = ''; + + for (const arg of args) { + if (arg === '--execute') { + execute = true; + } else if (arg === '--yes' || arg === '-y') { + yes = true; + } else if (arg.startsWith('--batch-size=')) { + const value = parseInt(arg.split('=')[1], 10); + if (isNaN(value) || value <= 0) { + console.error(`Invalid --batch-size value: ${arg}`); + process.exit(1); + } + batchSize = value; + } else if (arg.startsWith('--concurrency=')) { + const value = parseInt(arg.split('=')[1], 10); + if (isNaN(value) || value <= 0) { + console.error(`Invalid --concurrency value: ${arg}`); + process.exit(1); + } + concurrency = value; + } else if (arg.startsWith('--input=')) { + input = arg.slice('--input='.length); + } + } + + if (!input) { + console.error('Missing required argument: --input='); + process.exit(1); + } + + return { execute, yes, batchSize, concurrency, input }; +} + +// ── Process a single user ──────────────────────────────────────────────────── + +async function processUser( + userId: string, + affectedCredits: (typeof credit_transactions.$inferSelect)[], + execute: boolean, + output: ReturnType, + mutationLog: ReturnType, + lookup: Map +): Promise<{ creditsAffected: number; creditsSkipped: number; projectedExpiration: number }> { + // 1. Fetch user info + const [user] = await db + .select({ + id: kilocode_users.id, + microdollars_used: kilocode_users.microdollars_used, + total_microdollars_acquired: kilocode_users.total_microdollars_acquired, + next_credit_expiration_at: kilocode_users.next_credit_expiration_at, + }) + .from(kilocode_users) + .where(eq(kilocode_users.id, userId)); + + if (!user) throw new Error(`User ${userId} not found`); + + // 2. Fetch all user credit transactions (excluding org-scoped) for simulation context + const allTransactions = await db + .select() + .from(credit_transactions) + .where( + and(eq(credit_transactions.kilo_user_id, userId), isNull(credit_transactions.organization_id)) + ); + + // 3. Build already-processed set (expiration records that exist) + const processedOriginalIds = new Set( + allTransactions + .filter( + t => + t.original_transaction_id != null && + (t.credit_category === 'credits_expired' || + t.credit_category === 'orb_credit_expired' || + t.credit_category === 'orb_credit_voided') + ) + .map(t => t.original_transaction_id) + ); + + // 4. Build simulation input: existing unprocessed expiring credits + modified affected credits + const existingExpiring: ExpiringTransaction[] = allTransactions + .filter( + t => t.expiry_date != null && t.amount_microdollars > 0 && !processedOriginalIds.has(t.id) + ) + .map(t => ({ + id: t.id, + amount_microdollars: t.amount_microdollars, + expiration_baseline_microdollars_used: t.expiration_baseline_microdollars_used, + expiry_date: t.expiry_date, + description: t.description, + is_free: t.is_free, + })); + + // Resolve per-credit expiry dates from lookup + const modifiedAffected: ExpiringTransaction[] = affectedCredits.map(t => { + const resolved = resolveCreditOrThrow(lookup, t.credit_category ?? '', t.description); + const expiryDateIso = resolved.expiryDateIso; + return { + id: t.id, + amount_microdollars: t.amount_microdollars, + expiration_baseline_microdollars_used: t.original_baseline_microdollars_used ?? 0, + expiry_date: expiryDateIso, + description: t.description, + is_free: t.is_free, + }; + }); + + const simulationInput = [...existingExpiring, ...modifiedAffected]; + + // 5. Run simulation — use the latest expiry date among modifiedAffected as `now` + // to project what would happen at expiry + const entity = { id: user.id, microdollars_used: user.microdollars_used }; + const latestExpiry = modifiedAffected.reduce((latest, t) => { + const d = new Date(t.expiry_date as string); + return d > latest ? d : latest; + }, new Date(0)); + const simulationNow = latestExpiry.getTime() > 0 ? latestExpiry : new Date(); + const { newTransactions } = computeExpiration(simulationInput, entity, simulationNow, user.id); + + // 6. Map projected expired amounts back to affected credits + const expiredByOriginalId = new Map( + newTransactions.map(t => [t.original_transaction_id, Math.abs(t.amount_microdollars ?? 0)]) + ); + + const currentBalance = user.total_microdollars_acquired - user.microdollars_used; + + // 5a. Floor at zero: if setting expiry on these credits would push the user's + // balance negative (e.g. because Orb already clawed back spend via + // total_microdollars_acquired adjustments), skip them — don't set expiry. + // The credits are effectively already consumed from a balance perspective. + const existingExpiredTotal = existingExpiring.reduce((sum, t) => { + return sum + (expiredByOriginalId.get(t.id) ?? 0); + }, 0); + const headroom = currentBalance - existingExpiredTotal; + // Determine which affected credits to actually tag with expiry. + // Sort by expiry date ascending so earlier-expiring credits are consumed first + // when headroom is limited. Tie-break by id for determinism. + const sortedAffected = [...affectedCredits].sort((a, b) => { + const aExpiry = resolveCreditOrThrow( + lookup, + a.credit_category ?? '', + a.description + ).expiryDateIso; + const bExpiry = resolveCreditOrThrow( + lookup, + b.credit_category ?? '', + b.description + ).expiryDateIso; + if (aExpiry !== bExpiry) return aExpiry < bExpiry ? -1 : 1; + return a.id < b.id ? -1 : 1; + }); + // Walk credits in order; include each one as long as it fits within headroom. + let remainingHeadroom = headroom; + const creditsToExpire: typeof affectedCredits = []; + const creditsSkipped: typeof affectedCredits = []; + for (const credit of sortedAffected) { + const expiredAmt = expiredByOriginalId.get(credit.id) ?? 0; + if (remainingHeadroom >= expiredAmt) { + remainingHeadroom -= expiredAmt; + creditsToExpire.push(credit); + } else { + creditsSkipped.push(credit); + } + } + + const creditsAffectedWithProjection = affectedCredits.map(t => ({ + ...t, + projected_expired_amount_microdollars: expiredByOriginalId.get(t.id) ?? 0, + })); + + const projectedNoScript = currentBalance - existingExpiredTotal; + const scriptExpiredTotal = creditsToExpire.reduce( + (sum, t) => sum + (expiredByOriginalId.get(t.id) ?? 0), + 0 + ); + const projectedAfterScript = projectedNoScript - scriptExpiredTotal; + + // 7. Write JSONL line + const logLine = JSON.stringify({ + user_id: user.id, + next_credit_expiration_at: user.next_credit_expiration_at, + current_balance_microdollars: currentBalance, + projected_no_script_microdollars: projectedNoScript, + projected_after_script_microdollars: projectedAfterScript, + credits_affected: creditsAffectedWithProjection, + credits_skipped: creditsSkipped.length > 0 ? creditsSkipped.map(c => c.id) : undefined, + }); + output.write(logLine + '\n'); + + // 8. Execute mode: write DB changes (only for credits that fit within headroom) + if (execute && creditsToExpire.length > 0) { + // Find earliest expiry date among credits to expire + const earliestExpiry = creditsToExpire.reduce((earliest, credit) => { + const resolved = resolveCreditOrThrow( + lookup, + credit.credit_category ?? '', + credit.description + ); + return earliest === '' || resolved.expiryDateIso < earliest + ? resolved.expiryDateIso + : earliest; + }, ''); + + await db.transaction(async tx => { + // Update each credit individually with its own expiry date + for (const credit of creditsToExpire) { + const resolved = resolveCreditOrThrow( + lookup, + credit.credit_category ?? '', + credit.description + ); + await tx + .update(credit_transactions) + .set({ + expiry_date: resolved.expiryDateIso, + expiration_baseline_microdollars_used: sql`COALESCE(${credit_transactions.original_baseline_microdollars_used}, 0)`, + }) + .where(eq(credit_transactions.id, credit.id)); + } + + // COALESCE needed because LEAST(NULL, x) returns NULL in PostgreSQL + await tx + .update(kilocode_users) + .set({ + next_credit_expiration_at: sql`COALESCE(LEAST(${kilocode_users.next_credit_expiration_at}, ${earliestExpiry}), ${earliestExpiry})`, + }) + .where(eq(kilocode_users.id, user.id)); + }); + } + + // 9. Log mutations (both dry run and execute — after commit in execute mode + // so rollbacks don't leave phantom entries) + if (creditsToExpire.length > 0) { + // Find earliest expiry for user record log + const earliestExpiryForLog = creditsToExpire.reduce((earliest, credit) => { + const resolved = resolveCreditOrThrow( + lookup, + credit.credit_category ?? '', + credit.description + ); + return earliest === '' || resolved.expiryDateIso < earliest + ? resolved.expiryDateIso + : earliest; + }, ''); + + for (const credit of creditsToExpire) { + const resolved = resolveCreditOrThrow( + lookup, + credit.credit_category ?? '', + credit.description + ); + mutationLog.write( + JSON.stringify({ + type: 'credit_transaction', + id: credit.id, + user_id: userId, + old: { + expiry_date: credit.expiry_date, + expiration_baseline_microdollars_used: credit.expiration_baseline_microdollars_used, + }, + new: { + expiry_date: resolved.expiryDateIso, + expiration_baseline_microdollars_used: credit.original_baseline_microdollars_used ?? 0, + }, + }) + '\n' + ); + } + mutationLog.write( + JSON.stringify({ + type: 'kilocode_user', + id: userId, + old: { + next_credit_expiration_at: user.next_credit_expiration_at, + }, + new: { + next_credit_expiration_at_input: earliestExpiryForLog, + }, + }) + '\n' + ); + } + + // 9. Return total projected expiration only for the credits we actually tagged + const projectedExpirationForExpired = creditsToExpire.reduce( + (sum, t) => sum + (expiredByOriginalId.get(t.id) ?? 0), + 0 + ); + + return { + creditsAffected: creditsToExpire.length, + creditsSkipped: creditsSkipped.length, + projectedExpiration: projectedExpirationForExpired, + }; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const { execute, yes, batchSize, concurrency, input } = parseArgs(); + const { rows, lookup, categoriesToQuery } = parseCsv(input); + + const trueRows = rows.filter(r => r.shouldExpire); + + console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); + console.log(`Input file: ${input}`); + console.log(`Total rows: ${rows.length}`); + console.log(`TRUE rows: ${trueRows.length}`); + console.log(`Categories to query: ${categoriesToQuery.join(', ')}`); + console.log(`Batch size: ${batchSize}`); + console.log(`Concurrency: ${concurrency}\n`); + + console.log('Rows to expire:'); + for (const row of trueRows) { + const rowLookup = lookup.get(pairKey(row.category, row.description)); + console.log( + ` ${row.category} | ${row.description ?? '(any)'} [expire_in_days=${row.expireInDays}, expiry=${rowLookup?.expiryDateIso ?? '?'}]` + ); + } + console.log(); + + // Show DB target and ask for confirmation + const dbUrl = process.env.POSTGRES_SCRIPT_URL ?? process.env.POSTGRES_URL ?? '(unknown)'; + const dbHost = (() => { + try { + return new URL(dbUrl).hostname; + } catch { + return dbUrl; + } + })(); + console.log(`Database: ${dbHost}`); + if (!yes) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise(resolve => { + rl.question(`\nProceed? (y/N) `, resolve); + }); + rl.close(); + if (answer.trim().toLowerCase() !== 'y') { + console.log('Aborted.'); + return; + } + } + console.log(); + + const outputDir = path.join(__dirname, 'output'); + await mkdir(outputDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/:/g, '-'); + const outputFile = `expire-free-credits-${timestamp}.jsonl`; + const errorsFile = `expire-free-credits-${timestamp}.errors.jsonl`; + const mutationsFile = `expire-free-credits-${timestamp}.mutations.jsonl`; + const output = createWriteStream(path.join(outputDir, outputFile)); + const errorLog = createWriteStream(path.join(outputDir, errorsFile)); + const mutationLog = createWriteStream(path.join(outputDir, mutationsFile)); + console.log(`Output: ${path.join(outputDir, outputFile)}`); + console.log(`Mutations: ${path.join(outputDir, mutationsFile)}`); + console.log(`Errors: ${path.join(outputDir, errorsFile)}\n`); + + const limit = pLimit(concurrency); + + // Per-(category, description) stats + const pairStats = new Map< + PairKey, + { credits: number; amount: number; projectedExpiration: number } + >(); + for (const row of rows) { + pairStats.set(pairKey(row.category, row.description), { + credits: 0, + amount: 0, + projectedExpiration: 0, + }); + } + + let lastUserId = ''; + let totalCredits = 0; + let totalCreditsSkipped = 0; + let totalProjectedExpiration = 0; + let usersProcessed = 0; + let totalErrors = 0; + + const baseFilter = and( + inArray(credit_transactions.credit_category, categoriesToQuery), + eq(credit_transactions.is_free, true), + isNull(credit_transactions.expiry_date), + isNull(credit_transactions.organization_id) + ); + + while (true) { + // Fetch all matching credits for the next batch of users in one query. + // The subquery selects the next N distinct user IDs; the outer query + // fetches every matching credit row for those users. + const userIdSubquery = db + .selectDistinct({ kilo_user_id: credit_transactions.kilo_user_id }) + .from(credit_transactions) + .where(and(baseFilter, gt(credit_transactions.kilo_user_id, lastUserId))) + .orderBy(credit_transactions.kilo_user_id) + .limit(batchSize); + + const rawBatch = await db + .select() + .from(credit_transactions) + .where(and(baseFilter, inArray(credit_transactions.kilo_user_id, userIdSubquery))); + + if (rawBatch.length === 0) break; + + // App-side filtering: only keep credits where resolveCredit returns shouldExpire=true + const batch = rawBatch.filter(credit => { + const resolved = resolveCredit(lookup, credit.credit_category ?? '', credit.description); + return resolved?.shouldExpire === true; + }); + + if (batch.length === 0) { + // Advance cursor using the raw batch to avoid infinite loop + lastUserId = [...new Set(rawBatch.map(c => c.kilo_user_id))].sort().pop() ?? lastUserId; + continue; + } + + totalCredits += batch.length; + + // Track per-pair stats from filtered batch + for (const credit of batch) { + const specificKey = pairKey(credit.credit_category ?? '', credit.description); + const anyKey = pairKey(credit.credit_category ?? '', null); + const stats = pairStats.get(specificKey) ?? pairStats.get(anyKey); + if (stats) { + stats.credits++; + stats.amount += credit.amount_microdollars; + } + } + + // Group by user + const byUser = new Map(); + for (const credit of batch) { + const existing = byUser.get(credit.kilo_user_id); + if (existing) { + existing.push(credit); + } else { + byUser.set(credit.kilo_user_id, [credit]); + } + } + + const results = await Promise.allSettled( + [...byUser.entries()].map(([userId, credits]) => + limit(async () => { + const result = await processUser(userId, credits, execute, output, mutationLog, lookup); + return { userId, result }; + }) + ) + ); + + for (const settled of results) { + if (settled.status === 'rejected') { + totalErrors++; + const error = + settled.reason instanceof Error ? settled.reason.message : String(settled.reason); + errorLog.write(JSON.stringify({ error }) + '\n'); + } else { + usersProcessed++; + totalProjectedExpiration += settled.value.result.projectedExpiration; + totalCreditsSkipped += settled.value.result.creditsSkipped; + } + } + + lastUserId = [...new Set(rawBatch.map(c => c.kilo_user_id))].sort().pop() ?? lastUserId; + console.log( + ` ${totalCredits} credits fetched, ${usersProcessed} users processed, ${totalErrors} errors` + ); + } + + output.end(); + errorLog.end(); + mutationLog.end(); + + const fmt = (microdollars: number) => + `$${(microdollars / 1_000_000).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + + console.log('\n--- Per-category breakdown ---'); + for (const [key, stats] of pairStats) { + if (stats.credits === 0) { + console.log(` ${key}: no matching credits found`); + } else { + console.log(` ${key}: ${stats.credits} credits, ${fmt(stats.amount)} total`); + } + } + + console.log('\n--- Summary ---'); + console.log( + `Total credits: ${totalCredits} (${fmt(totalCredits > 0 ? [...pairStats.values()].reduce((s, v) => s + v.amount, 0) : 0)})` + ); + console.log(`Users processed: ${usersProcessed}`); + console.log(`Credits skipped (neg bal): ${totalCreditsSkipped}`); + console.log(`Projected expiration: ${fmt(totalProjectedExpiration)}`); + console.log(`Errors: ${totalErrors}`); + console.log(`Mode: ${execute ? 'EXECUTED' : 'DRY RUN'}`); +} + +void main() + .catch(err => { + console.error('Fatal error:', err); + process.exit(1); + }) + .finally(() => closeAllDrizzleConnections()); diff --git a/src/scripts/output/.gitignore b/src/scripts/output/.gitignore new file mode 100644 index 0000000000..39646a4e0f --- /dev/null +++ b/src/scripts/output/.gitignore @@ -0,0 +1 @@ +*.jsonl \ No newline at end of file