From 87a70c58871630cedd4df1ef7069a88fc8c96704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 14:22:13 +0100 Subject: [PATCH 01/33] feat: first pass at free credits script --- .../d2026-03-18_expire-free-credits.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 src/scripts/d2026-03-18_expire-free-credits.ts 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..8adb15fe0f --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -0,0 +1,264 @@ +/** + * Adds expiry dates to free, non-expiring credit transactions so they expire + * on 2026-04-15. + * + * For each user the script: + * 1. Fetches all personal credit transactions (excluding org-scoped). + * 2. Identifies free, positive credits that have no expiry_date. + * 3. Simulates what would expire on 2026-04-15 using computeExpiration(). + * 4. Writes a JSONL log line with the user's current/projected balance and + * per-credit projected expired amounts. + * 5. 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 + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --batch-size=1000 + */ + +import '../lib/load-env'; + +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +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 EXPIRY_DATE = '2026-04-15T00:00:00.000Z'; +const EXPIRY_DATE_OBJ = new Date(EXPIRY_DATE); + +// ── Arg parsing ────────────────────────────────────────────────────────────── + +function parseArgs(): { execute: boolean; batchSize: number } { + const args = process.argv.slice(2); + let execute = false; + let batchSize = 500; + + for (const arg of args) { + if (arg === '--execute') { + execute = 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; + } + } + + return { execute, batchSize }; +} + +// ── Output file ────────────────────────────────────────────────────────────── + +async function createOutputFile() { + const outputDir = path.join(__dirname, 'output'); + await mkdir(outputDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/:/g, '-'); + const filePath = path.join(outputDir, `expire-free-credits-${timestamp}.jsonl`); + console.log(`Writing output to ${filePath}\n`); + return createWriteStream(filePath); +} + +// ── Process a single user ──────────────────────────────────────────────────── + +async function processUser( + user: { + id: string; + microdollars_used: number; + total_microdollars_acquired: number; + next_credit_expiration_at: string | null; + }, + execute: boolean, + output: ReturnType +): Promise<{ creditsAffected: number; projectedExpiration: number } | null> { + // 1. Fetch all user credit transactions (excluding org-scoped) + const allTransactions = await db + .select() + .from(credit_transactions) + .where( + and( + eq(credit_transactions.kilo_user_id, user.id), + isNull(credit_transactions.organization_id) + ) + ); + + // 2. Find affected: free, non-expiring, positive credits + const affected = allTransactions.filter( + t => t.is_free && t.expiry_date == null && t.amount_microdollars > 0 + ); + if (affected.length === 0) return null; + + // 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, + })); + + const modifiedAffected: ExpiringTransaction[] = affected.map(t => ({ + id: t.id, + amount_microdollars: t.amount_microdollars, + expiration_baseline_microdollars_used: t.original_baseline_microdollars_used ?? 0, + expiry_date: EXPIRY_DATE, + description: t.description, + is_free: t.is_free, + })); + + const simulationInput = [...existingExpiring, ...modifiedAffected]; + + // 5. Run simulation — use EXPIRY_DATE_OBJ as `now` to project what would happen on 2026-04-15 + const entity = { id: user.id, microdollars_used: user.microdollars_used }; + const { newTransactions } = computeExpiration(simulationInput, entity, EXPIRY_DATE_OBJ, 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; + const totalExpiredAll = newTransactions.reduce( + (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), + 0 + ); + const projectedBalance = currentBalance - totalExpiredAll; + + const creditsAffectedWithProjection = affected.map(t => ({ + ...t, + projected_expired_amount_microdollars: expiredByOriginalId.get(t.id) ?? 0, + })); + + // 7. Write JSONL line + const logLine = JSON.stringify({ + user_id: user.id, + current_balance_microdollars: currentBalance, + projected_balance_microdollars: projectedBalance, + credits_affected: creditsAffectedWithProjection, + }); + output.write(logLine + '\n'); + + // 8. Execute mode: write DB changes + if (execute) { + const affectedIds = affected.map(t => t.id); + await db.transaction(async tx => { + await tx + .update(credit_transactions) + .set({ + expiry_date: EXPIRY_DATE, + expiration_baseline_microdollars_used: sql`COALESCE(${credit_transactions.original_baseline_microdollars_used}, 0)`, + }) + .where(inArray(credit_transactions.id, affectedIds)); + + // 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}, ${EXPIRY_DATE}), ${EXPIRY_DATE})`, + }) + .where(eq(kilocode_users.id, user.id)); + }); + } + + // 9. Return total projected expiration only for the newly-tagged credits + const projectedExpirationForAffected = affected.reduce( + (sum, t) => sum + (expiredByOriginalId.get(t.id) ?? 0), + 0 + ); + + return { creditsAffected: affected.length, projectedExpiration: projectedExpirationForAffected }; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const { execute, batchSize } = parseArgs(); + + console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); + console.log(`Batch size: ${batchSize}\n`); + + const output = await createOutputFile(); + + let lastId = ''; + let totalUsers = 0; + let totalCreditsAffected = 0; + let totalProjectedExpiration = 0; + let usersAffected = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + const batch = 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(gt(kilocode_users.id, lastId)) + .orderBy(kilocode_users.id) + .limit(batchSize); + + if (batch.length === 0) break; + + for (const user of batch) { + const result = await processUser(user, execute, output); + totalUsers++; + + if (result) { + usersAffected++; + totalCreditsAffected += result.creditsAffected; + totalProjectedExpiration += result.projectedExpiration; + } + } + + lastId = batch[batch.length - 1].id; + console.log( + `Processed ${totalUsers} users so far (${usersAffected} affected, ${totalCreditsAffected} credits tagged)...` + ); + } + + output.end(); + + console.log('\n--- Summary ---'); + console.log(`Total users scanned: ${totalUsers}`); + console.log(`Users with affected credits: ${usersAffected}`); + console.log(`Total credits tagged: ${totalCreditsAffected}`); + console.log( + `Projected expiration total: ${totalProjectedExpiration} microdollars ($${(totalProjectedExpiration / 1_000_000).toFixed(2)})` + ); + console.log(`Mode: ${execute ? 'EXECUTED' : 'DRY RUN'}`); +} + +void main() + .catch(err => { + console.error('Fatal error:', err); + process.exit(1); + }) + .finally(() => closeAllDrizzleConnections()); From 4a8decdf3284548b2ffc78023cecf72f16248d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 15:02:16 +0100 Subject: [PATCH 02/33] feat(expire-free-credits): add configurable concurrency with p-limit introduce configurable concurrency for expire-free-credits via p-limit parse --concurrency, defaulting to 50 and validate values increase default batch size to 10000 for throughput create timestamped output and error log files under output/ log created file paths and active concurrency for visibility --- .../d2026-03-18_expire-free-credits.ts | 75 +++++++++++++------ 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 8adb15fe0f..e2cd53cdfe 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -15,6 +15,7 @@ * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --batch-size=1000 + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --concurrency=20 */ import '../lib/load-env'; @@ -22,6 +23,7 @@ import '../lib/load-env'; import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; +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'; @@ -34,10 +36,11 @@ const EXPIRY_DATE_OBJ = new Date(EXPIRY_DATE); // ── Arg parsing ────────────────────────────────────────────────────────────── -function parseArgs(): { execute: boolean; batchSize: number } { +function parseArgs(): { execute: boolean; batchSize: number; concurrency: number } { const args = process.argv.slice(2); let execute = false; - let batchSize = 500; + let batchSize = 10_000; + let concurrency = 50; for (const arg of args) { if (arg === '--execute') { @@ -49,21 +52,17 @@ function parseArgs(): { execute: boolean; batchSize: number } { 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; } } - return { execute, batchSize }; -} - -// ── Output file ────────────────────────────────────────────────────────────── - -async function createOutputFile() { - const outputDir = path.join(__dirname, 'output'); - await mkdir(outputDir, { recursive: true }); - const timestamp = new Date().toISOString().replace(/:/g, '-'); - const filePath = path.join(outputDir, `expire-free-credits-${timestamp}.jsonl`); - console.log(`Writing output to ${filePath}\n`); - return createWriteStream(filePath); + return { execute, batchSize, concurrency }; } // ── Process a single user ──────────────────────────────────────────────────── @@ -198,18 +197,32 @@ async function processUser( // ── Main ───────────────────────────────────────────────────────────────────── async function main() { - const { execute, batchSize } = parseArgs(); + const { execute, batchSize, concurrency } = parseArgs(); console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); - console.log(`Batch size: ${batchSize}\n`); + console.log(`Batch size: ${batchSize}`); + console.log(`Concurrency: ${concurrency}\n`); - const output = await createOutputFile(); + const outputDir = path.join(__dirname, 'output'); + await mkdir(outputDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/:/g, '-'); + const output = createWriteStream( + path.join(outputDir, `expire-free-credits-${timestamp}.jsonl`) + ); + const errorLog = createWriteStream( + path.join(outputDir, `expire-free-credits-${timestamp}.errors.jsonl`) + ); + console.log(`Output: ${path.join(outputDir, `expire-free-credits-${timestamp}.jsonl`)}`); + console.log(`Errors: ${path.join(outputDir, `expire-free-credits-${timestamp}.errors.jsonl`)}\n`); + + const limit = pLimit(concurrency); let lastId = ''; let totalUsers = 0; let totalCreditsAffected = 0; let totalProjectedExpiration = 0; let usersAffected = 0; + let totalErrors = 0; // eslint-disable-next-line no-constant-condition while (true) { @@ -227,24 +240,37 @@ async function main() { if (batch.length === 0) break; - for (const user of batch) { - const result = await processUser(user, execute, output); - totalUsers++; + const results = await Promise.allSettled( + batch.map((user, i) => + limit(async () => { + const result = await processUser(user, execute, output); + return { index: i, result }; + }) + ) + ); - if (result) { + for (let i = 0; i < results.length; i++) { + const settled = results[i]; + totalUsers++; + if (settled.status === 'rejected') { + totalErrors++; + const error = settled.reason instanceof Error ? settled.reason.message : String(settled.reason); + errorLog.write(JSON.stringify({ user_id: batch[i].id, error }) + '\n'); + } else if (settled.value.result) { usersAffected++; - totalCreditsAffected += result.creditsAffected; - totalProjectedExpiration += result.projectedExpiration; + totalCreditsAffected += settled.value.result.creditsAffected; + totalProjectedExpiration += settled.value.result.projectedExpiration; } } lastId = batch[batch.length - 1].id; console.log( - `Processed ${totalUsers} users so far (${usersAffected} affected, ${totalCreditsAffected} credits tagged)...` + `Processed ${totalUsers} users so far (${usersAffected} affected, ${totalCreditsAffected} credits tagged, ${totalErrors} errors)...` ); } output.end(); + errorLog.end(); console.log('\n--- Summary ---'); console.log(`Total users scanned: ${totalUsers}`); @@ -253,6 +279,7 @@ async function main() { console.log( `Projected expiration total: ${totalProjectedExpiration} microdollars ($${(totalProjectedExpiration / 1_000_000).toFixed(2)})` ); + console.log(`Errors: ${totalErrors}`); console.log(`Mode: ${execute ? 'EXECUTED' : 'DRY RUN'}`); } From 35d1e904dfeef637fc2dd00542b81980fd102f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 16:58:41 +0100 Subject: [PATCH 03/33] fix(scripts): exclude categories from expiration to avoid expiring credits exclude categories from expiration to avoid expiring credits exclude list: orb_migration_accounting_adjustment, credits_expired custom, usage_issue, feedback --- src/scripts/d2026-03-18_expire-free-credits.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index e2cd53cdfe..9a8719777c 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -88,9 +88,20 @@ async function processUser( ) ); - // 2. Find affected: free, non-expiring, positive credits + // 2. Find affected: free, non-expiring, positive credits (excluding categories we don't want to expire) + const excludedCategories = new Set([ + 'orb_migration_accounting_adjustment', + 'credits_expired', + 'custom', + 'usage_issue', + 'feedback', + ]); const affected = allTransactions.filter( - t => t.is_free && t.expiry_date == null && t.amount_microdollars > 0 + t => + t.is_free && + t.expiry_date == null && + t.amount_microdollars > 0 && + !excludedCategories.has(t.credit_category ?? '') ); if (affected.length === 0) return null; From ae0feb51336223b7d69736a6f5a2c4802a543207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 17:24:08 +0100 Subject: [PATCH 04/33] refactor(expire-free-credits): require --category param and query by category first Instead of scanning all users and filtering, the script now takes a mandatory --category= arg and queries credit_transactions by category directly, then groups by user. Much faster for targeted runs. --- .../d2026-03-18_expire-free-credits.ts | 183 ++++++++++-------- 1 file changed, 102 insertions(+), 81 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 9a8719777c..fe9c0f5946 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -1,21 +1,23 @@ /** - * Adds expiry dates to free, non-expiring credit transactions so they expire - * on 2026-04-15. + * Adds expiry dates to free, non-expiring credit transactions for a single + * category so they expire on 2026-04-15. * - * For each user the script: + * 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. Identifies free, positive credits that have no expiry_date. - * 3. Simulates what would expire on 2026-04-15 using computeExpiration(). - * 4. Writes a JSONL log line with the user's current/projected balance and + * 2. Simulates what would expire on 2026-04-15 using computeExpiration(). + * 3. Writes a JSONL log line with the user's current/projected balance and * per-credit projected expired amounts. - * 5. In --execute mode, sets expiry_date and expiration_baseline on the + * 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 - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --batch-size=1000 - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --concurrency=20 + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation --execute + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation --batch-size=1000 + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation --concurrency=20 */ import '../lib/load-env'; @@ -36,15 +38,23 @@ const EXPIRY_DATE_OBJ = new Date(EXPIRY_DATE); // ── Arg parsing ────────────────────────────────────────────────────────────── -function parseArgs(): { execute: boolean; batchSize: number; concurrency: number } { +function parseArgs(): { + category: string; + execute: boolean; + batchSize: number; + concurrency: number; +} { const args = process.argv.slice(2); let execute = false; let batchSize = 10_000; let concurrency = 50; + let category: string | undefined; for (const arg of args) { if (arg === '--execute') { execute = true; + } else if (arg.startsWith('--category=')) { + category = arg.split('=')[1]; } else if (arg.startsWith('--batch-size=')) { const value = parseInt(arg.split('=')[1], 10); if (isNaN(value) || value <= 0) { @@ -62,49 +72,43 @@ function parseArgs(): { execute: boolean; batchSize: number; concurrency: number } } - return { execute, batchSize, concurrency }; + if (!category) { + console.error('Missing required --category= argument'); + process.exit(1); + } + + return { category, execute, batchSize, concurrency }; } // ── Process a single user ──────────────────────────────────────────────────── async function processUser( - user: { - id: string; - microdollars_used: number; - total_microdollars_acquired: number; - next_credit_expiration_at: string | null; - }, + userId: string, + affectedCredits: (typeof credit_transactions.$inferSelect)[], execute: boolean, output: ReturnType -): Promise<{ creditsAffected: number; projectedExpiration: number } | null> { - // 1. Fetch all user credit transactions (excluding org-scoped) +): Promise<{ creditsAffected: 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, user.id), - isNull(credit_transactions.organization_id) - ) + and(eq(credit_transactions.kilo_user_id, userId), isNull(credit_transactions.organization_id)) ); - // 2. Find affected: free, non-expiring, positive credits (excluding categories we don't want to expire) - const excludedCategories = new Set([ - 'orb_migration_accounting_adjustment', - 'credits_expired', - 'custom', - 'usage_issue', - 'feedback', - ]); - const affected = allTransactions.filter( - t => - t.is_free && - t.expiry_date == null && - t.amount_microdollars > 0 && - !excludedCategories.has(t.credit_category ?? '') - ); - if (affected.length === 0) return null; - // 3. Build already-processed set (expiration records that exist) const processedOriginalIds = new Set( allTransactions @@ -121,8 +125,7 @@ async function processUser( // 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) + t => t.expiry_date != null && t.amount_microdollars > 0 && !processedOriginalIds.has(t.id) ) .map(t => ({ id: t.id, @@ -133,7 +136,7 @@ async function processUser( is_free: t.is_free, })); - const modifiedAffected: ExpiringTransaction[] = affected.map(t => ({ + const modifiedAffected: ExpiringTransaction[] = affectedCredits.map(t => ({ id: t.id, amount_microdollars: t.amount_microdollars, expiration_baseline_microdollars_used: t.original_baseline_microdollars_used ?? 0, @@ -160,7 +163,7 @@ async function processUser( ); const projectedBalance = currentBalance - totalExpiredAll; - const creditsAffectedWithProjection = affected.map(t => ({ + const creditsAffectedWithProjection = affectedCredits.map(t => ({ ...t, projected_expired_amount_microdollars: expiredByOriginalId.get(t.id) ?? 0, })); @@ -176,7 +179,7 @@ async function processUser( // 8. Execute mode: write DB changes if (execute) { - const affectedIds = affected.map(t => t.id); + const affectedIds = affectedCredits.map(t => t.id); await db.transaction(async tx => { await tx .update(credit_transactions) @@ -197,19 +200,23 @@ async function processUser( } // 9. Return total projected expiration only for the newly-tagged credits - const projectedExpirationForAffected = affected.reduce( + const projectedExpirationForAffected = affectedCredits.reduce( (sum, t) => sum + (expiredByOriginalId.get(t.id) ?? 0), 0 ); - return { creditsAffected: affected.length, projectedExpiration: projectedExpirationForAffected }; + return { + creditsAffected: affectedCredits.length, + projectedExpiration: projectedExpirationForAffected, + }; } // ── Main ───────────────────────────────────────────────────────────────────── async function main() { - const { execute, batchSize, concurrency } = parseArgs(); + const { category, execute, batchSize, concurrency } = parseArgs(); + console.log(`Category: ${category}`); console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); console.log(`Batch size: ${batchSize}`); console.log(`Concurrency: ${concurrency}\n`); @@ -217,19 +224,17 @@ async function main() { const outputDir = path.join(__dirname, 'output'); await mkdir(outputDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/:/g, '-'); - const output = createWriteStream( - path.join(outputDir, `expire-free-credits-${timestamp}.jsonl`) - ); - const errorLog = createWriteStream( - path.join(outputDir, `expire-free-credits-${timestamp}.errors.jsonl`) - ); - console.log(`Output: ${path.join(outputDir, `expire-free-credits-${timestamp}.jsonl`)}`); - console.log(`Errors: ${path.join(outputDir, `expire-free-credits-${timestamp}.errors.jsonl`)}\n`); + const outputFile = `expire-free-credits-${category}-${timestamp}.jsonl`; + const errorsFile = `expire-free-credits-${category}-${timestamp}.errors.jsonl`; + const output = createWriteStream(path.join(outputDir, outputFile)); + const errorLog = createWriteStream(path.join(outputDir, errorsFile)); + console.log(`Output: ${path.join(outputDir, outputFile)}`); + console.log(`Errors: ${path.join(outputDir, errorsFile)}\n`); const limit = pLimit(concurrency); let lastId = ''; - let totalUsers = 0; + let totalCredits = 0; let totalCreditsAffected = 0; let totalProjectedExpiration = 0; let usersAffected = 0; @@ -237,46 +242,61 @@ async function main() { // eslint-disable-next-line no-constant-condition while (true) { + // Query credits by category directly — much faster than scanning all users const batch = 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(gt(kilocode_users.id, lastId)) - .orderBy(kilocode_users.id) + .select() + .from(credit_transactions) + .where( + and( + eq(credit_transactions.credit_category, category), + eq(credit_transactions.is_free, true), + isNull(credit_transactions.expiry_date), + isNull(credit_transactions.organization_id), + gt(credit_transactions.kilo_user_id, lastId) + ) + ) + .orderBy(credit_transactions.kilo_user_id) .limit(batchSize); if (batch.length === 0) break; + totalCredits += batch.length; + + // 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( - batch.map((user, i) => + [...byUser.entries()].map(([userId, credits]) => limit(async () => { - const result = await processUser(user, execute, output); - return { index: i, result }; + const result = await processUser(userId, credits, execute, output); + return { userId, result }; }) ) ); - for (let i = 0; i < results.length; i++) { - const settled = results[i]; - totalUsers++; + 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({ user_id: batch[i].id, error }) + '\n'); - } else if (settled.value.result) { + const error = + settled.reason instanceof Error ? settled.reason.message : String(settled.reason); + errorLog.write(JSON.stringify({ error }) + '\n'); + } else { usersAffected++; totalCreditsAffected += settled.value.result.creditsAffected; totalProjectedExpiration += settled.value.result.projectedExpiration; } } - lastId = batch[batch.length - 1].id; + lastId = batch[batch.length - 1].kilo_user_id; console.log( - `Processed ${totalUsers} users so far (${usersAffected} affected, ${totalCreditsAffected} credits tagged, ${totalErrors} errors)...` + `Processed ${totalCredits} credits, ${usersAffected} users so far (${totalCreditsAffected} credits tagged, ${totalErrors} errors)...` ); } @@ -284,8 +304,9 @@ async function main() { errorLog.end(); console.log('\n--- Summary ---'); - console.log(`Total users scanned: ${totalUsers}`); - console.log(`Users with affected credits: ${usersAffected}`); + console.log(`Category: ${category}`); + console.log(`Total credits found: ${totalCredits}`); + console.log(`Users affected: ${usersAffected}`); console.log(`Total credits tagged: ${totalCreditsAffected}`); console.log( `Projected expiration total: ${totalProjectedExpiration} microdollars ($${(totalProjectedExpiration / 1_000_000).toFixed(2)})` From d2448d1af3b4bce3de9e0cdd7c6ad840414e01e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 17:25:21 +0100 Subject: [PATCH 05/33] style(scripts): remove eslint-disable-next-line above infinite loop --- src/scripts/d2026-03-18_expire-free-credits.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index fe9c0f5946..f74ed06e9e 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -240,7 +240,6 @@ async function main() { let usersAffected = 0; let totalErrors = 0; - // eslint-disable-next-line no-constant-condition while (true) { // Query credits by category directly — much faster than scanning all users const batch = await db From 18f2baa30e40f418c18aa1a558e779229e1bbf3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 17:44:09 +0100 Subject: [PATCH 06/33] feat(expire-free-credits): switch to dynamic 30-day expiry and category-based filtering --- .../d2026-03-18_expire-free-credits.ts | 154 +++++++++++++++--- 1 file changed, 127 insertions(+), 27 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index f74ed06e9e..322630ac59 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -1,6 +1,10 @@ /** - * Adds expiry dates to free, non-expiring credit transactions for a single - * category so they expire on 2026-04-15. + * Adds expiry dates to free, non-expiring credit transactions so they expire + * 30 days from when the script is run. + * + * The set of credits to expire is defined by (credit_category, description) + * pairs copied from the reviewed spreadsheet. Each row must match both fields + * (empty description matches any). * * The script queries by credit_category first (much faster than scanning all * users), then processes each affected user. @@ -14,10 +18,10 @@ * affected transactions and updates the user's next_credit_expiration_at. * * Usage: - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation --execute - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation --batch-size=1000 - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --category=stytch-validation --concurrency=20 + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --batch-size=1000 + * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --concurrency=20 */ import '../lib/load-env'; @@ -28,18 +32,103 @@ import path from 'node:path'; 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 { and, eq, gt, isNull, sql, inArray, or } from 'drizzle-orm'; import { computeExpiration, type ExpiringTransaction } from '@/lib/creditExpiration'; // ── Constants ──────────────────────────────────────────────────────────────── -const EXPIRY_DATE = '2026-04-15T00:00:00.000Z'; -const EXPIRY_DATE_OBJ = new Date(EXPIRY_DATE); +const EXPIRY_DATE_OBJ = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); +EXPIRY_DATE_OBJ.setUTCHours(0, 0, 0, 0); +const EXPIRY_DATE = EXPIRY_DATE_OBJ.toISOString(); + +// ── Excel data ─────────────────────────────────────────────────────────────── +// https://docs.google.com/spreadsheets/d/1G8EAUD39Hn3C01qNnjvWSEQpG3te0HIgiSi0yMD-AZk/edit?gid=458053126#gid=458053126 +// set the should expire filter to true +// copy credit category name column (without the header) +// same for credit category description + +const creditCategoryNames = ` +orb_free_credits +card-validation-upgrade +stytch-validation +automatic-welcome-credits +XCURSOR-W92X91 +XCURSOR-REF-W92X91 +card-validation-no-stytch +in-app-5usd +payment-tripled +THEO +referral-referring-bonus +referral-redeeming-bonus +windsurf-promo-2025-07-12 +orb_free_credits +THEOKILO +orb_free_credits +orb_free_credits +POWER-OF-EUROPE +windsurf-promo-2025-07-12 +orb_free_credits +windsurf-promo-2025-07-12 +windsurf-promo-2025-07-12 +orb_free_credits +custom +custom +custom +custom`; + +const creditCategoryDescriptions = ` + +Upgrade credits for passing card validation after having already passed Stytch validation. +Free credits for passing Stytch fraud detection. +Free credits for new users, obtained by stych approval, card validation, or maybe some other method +Cursor promo 2025-07-17 +Cursor promo 2025-07-17 (referral) +Free credits for passing card validation without prior Stytch validation. +In-app survey completion + +Influencer: Theo T3 + + +Windsurf promo 2025-07-12 (Brendan O'Leary) + +Influencer: Theo T3 +Cohort B - Automated May 1-time early adopter credit +Cohort 100A - Automated May 1-time early adopter credit +Hackathon: Power of Europe Amsterdam 2025 +Windsurf promo 2025-07-12 (Olesya Elfimova) +Email 100 non-expire (via script) +Windsurf promo 2025-07-12 (Tirumari Jothi) +Windsurf promo 2025-07-12 +2025-05-24 JP gives stragglers $100 +Dev (Catriel Müller) +workwork (Eamon Nerbonne) +Darko: I thought he's a leecher. He paid us in Stripe..a lot (verified in Orb) (Darko Gjorgjievski) +Part-time UX hire, providing tokens to use product and be productive (Joshua Lambert)`; + +// ── Parse excel rows into (category, description) pairs ───────────────────── + +type CreditCategoryRow = { category: string; description: string | null }; + +function parseCreditCategoryRows(): CreditCategoryRow[] { + // Split on newlines, dropping the first empty line from the template literal + const names = creditCategoryNames.split('\n').slice(1); + const descriptions = creditCategoryDescriptions.split('\n').slice(1); + + if (names.length !== descriptions.length) { + throw new Error( + `Mismatch: ${names.length} category names vs ${descriptions.length} descriptions` + ); + } + + return names.map((name, i) => ({ + category: name.trim(), + description: descriptions[i].trim() || null, + })); +} // ── Arg parsing ────────────────────────────────────────────────────────────── function parseArgs(): { - category: string; execute: boolean; batchSize: number; concurrency: number; @@ -48,13 +137,10 @@ function parseArgs(): { let execute = false; let batchSize = 10_000; let concurrency = 50; - let category: string | undefined; for (const arg of args) { if (arg === '--execute') { execute = true; - } else if (arg.startsWith('--category=')) { - category = arg.split('=')[1]; } else if (arg.startsWith('--batch-size=')) { const value = parseInt(arg.split('=')[1], 10); if (isNaN(value) || value <= 0) { @@ -72,12 +158,7 @@ function parseArgs(): { } } - if (!category) { - console.error('Missing required --category= argument'); - process.exit(1); - } - - return { category, execute, batchSize, concurrency }; + return { execute, batchSize, concurrency }; } // ── Process a single user ──────────────────────────────────────────────────── @@ -147,7 +228,7 @@ async function processUser( const simulationInput = [...existingExpiring, ...modifiedAffected]; - // 5. Run simulation — use EXPIRY_DATE_OBJ as `now` to project what would happen on 2026-04-15 + // 5. Run simulation — use EXPIRY_DATE_OBJ as `now` to project what would happen at expiry const entity = { id: user.id, microdollars_used: user.microdollars_used }; const { newTransactions } = computeExpiration(simulationInput, entity, EXPIRY_DATE_OBJ, user.id); @@ -214,18 +295,37 @@ async function processUser( // ── Main ───────────────────────────────────────────────────────────────────── async function main() { - const { category, execute, batchSize, concurrency } = parseArgs(); + const { execute, batchSize, concurrency } = parseArgs(); + const rows = parseCreditCategoryRows(); - console.log(`Category: ${category}`); console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); + console.log(`Expiry date: ${EXPIRY_DATE}`); + console.log(`Rows: ${rows.length} (category, description) pairs`); console.log(`Batch size: ${batchSize}`); console.log(`Concurrency: ${concurrency}\n`); + for (const row of rows) { + console.log(` ${row.category} | ${row.description ?? '(any description)'}`); + } + console.log(); + + // Build the OR condition matching all (category, description) pairs. + // Empty description means "match any description for this category". + const rowConditions = rows.map(row => + row.description + ? and( + eq(credit_transactions.credit_category, row.category), + eq(credit_transactions.description, row.description) + ) + : eq(credit_transactions.credit_category, row.category) + ); + const categoryFilter = rowConditions.length === 1 ? rowConditions[0] : or(...rowConditions); + const outputDir = path.join(__dirname, 'output'); await mkdir(outputDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/:/g, '-'); - const outputFile = `expire-free-credits-${category}-${timestamp}.jsonl`; - const errorsFile = `expire-free-credits-${category}-${timestamp}.errors.jsonl`; + const outputFile = `expire-free-credits-${timestamp}.jsonl`; + const errorsFile = `expire-free-credits-${timestamp}.errors.jsonl`; const output = createWriteStream(path.join(outputDir, outputFile)); const errorLog = createWriteStream(path.join(outputDir, errorsFile)); console.log(`Output: ${path.join(outputDir, outputFile)}`); @@ -241,13 +341,13 @@ async function main() { let totalErrors = 0; while (true) { - // Query credits by category directly — much faster than scanning all users + // Query credits matching any (category, description) pair const batch = await db .select() .from(credit_transactions) .where( and( - eq(credit_transactions.credit_category, category), + categoryFilter, eq(credit_transactions.is_free, true), isNull(credit_transactions.expiry_date), isNull(credit_transactions.organization_id), @@ -303,7 +403,7 @@ async function main() { errorLog.end(); console.log('\n--- Summary ---'); - console.log(`Category: ${category}`); + console.log(`Rows: ${rows.length}`); console.log(`Total credits found: ${totalCredits}`); console.log(`Users affected: ${usersAffected}`); console.log(`Total credits tagged: ${totalCreditsAffected}`); From dee2668766336fceeac68e99f571dfc76a7e83ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 17:46:55 +0100 Subject: [PATCH 07/33] feat(expire-free-credits): use spreadsheet-driven category/description pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace --category param with embedded (category, description) pairs copied from the reviewed spreadsheet - Empty description matches NULL or empty, specific description matches exactly — handles same category with different descriptions - Expiry date is now 30 days from runtime instead of hardcoded - Add per-category breakdown in summary output - Improve progress logging clarity --- .../d2026-03-18_expire-free-credits.ts | 65 +++++++++++++++---- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 322630ac59..3dd52130ff 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -4,7 +4,7 @@ * * The set of credits to expire is defined by (credit_category, description) * pairs copied from the reviewed spreadsheet. Each row must match both fields - * (empty description matches any). + * (empty description matches NULL or empty). * * The script queries by credit_category first (much faster than scanning all * users), then processes each affected user. @@ -310,14 +310,17 @@ async function main() { console.log(); // Build the OR condition matching all (category, description) pairs. - // Empty description means "match any description for this category". + // Empty description means "match NULL or empty string description". const rowConditions = rows.map(row => row.description ? and( eq(credit_transactions.credit_category, row.category), eq(credit_transactions.description, row.description) ) - : eq(credit_transactions.credit_category, row.category) + : and( + eq(credit_transactions.credit_category, row.category), + or(isNull(credit_transactions.description), eq(credit_transactions.description, '')) + ) ); const categoryFilter = rowConditions.length === 1 ? rowConditions[0] : or(...rowConditions); @@ -333,11 +336,26 @@ async function main() { const limit = pLimit(concurrency); + // Per-(category, description) stats + type PairKey = string; + const pairKey = (cat: string, desc: string | null): PairKey => + desc ? `${cat} | ${desc}` : `${cat} | (empty)`; + 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 lastId = ''; let totalCredits = 0; - let totalCreditsAffected = 0; let totalProjectedExpiration = 0; - let usersAffected = 0; + let usersProcessed = 0; let totalErrors = 0; while (true) { @@ -360,6 +378,18 @@ async function main() { if (batch.length === 0) break; totalCredits += batch.length; + // Track per-pair stats from raw batch + for (const credit of batch) { + // Find the matching row (specific description match first, then any-description) + 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) { @@ -387,29 +417,38 @@ async function main() { settled.reason instanceof Error ? settled.reason.message : String(settled.reason); errorLog.write(JSON.stringify({ error }) + '\n'); } else { - usersAffected++; - totalCreditsAffected += settled.value.result.creditsAffected; + usersProcessed++; totalProjectedExpiration += settled.value.result.projectedExpiration; } } lastId = batch[batch.length - 1].kilo_user_id; console.log( - `Processed ${totalCredits} credits, ${usersAffected} users so far (${totalCreditsAffected} credits tagged, ${totalErrors} errors)...` + ` ${totalCredits} credits fetched, ${usersProcessed} users processed, ${totalErrors} errors` ); } output.end(); errorLog.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(`Rows: ${rows.length}`); - console.log(`Total credits found: ${totalCredits}`); - console.log(`Users affected: ${usersAffected}`); - console.log(`Total credits tagged: ${totalCreditsAffected}`); console.log( - `Projected expiration total: ${totalProjectedExpiration} microdollars ($${(totalProjectedExpiration / 1_000_000).toFixed(2)})` + `Total credits: ${totalCredits} (${fmt(totalCredits > 0 ? [...pairStats.values()].reduce((s, v) => s + v.amount, 0) : 0)})` ); + console.log(`Users processed: ${usersProcessed}`); + console.log(`Projected expiration: ${fmt(totalProjectedExpiration)}`); console.log(`Errors: ${totalErrors}`); console.log(`Mode: ${execute ? 'EXECUTED' : 'DRY RUN'}`); } From e29e2a31afe6273d4a0472aa2daba144afec6901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 17:48:22 +0100 Subject: [PATCH 08/33] fix(expire-free-credits): correct empty description label in startup log --- src/scripts/d2026-03-18_expire-free-credits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 3dd52130ff..a9d4e36ded 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -305,7 +305,7 @@ async function main() { console.log(`Concurrency: ${concurrency}\n`); for (const row of rows) { - console.log(` ${row.category} | ${row.description ?? '(any description)'}`); + console.log(` ${row.category} | ${row.description ?? '(empty)'}`); } console.log(); From 3f43ddaa146b5a1632beec204db580d8bdd5c01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 18:15:25 +0100 Subject: [PATCH 09/33] fix(expire-free-credits): log next_credit_expiration_at for rollback correctness --- src/scripts/d2026-03-18_expire-free-credits.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index a9d4e36ded..5b7f8ee399 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -252,6 +252,7 @@ async function processUser( // 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_balance_microdollars: projectedBalance, credits_affected: creditsAffectedWithProjection, From 06639ef8f7129c46933d68748544728126ac3d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 18 Mar 2026 18:23:18 +0100 Subject: [PATCH 10/33] fix(expire-free-credits): empty description matches any description for that category --- src/scripts/d2026-03-18_expire-free-credits.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 5b7f8ee399..d587888af3 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -4,7 +4,7 @@ * * The set of credits to expire is defined by (credit_category, description) * pairs copied from the reviewed spreadsheet. Each row must match both fields - * (empty description matches NULL or empty). + * (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. @@ -306,22 +306,19 @@ async function main() { console.log(`Concurrency: ${concurrency}\n`); for (const row of rows) { - console.log(` ${row.category} | ${row.description ?? '(empty)'}`); + console.log(` ${row.category} | ${row.description ?? '(any)'}`); } console.log(); // Build the OR condition matching all (category, description) pairs. - // Empty description means "match NULL or empty string description". + // Empty description means "match any description for that category". const rowConditions = rows.map(row => row.description ? and( eq(credit_transactions.credit_category, row.category), eq(credit_transactions.description, row.description) ) - : and( - eq(credit_transactions.credit_category, row.category), - or(isNull(credit_transactions.description), eq(credit_transactions.description, '')) - ) + : eq(credit_transactions.credit_category, row.category) ); const categoryFilter = rowConditions.length === 1 ? rowConditions[0] : or(...rowConditions); @@ -340,7 +337,7 @@ async function main() { // Per-(category, description) stats type PairKey = string; const pairKey = (cat: string, desc: string | null): PairKey => - desc ? `${cat} | ${desc}` : `${cat} | (empty)`; + desc ? `${cat} | ${desc}` : `${cat} | (any)`; const pairStats = new Map< PairKey, { credits: number; amount: number; projectedExpiration: number } From 73ba0565045d76528da27d81ecf627d08371fb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 11:42:43 +0100 Subject: [PATCH 11/33] chore(prettier): add .kilo to ignore list ignore runtime artifacts produced by the Kilo agent in prettier --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index 6929b630a4..7bb77ba53b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -33,6 +33,7 @@ pnpm-lock.yaml # Kilo agent runtime artifacts .kilocode/ +.kilo # Misc .DS_Store From 39596b7587164ddea6ad8635966e4b1421ef9bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 11:59:37 +0100 Subject: [PATCH 12/33] test(expire-free-credits): add integration test script Covers: fully/partially/unspent users, any-description matching, non-free exclusion, org-scoped exclusion, already-expiring exclusion, wrong description, mixed credits, multiple matching credits, zero-amount, existing next_credit_expiration_at LEAST, and multi-block projected expiration correctness. --- .../d2026-03-18_expire-free-credits.test.ts | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 src/scripts/d2026-03-18_expire-free-credits.test.ts 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..a1b090fa2e --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -0,0 +1,531 @@ +/** + * 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 { db, closeAllDrizzleConnections } from '@/lib/drizzle'; +import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; +import { eq, and, 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 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, +]; + +// ── 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; + } = {} +) { + 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: 0, + check_category_uniqueness: false, + }; +} + +let insertedCreditIds: string[] = []; + +async function setup() { + 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), + ]); + + // 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.', + }), + ]) + .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)); + 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)}`, + }); + } + + // --- 19. Multi-block: verify projected expiration via JSONL output + // User has 3 x $5 = $15, spent $7 → $8 should expire + // We verify this by running computeExpiration directly on the post-script state + { + const credits = creditsFor(USER_MULTI_BLOCK).filter(c => c.expiry_date != null); + const user = userById(USER_MULTI_BLOCK); + + // Import computeExpiration to verify the projection + const { computeExpiration } = await import('@/lib/creditExpiration'); + + 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 + ); + + const totalExpired = newTransactions.reduce( + (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), + 0 + ); + const expectedExpired = 8 * MICRODOLLARS; // $15 - $7 = $8 + + results.push({ + name: 'Multi-block: projected expiration is $8 (3x$5 - $7 spent)', + passed: totalExpired === expectedExpired, + detail: `Expected ${expectedExpired}, got ${totalExpired}`, + }); + } + + return results; +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + try { + 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 --execute', + { + 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); +}); From 491fafa512b5088be03918787a09e8e8d97615fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 12:13:38 +0100 Subject: [PATCH 13/33] docs(expire-free-credits): replace hardcoded date with EXPIRY_DATE in comment the code comment now uses EXPIRY_DATE instead of a fixed date to clarify runtime behavior and enable easier configuration of the expiry date used by the script --- src/scripts/d2026-03-18_expire-free-credits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index d587888af3..0286223cb0 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -11,7 +11,7 @@ * * For each affected user the script: * 1. Fetches all personal credit transactions (excluding org-scoped). - * 2. Simulates what would expire on 2026-04-15 using computeExpiration(). + * 2. Simulates what would expire on 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 From 93d3695e57040919ceed48ae81e4e0e349b13232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 12:15:39 +0100 Subject: [PATCH 14/33] test(expire-free-credits): add buy-use-free vs free-use-buy ordering cases Verifies that original_baseline_microdollars_used correctly determines whether free credits are covered by prior usage: free credits granted after spending are not covered and expire fully, while free credits granted before spending are covered and nothing expires. --- .../d2026-03-18_expire-free-credits.test.ts | 97 ++++++++++++++++--- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index a1b090fa2e..65605a8f76 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -32,6 +32,8 @@ 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 ALL_USER_IDS = [ USER_FULLY_SPENT, @@ -47,6 +49,8 @@ const ALL_USER_IDS = [ USER_ZERO_AMOUNT, USER_EXISTING_EXPIRATION, USER_MULTI_BLOCK, + USER_BUY_USE_FREE, + USER_FREE_USE_BUY, ]; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -72,6 +76,7 @@ function makeCredit( isFree?: boolean; expiryDate?: string | null; organizationId?: string | null; + originalBaseline?: number; } = {} ) { return { @@ -84,7 +89,7 @@ function makeCredit( '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: 0, + original_baseline_microdollars_used: (opts.originalBaseline ?? 0) * MICRODOLLARS, check_category_uniqueness: false, }; } @@ -114,6 +119,9 @@ async function setup() { 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), ]); // Insert credits @@ -188,6 +196,15 @@ async function setup() { 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 }), ]) .returning({ id: credit_transactions.id }); @@ -437,15 +454,13 @@ async function runAssertions(): Promise { }); } - // --- 19. Multi-block: verify projected expiration via JSONL output - // User has 3 x $5 = $15, spent $7 → $8 should expire - // We verify this by running computeExpiration directly on the post-script state - { - const credits = creditsFor(USER_MULTI_BLOCK).filter(c => c.expiry_date != null); - const user = userById(USER_MULTI_BLOCK); + // --- Helper: simulate expiration and return total expired amount + const { computeExpiration } = await import('@/lib/creditExpiration'); - // Import computeExpiration to verify the projection - 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, @@ -464,12 +479,14 @@ async function runAssertions(): Promise { user.id ); - const totalExpired = newTransactions.reduce( - (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), - 0 - ); - const expectedExpired = 8 * MICRODOLLARS; // $15 - $7 = $8 + 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, @@ -477,6 +494,58 @@ async function runAssertions(): Promise { }); } + // --- 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}`, + }); + } + return results; } From 49fc5af9e99355b2a6021b040a75328d1b6488cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 12:24:47 +0100 Subject: [PATCH 15/33] fix(expire-free-credits): paginate by user ID to avoid splitting credits across batches Uses a subquery to select the next N distinct user IDs, then fetches all matching credits for those users in a single query. Prevents the previous row-level pagination from skipping credits when a user's rows span a batch boundary. Test runs with --batch-size=1 to exercise this. --- .../d2026-03-18_expire-free-credits.test.ts | 4 +-- .../d2026-03-18_expire-free-credits.ts | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index 65605a8f76..f864eddb80 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -13,7 +13,7 @@ import '../lib/load-env'; import { execSync } from 'node:child_process'; import { db, closeAllDrizzleConnections } from '@/lib/drizzle'; import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; +import { inArray } from 'drizzle-orm'; import { defineTestUser } from '@/tests/helpers/user.helper'; // ── Test user IDs (prefixed to avoid collisions) ──────────────────────────── @@ -557,7 +557,7 @@ async function main() { console.log('Running expire-free-credits script with --execute...\n'); const output = execSync( - 'pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute', + 'pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute --batch-size=1', { cwd: process.cwd(), encoding: 'utf-8', diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 0286223cb0..327b132ea0 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -350,29 +350,35 @@ async function main() { }); } - let lastId = ''; + let lastUserId = ''; let totalCredits = 0; let totalProjectedExpiration = 0; let usersProcessed = 0; let totalErrors = 0; + const baseFilter = and( + categoryFilter, + eq(credit_transactions.is_free, true), + isNull(credit_transactions.expiry_date), + isNull(credit_transactions.organization_id) + ); + while (true) { - // Query credits matching any (category, description) pair - const batch = await db - .select() + // 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( - categoryFilter, - eq(credit_transactions.is_free, true), - isNull(credit_transactions.expiry_date), - isNull(credit_transactions.organization_id), - gt(credit_transactions.kilo_user_id, lastId) - ) - ) + .where(and(baseFilter, gt(credit_transactions.kilo_user_id, lastUserId))) .orderBy(credit_transactions.kilo_user_id) .limit(batchSize); + const batch = await db + .select() + .from(credit_transactions) + .where(and(baseFilter, inArray(credit_transactions.kilo_user_id, userIdSubquery))); + if (batch.length === 0) break; totalCredits += batch.length; @@ -420,7 +426,7 @@ async function main() { } } - lastId = batch[batch.length - 1].kilo_user_id; + lastUserId = [...byUser.keys()].sort().pop()!; console.log( ` ${totalCredits} credits fetched, ${usersProcessed} users processed, ${totalErrors} errors` ); From 6ff6482f39f6d94495c69a9f0a3823032dfb8190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 12:25:19 +0100 Subject: [PATCH 16/33] chore(scripts): add .gitignore to ignore *.jsonl in output dir --- src/scripts/output/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/scripts/output/.gitignore 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 From cd3542b8c26fd5190503ca06f0ae5936c2277c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 18:46:38 +0100 Subject: [PATCH 17/33] fix(expire-free-credits): floor projected balance at zero to prevent Orb double-deductions When Orb clawed back spent free credits (reducing total_microdollars_acquired), the expiration simulation would still try to expire the full credit amount, pushing ~1,610 users into negative balance. Now boosts expiration baselines on newly-tagged credits so total expiration never exceeds the current balance. --- .../d2026-03-18_expire-free-credits.test.ts | 87 +++++++++++++++++++ .../d2026-03-18_expire-free-credits.ts | 80 +++++++++++++---- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index f864eddb80..6e403c05ef 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -34,6 +34,8 @@ 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 ALL_USER_IDS = [ USER_FULLY_SPENT, @@ -51,6 +53,8 @@ const ALL_USER_IDS = [ USER_MULTI_BLOCK, USER_BUY_USE_FREE, USER_FREE_USE_BUY, + USER_ORB_DOUBLE_DEDUCT, + USER_ORB_EXISTING_EXPIRY, ]; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -122,6 +126,18 @@ async function setup() { // 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, + }, ]); // Insert credits @@ -205,6 +221,22 @@ async function setup() { // 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), ]) .returning({ id: credit_transactions.id }); @@ -546,6 +578,61 @@ async function runAssertions(): Promise { }); } + // --- 22. Orb double-deduction: balance stays at $0, never goes negative + // 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, baseline is boosted so $0 expires. + { + const user = userById(USER_ORB_DOUBLE_DEDUCT); + const balanceNow = user.total_microdollars_acquired - user.microdollars_used; + const totalExpired = simulateExpiration(USER_ORB_DOUBLE_DEDUCT); + const balanceAfter = balanceNow - totalExpired; + + results.push({ + name: 'Orb double-deduct: balance is $0 today', + passed: balanceNow === 0, + detail: `Expected 0, got ${balanceNow}`, + }); + results.push({ + name: 'Orb double-deduct: $0 expires (floor at zero)', + passed: totalExpired === 0, + detail: `Expected 0, got ${totalExpired}`, + }); + results.push({ + name: 'Orb double-deduct: balance is $0 after expiry', + passed: balanceAfter === 0, + detail: `Expected 0, got ${balanceAfter}`, + }); + // Verify the baseline was boosted + const credit = creditsFor(USER_ORB_DOUBLE_DEDUCT).find(c => c.expiry_date != null); + results.push({ + name: 'Orb double-deduct: baseline boosted to absorb deficit', + passed: (credit?.expiration_baseline_microdollars_used ?? 0) > 0, + detail: `Baseline: ${credit?.expiration_baseline_microdollars_used}`, + }); + } + + // --- 23. Orb double-deduction with existing expiring credit: + // User has $5 balance. Existing $5 credit (with expiry) expires all $5. + // New $5 credit should NOT expire anything — floor at zero prevents -$5. + { + const user = userById(USER_ORB_EXISTING_EXPIRY); + const balanceNow = user.total_microdollars_acquired - user.microdollars_used; + const totalExpired = simulateExpiration(USER_ORB_EXISTING_EXPIRY); + const balanceAfter = balanceNow - totalExpired; + + results.push({ + name: 'Orb existing-expiry: balance is $5 today', + passed: balanceNow === 5 * MICRODOLLARS, + detail: `Expected ${5 * MICRODOLLARS}, got ${balanceNow}`, + }); + results.push({ + name: 'Orb existing-expiry: balance is $0 after expiry (not negative)', + passed: balanceAfter >= 0, + detail: `Expected >= 0, got ${balanceAfter}`, + }); + } + return results; } diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 327b132ea0..853540c36d 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -230,19 +230,64 @@ async function processUser( // 5. Run simulation — use EXPIRY_DATE_OBJ as `now` to project what would happen at expiry const entity = { id: user.id, microdollars_used: user.microdollars_used }; - const { newTransactions } = computeExpiration(simulationInput, entity, EXPIRY_DATE_OBJ, 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)]) - ); + let { newTransactions } = computeExpiration(simulationInput, entity, EXPIRY_DATE_OBJ, user.id); + // 5a. Floor at zero: if the simulation would push the balance negative (e.g. because + // Orb already clawed back spend via total_microdollars_acquired adjustments), + // inflate baselines on the newly-tagged credits so total expiration never exceeds + // the current balance. const currentBalance = user.total_microdollars_acquired - user.microdollars_used; const totalExpiredAll = newTransactions.reduce( (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), 0 ); - const projectedBalance = currentBalance - totalExpiredAll; + let projectedBalance = currentBalance - totalExpiredAll; + + const baselineBoosts = new Map(); + + if (projectedBalance < 0) { + let deficit = Math.abs(projectedBalance); + const affectedIdSet = new Set(affectedCredits.map(t => t.id)); + + // Reduce expiration on newly-tagged credits, starting from last (least priority) + for (let i = newTransactions.length - 1; i >= 0 && deficit > 0; i--) { + const tx = newTransactions[i]; + if (!affectedIdSet.has(tx.original_transaction_id!)) continue; + + const expiredAmt = Math.abs(tx.amount_microdollars ?? 0); + if (expiredAmt === 0) continue; + + const reduction = Math.min(deficit, expiredAmt); + baselineBoosts.set(tx.original_transaction_id!, reduction); + deficit -= reduction; + } + + // Re-run simulation with boosted baselines + const boostedAffected: ExpiringTransaction[] = affectedCredits.map(t => ({ + id: t.id, + amount_microdollars: t.amount_microdollars, + expiration_baseline_microdollars_used: + (t.original_baseline_microdollars_used ?? 0) + (baselineBoosts.get(t.id) ?? 0), + expiry_date: EXPIRY_DATE, + description: t.description, + is_free: t.is_free, + })); + + const boostedInput = [...existingExpiring, ...boostedAffected]; + const rerun = computeExpiration(boostedInput, entity, EXPIRY_DATE_OBJ, user.id); + newTransactions = rerun.newTransactions; + + const newTotalExpired = newTransactions.reduce( + (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), + 0 + ); + projectedBalance = currentBalance - newTotalExpired; + } + + // 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 creditsAffectedWithProjection = affectedCredits.map(t => ({ ...t, @@ -256,20 +301,25 @@ async function processUser( current_balance_microdollars: currentBalance, projected_balance_microdollars: projectedBalance, credits_affected: creditsAffectedWithProjection, + baseline_boosts: baselineBoosts.size > 0 ? Object.fromEntries(baselineBoosts) : undefined, }); output.write(logLine + '\n'); // 8. Execute mode: write DB changes if (execute) { - const affectedIds = affectedCredits.map(t => t.id); await db.transaction(async tx => { - await tx - .update(credit_transactions) - .set({ - expiry_date: EXPIRY_DATE, - expiration_baseline_microdollars_used: sql`COALESCE(${credit_transactions.original_baseline_microdollars_used}, 0)`, - }) - .where(inArray(credit_transactions.id, affectedIds)); + // Write each credit individually to apply per-credit boosted baselines + for (const credit of affectedCredits) { + const boost = baselineBoosts.get(credit.id) ?? 0; + const baseline = (credit.original_baseline_microdollars_used ?? 0) + boost; + await tx + .update(credit_transactions) + .set({ + expiry_date: EXPIRY_DATE, + expiration_baseline_microdollars_used: baseline, + }) + .where(eq(credit_transactions.id, credit.id)); + } // COALESCE needed because LEAST(NULL, x) returns NULL in PostgreSQL await tx From 2d8c60ee5d487eb68206d4b327e1a1624defaa70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 19:08:17 +0100 Subject: [PATCH 18/33] fix(ci): exclude src/scripts/ from Jest and fix lint errors - Add src/scripts/ to testPathIgnorePatterns (integration tests need POSTGRES_SCRIPT_URL which isn't available in CI) - Replace non-null assertions with null checks / fallbacks --- jest.config.ts | 1 + src/scripts/d2026-03-18_expire-free-credits.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index 3fe6cce8c0..80f0a5e032 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/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 853540c36d..b0e412c125 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -252,13 +252,14 @@ async function processUser( // Reduce expiration on newly-tagged credits, starting from last (least priority) for (let i = newTransactions.length - 1; i >= 0 && deficit > 0; i--) { const tx = newTransactions[i]; - if (!affectedIdSet.has(tx.original_transaction_id!)) continue; + const origId = tx.original_transaction_id; + if (origId == null || !affectedIdSet.has(origId)) continue; const expiredAmt = Math.abs(tx.amount_microdollars ?? 0); if (expiredAmt === 0) continue; const reduction = Math.min(deficit, expiredAmt); - baselineBoosts.set(tx.original_transaction_id!, reduction); + baselineBoosts.set(origId, reduction); deficit -= reduction; } @@ -476,7 +477,7 @@ async function main() { } } - lastUserId = [...byUser.keys()].sort().pop()!; + lastUserId = [...byUser.keys()].sort().pop() ?? lastUserId; console.log( ` ${totalCredits} credits fetched, ${usersProcessed} users processed, ${totalErrors} errors` ); From 1b4de9ea9e2786c7bb574d39a5344e8948f7e6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 19 Mar 2026 19:13:24 +0100 Subject: [PATCH 19/33] fix(expire-free-credits): skip credits that would push balance negative instead of boosting baselines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baseline boosting was wrong — increasing a credit's baseline shifts its claim window right, which *increases* expiration, not decreases it. With microdollars_used=0 (Orb users), no baseline prevents full expiration. New approach: simulate all expirations, compute headroom (balance minus existing expirations), then only set expiry on credits that fit within headroom. Credits that would cause over-expiration are skipped entirely. --- .../d2026-03-18_expire-free-credits.test.ts | 44 +++---- .../d2026-03-18_expire-free-credits.ts | 119 +++++++----------- 2 files changed, 66 insertions(+), 97 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index 6e403c05ef..778c91ab42 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -578,58 +578,50 @@ async function runAssertions(): Promise { }); } - // --- 22. Orb double-deduction: balance stays at $0, never goes negative + // --- 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, baseline is boosted so $0 expires. + // 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; - const totalExpired = simulateExpiration(USER_ORB_DOUBLE_DEDUCT); - const balanceAfter = balanceNow - totalExpired; 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: $0 expires (floor at zero)', - passed: totalExpired === 0, - detail: `Expected 0, got ${totalExpired}`, - }); - results.push({ - name: 'Orb double-deduct: balance is $0 after expiry', - passed: balanceAfter === 0, - detail: `Expected 0, got ${balanceAfter}`, - }); - // Verify the baseline was boosted - const credit = creditsFor(USER_ORB_DOUBLE_DEDUCT).find(c => c.expiry_date != null); - results.push({ - name: 'Orb double-deduct: baseline boosted to absorb deficit', - passed: (credit?.expiration_baseline_microdollars_used ?? 0) > 0, - detail: `Baseline: ${credit?.expiration_baseline_microdollars_used}`, + 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) expires all $5. - // New $5 credit should NOT expire anything — floor at zero prevents -$5. + // 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; - const totalExpired = simulateExpiration(USER_ORB_EXISTING_EXPIRY); - const balanceAfter = balanceNow - totalExpired; 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: balance is $0 after expiry (not negative)', - passed: balanceAfter >= 0, - detail: `Expected >= 0, got ${balanceAfter}`, + name: 'Orb existing-expiry: new credit skipped (no expiry set)', + passed: newCredit?.expiry_date == null, + detail: `expiry_date: ${newCredit?.expiry_date}`, }); } diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index b0e412c125..13af745a78 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -168,7 +168,7 @@ async function processUser( affectedCredits: (typeof credit_transactions.$inferSelect)[], execute: boolean, output: ReturnType -): Promise<{ creditsAffected: number; projectedExpiration: number }> { +): Promise<{ creditsAffected: number; creditsSkipped: number; projectedExpiration: number }> { // 1. Fetch user info const [user] = await db .select({ @@ -230,66 +230,43 @@ async function processUser( // 5. Run simulation — use EXPIRY_DATE_OBJ as `now` to project what would happen at expiry const entity = { id: user.id, microdollars_used: user.microdollars_used }; - let { newTransactions } = computeExpiration(simulationInput, entity, EXPIRY_DATE_OBJ, user.id); + const { newTransactions } = computeExpiration(simulationInput, entity, EXPIRY_DATE_OBJ, 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)]) + ); - // 5a. Floor at zero: if the simulation would push the balance negative (e.g. because - // Orb already clawed back spend via total_microdollars_acquired adjustments), - // inflate baselines on the newly-tagged credits so total expiration never exceeds - // the current balance. const currentBalance = user.total_microdollars_acquired - user.microdollars_used; const totalExpiredAll = newTransactions.reduce( (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), 0 ); - let projectedBalance = currentBalance - totalExpiredAll; - - const baselineBoosts = new Map(); - - if (projectedBalance < 0) { - let deficit = Math.abs(projectedBalance); - const affectedIdSet = new Set(affectedCredits.map(t => t.id)); - - // Reduce expiration on newly-tagged credits, starting from last (least priority) - for (let i = newTransactions.length - 1; i >= 0 && deficit > 0; i--) { - const tx = newTransactions[i]; - const origId = tx.original_transaction_id; - if (origId == null || !affectedIdSet.has(origId)) continue; - - const expiredAmt = Math.abs(tx.amount_microdollars ?? 0); - if (expiredAmt === 0) continue; - - const reduction = Math.min(deficit, expiredAmt); - baselineBoosts.set(origId, reduction); - deficit -= reduction; + const projectedBalance = currentBalance - totalExpiredAll; + + // 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. + // 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 affectedCredits) { + const expiredAmt = expiredByOriginalId.get(credit.id) ?? 0; + if (remainingHeadroom >= expiredAmt) { + remainingHeadroom -= expiredAmt; + creditsToExpire.push(credit); + } else { + creditsSkipped.push(credit); } - - // Re-run simulation with boosted baselines - const boostedAffected: ExpiringTransaction[] = affectedCredits.map(t => ({ - id: t.id, - amount_microdollars: t.amount_microdollars, - expiration_baseline_microdollars_used: - (t.original_baseline_microdollars_used ?? 0) + (baselineBoosts.get(t.id) ?? 0), - expiry_date: EXPIRY_DATE, - description: t.description, - is_free: t.is_free, - })); - - const boostedInput = [...existingExpiring, ...boostedAffected]; - const rerun = computeExpiration(boostedInput, entity, EXPIRY_DATE_OBJ, user.id); - newTransactions = rerun.newTransactions; - - const newTotalExpired = newTransactions.reduce( - (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), - 0 - ); - projectedBalance = currentBalance - newTotalExpired; } - // 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 creditsAffectedWithProjection = affectedCredits.map(t => ({ ...t, projected_expired_amount_microdollars: expiredByOriginalId.get(t.id) ?? 0, @@ -302,25 +279,21 @@ async function processUser( current_balance_microdollars: currentBalance, projected_balance_microdollars: projectedBalance, credits_affected: creditsAffectedWithProjection, - baseline_boosts: baselineBoosts.size > 0 ? Object.fromEntries(baselineBoosts) : undefined, + credits_skipped: creditsSkipped.length > 0 ? creditsSkipped.map(c => c.id) : undefined, }); output.write(logLine + '\n'); - // 8. Execute mode: write DB changes - if (execute) { + // 8. Execute mode: write DB changes (only for credits that fit within headroom) + if (execute && creditsToExpire.length > 0) { + const idsToExpire = creditsToExpire.map(t => t.id); await db.transaction(async tx => { - // Write each credit individually to apply per-credit boosted baselines - for (const credit of affectedCredits) { - const boost = baselineBoosts.get(credit.id) ?? 0; - const baseline = (credit.original_baseline_microdollars_used ?? 0) + boost; - await tx - .update(credit_transactions) - .set({ - expiry_date: EXPIRY_DATE, - expiration_baseline_microdollars_used: baseline, - }) - .where(eq(credit_transactions.id, credit.id)); - } + await tx + .update(credit_transactions) + .set({ + expiry_date: EXPIRY_DATE, + expiration_baseline_microdollars_used: sql`COALESCE(${credit_transactions.original_baseline_microdollars_used}, 0)`, + }) + .where(inArray(credit_transactions.id, idsToExpire)); // COALESCE needed because LEAST(NULL, x) returns NULL in PostgreSQL await tx @@ -332,15 +305,16 @@ async function processUser( }); } - // 9. Return total projected expiration only for the newly-tagged credits - const projectedExpirationForAffected = affectedCredits.reduce( + // 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: affectedCredits.length, - projectedExpiration: projectedExpirationForAffected, + creditsAffected: creditsToExpire.length, + creditsSkipped: creditsSkipped.length, + projectedExpiration: projectedExpirationForExpired, }; } @@ -403,6 +377,7 @@ async function main() { let lastUserId = ''; let totalCredits = 0; + let totalCreditsSkipped = 0; let totalProjectedExpiration = 0; let usersProcessed = 0; let totalErrors = 0; @@ -474,6 +449,7 @@ async function main() { } else { usersProcessed++; totalProjectedExpiration += settled.value.result.projectedExpiration; + totalCreditsSkipped += settled.value.result.creditsSkipped; } } @@ -503,6 +479,7 @@ async function main() { `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'}`); From 869ea0ceaef85a1e71f1f9dc426030b33ec6a85c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Mar 2026 11:56:38 +0100 Subject: [PATCH 20/33] fix(expire-free-credits): split projected balance into no-script vs after-script Replace the misleading single projected_balance_microdollars with two fields: - projected_no_script_microdollars: balance after only pre-existing expirations - projected_after_script_microdollars: balance after script-set expirations too --- src/scripts/d2026-03-18_expire-free-credits.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 13af745a78..7f604fb46b 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -272,12 +272,20 @@ async function processUser( 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_balance_microdollars: projectedBalance, + projected_no_script_microdollars: projectedNoScript, + projected_after_script_microdollars: projectedAfterScript, credits_affected: creditsAffectedWithProjection, credits_skipped: creditsSkipped.length > 0 ? creditsSkipped.map(c => c.id) : undefined, }); From ee4175b556a47b71c5a350e027351246a5962b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Mar 2026 12:01:57 +0100 Subject: [PATCH 21/33] feat(expire-free-credits): add mutations log and revert script The expire script now writes a mutations.jsonl file recording every DB change (old/new values for each credit transaction and user row). New revert script reads the mutations file and undoes the changes: - Resets expiry_date and expiration_baseline to null on credit transactions - Recomputes next_credit_expiration_at from remaining expiring credits Usage: pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts [--execute] --- .../d2026-03-18_expire-free-credits-revert.ts | 150 ++++++++++++++++++ .../d2026-03-18_expire-free-credits.ts | 51 ++++-- 2 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 src/scripts/d2026-03-18_expire-free-credits-revert.ts 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..06780e616f --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.ts @@ -0,0 +1,150 @@ +/** + * 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, inArray } 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'); + + if (!mutationsFile) { + console.error('Usage: pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts [--execute]'); + process.exit(1); + } + + console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); + console.log(`Mutations file: ${mutationsFile}\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; + } + + // Revert credit transactions in batches + const BATCH_SIZE = 500; + let reverted = 0; + for (let i = 0; i < creditMutations.length; i += BATCH_SIZE) { + const batch = creditMutations.slice(i, i + BATCH_SIZE); + const ids = batch.map(m => m.id); + + await db + .update(credit_transactions) + .set({ + expiry_date: null, + expiration_baseline_microdollars_used: null, + }) + .where(inArray(credit_transactions.id, ids)); + + reverted += batch.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.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 7f604fb46b..7e593ea6b5 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -167,7 +167,8 @@ async function processUser( userId: string, affectedCredits: (typeof credit_transactions.$inferSelect)[], execute: boolean, - output: ReturnType + output: ReturnType, + mutationLog: ReturnType ): Promise<{ creditsAffected: number; creditsSkipped: number; projectedExpiration: number }> { // 1. Fetch user info const [user] = await db @@ -238,11 +239,6 @@ async function processUser( ); const currentBalance = user.total_microdollars_acquired - user.microdollars_used; - const totalExpiredAll = newTransactions.reduce( - (sum, t) => sum + Math.abs(t.amount_microdollars ?? 0), - 0 - ); - const projectedBalance = currentBalance - totalExpiredAll; // 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 @@ -294,6 +290,39 @@ async function processUser( // 8. Execute mode: write DB changes (only for credits that fit within headroom) if (execute && creditsToExpire.length > 0) { const idsToExpire = creditsToExpire.map(t => t.id); + + // Log mutations before writing so we can revert if needed + for (const credit of creditsToExpire) { + 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: EXPIRY_DATE, + 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, + }, + // Actual new value is computed by LEAST in DB; record what we know + new: { + next_credit_expiration_at_input: EXPIRY_DATE, + }, + }) + '\n' + ); + await db.transaction(async tx => { await tx .update(credit_transactions) @@ -360,10 +389,13 @@ async function main() { 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)); - console.log(`Output: ${path.join(outputDir, outputFile)}`); - console.log(`Errors: ${path.join(outputDir, errorsFile)}\n`); + 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); @@ -442,7 +474,7 @@ async function main() { const results = await Promise.allSettled( [...byUser.entries()].map(([userId, credits]) => limit(async () => { - const result = await processUser(userId, credits, execute, output); + const result = await processUser(userId, credits, execute, output, mutationLog); return { userId, result }; }) ) @@ -469,6 +501,7 @@ async function main() { output.end(); errorLog.end(); + mutationLog.end(); const fmt = (microdollars: number) => `$${(microdollars / 1_000_000).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; From 8266911186455758b93acd830b4bab9429dbec85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Mar 2026 12:06:33 +0100 Subject: [PATCH 22/33] test(expire-free-credits): add integration test for revert script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs expire script → takes snapshot → runs revert script → verifies all credits and users are restored to their original state. --- ...6-03-18_expire-free-credits-revert.test.ts | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 src/scripts/d2026-03-18_expire-free-credits-revert.test.ts 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..ee8ff7a219 --- /dev/null +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts @@ -0,0 +1,269 @@ +/** + * 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 } from 'node:fs'; +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 }]) + ), + }; +} + +let insertedCreditIds: string[] = []; + +async function setup() { + 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)); + 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 { + 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 --execute --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`, + { 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); +}); From ba1f294773da861e4a8684c2c2d68d1e12789f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Mar 2026 12:16:52 +0100 Subject: [PATCH 23/33] fix(expire-free-credits): log mutations after commit, revert using old values - Move mutation logging after the DB transaction commits so rolled-back transactions don't leave phantom entries in the mutations file - Revert script now restores the exact old values from the mutations log instead of blindly writing nulls --- .../d2026-03-18_expire-free-credits-revert.ts | 26 ++++++------- .../d2026-03-18_expire-free-credits.ts | 39 +++++++++---------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits-revert.ts b/src/scripts/d2026-03-18_expire-free-credits-revert.ts index 06780e616f..47da78cdfe 100644 --- a/src/scripts/d2026-03-18_expire-free-credits-revert.ts +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.ts @@ -20,7 +20,7 @@ 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, inArray } from 'drizzle-orm'; +import { and, eq, isNull, isNotNull } from 'drizzle-orm'; type CreditMutation = { type: 'credit_transaction'; @@ -55,7 +55,9 @@ async function main() { const execute = args.includes('--execute'); if (!mutationsFile) { - console.error('Usage: pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts [--execute]'); + console.error( + 'Usage: pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts [--execute]' + ); process.exit(1); } @@ -84,23 +86,21 @@ async function main() { return; } - // Revert credit transactions in batches - const BATCH_SIZE = 500; + // Revert credit transactions using their logged old values let reverted = 0; - for (let i = 0; i < creditMutations.length; i += BATCH_SIZE) { - const batch = creditMutations.slice(i, i + BATCH_SIZE); - const ids = batch.map(m => m.id); - + for (const mutation of creditMutations) { await db .update(credit_transactions) .set({ - expiry_date: null, - expiration_baseline_microdollars_used: null, + expiry_date: mutation.old.expiry_date, + expiration_baseline_microdollars_used: mutation.old.expiration_baseline_microdollars_used, }) - .where(inArray(credit_transactions.id, ids)); + .where(eq(credit_transactions.id, mutation.id)); - reverted += batch.length; - console.log(` Reverted ${reverted}/${creditMutations.length} credit transactions`); + 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 diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 7e593ea6b5..fa4128de86 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -291,7 +291,25 @@ async function processUser( if (execute && creditsToExpire.length > 0) { const idsToExpire = creditsToExpire.map(t => t.id); - // Log mutations before writing so we can revert if needed + await db.transaction(async tx => { + await tx + .update(credit_transactions) + .set({ + expiry_date: EXPIRY_DATE, + expiration_baseline_microdollars_used: sql`COALESCE(${credit_transactions.original_baseline_microdollars_used}, 0)`, + }) + .where(inArray(credit_transactions.id, idsToExpire)); + + // 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}, ${EXPIRY_DATE}), ${EXPIRY_DATE})`, + }) + .where(eq(kilocode_users.id, user.id)); + }); + + // Log mutations after commit so rollbacks don't leave phantom entries for (const credit of creditsToExpire) { mutationLog.write( JSON.stringify({ @@ -316,30 +334,11 @@ async function processUser( old: { next_credit_expiration_at: user.next_credit_expiration_at, }, - // Actual new value is computed by LEAST in DB; record what we know new: { next_credit_expiration_at_input: EXPIRY_DATE, }, }) + '\n' ); - - await db.transaction(async tx => { - await tx - .update(credit_transactions) - .set({ - expiry_date: EXPIRY_DATE, - expiration_baseline_microdollars_used: sql`COALESCE(${credit_transactions.original_baseline_microdollars_used}, 0)`, - }) - .where(inArray(credit_transactions.id, idsToExpire)); - - // 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}, ${EXPIRY_DATE}), ${EXPIRY_DATE})`, - }) - .where(eq(kilocode_users.id, user.id)); - }); } // 9. Return total projected expiration only for the credits we actually tagged From 556c3549059912b3795343040932940206becfb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Mar 2026 12:18:39 +0100 Subject: [PATCH 24/33] style: format revert test file --- src/scripts/d2026-03-18_expire-free-credits-revert.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 index ee8ff7a219..7d5830590e 100644 --- a/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts @@ -28,12 +28,7 @@ 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, -]; +const ALL_USER_IDS = [USER_PARTIALLY_SPENT, USER_UNSPENT, USER_FULLY_SPENT, USER_EXISTING_EXPIRY]; // ── Helpers ────────────────────────────────────────────────────────────────── From b8664a8b10d1e6bb055e2e619108e7fb95eaa98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Mar 2026 12:20:58 +0100 Subject: [PATCH 25/33] fix(expire-free-credits): log mutations in dry run mode too --- src/scripts/d2026-03-18_expire-free-credits.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index fa4128de86..c2e14f4015 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -308,8 +308,11 @@ async function processUser( }) .where(eq(kilocode_users.id, user.id)); }); + } - // Log mutations after commit so rollbacks don't leave phantom entries + // 9. Log mutations (both dry run and execute — after commit in execute mode + // so rollbacks don't leave phantom entries) + if (creditsToExpire.length > 0) { for (const credit of creditsToExpire) { mutationLog.write( JSON.stringify({ From 91e1c10552054f0fb9c603c5f09ee5d41e919775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 20 Mar 2026 12:40:08 +0100 Subject: [PATCH 26/33] feat(expire-free-credits): add confirmation prompt with DB host display Both the expire and revert scripts now show the target database hostname and prompt for confirmation before proceeding. Bypass with --yes/-y. Tests assert the DB URL is local postgres and pass --yes automatically. --- ...6-03-18_expire-free-credits-revert.test.ts | 16 ++++++++-- .../d2026-03-18_expire-free-credits-revert.ts | 28 ++++++++++++++-- .../d2026-03-18_expire-free-credits.test.ts | 14 +++++++- .../d2026-03-18_expire-free-credits.ts | 32 +++++++++++++++++-- 4 files changed, 83 insertions(+), 7 deletions(-) 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 index 7d5830590e..abc3cdadb5 100644 --- a/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts @@ -100,6 +100,17 @@ async function takeSnapshot(): Promise { }; } +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 insertedCreditIds: string[] = []; async function setup() { @@ -163,6 +174,7 @@ type AssertionResult = { name: string; passed: boolean; detail?: string }; async function main() { try { + assertLocalDatabase(); await setup(); // 1. Take snapshot of original state @@ -171,7 +183,7 @@ async function main() { // 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 --execute --batch-size=1', + 'pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute --yes --batch-size=1', { cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 } ); console.log(expireOutput); @@ -189,7 +201,7 @@ async function main() { 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`, + `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); diff --git a/src/scripts/d2026-03-18_expire-free-credits-revert.ts b/src/scripts/d2026-03-18_expire-free-credits-revert.ts index 47da78cdfe..2b4d020e66 100644 --- a/src/scripts/d2026-03-18_expire-free-credits-revert.ts +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.ts @@ -53,16 +53,27 @@ 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]' + '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}\n`); + 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[] = []; @@ -86,6 +97,19 @@ async function main() { 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) { diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index 778c91ab42..2719d7acd5 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -98,6 +98,17 @@ function makeCredit( }; } +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 insertedCreditIds: string[] = []; async function setup() { @@ -632,11 +643,12 @@ async function runAssertions(): Promise { 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 --execute --batch-size=1', + 'pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute --yes --batch-size=1', { cwd: process.cwd(), encoding: 'utf-8', diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index c2e14f4015..7bea7585b8 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -29,6 +29,7 @@ import '../lib/load-env'; import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; +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'; @@ -130,17 +131,21 @@ function parseCreditCategoryRows(): CreditCategoryRow[] { function parseArgs(): { execute: boolean; + yes: boolean; batchSize: number; concurrency: number; } { const args = process.argv.slice(2); let execute = false; + let yes = false; let batchSize = 10_000; let concurrency = 50; 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) { @@ -158,7 +163,7 @@ function parseArgs(): { } } - return { execute, batchSize, concurrency }; + return { execute, yes, batchSize, concurrency }; } // ── Process a single user ──────────────────────────────────────────────────── @@ -360,7 +365,7 @@ async function processUser( // ── Main ───────────────────────────────────────────────────────────────────── async function main() { - const { execute, batchSize, concurrency } = parseArgs(); + const { execute, yes, batchSize, concurrency } = parseArgs(); const rows = parseCreditCategoryRows(); console.log(`Mode: ${execute ? 'EXECUTE' : 'DRY RUN'}`); @@ -374,6 +379,29 @@ async function main() { } 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(); + // Build the OR condition matching all (category, description) pairs. // Empty description means "match any description for that category". const rowConditions = rows.map(row => From d1b1096f8fcf9d383444bef63a45d2921ce8d86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 31 Mar 2026 15:58:34 +0200 Subject: [PATCH 27/33] refactor(expire-credits): replace hardcoded data with CSV parser and matching logic - Remove hardcoded template literal credit category/description data and global EXPIRY_DATE constants - Add --input= required CLI argument for CSV file - Add parseCsv() that validates columns, parses rows, deduplicates pairs, and builds a Map with pre-computed per-row expiry dates - Add resolveCredit() with specific-match-then-catch-all fallback - Update processUser() to accept lookup map, resolve per-credit expiry dates, update each credit individually in the DB transaction, and use earliest expiry for next_credit_expiration_at - Replace OR condition query filter with simpler inArray on categoriesToQuery, with app-side filtering via resolveCredit after fetch - Remove unused `or` import from drizzle-orm --- .../d2026-03-18_expire-free-credits.ts | 392 +++++++++++------- 1 file changed, 246 insertions(+), 146 deletions(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 7bea7585b8..144c73b099 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -1,130 +1,169 @@ /** * Adds expiry dates to free, non-expiring credit transactions so they expire - * 30 days from when the script is run. + * after a configurable number of days (default 30) from when the script is run. * - * The set of credits to expire is defined by (credit_category, description) - * pairs copied from the reviewed spreadsheet. Each row must match both fields - * (empty description matches any description for that category). + * The set of credits to expire is defined by a CSV file passed via --input. + * 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 EXPIRY_DATE using computeExpiration(). + * 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 - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --batch-size=1000 - * pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --concurrency=20 + * 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 } from 'node:fs'; +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, or } from 'drizzle-orm'; +import { and, eq, gt, isNull, sql, inArray } from 'drizzle-orm'; import { computeExpiration, type ExpiringTransaction } from '@/lib/creditExpiration'; // ── Constants ──────────────────────────────────────────────────────────────── -const EXPIRY_DATE_OBJ = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); -EXPIRY_DATE_OBJ.setUTCHours(0, 0, 0, 0); -const EXPIRY_DATE = EXPIRY_DATE_OBJ.toISOString(); - -// ── Excel data ─────────────────────────────────────────────────────────────── -// https://docs.google.com/spreadsheets/d/1G8EAUD39Hn3C01qNnjvWSEQpG3te0HIgiSi0yMD-AZk/edit?gid=458053126#gid=458053126 -// set the should expire filter to true -// copy credit category name column (without the header) -// same for credit category description - -const creditCategoryNames = ` -orb_free_credits -card-validation-upgrade -stytch-validation -automatic-welcome-credits -XCURSOR-W92X91 -XCURSOR-REF-W92X91 -card-validation-no-stytch -in-app-5usd -payment-tripled -THEO -referral-referring-bonus -referral-redeeming-bonus -windsurf-promo-2025-07-12 -orb_free_credits -THEOKILO -orb_free_credits -orb_free_credits -POWER-OF-EUROPE -windsurf-promo-2025-07-12 -orb_free_credits -windsurf-promo-2025-07-12 -windsurf-promo-2025-07-12 -orb_free_credits -custom -custom -custom -custom`; - -const creditCategoryDescriptions = ` - -Upgrade credits for passing card validation after having already passed Stytch validation. -Free credits for passing Stytch fraud detection. -Free credits for new users, obtained by stych approval, card validation, or maybe some other method -Cursor promo 2025-07-17 -Cursor promo 2025-07-17 (referral) -Free credits for passing card validation without prior Stytch validation. -In-app survey completion - -Influencer: Theo T3 - - -Windsurf promo 2025-07-12 (Brendan O'Leary) - -Influencer: Theo T3 -Cohort B - Automated May 1-time early adopter credit -Cohort 100A - Automated May 1-time early adopter credit -Hackathon: Power of Europe Amsterdam 2025 -Windsurf promo 2025-07-12 (Olesya Elfimova) -Email 100 non-expire (via script) -Windsurf promo 2025-07-12 (Tirumari Jothi) -Windsurf promo 2025-07-12 -2025-05-24 JP gives stragglers $100 -Dev (Catriel Müller) -workwork (Eamon Nerbonne) -Darko: I thought he's a leecher. He paid us in Stripe..a lot (verified in Orb) (Darko Gjorgjievski) -Part-time UX hire, providing tokens to use product and be productive (Joshua Lambert)`; - -// ── Parse excel rows into (category, description) pairs ───────────────────── - -type CreditCategoryRow = { category: string; description: string | null }; - -function parseCreditCategoryRows(): CreditCategoryRow[] { - // Split on newlines, dropping the first empty line from the template literal - const names = creditCategoryNames.split('\n').slice(1); - const descriptions = creditCategoryDescriptions.split('\n').slice(1); - - if (names.length !== descriptions.length) { +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)) { + throw new Error(`Duplicate (category, description) pair in CSV: "${key}"`); + } + 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( - `Mismatch: ${names.length} category names vs ${descriptions.length} descriptions` + `No CSV row found for (${category}, ${description ?? '(any)'}). This should not happen — credit passed the shouldExpire filter.` ); } - - return names.map((name, i) => ({ - category: name.trim(), - description: descriptions[i].trim() || null, - })); + return resolved; } // ── Arg parsing ────────────────────────────────────────────────────────────── @@ -134,12 +173,14 @@ function parseArgs(): { 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') { @@ -160,10 +201,17 @@ function parseArgs(): { process.exit(1); } concurrency = value; + } else if (arg.startsWith('--input=')) { + input = arg.slice('--input='.length); } } - return { execute, yes, batchSize, concurrency }; + if (!input) { + console.error('Missing required argument: --input='); + process.exit(1); + } + + return { execute, yes, batchSize, concurrency, input }; } // ── Process a single user ──────────────────────────────────────────────────── @@ -173,7 +221,8 @@ async function processUser( affectedCredits: (typeof credit_transactions.$inferSelect)[], execute: boolean, output: ReturnType, - mutationLog: ReturnType + mutationLog: ReturnType, + lookup: Map ): Promise<{ creditsAffected: number; creditsSkipped: number; projectedExpiration: number }> { // 1. Fetch user info const [user] = await db @@ -223,20 +272,31 @@ async function processUser( is_free: t.is_free, })); - const modifiedAffected: ExpiringTransaction[] = affectedCredits.map(t => ({ - id: t.id, - amount_microdollars: t.amount_microdollars, - expiration_baseline_microdollars_used: t.original_baseline_microdollars_used ?? 0, - expiry_date: 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 EXPIRY_DATE_OBJ as `now` to project what would happen at expiry + // 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 { newTransactions } = computeExpiration(simulationInput, entity, EXPIRY_DATE_OBJ, user.id); + 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( @@ -294,22 +354,40 @@ async function processUser( // 8. Execute mode: write DB changes (only for credits that fit within headroom) if (execute && creditsToExpire.length > 0) { - const idsToExpire = creditsToExpire.map(t => t.id); + // 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 => { - await tx - .update(credit_transactions) - .set({ - expiry_date: EXPIRY_DATE, - expiration_baseline_microdollars_used: sql`COALESCE(${credit_transactions.original_baseline_microdollars_used}, 0)`, - }) - .where(inArray(credit_transactions.id, idsToExpire)); + // 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}, ${EXPIRY_DATE}), ${EXPIRY_DATE})`, + next_credit_expiration_at: sql`COALESCE(LEAST(${kilocode_users.next_credit_expiration_at}, ${earliestExpiry}), ${earliestExpiry})`, }) .where(eq(kilocode_users.id, user.id)); }); @@ -318,7 +396,24 @@ async function processUser( // 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', @@ -329,7 +424,7 @@ async function processUser( expiration_baseline_microdollars_used: credit.expiration_baseline_microdollars_used, }, new: { - expiry_date: EXPIRY_DATE, + expiry_date: resolved.expiryDateIso, expiration_baseline_microdollars_used: credit.original_baseline_microdollars_used ?? 0, }, }) + '\n' @@ -343,7 +438,7 @@ async function processUser( next_credit_expiration_at: user.next_credit_expiration_at, }, new: { - next_credit_expiration_at_input: EXPIRY_DATE, + next_credit_expiration_at_input: earliestExpiryForLog, }, }) + '\n' ); @@ -365,17 +460,25 @@ async function processUser( // ── Main ───────────────────────────────────────────────────────────────────── async function main() { - const { execute, yes, batchSize, concurrency } = parseArgs(); - const rows = parseCreditCategoryRows(); + 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(`Expiry date: ${EXPIRY_DATE}`); - console.log(`Rows: ${rows.length} (category, description) pairs`); - console.log(`Batch size: ${batchSize}`); + 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`); - for (const row of rows) { - console.log(` ${row.category} | ${row.description ?? '(any)'}`); + 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(); @@ -402,18 +505,6 @@ async function main() { } console.log(); - // Build the OR condition matching all (category, description) pairs. - // Empty description means "match any description for that category". - const rowConditions = rows.map(row => - row.description - ? and( - eq(credit_transactions.credit_category, row.category), - eq(credit_transactions.description, row.description) - ) - : eq(credit_transactions.credit_category, row.category) - ); - const categoryFilter = rowConditions.length === 1 ? rowConditions[0] : or(...rowConditions); - const outputDir = path.join(__dirname, 'output'); await mkdir(outputDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/:/g, '-'); @@ -430,9 +521,6 @@ async function main() { const limit = pLimit(concurrency); // Per-(category, description) stats - type PairKey = string; - const pairKey = (cat: string, desc: string | null): PairKey => - desc ? `${cat} | ${desc}` : `${cat} | (any)`; const pairStats = new Map< PairKey, { credits: number; amount: number; projectedExpiration: number } @@ -453,7 +541,7 @@ async function main() { let totalErrors = 0; const baseFilter = and( - categoryFilter, + inArray(credit_transactions.credit_category, categoriesToQuery), eq(credit_transactions.is_free, true), isNull(credit_transactions.expiry_date), isNull(credit_transactions.organization_id) @@ -470,17 +558,29 @@ async function main() { .orderBy(credit_transactions.kilo_user_id) .limit(batchSize); - const batch = await db + const rawBatch = await db .select() .from(credit_transactions) .where(and(baseFilter, inArray(credit_transactions.kilo_user_id, userIdSubquery))); - if (batch.length === 0) break; + 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 raw batch + // Track per-pair stats from filtered batch for (const credit of batch) { - // Find the matching row (specific description match first, then any-description) const specificKey = pairKey(credit.credit_category ?? '', credit.description); const anyKey = pairKey(credit.credit_category ?? '', null); const stats = pairStats.get(specificKey) ?? pairStats.get(anyKey); @@ -504,7 +604,7 @@ async function main() { const results = await Promise.allSettled( [...byUser.entries()].map(([userId, credits]) => limit(async () => { - const result = await processUser(userId, credits, execute, output, mutationLog); + const result = await processUser(userId, credits, execute, output, mutationLog, lookup); return { userId, result }; }) ) From 65dcecba5189b9cd4d1c0bdf53bb897f8d1b2ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 31 Mar 2026 16:04:59 +0200 Subject: [PATCH 28/33] test: update expire-credits test to use generated CSV input --- .../d2026-03-18_expire-free-credits.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index 2719d7acd5..3ac1a0beb2 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -11,6 +11,9 @@ 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'; @@ -109,9 +112,31 @@ function assertLocalDatabase() { } } +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(); @@ -261,6 +286,11 @@ async function cleanup() { .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 {} + } console.log(' Done.\n'); } @@ -648,7 +678,7 @@ async function main() { console.log('Running expire-free-credits script with --execute...\n'); const output = execSync( - 'pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --execute --yes --batch-size=1', + `pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=${testCsvPath} --execute --yes --batch-size=1`, { cwd: process.cwd(), encoding: 'utf-8', From b27d60615c0949a74bbb3d8ceefa6b61552a0579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 31 Mar 2026 16:05:03 +0200 Subject: [PATCH 29/33] test: update revert test to use generated CSV input --- ...6-03-18_expire-free-credits-revert.test.ts | 31 +++++++++++++++++-- .../d2026-03-18_expire-free-credits.ts | 5 ++- 2 files changed, 33 insertions(+), 3 deletions(-) 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 index abc3cdadb5..67a1ca56b4 100644 --- a/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts @@ -13,7 +13,8 @@ import '../lib/load-env'; import { execSync } from 'node:child_process'; -import { readdirSync } from 'node:fs'; +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'; @@ -111,9 +112,30 @@ function assertLocalDatabase() { } } +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(); @@ -156,6 +178,11 @@ async function cleanup() { .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 {} + } console.log(' Done.\n'); } @@ -183,7 +210,7 @@ async function main() { // 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 --execute --yes --batch-size=1', + `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); diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 144c73b099..563aa5f0d3 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -114,7 +114,10 @@ function parseCsv(inputPath: string): { const key = pairKey(category, description); if (seenPairs.has(key)) { - throw new Error(`Duplicate (category, description) pair in CSV: "${key}"`); + console.warn( + ` Warning: duplicate (category, description) pair in CSV: "${key}" — using first occurrence` + ); + continue; } seenPairs.add(key); From b05dc901b8850685d957cffc414e8611de546c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 31 Mar 2026 16:10:51 +0200 Subject: [PATCH 30/33] chore: add csv-parse dependency for expire-credits script --- package.json | 1 + pnpm-lock.yaml | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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 From 16b433507da91367559c8e691954f2a30127a5b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 31 Mar 2026 16:14:55 +0200 Subject: [PATCH 31/33] fix: cursor advancement, add FALSE-override test, add sheet URL comment --- .../d2026-03-18_expire-free-credits.test.ts | 26 +++++++++++++++++++ .../d2026-03-18_expire-free-credits.ts | 4 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index 3ac1a0beb2..4e367f9e68 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -39,6 +39,7 @@ 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 ALL_USER_IDS = [ USER_FULLY_SPENT, @@ -58,6 +59,7 @@ const ALL_USER_IDS = [ USER_FREE_USE_BUY, USER_ORB_DOUBLE_DEDUCT, USER_ORB_EXISTING_EXPIRY, + USER_FALSE_OVERRIDE, ]; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -125,6 +127,7 @@ function generateTestCsv(): string { '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', ].join('\n'); writeFileSync(csvPath, csvContent); @@ -174,6 +177,9 @@ async function setup() { ...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), ]); // Insert credits @@ -273,6 +279,14 @@ async function setup() { }), // $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', + }), ]) .returning({ id: credit_transactions.id }); @@ -666,6 +680,18 @@ async function runAssertions(): Promise { }); } + // --- 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}`, + }); + } + return results; } diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 563aa5f0d3..2a579e6bac 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -3,6 +3,8 @@ * 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. @@ -626,7 +628,7 @@ async function main() { } } - lastUserId = [...byUser.keys()].sort().pop() ?? lastUserId; + lastUserId = [...new Set(rawBatch.map(c => c.kilo_user_id))].sort().pop() ?? lastUserId; console.log( ` ${totalCredits} credits fetched, ${usersProcessed} users processed, ${totalErrors} errors` ); From dae924e183c49122bb1c0425a832347013b728f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 31 Mar 2026 16:17:07 +0200 Subject: [PATCH 32/33] fix: add comment in empty catch blocks to satisfy linter --- src/scripts/d2026-03-18_expire-free-credits-revert.test.ts | 4 +++- src/scripts/d2026-03-18_expire-free-credits.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 index 67a1ca56b4..e773ea18cd 100644 --- a/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits-revert.test.ts @@ -181,7 +181,9 @@ async function cleanup() { if (testCsvPath) { try { unlinkSync(testCsvPath); - } catch {} + } catch { + // ignore — temp file cleanup is best-effort + } } console.log(' Done.\n'); } diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index 4e367f9e68..b2021b7e88 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -303,7 +303,9 @@ async function cleanup() { if (testCsvPath) { try { unlinkSync(testCsvPath); - } catch {} + } catch { + // ignore — temp file cleanup is best-effort + } } console.log(' Done.\n'); } From 5da5372425b19e6d07249cfb008553191d3a1e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 31 Mar 2026 16:49:31 +0200 Subject: [PATCH 33/33] fix: sort credits by expiry date before headroom selection When headroom is limited (Orb clawback cases), the order in which credits are consumed matters. Sort by resolved expiry date ascending so earlier-expiring credits are preferred over later-expiring ones. --- .../d2026-03-18_expire-free-credits.test.ts | 37 +++++++++++++++++++ .../d2026-03-18_expire-free-credits.ts | 18 ++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/scripts/d2026-03-18_expire-free-credits.test.ts b/src/scripts/d2026-03-18_expire-free-credits.test.ts index b2021b7e88..28d51ffb2d 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.test.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.test.ts @@ -40,6 +40,7 @@ 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, @@ -60,6 +61,7 @@ const ALL_USER_IDS = [ USER_ORB_DOUBLE_DEDUCT, USER_ORB_EXISTING_EXPIRY, USER_FALSE_OVERRIDE, + USER_MIXED_EXPIRY_HEADROOM, ]; // ── Helpers ───────────────────────────────────────────────────────────────── @@ -128,6 +130,7 @@ function generateTestCsv(): string { '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); @@ -180,6 +183,11 @@ async function setup() { // 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 @@ -287,6 +295,15 @@ async function setup() { 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 }); @@ -694,6 +711,26 @@ async function runAssertions(): Promise { }); } + // --- 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; } diff --git a/src/scripts/d2026-03-18_expire-free-credits.ts b/src/scripts/d2026-03-18_expire-free-credits.ts index 2a579e6bac..ebc3ed8ece 100644 --- a/src/scripts/d2026-03-18_expire-free-credits.ts +++ b/src/scripts/d2026-03-18_expire-free-credits.ts @@ -319,11 +319,27 @@ async function processUser( }, 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 affectedCredits) { + for (const credit of sortedAffected) { const expiredAmt = expiredByOriginalId.get(credit.id) ?? 0; if (remainingHeadroom >= expiredAmt) { remainingHeadroom -= expiredAmt;