mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[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
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
services/web/scripts/stripe/archive_prices_by_version_key.mjs
Executable file
209
services/web/scripts/stripe/archive_prices_by_version_key.mjs
Executable file
@@ -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
services/web/scripts/stripe/export_products_from_environment.mjs
Executable file
158
services/web/scripts/stripe/export_products_from_environment.mjs
Executable file
@@ -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
services/web/scripts/stripe/import_products_to_environment.mjs
Executable file
279
services/web/scripts/stripe/import_products_to_environment.mjs
Executable file
@@ -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
services/web/scripts/stripe/update_prices_from_csv.mjs
Normal file
595
services/web/scripts/stripe/update_prices_from_csv.mjs
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user