-
Notifications
You must be signed in to change notification settings - Fork 37
Add script to expire non-expiring free credits #1269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
87a70c5
feat: first pass at free credits script
iscekic 4a8decd
feat(expire-free-credits): add configurable concurrency with p-limit
iscekic 35d1e90
fix(scripts): exclude categories from expiration to avoid expiring cr…
iscekic ae0feb5
refactor(expire-free-credits): require --category param and query by …
iscekic d2448d1
style(scripts): remove eslint-disable-next-line above infinite loop
iscekic 18f2baa
feat(expire-free-credits): switch to dynamic 30-day expiry and catego…
iscekic dee2668
feat(expire-free-credits): use spreadsheet-driven category/descriptio…
iscekic e29e2a3
fix(expire-free-credits): correct empty description label in startup log
iscekic 3f43dda
fix(expire-free-credits): log next_credit_expiration_at for rollback …
iscekic 06639ef
fix(expire-free-credits): empty description matches any description f…
iscekic 73ba056
chore(prettier): add .kilo to ignore list
iscekic 39596b7
test(expire-free-credits): add integration test script
iscekic 491fafa
docs(expire-free-credits): replace hardcoded date with EXPIRY_DATE in…
iscekic 93d3695
test(expire-free-credits): add buy-use-free vs free-use-buy ordering …
iscekic 49fc5af
fix(expire-free-credits): paginate by user ID to avoid splitting cred…
iscekic 6ff6482
chore(scripts): add .gitignore to ignore *.jsonl in output dir
iscekic cd3542b
fix(expire-free-credits): floor projected balance at zero to prevent …
iscekic 6353835
Merge remote-tracking branch 'origin/main' into fix/expire-credits
iscekic 2d8c60e
fix(ci): exclude src/scripts/ from Jest and fix lint errors
iscekic 1b4de9e
fix(expire-free-credits): skip credits that would push balance negati…
iscekic 869ea0c
fix(expire-free-credits): split projected balance into no-script vs a…
iscekic ee4175b
feat(expire-free-credits): add mutations log and revert script
iscekic 8266911
test(expire-free-credits): add integration test for revert script
iscekic ba1f294
fix(expire-free-credits): log mutations after commit, revert using ol…
iscekic 556c354
style: format revert test file
iscekic b8664a8
fix(expire-free-credits): log mutations in dry run mode too
iscekic 91e1c10
feat(expire-free-credits): add confirmation prompt with DB host display
iscekic d48f4e2
Merge branch 'main' into fix/expire-credits
iscekic d1b1096
refactor(expire-credits): replace hardcoded data with CSV parser and …
iscekic 65dcecb
test: update expire-credits test to use generated CSV input
iscekic b27d606
test: update revert test to use generated CSV input
iscekic b05dc90
chore: add csv-parse dependency for expire-credits script
iscekic 16b4335
fix: cursor advancement, add FALSE-override test, add sheet URL comment
iscekic dae924e
fix: add comment in empty catch blocks to satisfy linter
iscekic 5da5372
fix: sort credits by expiry date before headroom selection
iscekic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ pnpm-lock.yaml | |
|
|
||
| # Kilo agent runtime artifacts | ||
| .kilocode/ | ||
| .kilo | ||
|
|
||
| # Misc | ||
| .DS_Store | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
305 changes: 305 additions & 0 deletions
305
src/scripts/d2026-03-18_expire-free-credits-revert.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,305 @@ | ||
| /** | ||
| * Integration test for the expire-free-credits revert script. | ||
| * | ||
| * 1. Inserts test users + credits | ||
| * 2. Runs the expire script with --execute (produces a mutations file) | ||
| * 3. Runs the revert script with --execute using that mutations file | ||
| * 4. Asserts DB state is back to the original state | ||
| * | ||
| * Usage: | ||
| * pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.test.ts | ||
| */ | ||
|
|
||
| import '../lib/load-env'; | ||
|
|
||
| import { execSync } from 'node:child_process'; | ||
| import { readdirSync, writeFileSync, unlinkSync, mkdtempSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import path from 'node:path'; | ||
| import { db, closeAllDrizzleConnections } from '@/lib/drizzle'; | ||
| import { credit_transactions, kilocode_users } from '@kilocode/db/schema'; | ||
| import { inArray } from 'drizzle-orm'; | ||
| import { defineTestUser } from '@/tests/helpers/user.helper'; | ||
|
|
||
| // ── Test user IDs ──────────────────────────────────────────────────────────── | ||
|
|
||
| const TEST_PREFIX = `revert-test-${Date.now()}`; | ||
| const USER_PARTIALLY_SPENT = `${TEST_PREFIX}-partially-spent`; | ||
| const USER_UNSPENT = `${TEST_PREFIX}-unspent`; | ||
| const USER_FULLY_SPENT = `${TEST_PREFIX}-fully-spent`; | ||
| const USER_EXISTING_EXPIRY = `${TEST_PREFIX}-existing-expiry`; | ||
|
|
||
| const ALL_USER_IDS = [USER_PARTIALLY_SPENT, USER_UNSPENT, USER_FULLY_SPENT, USER_EXISTING_EXPIRY]; | ||
|
|
||
| // ── Helpers ────────────────────────────────────────────────────────────────── | ||
|
|
||
| const MICRODOLLARS = 1_000_000; | ||
|
|
||
| function makeUser(id: string, spent: number, acquired: number) { | ||
| return defineTestUser({ | ||
| id, | ||
| google_user_email: `${id}@test.local`, | ||
| stripe_customer_id: `stripe-${id}`, | ||
| microdollars_used: spent * MICRODOLLARS, | ||
| total_microdollars_acquired: acquired * MICRODOLLARS, | ||
| }); | ||
| } | ||
|
|
||
| function makeCredit( | ||
| userId: string, | ||
| amount: number, | ||
| opts: { | ||
| category?: string; | ||
| description?: string | null; | ||
| expiryDate?: string | null; | ||
| } = {} | ||
| ) { | ||
| return { | ||
| kilo_user_id: userId, | ||
| amount_microdollars: amount * MICRODOLLARS, | ||
| is_free: true, | ||
| credit_category: opts.category ?? 'automatic-welcome-credits', | ||
| description: | ||
| opts.description ?? | ||
| 'Free credits for new users, obtained by stych approval, card validation, or maybe some other method', | ||
| expiry_date: opts.expiryDate ?? null, | ||
| organization_id: null, | ||
| original_baseline_microdollars_used: 0, | ||
| check_category_uniqueness: false, | ||
| }; | ||
| } | ||
|
|
||
| type Snapshot = { | ||
| credits: Map<string, { expiry_date: string | null; expiration_baseline: number | null }>; | ||
| users: Map<string, { next_credit_expiration_at: string | null }>; | ||
| }; | ||
|
|
||
| async function takeSnapshot(): Promise<Snapshot> { | ||
| const credits = await db | ||
| .select() | ||
| .from(credit_transactions) | ||
| .where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS)); | ||
|
|
||
| const users = await db | ||
| .select() | ||
| .from(kilocode_users) | ||
| .where(inArray(kilocode_users.id, ALL_USER_IDS)); | ||
|
|
||
| return { | ||
| credits: new Map( | ||
| credits.map(c => [ | ||
| c.id, | ||
| { | ||
| expiry_date: c.expiry_date, | ||
| expiration_baseline: c.expiration_baseline_microdollars_used, | ||
| }, | ||
| ]) | ||
| ), | ||
| users: new Map( | ||
| users.map(u => [u.id, { next_credit_expiration_at: u.next_credit_expiration_at }]) | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
| const EXPECTED_LOCAL_DB_URL = 'postgres://postgres:postgres@localhost:5432/postgres'; | ||
|
|
||
| function assertLocalDatabase() { | ||
| const dbUrl = process.env.POSTGRES_SCRIPT_URL ?? process.env.POSTGRES_URL ?? ''; | ||
| if (dbUrl !== EXPECTED_LOCAL_DB_URL) { | ||
| console.error(`ABORT: Expected local database URL but got: ${dbUrl}`); | ||
| console.error(`Expected: ${EXPECTED_LOCAL_DB_URL}`); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| let testCsvPath = ''; | ||
|
|
||
| function generateTestCsv(): string { | ||
| const dir = mkdtempSync(path.join(tmpdir(), 'expire-test-')); | ||
| const csvPath = path.join(dir, 'input.csv'); | ||
|
|
||
| const csvContent = [ | ||
| 'CREDIT_CATEGORY,SHOULD_EXPIRE,DESCRIPTION,RECORDS,USERS,BLOCKED_USERS,FIRST_ISSUED_AT,LAST_ISSUED_AT,AMOUNT_GRANTED_USD,PCT,EXPIRE_IN_DAYS,REVIEWED_BY', | ||
| 'automatic-welcome-credits,TRUE,"Free credits for new users, obtained by stych approval, card validation, or maybe some other method",0,0,0,,,,,30,test', | ||
| 'referral-redeeming-bonus,TRUE,,0,0,0,,,,,30,test', | ||
| 'card-validation-upgrade,TRUE,Upgrade credits for passing card validation after having already passed Stytch validation.,0,0,0,,,,,30,test', | ||
| 'card-validation-no-stytch,TRUE,Free credits for passing card validation without prior Stytch validation.,0,0,0,,,,,30,test', | ||
| 'stytch-validation,TRUE,Free credits for passing Stytch fraud detection.,0,0,0,,,,,30,test', | ||
| ].join('\n'); | ||
|
|
||
| writeFileSync(csvPath, csvContent); | ||
| return csvPath; | ||
| } | ||
|
|
||
| let insertedCreditIds: string[] = []; | ||
|
|
||
| async function setup() { | ||
| testCsvPath = generateTestCsv(); | ||
| console.log(` Generated test CSV: ${testCsvPath}\n`); | ||
| console.log('Setting up test data...\n'); | ||
|
|
||
| const EARLIER_EXPIRY = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); | ||
|
|
||
| await db.insert(kilocode_users).values([ | ||
| makeUser(USER_PARTIALLY_SPENT, 5, 10), | ||
| makeUser(USER_UNSPENT, 0, 10), | ||
| makeUser(USER_FULLY_SPENT, 10, 10), | ||
| { | ||
| ...makeUser(USER_EXISTING_EXPIRY, 5, 15), | ||
| next_credit_expiration_at: EARLIER_EXPIRY, | ||
| }, | ||
| ]); | ||
|
|
||
| const credits = await db | ||
| .insert(credit_transactions) | ||
| .values([ | ||
| // Partially spent: $10 credit, $5 spent → $5 would expire | ||
| makeCredit(USER_PARTIALLY_SPENT, 10), | ||
|
|
||
| // Unspent: $10 credit, $0 spent → $10 would expire | ||
| makeCredit(USER_UNSPENT, 10), | ||
|
|
||
| // Fully spent: $10 credit, $10 spent → $0 would expire | ||
| makeCredit(USER_FULLY_SPENT, 10), | ||
|
|
||
| // Existing expiry user: has an independent expiring credit + a new one | ||
| makeCredit(USER_EXISTING_EXPIRY, 5, { expiryDate: EARLIER_EXPIRY }), | ||
| makeCredit(USER_EXISTING_EXPIRY, 10), | ||
| ]) | ||
| .returning({ id: credit_transactions.id }); | ||
|
|
||
| insertedCreditIds = credits.map(c => c.id); | ||
| console.log(` Inserted ${ALL_USER_IDS.length} users and ${insertedCreditIds.length} credits\n`); | ||
| } | ||
|
|
||
| async function cleanup() { | ||
| console.log('\nCleaning up test data...'); | ||
| await db | ||
| .delete(credit_transactions) | ||
| .where(inArray(credit_transactions.kilo_user_id, ALL_USER_IDS)); | ||
| await db.delete(kilocode_users).where(inArray(kilocode_users.id, ALL_USER_IDS)); | ||
| if (testCsvPath) { | ||
| try { | ||
| unlinkSync(testCsvPath); | ||
| } catch { | ||
| // ignore — temp file cleanup is best-effort | ||
| } | ||
| } | ||
| console.log(' Done.\n'); | ||
| } | ||
|
|
||
| function findLatestMutationsFile(): string { | ||
| const outputDir = path.join(__dirname, 'output'); | ||
| const files = readdirSync(outputDir) | ||
| .filter(f => f.includes('.mutations.jsonl')) | ||
| .sort(); | ||
| if (files.length === 0) throw new Error('No mutations file found'); | ||
| return path.join(outputDir, files[files.length - 1]); | ||
| } | ||
|
|
||
| type AssertionResult = { name: string; passed: boolean; detail?: string }; | ||
|
|
||
| // ── Main ───────────────────────────────────────────────────────────────────── | ||
|
|
||
| async function main() { | ||
| try { | ||
| assertLocalDatabase(); | ||
| await setup(); | ||
|
|
||
| // 1. Take snapshot of original state | ||
| const before = await takeSnapshot(); | ||
|
|
||
| // 2. Run the expire script | ||
| console.log('Running expire-free-credits script with --execute...\n'); | ||
| const expireOutput = execSync( | ||
| `pnpm script src/scripts/d2026-03-18_expire-free-credits.ts --input=${testCsvPath} --execute --yes --batch-size=1`, | ||
| { cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 } | ||
| ); | ||
| console.log(expireOutput); | ||
|
|
||
| // 3. Verify something actually changed | ||
| const afterExpire = await takeSnapshot(); | ||
| let creditsChanged = 0; | ||
| for (const [id, snap] of afterExpire.credits) { | ||
| const orig = before.credits.get(id); | ||
| if (orig && orig.expiry_date !== snap.expiry_date) creditsChanged++; | ||
| } | ||
| console.log(`Credits modified by expire script: ${creditsChanged}\n`); | ||
|
|
||
| // 4. Run the revert script | ||
| const mutationsFile = findLatestMutationsFile(); | ||
| console.log(`Running revert script with mutations file: ${mutationsFile}\n`); | ||
| const revertOutput = execSync( | ||
| `pnpm script src/scripts/d2026-03-18_expire-free-credits-revert.ts ${mutationsFile} --execute --yes`, | ||
| { cwd: process.cwd(), encoding: 'utf-8', env: { ...process.env }, timeout: 120_000 } | ||
| ); | ||
| console.log(revertOutput); | ||
|
|
||
| // 5. Take snapshot of reverted state and compare to original | ||
| const afterRevert = await takeSnapshot(); | ||
|
|
||
| const results: AssertionResult[] = []; | ||
|
|
||
| // Check all credits are back to original | ||
| for (const [id, orig] of before.credits) { | ||
| const reverted = afterRevert.credits.get(id); | ||
| results.push({ | ||
| name: `Credit ${id.slice(0, 8)}: expiry_date restored`, | ||
| passed: orig.expiry_date === (reverted?.expiry_date ?? null), | ||
| detail: `before=${orig.expiry_date}, after=${reverted?.expiry_date}`, | ||
| }); | ||
| results.push({ | ||
| name: `Credit ${id.slice(0, 8)}: baseline restored`, | ||
| passed: orig.expiration_baseline === (reverted?.expiration_baseline ?? null), | ||
| detail: `before=${orig.expiration_baseline}, after=${reverted?.expiration_baseline}`, | ||
| }); | ||
| } | ||
|
|
||
| // Check user next_credit_expiration_at is correct | ||
| // For USER_EXISTING_EXPIRY: should still point to the independent credit's expiry | ||
| // For others: should be null (no remaining expiring credits) | ||
| for (const userId of ALL_USER_IDS) { | ||
| const orig = before.users.get(userId); | ||
| const reverted = afterRevert.users.get(userId); | ||
| results.push({ | ||
| name: `User ${userId.split('-').pop()}: next_credit_expiration_at restored`, | ||
| passed: orig?.next_credit_expiration_at === reverted?.next_credit_expiration_at, | ||
| detail: `before=${orig?.next_credit_expiration_at}, after=${reverted?.next_credit_expiration_at}`, | ||
| }); | ||
| } | ||
|
|
||
| // Verify at least some credits were actually changed and reverted | ||
| results.push({ | ||
| name: 'Sanity: some credits were modified by expire script', | ||
| passed: creditsChanged > 0, | ||
| detail: `${creditsChanged} credits changed`, | ||
| }); | ||
|
|
||
| let passed = 0; | ||
| let failed = 0; | ||
| for (const r of results) { | ||
| const icon = r.passed ? 'PASS' : 'FAIL'; | ||
| console.log(` [${icon}] ${r.name}`); | ||
| if (!r.passed) { | ||
| console.log(` ${r.detail}`); | ||
| failed++; | ||
| } else { | ||
| passed++; | ||
| } | ||
| } | ||
|
|
||
| console.log(`\n${passed} passed, ${failed} failed out of ${results.length} assertions`); | ||
|
|
||
| if (failed > 0) { | ||
| process.exitCode = 1; | ||
| } | ||
| } finally { | ||
| await cleanup(); | ||
| await closeAllDrizzleConnections(); | ||
| } | ||
| } | ||
|
|
||
| void main().catch(err => { | ||
| console.error('Fatal error:', err); | ||
| process.exit(1); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Check warning
Code scanning / CodeQL
Shell command built from environment values Medium
Copilot Autofix
AI about 1 month ago
In general, the fix is to avoid constructing a shell command string that embeds unescaped, dynamic values (like filenames) and then running it through a shell. Instead, invoke the command with an API that accepts the executable and its arguments separately (for example,
execFileSyncorspawnSyncfromnode:child_process), passingmutationsFileas a distinct argument so the shell does not interpret it.Concretely here, we should:
execSyncwithexecFileSyncfor both command invocations:mutationsFileis interpolated."pnpm"as the command,"script"as the first argument, followed by the script path and other flags, and in the second call includemutationsFileas its own argument in the args array.execFileSyncfromnode:child_processinstead ofexecSync.cwd,encoding,env,timeout) by passing the same options object toexecFileSync.All of these changes must be made within
src/scripts/d2026-03-18_expire-free-credits-revert.test.ts:execSynctoexecFileSync.expireOutputassignment on lines 214–217 to callexecFileSync('pnpm', [...args...], options).revertOutputassignment on lines 232–235 similarly, ensuringmutationsFileis in the args array and not interpolated into a single command string.No additional helper methods are required; only the import and function calls need updating.