Pārlūkot izejas kodu

[web] update Stripe archive script to avoid archiving actively used prices (#29978)

* make Stripe export script only export active products and prices
* add a gitignore for the scripts/stripe/output

GitOrigin-RevId: 975d84077c3940d4f5af518e5f8292ea455e1c3a
Kristina 8 mēneši atpakaļ
vecāks
revīzija
eafef60b75

+ 1 - 0
services/web/scripts/stripe/.gitignore

@@ -0,0 +1 @@
+output

+ 115 - 14
services/web/scripts/stripe/archive_prices_by_version_key.mjs

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 
 /**
- * This script archives or unarchives all prices with a matching version key in their lookup key
+ * This script marks as archived all prices with a matching version key in their lookup key
  *
  * Usage:
  *   node scripts/stripe/archive_prices_by_version_key.mjs --region us --version versionKey [options]
@@ -47,6 +47,56 @@ async function rateLimitSleep() {
   return new Promise(resolve => setTimeout(resolve, 50))
 }
 
+/**
+ * Check if a price has active subscriptions (if an active subscription has
+ * archived prices, those customers will run into issues modifying their
+ * subscriptions)
+ *
+ * @param {Stripe} stripe
+ * @param {string} priceId
+ * @returns {Promise<boolean>}
+ */
+async function getHasActiveSubscriptions(stripe, priceId) {
+  const potentiallyActiveStatuses = [
+    'active',
+    'trialing',
+    'past_due',
+    'unpaid',
+    'paused',
+    'incomplete',
+  ]
+  let hasMore = true
+  let startingAfter
+
+  while (hasMore) {
+    const params = {
+      price: priceId,
+      limit: 100,
+    }
+    if (startingAfter) {
+      params.starting_after = startingAfter
+    }
+    const subscriptions = await stripe.subscriptions.list(params)
+    await rateLimitSleep()
+
+    const hasActiveInBatch = subscriptions.data.some(subscription =>
+      potentiallyActiveStatuses.includes(subscription.status)
+    )
+
+    if (hasActiveInBatch) {
+      return true
+    }
+
+    hasMore = subscriptions.has_more
+
+    if (hasMore && subscriptions.data.length > 0) {
+      startingAfter = subscriptions.data[subscriptions.data.length - 1].id
+    }
+  }
+
+  return false
+}
+
 /**
  * Fetch all prices matching the version key from Stripe
  *
@@ -102,29 +152,77 @@ async function processPrices(prices, stripe, action, commit, trackProgress) {
   const results = {
     processed: 0,
     skipped: 0,
+    hasSubscriptions: 0,
     errored: 0,
   }
 
+  // pre-filter prices already in the desired state to avoid unnecessary API calls
+  const pricesToProcess = []
   for (const price of prices) {
+    const hasArchivedNickname = price.nickname?.includes('[ARCHIVED]')
+    const alreadyInDesiredState =
+      action === 'archive'
+        ? hasArchivedNickname
+        : price.active && !hasArchivedNickname
+
+    if (alreadyInDesiredState) {
+      await trackProgress(
+        `Skipping price ${price.id} (${price.lookup_key}) - already ${price.active ? 'active' : 'archived'}`
+      )
+      results.skipped++
+    } else {
+      pricesToProcess.push(price)
+    }
+  }
+
+  if (pricesToProcess.length === 0) {
+    return results
+  }
+
+  await trackProgress(`Processing ${pricesToProcess.length} prices...`)
+
+  for (const price of pricesToProcess) {
     try {
-      // Skip if already in the desired state
-      if (price.active === targetActiveStatus) {
-        await trackProgress(
-          `Skipping price ${price.id} (${price.lookup_key}) - already ${price.active ? 'active' : 'archived'}`
-        )
-        results.skipped++
-        continue
+      const hasActiveSubscriptions =
+        action === 'archive'
+          ? await getHasActiveSubscriptions(stripe, price.id)
+          : false
+      if (hasActiveSubscriptions) {
+        results.hasSubscriptions++
       }
 
       if (commit) {
-        await stripe.prices.update(price.id, { active: targetActiveStatus })
-        await trackProgress(
-          `${action === 'archive' ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key})`
-        )
-        await rateLimitSleep()
+        const updateParams = {}
+
+        if (!hasActiveSubscriptions) {
+          updateParams.active = targetActiveStatus
+        }
+
+        if (action === 'archive' && !price.nickname?.includes('[ARCHIVED]')) {
+          updateParams.nickname = price.nickname
+            ? `[ARCHIVED] ${price.nickname}`
+            : '[ARCHIVED]'
+        }
+        if (action === 'unarchive' && price.nickname?.includes('[ARCHIVED]')) {
+          updateParams.nickname = price.nickname.replace(/^\[ARCHIVED\]\s*/, '')
+        }
+
+        if (Object.keys(updateParams).length > 0) {
+          await stripe.prices.update(price.id, updateParams)
+          const statusNote = hasActiveSubscriptions
+            ? '(nickname only - has active subscriptions)'
+            : ''
+          await trackProgress(
+            `${action === 'archive' ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key}) ${statusNote}`
+          )
+          await rateLimitSleep()
+        }
       } else {
+        const statusNote = hasActiveSubscriptions
+          ? '(nickname only - has active subscriptions)'
+          : ''
         await trackProgress(
-          `[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key})`
+          `[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key}) ${statusNote}`
         )
       }
 
@@ -183,6 +281,9 @@ async function main(trackProgress) {
   await trackProgress(
     `Prices skipped (already in desired state): ${results.skipped}`
   )
+  await trackProgress(
+    `Prices skipped (has active subscriptions): ${results.hasSubscriptions}`
+  )
   await trackProgress(`Prices errored: ${results.errored}`)
 
   if (results.errored > 0) {

+ 16 - 6
services/web/scripts/stripe/export_products_from_environment.mjs

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 
 /**
- * This script exports all products and their prices from a Stripe environment to a JSON file
+ * This script exports active products and their active prices from a Stripe environment to a JSON file
  *
  * Usage:
  *   node scripts/stripe/export_products_from_environment.mjs --region us -o fileName [options]
@@ -21,6 +21,7 @@
 
 import minimist from 'minimist'
 import fs from 'node:fs'
+import path from 'node:path'
 import { z } from '../../app/src/infrastructure/Validation.mjs'
 import { scriptRunner } from '../lib/ScriptRunner.mjs'
 import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
@@ -93,15 +94,22 @@ function buildExportData(prices) {
     const product = price.product
     const productId = typeof product === 'string' ? product : product.id
 
-    // Store the product object if it's expanded
-    if (typeof product !== 'string' && !productMap.has(productId)) {
+    // Store the product object if it's expanded and active
+    if (
+      typeof product !== 'string' &&
+      product.active &&
+      !productMap.has(productId)
+    ) {
       productMap.set(productId, product)
     }
 
-    if (!pricesByProduct.has(productId)) {
-      pricesByProduct.set(productId, [])
+    // Only include prices for active products
+    if (typeof product !== 'string' && product.active) {
+      if (!pricesByProduct.has(productId)) {
+        pricesByProduct.set(productId, [])
+      }
+      pricesByProduct.get(productId).push(price)
     }
-    pricesByProduct.get(productId).push(price)
   }
 
   const products = Array.from(productMap.values())
@@ -141,6 +149,8 @@ async function main(trackProgress) {
   const exportData = buildExportData(prices)
 
   await trackProgress(`Writing to file: ${outputFile}`)
+  const outputDir = path.dirname(outputFile)
+  fs.mkdirSync(outputDir, { recursive: true })
   fs.writeFileSync(outputFile, JSON.stringify(exportData, null, 2))
 
   await trackProgress('EXPORT COMPLETE')