mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01: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-annual': 'student_annual',
|
||||||
student_free_trial_7_days: 'student_monthly',
|
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: 'group_standard_enterprise',
|
||||||
group_collaborator_educational: 'group_standard_educational',
|
group_collaborator_educational: 'group_standard_educational',
|
||||||
group_professional: 'group_professional_enterprise',
|
group_professional: 'group_professional_enterprise',
|
||||||
@@ -181,4 +180,5 @@ export default {
|
|||||||
getPlanTypeAndPeriodFromRecurlyPlanCode,
|
getPlanTypeAndPeriodFromRecurlyPlanCode,
|
||||||
isGroupPlanCode,
|
isGroupPlanCode,
|
||||||
convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded,
|
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
|
// Convert the raw records into the output format
|
||||||
|
|
||||||
function transformRecordToPlan(record) {
|
function transformRecordToPlan(record) {
|
||||||
@@ -104,8 +114,12 @@ function transformRecordToPlan(record) {
|
|||||||
function generate(inputFile, outputFile) {
|
function generate(inputFile, outputFile) {
|
||||||
const input = fs.readFileSync(inputFile, 'utf8')
|
const input = fs.readFileSync(inputFile, 'utf8')
|
||||||
const rawRecords = csv.parse(input, { columns: true })
|
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
|
// 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)
|
const output = JSON.stringify(plans, null, 2)
|
||||||
fs.writeFileSync(outputFile, output)
|
fs.writeFileSync(outputFile, output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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