Last active
June 21, 2026 10:17
-
-
Save AlejandroAkbal/44a952ec534f203c4d8a098c30ebbc8e to your computer and use it in GitHub Desktop.
Stripe — Bulk disable subscription renewals (cancel_at_period_end)
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
| # Your Stripe secret key (starts with sk_live_ in production) | |
| # Get it at: https://dashboard.stripe.com/apikeys | |
| STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx |
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
| import "dotenv/config"; | |
| import Stripe from "stripe"; | |
| // ─── Constants ──────────────────────────────────────────────────────────────── | |
| const RETRY_MAX = 4; // max retries on transient errors | |
| const RETRY_BASE_MS = 500; // exponential backoff base | |
| const MAX_SUBS_PER_PAGE = 100; // Stripe's max per page | |
| // ─── Credential check ───────────────────────────────────────────────────────── | |
| const SECRET_KEY = process.env.STRIPE_SECRET_KEY; | |
| if (!SECRET_KEY) { | |
| console.error("❌ STRIPE_SECRET_KEY is not set."); | |
| console.error(" Create a .env file from .env.example or export it as an environment variable."); | |
| process.exit(1); | |
| } | |
| if (!SECRET_KEY.startsWith("sk_live_") && !SECRET_KEY.startsWith("sk_test_")) { | |
| console.error("❌ STRIPE_SECRET_KEY doesn't look valid (must start with sk_live_ or sk_test_)."); | |
| process.exit(1); | |
| } | |
| // ─── Stripe client ──────────────────────────────────────────────────────────── | |
| const stripe = new Stripe(SECRET_KEY); | |
| // ─── Retry with exponential backoff ─────────────────────────────────────────── | |
| async function withRetry(fn, label) { | |
| for (let attempt = 1; attempt <= RETRY_MAX; attempt++) { | |
| try { | |
| return await fn(); | |
| } catch (err) { | |
| const isRecoverable = | |
| err.statusCode === 429 || | |
| err.statusCode >= 500 || | |
| err.type === "StripeConnectionError" || | |
| err.code === "rate_limit"; | |
| if (!isRecoverable || attempt === RETRY_MAX) throw err; | |
| const delay = RETRY_BASE_MS * Math.pow(2, attempt - 1) + Math.random() * 1000; | |
| console.warn( | |
| ` ⏳ Error on ${label} (attempt ${attempt}/${RETRY_MAX}) — ` + | |
| `retrying in ${Math.round(delay)}ms…` | |
| ); | |
| await new Promise((r) => setTimeout(r, delay)); | |
| } | |
| } | |
| } | |
| // ─── Graceful shutdown (Ctrl+C) ─────────────────────────────────────────────── | |
| let gracefulShutdown = false; | |
| process.on("SIGINT", () => { | |
| if (gracefulShutdown) { | |
| console.error("\n\n⚠️ Force exiting…"); | |
| process.exit(1); | |
| } | |
| gracefulShutdown = true; | |
| console.error("\n\n⏸️ Ctrl+C received — finishing current subscription and exiting…"); | |
| }); | |
| // ─── Progress bar (single line) ──────────────────────────────────────────────── | |
| function progressStr(done, total, label) { | |
| const pct = total > 0 ? ((done / total) * 100).toFixed(1) : "0.0"; | |
| const barLen = 24; | |
| const filled = Math.round((done / total) * barLen); | |
| const bar = "█".repeat(filled) + "░".repeat(barLen - filled); | |
| return ` ${bar} ${done}/${total} (${pct}%) — ${label}`; | |
| } | |
| // ─── Stats ──────────────────────────────────────────────────────────────────── | |
| const stats = { | |
| total: 0, | |
| alreadySet: 0, | |
| updated: 0, | |
| errors: 0, | |
| }; | |
| // ─── Main ───────────────────────────────────────────────────────────────────── | |
| async function main() { | |
| console.log(""); | |
| console.log("╔══════════════════════════════════════════════════════════╗"); | |
| console.log("║ Stripe — Disable subscription renewals ║"); | |
| console.log("║ Mode: cancel_at_period_end = true ║"); | |
| console.log("╚══════════════════════════════════════════════════════════╝"); | |
| console.log(""); | |
| // ── Phase 1: Scan ────────────────────────────────────────────────────────── | |
| console.log("🔍 Scanning subscriptions with status = active…\n"); | |
| const subs = []; | |
| for await (const sub of stripe.subscriptions.list({ | |
| status: "active", | |
| limit: MAX_SUBS_PER_PAGE, | |
| })) { | |
| subs.push({ | |
| id: sub.id, | |
| customer: sub.customer, | |
| cancelAtPeriodEnd: sub.cancel_at_period_end, | |
| }); | |
| } | |
| stats.total = subs.length; | |
| console.log(` 📊 Total active subscriptions found: ${stats.total}\n`); | |
| if (stats.total === 0) { | |
| console.log("✅ No active subscriptions found. Exiting."); | |
| return; | |
| } | |
| // ── Phase 2: Pre-filter ──────────────────────────────────────────────────── | |
| const pending = []; | |
| for (const sub of subs) { | |
| if (sub.cancelAtPeriodEnd) { | |
| stats.alreadySet++; | |
| } else { | |
| pending.push(sub); | |
| } | |
| } | |
| if (stats.alreadySet > 0) { | |
| console.log( | |
| ` ⏭️ ${stats.alreadySet} already had cancel_at_period_end = true (no changes needed).` | |
| ); | |
| } | |
| if (pending.length === 0) { | |
| console.log("\n✅ All subscriptions were already set not to renew."); | |
| return; | |
| } | |
| console.log( | |
| ` ⚙️ ${pending.length} subscriptions need to be updated.\n` | |
| ); | |
| console.log(" Processing (press Ctrl+C for safe interruption)…\n"); | |
| // ── Phase 3: Sequential update ───────────────────────────────────────────── | |
| let processed = 0; | |
| for (const sub of pending) { | |
| if (gracefulShutdown) break; | |
| processed++; | |
| process.stdout.write(progressStr(processed - 1, pending.length, sub.id) + "\n"); | |
| try { | |
| await withRetry( | |
| () => | |
| stripe.subscriptions.update(sub.id, { | |
| cancel_at_period_end: true, | |
| }), | |
| sub.id | |
| ); | |
| stats.updated++; | |
| // Erase previous line and write the completed version | |
| process.stdout.write("\x1b[1A\x1b[2K"); | |
| process.stdout.write(progressStr(processed, pending.length, sub.id) + " ✅\n"); | |
| } catch (err) { | |
| stats.errors++; | |
| process.stdout.write("\x1b[1A\x1b[2K"); | |
| process.stdout.write(progressStr(processed, pending.length, sub.id) + ` ❌ ${err.message}\n`); | |
| } | |
| } | |
| // ── Final summary ───────────────────────────────────────────────────────── | |
| console.log(""); | |
| console.log("╔" + "═".repeat(56) + "╗"); | |
| console.log("║ SUMMARY" + " ".repeat(48) + "║"); | |
| console.log("╟" + "─".repeat(56) + "╢"); | |
| console.log(`║ 📦 Active subscriptions found: ${String(stats.total).padStart(5)} ║`); | |
| console.log(`║ ⏭️ Already set not to renew: ${String(stats.alreadySet).padStart(5)} ║`); | |
| console.log(`║ ✅ Updated now: ${String(stats.updated).padStart(5)} ║`); | |
| if (stats.errors > 0) { | |
| console.log(`║ ❌ Errors: ${String(stats.errors).padStart(5)} ║`); | |
| } else { | |
| console.log(`║ ❌ Errors: 0 ✅ No errors ║`); | |
| } | |
| console.log("╚" + "═".repeat(56) + "╝"); | |
| if (gracefulShutdown) { | |
| console.log("\n⚠️ Process interrupted by user. Some subscriptions may not have been updated."); | |
| console.log(" Run the script again to process the remaining ones (already done ones will be skipped)."); | |
| } | |
| console.log("\n🏁 Done. No subscription was cancelled immediately."); | |
| } | |
| main().catch((err) => { | |
| console.error("\n💥 Fatal error:", err.message); | |
| process.exit(1); | |
| }); |
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
| { | |
| "name": "stripe-cancel-subscriptions", | |
| "version": "1.0.0", | |
| "private": true, | |
| "description": "Bulk disable Stripe subscription renewals (cancel_at_period_end)", | |
| "type": "module", | |
| "dependencies": { | |
| "dotenv": "^16.4.7", | |
| "stripe": "^17.6.0" | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment