Bläddra i källkod

[web] add scripts for updating Stripe prices (#29858)

* update generate recurly prices script to skip stripe-only prices
* add script for creating new Stripe prices from a CSV
* add script to archive Stripe prices by version key
* add script for exporting Stripe products and prices
* add script to import Stripe products

GitOrigin-RevId: 3c9cf8037d956b9532c3efed5fe8d63f8be53a93
Kristina 8 månader sedan
förälder
incheckning
731bf1d8b6

+ 1 - 1
services/web/app/src/Features/Subscription/PlansLocator.mjs

@@ -46,7 +46,6 @@ const recurlyCodeToStripeBaseLookupKey = {
   'student-annual': 'student_annual',
   student_free_trial_7_days: 'student_monthly',
 
-  // TODO: change all group plans' lookup_keys to match the UK account after they have been added
   group_collaborator: 'group_standard_enterprise',
   group_collaborator_educational: 'group_standard_educational',
   group_professional: 'group_professional_enterprise',
@@ -181,4 +180,5 @@ export default {
   getPlanTypeAndPeriodFromRecurlyPlanCode,
   isGroupPlanCode,
   convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded,
+  LATEST_STRIPE_LOOKUP_KEY_VERSION,
 }

+ 15 - 1
services/web/scripts/recurly/generate_recurly_prices.mjs

@@ -78,6 +78,16 @@ function computeAddOnPrices(prices, size) {
   })
 }
 
+function shouldSkipPlan(record) {
+  const planCode = record.plan_code
+  // Skip non-legacy group plan codes (e.g. group_professional_20_enterprise)
+  if (planCode.startsWith('group_') && !GROUP_SIZE_REGEX.test(planCode)) {
+    return true
+  }
+
+  return false
+}
+
 // Convert the raw records into the output format
 
 function transformRecordToPlan(record) {
@@ -104,8 +114,12 @@ function transformRecordToPlan(record) {
 function generate(inputFile, outputFile) {
   const input = fs.readFileSync(inputFile, 'utf8')
   const rawRecords = csv.parse(input, { columns: true })
+  // filter out plans that should be skipped
+  const filteredRecords = rawRecords.filter(record => !shouldSkipPlan(record))
   // transform the raw records into the output format
-  const plans = _.sortBy(rawRecords, 'plan_code').map(transformRecordToPlan)
+  const plans = _.sortBy(filteredRecords, 'plan_code').map(
+    transformRecordToPlan
+  )
   const output = JSON.stringify(plans, null, 2)
   fs.writeFileSync(outputFile, output)
 }

+ 209 - 0
services/web/scripts/stripe/archive_prices_by_version_key.mjs

@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+
+/**
+ * This script archives or unarchives 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]
+ *   node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version versionKey [options]
+ *
+ * Options:
+ *   --region           Required. Stripe region to process (us or uk)
+ *   --version          Required. Version key to match in lookup keys (e.g., 'jul2025')
+ *   --action           Required. Action to perform: 'archive' or 'unarchive'
+ *   --commit           Actually perform the updates (default: dry-run mode)
+ *
+ * Examples:
+ *   # Dry run archive prices with version 'jul2025' in US region
+ *   node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action archive
+ *
+ *   # Commit archive prices with version 'jul2025' in UK region
+ *   node scripts/stripe/archive_prices_by_version_key.mjs --region uk --version jul2025 --action archive --commit
+ *
+ *   # Unarchive prices with version 'jul2025'
+ *   node scripts/stripe/archive_prices_by_version_key.mjs --region us --version jul2025 --action unarchive --commit
+ */
+
+import minimist from 'minimist'
+import { z } from '../../app/src/infrastructure/Validation.js'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
+
+/**
+ * @import Stripe from 'stripe'
+ */
+
+const paramsSchema = z.object({
+  region: z.enum(['us', 'uk']),
+  version: z.string(),
+  action: z.enum(['archive', 'unarchive']),
+  commit: z.boolean().default(false),
+})
+
+/**
+ * Sleep function to respect Stripe rate limits (100 requests per second)
+ */
+async function rateLimitSleep() {
+  return new Promise(resolve => setTimeout(resolve, 50))
+}
+
+/**
+ * Fetch all prices matching the version key from Stripe
+ *
+ * @param {Stripe} stripe
+ * @param {string} version
+ * @param {function} trackProgress
+ * @returns {Promise<Stripe.Price[]>}
+ */
+async function fetchPricesByVersion(stripe, version, trackProgress) {
+  const matchingPrices = []
+  let hasMore = true
+  let startingAfter
+
+  await trackProgress('Fetching prices from Stripe...')
+
+  while (hasMore) {
+    const pricesResult = await stripe.prices.list({
+      limit: 100,
+      starting_after: startingAfter,
+    })
+
+    // Filter prices that have the version in their lookup key
+    const filtered = pricesResult.data.filter(
+      price => price.lookup_key && price.lookup_key.includes(version)
+    )
+
+    matchingPrices.push(...filtered)
+    hasMore = pricesResult.has_more
+
+    if (hasMore) {
+      startingAfter = pricesResult.data[pricesResult.data.length - 1].id
+    }
+
+    await rateLimitSleep()
+  }
+
+  await trackProgress(`Found ${matchingPrices.length} matching prices...`)
+  return matchingPrices
+}
+
+/**
+ * Archive or unarchive prices in Stripe
+ *
+ * @param {Stripe.Price[]} prices
+ * @param {Stripe} stripe
+ * @param {string} action
+ * @param {boolean} commit
+ * @param {function} trackProgress
+ * @returns {Promise<object>}
+ */
+async function processPrices(prices, stripe, action, commit, trackProgress) {
+  const targetActiveStatus = action === 'unarchive'
+  const results = {
+    processed: 0,
+    skipped: 0,
+    errored: 0,
+  }
+
+  for (const price of prices) {
+    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
+      }
+
+      if (commit) {
+        await stripe.prices.update(price.id, { active: targetActiveStatus })
+        await trackProgress(
+          `${action === 'archive' ? 'Archived' : 'Unarchived'} price: ${price.id} (${price.lookup_key})`
+        )
+        await rateLimitSleep()
+      } else {
+        await trackProgress(
+          `[DRY RUN] Would ${action} price: ${price.id} (${price.lookup_key})`
+        )
+      }
+
+      results.processed++
+    } catch (error) {
+      await trackProgress(
+        `ERROR processing price ${price.id}: ${error.message}`
+      )
+      results.errored++
+    }
+  }
+
+  return results
+}
+
+async function main(trackProgress) {
+  const parseResult = paramsSchema.safeParse(
+    minimist(process.argv.slice(2), {
+      boolean: ['commit'],
+      string: ['region', 'version', 'action'],
+    })
+  )
+
+  if (!parseResult.success) {
+    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
+  }
+
+  const { region, version, action, commit } = parseResult.data
+
+  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
+  await trackProgress(`Starting ${action} in ${mode} for region: ${region}`)
+  await trackProgress(`Target version: ${version}`)
+
+  const stripe = getRegionClient(region).stripe
+
+  const prices = await fetchPricesByVersion(stripe, version, trackProgress)
+
+  if (prices.length === 0) {
+    await trackProgress('No prices found. Exiting.')
+    return
+  }
+
+  await trackProgress(`Processing ${action} operation...`)
+  const results = await processPrices(
+    prices,
+    stripe,
+    action,
+    commit,
+    trackProgress
+  )
+
+  await trackProgress('OPERATION SUMMARY')
+  await trackProgress(
+    `Prices ${commit ? 'processed' : 'would be processed'}: ${results.processed}`
+  )
+  await trackProgress(
+    `Prices skipped (already in desired state): ${results.skipped}`
+  )
+  await trackProgress(`Prices errored: ${results.errored}`)
+
+  if (results.errored > 0) {
+    await trackProgress(
+      'WARNING: Some prices failed to process. Check the logs above.'
+    )
+  }
+
+  if (!commit) {
+    await trackProgress(
+      'This was a dry run. Use --commit to actually perform the operation.'
+    )
+  }
+
+  await trackProgress(`Script completed in ${mode}`)
+}
+
+try {
+  await scriptRunner(main)
+  process.exit(0)
+} catch (error) {
+  console.error('Script failed:', error.message)
+  process.exit(1)
+}

+ 158 - 0
services/web/scripts/stripe/export_products_from_environment.mjs

@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+
+/**
+ * This script exports all products and their prices from a Stripe environment to a JSON file
+ *
+ * Usage:
+ *   node scripts/stripe/export_products_from_environment.mjs --region us -o fileName [options]
+ *   node scripts/stripe/export_products_from_environment.mjs --region uk -o fileName [options]
+ *
+ * Options:
+ *   --region           Required. Stripe region to export from (us or uk)
+ *   -o                 Output file path (JSON format)
+ *
+ * Examples:
+ *   # Export all active products from US region
+ *   node scripts/stripe/export_products_from_environment.mjs --region us -o export.json
+ *
+ *   # Export all active products from UK region
+ *   node scripts/stripe/export_products_from_environment.mjs --region uk -o export.json
+ */
+
+import minimist from 'minimist'
+import fs from 'node:fs'
+import { z } from '../../app/src/infrastructure/Validation.js'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
+
+/**
+ * @import Stripe from 'stripe'
+ */
+
+const paramsSchema = z.object({
+  region: z.enum(['us', 'uk']),
+  o: z.string(),
+})
+
+/**
+ * Sleep function to respect Stripe rate limits (100 requests per second)
+ */
+async function rateLimitSleep() {
+  return new Promise(resolve => setTimeout(resolve, 50))
+}
+
+/**
+ * Fetch all active prices with expanded product data from Stripe
+ *
+ * @param {Stripe} stripe
+ * @param {function} trackProgress
+ * @returns {Promise<Stripe.Price[]>}
+ */
+async function fetchAllPricesWithProducts(stripe, trackProgress) {
+  const allPrices = []
+  let hasMore = true
+  let startingAfter
+
+  await trackProgress('Fetching active prices with product data from Stripe...')
+
+  while (hasMore) {
+    const params = {
+      active: true,
+      limit: 100,
+      starting_after: startingAfter,
+      expand: ['data.product'],
+    }
+
+    const pricesResult = await stripe.prices.list(params)
+    allPrices.push(...pricesResult.data)
+    hasMore = pricesResult.has_more
+
+    if (hasMore) {
+      startingAfter = pricesResult.data[pricesResult.data.length - 1].id
+    }
+
+    await trackProgress(`Fetched ${allPrices.length} prices...`)
+    await rateLimitSleep()
+  }
+
+  return allPrices
+}
+
+/**
+ * Build export data structure from prices with expanded products
+ *
+ * @param {Stripe.Price[]} prices
+ * @returns {object}
+ */
+function buildExportData(prices) {
+  const productMap = new Map()
+  const pricesByProduct = new Map()
+
+  // Extract unique products and group prices by product
+  for (const price of 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)) {
+      productMap.set(productId, product)
+    }
+
+    if (!pricesByProduct.has(productId)) {
+      pricesByProduct.set(productId, [])
+    }
+    pricesByProduct.get(productId).push(price)
+  }
+
+  const products = Array.from(productMap.values())
+
+  return {
+    exportedAt: new Date().toISOString(),
+    totalProducts: products.length,
+    totalPrices: prices.length,
+    products: products.map(product => ({
+      product,
+      prices: pricesByProduct.get(product.id) || [],
+    })),
+  }
+}
+
+async function main(trackProgress) {
+  const parseResult = paramsSchema.safeParse(
+    minimist(process.argv.slice(2), {
+      string: ['region', 'o'],
+    })
+  )
+
+  if (!parseResult.success) {
+    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
+  }
+
+  const { region, o: outputFile } = parseResult.data
+
+  await trackProgress(`Starting export from region: ${region}`)
+
+  const stripe = getRegionClient(region).stripe
+
+  const prices = await fetchAllPricesWithProducts(stripe, trackProgress)
+  await trackProgress(`Found ${prices.length} active prices`)
+
+  await trackProgress('Building export data structure...')
+  const exportData = buildExportData(prices)
+
+  await trackProgress(`Writing to file: ${outputFile}`)
+  fs.writeFileSync(outputFile, JSON.stringify(exportData, null, 2))
+
+  await trackProgress('EXPORT COMPLETE')
+  await trackProgress(`Exported ${exportData.totalProducts} products`)
+  await trackProgress(`Exported ${exportData.totalPrices} prices`)
+  await trackProgress(`Output file: ${outputFile}`)
+}
+
+try {
+  await scriptRunner(main)
+  process.exit(0)
+} catch (error) {
+  console.error('Script failed:', error.message)
+  process.exit(1)
+}

+ 279 - 0
services/web/scripts/stripe/import_products_to_environment.mjs

@@ -0,0 +1,279 @@
+#!/usr/bin/env node
+
+/**
+ * This script imports products and prices into a Stripe environment from a JSON file
+ *
+ * Usage:
+ *   node scripts/stripe/import_products_to_environment.mjs -f fileName --region us [options]
+ *   node scripts/stripe/import_products_to_environment.mjs -f fileName --region uk [options]
+ *
+ * Options:
+ *   -f                 Path to import JSON file (from export_products_from_environment.mjs)
+ *   --region           Required. Stripe region to import to (us or uk)
+ *   --commit           Actually perform the imports (default: dry-run mode)
+ *
+ * Examples:
+ *   # Dry run import to US region
+ *   node scripts/stripe/import_products_to_environment.mjs -f export.json --region us
+ *
+ *   # Commit import to UK region
+ *   node scripts/stripe/import_products_to_environment.mjs -f export.json --region uk --commit
+ */
+
+import minimist from 'minimist'
+import fs from 'node:fs'
+import { z } from '../../app/src/infrastructure/Validation.js'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
+
+/**
+ * @import Stripe from 'stripe'
+ */
+
+const paramsSchema = z.object({
+  f: z.string(),
+  region: z.enum(['us', 'uk']),
+  commit: z.boolean().default(false),
+})
+
+/**
+ * Sleep function to respect Stripe rate limits (100 requests per second)
+ */
+async function rateLimitSleep() {
+  return new Promise(resolve => setTimeout(resolve, 50))
+}
+
+/**
+ * @typedef {object} ImportProduct
+ * @property {Stripe.Product} product
+ * @property {Stripe.Price[]} prices
+ */
+
+/**
+ * @typedef {object} ImportData
+ * @property {string} exportedAt
+ * @property {number} totalProducts
+ * @property {number} totalPrices
+ * @property {ImportProduct[]} products
+ */
+
+/**
+ * Load import data from JSON file
+ *
+ * @param {string} filePath
+ * @returns {ImportData}
+ */
+function loadImportData(filePath) {
+  const content = fs.readFileSync(filePath, 'utf-8')
+  const data = JSON.parse(content)
+
+  if (!data.products || !Array.isArray(data.products)) {
+    throw new Error('Invalid import file format: missing products array')
+  }
+
+  // Validate structure of each product entry
+  for (const entry of data.products) {
+    if (!entry.product) {
+      throw new Error(
+        'Invalid import file format: product entry missing "product" field'
+      )
+    }
+    if (!entry.prices || !Array.isArray(entry.prices)) {
+      throw new Error(
+        `Invalid import file format: product ${entry.product.id || 'unknown'} missing "prices" array`
+      )
+    }
+  }
+
+  return data
+}
+
+/**
+ * Create a product in Stripe
+ *
+ * @param {Stripe} stripe
+ * @param {Stripe.Product} productData
+ * @returns {Promise<Stripe.Product>}
+ */
+async function createProduct(stripe, productData) {
+  const params = {
+    name: productData.name,
+    active: productData.active,
+    metadata: productData.metadata,
+    tax_code: productData.tax_code,
+    images: productData.images,
+  }
+
+  if (productData.description) {
+    params.description = productData.description
+  }
+
+  return await stripe.products.create(params)
+}
+
+/**
+ * Create a price in Stripe
+ *
+ * @param {Stripe} stripe
+ * @param {Stripe.Price} priceData
+ * @param {string} productId
+ * @returns {Promise<Stripe.Price>}
+ */
+async function createPrice(stripe, priceData, productId) {
+  const params = {
+    product: productId,
+    currency: priceData.currency,
+    unit_amount: Number.parseInt(priceData.unit_amount),
+    billing_scheme: priceData.billing_scheme,
+    recurring: {
+      interval: priceData.recurring.interval,
+      interval_count: Number.parseInt(priceData.recurring.interval_count),
+    },
+    lookup_key: priceData.lookup_key,
+    active: priceData.active,
+    metadata: priceData.metadata,
+    nickname: priceData.nickname,
+    tax_behavior: priceData.tax_behavior,
+  }
+
+  return await stripe.prices.create(params)
+}
+
+/**
+ * Import products and prices to Stripe
+ *
+ * @param {ImportData} importData
+ * @param {Stripe} stripe
+ * @param {boolean} commit
+ * @param {function} trackProgress
+ * @returns {Promise<{productsCreated: number, productsSkipped: number, productsErrored: number, pricesCreated: number, pricesErrored: number}>}
+ */
+async function importToStripe(importData, stripe, commit, trackProgress) {
+  const results = {
+    productsCreated: 0,
+    productsErrored: 0,
+    pricesCreated: 0,
+    pricesErrored: 0,
+  }
+
+  for (const entry of importData.products) {
+    const { product, prices } = entry
+
+    try {
+      // Create product (Stripe will generate a new ID for this environment)
+      let createdProduct
+      if (commit) {
+        createdProduct = await createProduct(stripe, product)
+        await trackProgress(
+          `Created product: ${createdProduct.id} (${product.name})`
+        )
+        await rateLimitSleep()
+      } else {
+        await trackProgress(`[DRY RUN] Would create product: ${product.name}`)
+      }
+
+      results.productsCreated++
+
+      // Create prices for this product
+      for (const price of prices) {
+        try {
+          if (commit) {
+            const createdPrice = await createPrice(
+              stripe,
+              price,
+              createdProduct.id
+            )
+            await trackProgress(
+              `  Created price: ${createdPrice.id} (${price.currency}, ${price.unit_amount})`
+            )
+            await rateLimitSleep()
+          } else {
+            await trackProgress(
+              `  [DRY RUN] Would create price: ${price.nickname} (${price.currency}, ${price.unit_amount})`
+            )
+          }
+
+          results.pricesCreated++
+        } catch (error) {
+          await trackProgress(
+            `  ERROR creating price ${price.id}: ${error.message}`
+          )
+          results.pricesErrored++
+        }
+      }
+    } catch (error) {
+      await trackProgress(
+        `ERROR creating product ${product.id}: ${error.message}`
+      )
+      results.productsErrored++
+    }
+  }
+
+  return results
+}
+
+async function main(trackProgress) {
+  const parseResult = paramsSchema.safeParse(
+    minimist(process.argv.slice(2), {
+      boolean: ['commit'],
+      string: ['region', 'f'],
+    })
+  )
+
+  if (!parseResult.success) {
+    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
+  }
+
+  const { f: inputFile, region, commit } = parseResult.data
+
+  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
+  await trackProgress(`Starting import in ${mode} to region: ${region}`)
+
+  await trackProgress(`Loading import data from: ${inputFile}`)
+  const importData = loadImportData(inputFile)
+  await trackProgress(
+    `Loaded ${importData.totalProducts} products and ${importData.totalPrices} prices`
+  )
+
+  const stripe = getRegionClient(region).stripe
+
+  await trackProgress('Processing import...')
+  const results = await importToStripe(
+    importData,
+    stripe,
+    commit,
+    trackProgress
+  )
+
+  await trackProgress('IMPORT SUMMARY')
+  await trackProgress(
+    `Products ${commit ? 'created' : 'would be created'}: ${results.productsCreated}`
+  )
+  await trackProgress(`Products errored: ${results.productsErrored}`)
+  await trackProgress(
+    `Prices ${commit ? 'created' : 'would be created'}: ${results.pricesCreated}`
+  )
+  await trackProgress(`Prices errored: ${results.pricesErrored}`)
+
+  if (results.productsErrored > 0 || results.pricesErrored > 0) {
+    await trackProgress(
+      'WARNING: Some items failed to import. Check the logs above.'
+    )
+  }
+
+  if (!commit) {
+    await trackProgress(
+      'This was a dry run. Use --commit to actually create the items.'
+    )
+  }
+
+  await trackProgress(`Import completed in ${mode}`)
+}
+
+try {
+  await scriptRunner(main)
+  process.exit(0)
+} catch (error) {
+  console.error('Script failed:', error.message)
+  process.exit(1)
+}

+ 595 - 0
services/web/scripts/stripe/update_prices_from_csv.mjs

@@ -0,0 +1,595 @@
+#!/usr/bin/env node
+
+/**
+ * This script creates new price objects in Stripe from a CSV file of prices
+ *
+ * Usage:
+ *   node scripts/stripe/update_prices_from_csv.mjs -f fileName --region us --nextVersion versionKey [options]
+ *   node scripts/stripe/update_prices_from_csv.mjs -f fileName --region uk --nextVersion versionKey [options]
+ *
+ * Options:
+ *   -f                 Path to prices CSV file
+ *   --region           Required. Stripe region to process (us or uk)
+ *   --nextVersion      Next version key (e.g., 'jul2025')
+ *   --commit           Actually perform the updates (default: dry-run mode)
+ *
+ * Examples:
+ *   # Dry run for US region
+ *   node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region us --nextVersion jul2025
+ *
+ *   # Commit changes for UK region
+ *   node scripts/stripe/update_prices_from_csv.mjs -f inputFile --region uk --nextVersion jul2025 --commit
+ */
+
+import minimist from 'minimist'
+import fs from 'node:fs'
+// https://github.com/import-js/eslint-plugin-import/issues/1810
+// eslint-disable-next-line import/no-unresolved
+import * as csv from 'csv/sync'
+import { z } from '../../app/src/infrastructure/Validation.js'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
+import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
+
+/**
+ * @import Stripe from 'stripe'
+ * @import { StripeCurrencyCode } from '../../types/subscription/currency'
+ */
+
+const paramsSchema = z.object({
+  f: z.string(),
+  region: z.enum(['us', 'uk']),
+  nextVersion: z.string(),
+  commit: z.boolean().default(false),
+})
+
+/**
+ * Sleep function to respect Stripe rate limits (100 requests per second)
+ */
+async function rateLimitSleep() {
+  return new Promise(resolve => setTimeout(resolve, 50))
+}
+
+/**
+ * Convert amount to minor units (cents for most currencies)
+ * Some currencies like JPY, KRW, CLP, VND don't have cents
+ *
+ * Copied from services/web/frontend/js/shared/utils/currency.ts
+ *
+ * @param {number} amount - Amount in major units (dollars, euros, etc.)
+ * @param {string} currency - Currency code (lowercase)
+ * @returns {number} Amount in minor units
+ */
+function convertToMinorUnits(amount, currency) {
+  const isNoCentsCurrency = ['clp', 'jpy', 'krw', 'vnd'].includes(
+    currency.toLowerCase()
+  )
+
+  // Determine the multiplier based on currency
+  let multiplier = 100 // default for most currencies (2 decimal places)
+
+  if (isNoCentsCurrency) {
+    multiplier = 1 // no decimal places
+  }
+
+  // Convert and round to an integer
+  return Math.round(amount * multiplier)
+}
+
+/**
+ * Convert amount from minor units (cents for most currencies)
+ * Some currencies like JPY, KRW, CLP, VND don't have cents
+ *
+ * Copied from services/web/modules/subscriptions/app/src/StripeClient.mjs
+ *
+ * @param {number} amount - price in the smallest currency unit (e.g. dollar cents, CLP units, ...)
+ * @param {StripeCurrencyCode} currency - currency code
+ * @return {number}
+ */
+function convertFromMinorUnits(amount, currency) {
+  const isNoCentsCurrency = ['clp', 'jpy', 'krw', 'vnd'].includes(
+    currency.toLowerCase()
+  )
+  return isNoCentsCurrency ? amount : amount / 100
+}
+
+/**
+ * @typedef {object} CsvPrice
+ * @property {number} amountInMinorUnits
+ * @property {StripeCurrencyCode} currency
+ */
+
+/**
+ * Parse CSV file with price data
+ *
+ * @param {string} filePath
+ * @param {string} nextVersion
+ * @returns {Map<string, CsvPrice>}
+ */
+function loadPricesFromCSV(filePath, nextVersion) {
+  const content = fs.readFileSync(filePath, 'utf-8')
+  const records = csv.parse(content, {
+    columns: true,
+  })
+
+  if (records.length === 0) {
+    throw new Error('CSV file is empty')
+  }
+
+  const priceMap = new Map()
+
+  // Get currency codes from the first record's keys (all columns except plan_code)
+  const currencies = Object.keys(records[0])
+    .filter(key => key !== 'plan_code')
+    .map(c => c.toLowerCase())
+
+  // Process each record
+  for (const record of records) {
+    const planCode = record.plan_code
+
+    // Filter out unwanted plan codes
+    if (shouldSkipPlanCode(planCode)) {
+      continue
+    }
+
+    // For each currency column, create lookup keys and store in map
+    for (const currency of currencies) {
+      const unitAmount = parseFloat(
+        record[currency.toUpperCase()] || record[currency]
+      )
+
+      if (!isNaN(unitAmount) && unitAmount > 0) {
+        const minorUnits = convertToMinorUnits(unitAmount, currency)
+        const lookupKey = buildLookupKeyForPlan(planCode, currency, nextVersion)
+
+        if (lookupKey) {
+          priceMap.set(lookupKey, {
+            amountInMinorUnits: minorUnits,
+            currency,
+          })
+        }
+      }
+    }
+  }
+
+  return priceMap
+}
+
+/**
+ * Determine if a plan code should be skipped
+ *
+ * @param {string} planCode
+ * @returns {boolean}
+ */
+function shouldSkipPlanCode(planCode) {
+  if (planCode.includes('trial') || planCode.includes('paid-personal')) {
+    return true
+  }
+
+  // Skip if matches the specific pattern for non-consolidated group plans
+  const excludePattern =
+    /^group_(collaborator|professional)_\d+_(educational|enterprise)$/
+  if (excludePattern.test(planCode)) {
+    return true
+  }
+
+  return false
+}
+
+/**
+ * Build the Stripe lookup key for a plan code, handling discounts and special cases
+ *
+ * @param {string} planCode
+ * @param {string} currency
+ * @param {string} version
+ * @returns {string | null}
+ */
+function buildLookupKeyForPlan(planCode, currency, version) {
+  // rm "enterprise" from plan code, if present
+  const planCodeWithoutEnterprise = planCode.replace('_enterprise', '')
+
+  // Check if this plan code has a discount suffix (e.g., _discount_20)
+  const discountMatch = planCodeWithoutEnterprise.match(/^(.+)_discount_(\d+)$/)
+  const hasDiscount = discountMatch !== null
+  const planCodeWithoutDiscount = hasDiscount
+    ? discountMatch[1]
+    : planCodeWithoutEnterprise
+  const discountAmount = hasDiscount ? discountMatch[2] : null
+
+  // Special case: Nonprofit group plans
+  // These are constructed manually without using PlansLocator (these are not available for sale online)
+  if (planCode.includes('nonprofit')) {
+    let lookupKey = `${planCodeWithoutDiscount}_${version}_${currency}`
+    if (discountAmount) {
+      lookupKey += `_discount_${discountAmount}`
+    }
+    return lookupKey
+  }
+
+  // Standard case: Use PlansLocator to build the lookup key
+  const lookupKey = PlansLocator.buildStripeLookupKey(
+    planCodeWithoutDiscount,
+    currency
+  )
+
+  if (!lookupKey) {
+    return null
+  }
+
+  // Replace the current version with the new version
+  const lookupKeyWithNewVersion = lookupKey.replace(
+    PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
+    version
+  )
+
+  // If the plan code had a discount, append it to the lookup key
+  if (discountAmount) {
+    return `${lookupKeyWithNewVersion}_discount_${discountAmount}`
+  }
+
+  return lookupKeyWithNewVersion
+}
+
+/**
+ * Copy an existing price and update with pricing data from the CSV, if available
+ *
+ * @param {Stripe.Price} existingPrice
+ * @param {Map<string, CsvPrice>} csvPricesByLookupKey
+ * @param {string} nextVersion
+ * @returns {Promise<{ success: boolean, price: Stripe.PriceCreateParams | null, error: string | null }>}
+ */
+function copyPriceAndUpdate(existingPrice, csvPricesByLookupKey, nextVersion) {
+  try {
+    const nextLookupKey = existingPrice.lookup_key.replace(
+      PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
+      nextVersion
+    )
+
+    const csvData = csvPricesByLookupKey.get(nextLookupKey)
+    const unitAmount = csvData
+      ? csvData.amountInMinorUnits
+      : existingPrice.unit_amount
+
+    const nextPrice = getPriceParamsFromPriceObject(existingPrice)
+    nextPrice.unit_amount = unitAmount
+    nextPrice.lookup_key = nextLookupKey
+    // TODO: remove this after the June 2025 prices are archived
+    nextPrice.nickname = nextPrice.nickname.match(/June 2025/)
+      ? ''
+      : nextPrice.nickname
+
+    return { success: true, price: nextPrice, error: null }
+  } catch (error) {
+    return { success: false, price: null, error: error.message }
+  }
+}
+
+/**
+ * Returns params for cloning a price in Stripe
+ *
+ * @param {Stripe.Price} priceData
+ * @returns {Stripe.PriceCreateParams}
+ */
+function getPriceParamsFromPriceObject(priceData) {
+  return {
+    product: priceData.product,
+    currency: priceData.currency,
+    unit_amount: Number.parseInt(priceData.unit_amount),
+    billing_scheme: priceData.billing_scheme,
+    recurring: {
+      interval: priceData.recurring.interval,
+      interval_count: Number.parseInt(priceData.recurring.interval_count),
+    },
+    lookup_key: priceData.lookup_key,
+    active: priceData.active,
+    metadata: priceData.metadata,
+    nickname: priceData.nickname,
+    tax_behavior: priceData.tax_behavior,
+  }
+}
+
+/**
+ * Fetch all current version prices from Stripe
+ *
+ * @param {Stripe} stripe
+ * @returns {Promise<Stripe.PriceCreateParams[]>}
+ */
+async function fetchCurrentVersionPrices(stripe) {
+  const currentPrices = []
+  let hasMore = true
+  let startingAfter
+
+  while (hasMore) {
+    const pricesResult = await stripe.prices.list({
+      active: true,
+      limit: 100,
+      starting_after: startingAfter,
+    })
+
+    currentPrices.push(...pricesResult.data)
+    hasMore = pricesResult.has_more
+    if (hasMore) {
+      startingAfter = pricesResult.data[pricesResult.data.length - 1].id
+    }
+  }
+
+  const currentVersionPrices = currentPrices.filter(
+    price =>
+      price.lookup_key &&
+      price.lookup_key.includes(PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)
+  )
+
+  return currentVersionPrices
+}
+
+/**
+ * Compare CSV lookup keys with Stripe lookup keys and show differences
+ *
+ * @param {Map<string, CsvPrice>} csvPricesByLookupKey
+ * @param {Stripe.Price[]} currentVersionPrices
+ * @param {string} nextVersion
+ * @param {function} trackProgress
+ */
+async function compareCsvAndStripeLookupKeys(
+  csvPricesByLookupKey,
+  currentVersionPrices,
+  nextVersion,
+  trackProgress
+) {
+  // Get all CSV lookup keys
+  const csvLookupKeys = new Set(csvPricesByLookupKey.keys())
+
+  // Get all Stripe lookup keys (converted to next version)
+  const stripeLookupKeys = new Set(
+    currentVersionPrices.map(price =>
+      price.lookup_key.replace(
+        PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION,
+        nextVersion
+      )
+    )
+  )
+
+  // Find keys in CSV but not in Stripe
+  const inCsvNotInStripe = [...csvLookupKeys].filter(
+    key => !stripeLookupKeys.has(key)
+  )
+
+  if (inCsvNotInStripe.length > 0) {
+    await trackProgress(
+      `\n⚠️  ${inCsvNotInStripe.length} lookup key(s) in CSV but NOT in Stripe and will NOT be created:`
+    )
+    for (const key of inCsvNotInStripe.sort()) {
+      await trackProgress(
+        `  - ${key.replace(nextVersion, PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION)}`
+      )
+    }
+  }
+}
+
+/**
+ * Display a summary of unit amount changes
+ *
+ * @param {Stripe.Price[]} currentVersionPrices
+ * @param {Stripe.PriceCreateParams[]} nextPriceObjects
+ * @param {string} nextVersion
+ * @param {function} trackProgress
+ */
+async function showAmountChanges(
+  currentVersionPrices,
+  nextPriceObjects,
+  nextVersion,
+  trackProgress
+) {
+  const currentMap = new Map(currentVersionPrices.map(p => [p.lookup_key, p]))
+  const changeList = []
+  let changeCount = 0
+  for (const nextPrice of nextPriceObjects) {
+    const currentLookupKey = nextPrice.lookup_key.replace(
+      nextVersion,
+      PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION
+    )
+    const current = currentMap.get(currentLookupKey)
+    if (current) {
+      if (current.unit_amount !== nextPrice.unit_amount) {
+        const oldAmount = convertFromMinorUnits(
+          current.unit_amount,
+          current.currency
+        )
+        const newAmount = convertFromMinorUnits(
+          nextPrice.unit_amount,
+          nextPrice.currency
+        )
+        changeList.push(
+          `${nextPrice.lookup_key}: ${oldAmount} -> ${newAmount} ${nextPrice.currency}`
+        )
+        changeCount++
+      } else {
+        changeList.push(`${nextPrice.lookup_key}: UNCHANGED`)
+      }
+    } else {
+      changeList.push(`New: ${nextPrice.lookup_key}`)
+      changeCount++
+    }
+  }
+  if (changeCount === 0) {
+    await trackProgress('\nNo unit amount changes detected')
+  } else {
+    await trackProgress(`\nUnit amount changes (${changeCount} total changes):`)
+    for (const change of changeList) {
+      await trackProgress(`  ${change}`)
+    }
+  }
+}
+
+/**
+ * Create prices in Stripe
+ *
+ * @param {Stripe.PriceCreateParams[]} pricesToCreate
+ * @param {Stripe} stripe
+ * @param {function} trackProgress
+ * @returns {Promise<Stripe.Price[]>}
+ */
+async function createPricesInStripe(pricesToCreate, stripe, trackProgress) {
+  const createdPrices = []
+  let errorCount = 0
+
+  for (const priceObj of pricesToCreate) {
+    const amountDisplay = convertFromMinorUnits(
+      priceObj.unit_amount,
+      priceObj.currency
+    )
+
+    try {
+      const created = await stripe.prices.create(priceObj)
+      await trackProgress(
+        `✓ Created: ${priceObj.lookup_key} (${amountDisplay} ${priceObj.currency}) -> ${created.id}`
+      )
+      createdPrices.push(created)
+      await rateLimitSleep()
+    } catch (error) {
+      await trackProgress(
+        `✗ Error creating ${priceObj.lookup_key}: ${error.message}`
+      )
+      errorCount++
+    }
+  }
+
+  return { createdPrices, errorCount }
+}
+
+async function main(trackProgress) {
+  const parseResult = paramsSchema.safeParse(
+    minimist(process.argv.slice(2), {
+      boolean: ['commit'],
+      string: ['region', 'f', 'nextVersion'],
+    })
+  )
+
+  if (!parseResult.success) {
+    throw new Error(`Invalid parameters: ${parseResult.error.message}`)
+  }
+
+  const { f: inputFile, region, nextVersion, commit } = parseResult.data
+
+  const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
+  await trackProgress(`Starting script in ${mode} for region: ${region}`)
+  await trackProgress(
+    `Current version: ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
+  )
+  await trackProgress(`Next version: ${nextVersion}`)
+
+  await trackProgress(`\nLoading prices from: ${inputFile}`)
+  const csvPricesByLookupKey = loadPricesFromCSV(inputFile, nextVersion)
+  await trackProgress(
+    `Loaded ${csvPricesByLookupKey.size} price entries from CSV`
+  )
+
+  const stripe = getRegionClient(region).stripe
+
+  await trackProgress('\nFetching existing prices from Stripe...')
+  const currentVersionPrices = await fetchCurrentVersionPrices(stripe)
+  await trackProgress(
+    `Found ${currentVersionPrices.length} prices with version ${PlansLocator.LATEST_STRIPE_LOOKUP_KEY_VERSION}`
+  )
+
+  await trackProgress('\nProcessing prices...')
+
+  const nextPriceObjects = []
+  let buildPricesErrorCount = 0
+
+  for (const existingPrice of currentVersionPrices) {
+    const result = copyPriceAndUpdate(
+      existingPrice,
+      csvPricesByLookupKey,
+      nextVersion
+    )
+
+    if (result.success) {
+      nextPriceObjects.push(result.price)
+    } else {
+      buildPricesErrorCount++
+      if (result.error) {
+        await trackProgress(
+          `Error cloning ${existingPrice.lookup_key}: ${result.error}`
+        )
+      }
+    }
+  }
+
+  await trackProgress(`Built ${nextPriceObjects.length} price objects`)
+
+  await compareCsvAndStripeLookupKeys(
+    csvPricesByLookupKey,
+    currentVersionPrices,
+    nextVersion,
+    trackProgress
+  )
+
+  let createdPrices = []
+  let commitPricesErrorCount = 0
+
+  if (commit) {
+    await trackProgress('Creating prices in Stripe...')
+    const createResult = await createPricesInStripe(
+      nextPriceObjects,
+      stripe,
+      trackProgress
+    )
+    createdPrices = createResult.createdPrices
+    commitPricesErrorCount += createResult.errorCount
+  } else {
+    await showAmountChanges(
+      currentVersionPrices,
+      nextPriceObjects,
+      nextVersion,
+      trackProgress
+    )
+  }
+
+  await trackProgress('\nFINAL SUMMARY')
+  await trackProgress(
+    `Prices ${commit ? 'created' : 'would be created'}: ${nextPriceObjects.length}`
+  )
+  if (buildPricesErrorCount > 0) {
+    await trackProgress(
+      `⚠️  Errors encountered while building price objects: ${buildPricesErrorCount}`
+    )
+  }
+
+  if (commit) {
+    if (commitPricesErrorCount > 0) {
+      await trackProgress(
+        `⚠️  Errors encountered while creating prices in Stripe: ${commitPricesErrorCount}`
+      )
+    }
+
+    const lookupKeysString =
+      createdPrices.map(price => price.lookup_key).join(', ') || 'n/a'
+    await trackProgress(`Created Price Lookup Keys: ${lookupKeysString}`)
+  } else {
+    await trackProgress(
+      '💡  This was a DRY RUN. To actually create the prices, run with --commit'
+    )
+  }
+
+  if (commit) {
+    await trackProgress('NEXT STEPS:')
+    await trackProgress(
+      `1. Update LATEST_STRIPE_LOOKUP_KEY_VERSION in PlansLocator.mjs to: '${nextVersion}'`
+    )
+    await trackProgress('2. Deploy the updated code to production')
+    await trackProgress(
+      '3. Archive the old prices in Stripe (set active: false)'
+    )
+  }
+
+  await trackProgress(`Script completed successfully in ${mode}`)
+}
+
+try {
+  await scriptRunner(main)
+  process.exit(0)
+} catch (error) {
+  console.error('Script failed:', error.message)
+  process.exit(1)
+}