Просмотр исходного кода

[fix] use Stripe productId rather planCode when adding prices (#33344)

GitOrigin-RevId: 6e24317a0086332145c88a9be3b700b96d7a1187
Simon Gardner 3 месяцев назад
Родитель
Сommit
feb4f49859
1 измененных файлов с 62 добавлено и 83 удалено
  1. 62 83
      services/web/scripts/stripe/create_custom_prices_from_csv.mjs

+ 62 - 83
services/web/scripts/stripe/create_custom_prices_from_csv.mjs

@@ -1,8 +1,8 @@
 // @ts-check
 
 /**
- * This script creates new Products and Prices in Stripe from a CSV file.
- * Use this when adding entirely new plans that don't exist in Stripe yet.
+ * This script creates custom Prices in Stripe from a CSV file.
+ * It does not create products; each row must include an existing Stripe productId.
  *
  * Usage:
  * node scripts/stripe/create_custom_prices_from_csv.mjs -f <file> --region <us|uk> --version <v> [options]
@@ -11,13 +11,12 @@
  * -f           Path to the prices CSV file.
  * --region     Stripe region (us or uk).
  * --version    Version string for the lookup_key (e.g., 'v1', 'jan2026').
- * --productDescription Description to use for newly created products (default: blank).
  * --commit     Apply changes to Stripe (default is dry-run).
  *
  * CSV Format:
- * planCode,productName,priceDescription,interval,USD,GBP,EUR
- * essentials,Essentials Monthly,"Historical custom price",month,21,17,19
- * essentials-annual,Essentials Annual,"Historical custom price",year,199,159,179
+ * planCode,productId,productName,priceDescription,interval,USD,GBP,EUR
+ * essentials,prod_123,Essentials Monthly,"Historical custom price",month,21,17,19
+ * essentials-annual,prod_456,Essentials Annual,"Historical custom price",year,199,159,179
  */
 
 import minimist from 'minimist'
@@ -33,6 +32,7 @@ import { convertToMinorUnits, rateLimitSleep } from './helpers.mjs'
 /**
  * @typedef {object} PriceRecord
  * @property {string} planCode
+ * @property {string} productId - Optional expected Stripe Product ID to validate against
  * @property {string} productName - Optional, can be derived from planCode if not provided
  * @property {string} priceDescription - Optional
  * @property {string} interval - 'month' or 'year'
@@ -50,7 +50,6 @@ const paramsSchema = z.object({
   f: z.string(),
   region: z.enum(['us', 'uk']),
   version: z.string(),
-  productDescription: z.string().default(''),
   commit: z.boolean().default(false),
 })
 
@@ -108,26 +107,13 @@ async function getExistingProducts(stripe) {
   return productsById
 }
 
-/**
- * @param {unknown} err
- * @returns {boolean}
- */
-function isAlreadyExistsError(err) {
-  const maybeErr = /** @type {any} */ (err)
-  const code = maybeErr?.code || maybeErr?.raw?.code
-  if (code === 'resource_already_exists') return true
-
-  const message = err instanceof Error ? err.message : String(err)
-  return /already exists/i.test(message)
-}
-
 /**
  * @param {any} trackProgress
  */
 export async function main(trackProgress) {
   const args = minimist(process.argv.slice(2), {
     boolean: ['commit'],
-    string: ['region', 'f', 'version', 'productDescription'],
+    string: ['region', 'f', 'version'],
   })
 
   const parseResult = paramsSchema.safeParse(args)
@@ -135,13 +121,7 @@ export async function main(trackProgress) {
     throw new Error(`Invalid parameters: ${parseResult.error.message}`)
   }
 
-  const {
-    f: inputFile,
-    region,
-    version,
-    productDescription: defaultProductDescription,
-    commit,
-  } = parseResult.data
+  const { f: inputFile, region, version, commit } = parseResult.data
   const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
 
   const log = (message = '') =>
@@ -162,6 +142,7 @@ export async function main(trackProgress) {
   // Identify currency columns (everything except the known non-currency columns)
   const nonCurrencyKeys = new Set([
     'planCode',
+    'productId',
     'productName',
     'priceDescription',
     'interval',
@@ -174,6 +155,11 @@ export async function main(trackProgress) {
   await log('Fetching existing Stripe data...')
   const existingPrices = await getExistingPrices(stripe)
   const existingProducts = await getExistingProducts(stripe)
+  /** @type {Record<string, Product>} */
+  const existingProductsById = {}
+  for (const product of Object.values(existingProducts)) {
+    existingProductsById[product.id] = product
+  }
 
   const summary = {
     productsCreated: 0,
@@ -186,7 +172,9 @@ export async function main(trackProgress) {
   let rowNumber = 0 // For logging purposes, starting after header
   for (const /** @type {PriceRecord} */ record of records) {
     ++rowNumber
-    const { planCode, priceDescription, interval } = record
+    const { planCode, productId, priceDescription, interval } = record
+    const expectedProductId = String(productId || '').trim()
+
     if (!planCode) {
       await log(`✗ No plan code in row ${rowNumber}`)
       ++summary.invalidRows
@@ -200,64 +188,55 @@ export async function main(trackProgress) {
       continue
     }
 
+    // If productId is provided, treat it as an assertion that the product already
+    // exists and matches the planCode mapping. Skip the row before product-create.
+    if (expectedProductId) {
+      const existingProduct = existingProducts[planCode]
+      if (!existingProduct) {
+        await log(
+          `✗ CSV productId '${expectedProductId}' provided for plan '${planCode}', but no existing product was found for that planCode. Skipping row.`
+        )
+        summary.errors++
+        continue
+      }
+
+      if (existingProduct.id !== expectedProductId) {
+        await log(
+          `✗ CSV productId '${expectedProductId}' does not match existing product id '${existingProduct.id}' for plan '${planCode}'. Skipping row.`
+        )
+        summary.errors++
+        continue
+      }
+    }
+
     await log()
     await log(`--- Processing Plan: ${planCode} ---`)
 
-    // 1. Handle product
-    // Keep in-memory caches in sync so repeated plan rows are idempotent
-    // within a single run.
-    if (!existingProducts[planCode]) {
-      let productCreated = false
-      const productName =
-        record.productName ||
-        planCode
-          .split(/[_-]/) // Handle underscores or hyphens
-          .map(
-            /** @param {any} word */
-            word => word.charAt(0).toUpperCase() + word.slice(1)
-          )
-          .join(' ')
-
-      if (commit) {
-        try {
-          await stripe.products.create({
-            id: planCode,
-            name: productName,
-            description: defaultProductDescription || undefined, // Don't pass an empty string, Stripe thinks we're trying to unset it and doesn't like it
-            tax_code: 'txcd_10103000', // "Software as a service (SaaS) - personal use", which is what existing products have
-            metadata: { planCode },
-          })
-          await rateLimitSleep()
-          productCreated = true
-        } catch (err) {
-          if (isAlreadyExistsError(err)) {
-            await log(
-              `- Product '${planCode}' already exists (detected during create). Continuing.`
-            )
-          } else {
-            const errorMessage =
-              err instanceof Error ? err.message : String(err)
-            await log(`✗ Error creating product ${planCode}: ${errorMessage}`)
-            summary.errors++
-            continue // Skip prices if product creation failed
-          }
-        }
-      } else {
-        productCreated = true
-      }
+    // 1. Validate required existing productId from CSV.
+    const productIdForPrice = String(productId || '').trim()
+    if (!productIdForPrice) {
+      await log(`✗ No productId in row ${rowNumber}. Skipping row.`)
+      summary.invalidRows++
+      continue
+    }
 
-      // Keep in-memory cache in sync so later rows in this run are idempotent.
-      existingProducts[planCode] = /** @type {any} */ ({
-        id: planCode,
-        metadata: { planCode },
-      })
+    if (!existingProductsById[productIdForPrice]) {
+      await log(
+        `✗ Product '${productIdForPrice}' from CSV row ${rowNumber} was not found in Stripe. Skipping row.`
+      )
+      summary.errors++
+      continue
+    }
 
-      if (productCreated) {
-        await log(`✓ Created product: ${planCode} ("${productName}")`)
-        summary.productsCreated++
-      }
-    } else {
-      await log(`- Product '${planCode}' already exists.`)
+    if (
+      existingProducts[planCode]?.id &&
+      existingProducts[planCode].id !== productIdForPrice
+    ) {
+      await log(
+        `  ✗ productId mismatch for plan '${planCode}': CSV has '${productIdForPrice}', planCode resolves to '${existingProducts[planCode].id}'. Skipping row.`
+      )
+      summary.errors++
+      continue
     }
 
     // 2. Handle Prices for each currency column
@@ -280,7 +259,7 @@ export async function main(trackProgress) {
 
       /** @type {PriceCreateParams} */
       const priceParams = {
-        product: planCode,
+        product: productIdForPrice,
         currency: currencyLower,
         unit_amount: unitAmount,
         recurring: { interval },