change_existing_subscription_prices.mjs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. #!/usr/bin/env node
  2. /**
  3. * This script changes prices for existing Stripe subscriptions.
  4. * It schedules changes to apply at the next renewal.
  5. *
  6. * Usage:
  7. * node scripts/stripe/change_existing_subscription_prices.mjs [OPTS] [INPUT-FILE]
  8. *
  9. * Options:
  10. * --region REGION Either 'uk' or 'us' (required)
  11. * --timeframe TIMEFRAME Either 'renewal' or 'now' (default: renewal)
  12. * --output PATH Output file path (default: /tmp/change_prices_output_<timestamp>.csv)
  13. * Use '-' to write to stdout
  14. * --commit Apply changes (without this flag, runs in dry-run mode)
  15. * --throttle DURATION Minimum time (in ms) between subscriptions processed (default: 100)
  16. * --force Overwrite any existing pending changes
  17. * --help Show a help message
  18. *
  19. * CSV Input Format:
  20. * The CSV must have the following columns:
  21. * - subscription_id: Stripe subscription id
  22. * - current_lookup_key: Current price lookup key
  23. * - new_lookup_key: New price lookup key
  24. * - current_add_on_lookup_key: Current price lookup key for add-on (optional)
  25. * - new_add_on_lookup_key: New price lookup key for add-on (optional)
  26. *
  27. * Output:
  28. * Writes a CSV with columns:
  29. * - subscription_id: The subscription id processed
  30. * - status: Result status (changed, validated, not-found, inactive, mismatch, pending-change, or error)
  31. * - note: Additional information about the status (includes dry run notice when not using --commit)
  32. *
  33. * Running on a Pod:
  34. * This script may run for multiple days. When running using `rake run:longpod[ENV,web]`,
  35. * use one of these strategies to preserve output:
  36. *
  37. * 1. Tail the output file from another session (the filename is logged when the script starts):
  38. * kubectl exec -it <pod-name> -- tail -f /tmp/change_prices_output_<timestamp>.csv > local_backup.csv
  39. *
  40. * 2. Periodically copy the output file to your laptop:
  41. * kubectl cp <pod-name>:/tmp/change_prices_output_<timestamp>.csv ./backup.csv
  42. *
  43. * 3. Write to stdout and capture locally:
  44. * kubectl exec -it <pod-name> -- node scripts/stripe/change_existing_subscription_prices.mjs \
  45. * --timeframe renewal --commit --output - input.csv > output.csv
  46. *
  47. * For monitoring handoffs, have the next person start tailing (or copying periodically)
  48. * before the current monitor disconnects to ensure no records are lost.
  49. */
  50. import fs from 'node:fs'
  51. import path from 'node:path'
  52. import { setTimeout } from 'node:timers/promises'
  53. import * as csv from 'csv'
  54. import minimist from 'minimist'
  55. import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs'
  56. import { z } from '../../app/src/infrastructure/Validation.mjs'
  57. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  58. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  59. import {
  60. ReportError,
  61. getProductIdFromPrice,
  62. getProductIdFromItem,
  63. getPriceIdFromItem,
  64. } from './helpers.mjs'
  65. /**
  66. * @import { CSVSubscriptionChange, Timeframe, StripeClient } from './helpers.mjs'
  67. * @import Stripe from 'stripe'
  68. * @import { ReadStream } from 'node:fs'
  69. * @import { Parser } from 'csv-parse'
  70. * @import { Stringifier } from 'csv-stringify'
  71. */
  72. // 100 ms corresponds to 10 requests per second (cautious rate within Stripe's 100 req/s limit)
  73. const DEFAULT_THROTTLE = 100
  74. /**
  75. * cache for price objects to avoid redundant Stripe API calls
  76. * @type {Map<string, Stripe.Price>}
  77. */
  78. const priceCache = new Map()
  79. /**
  80. * Print usage information to stderr
  81. */
  82. function usage() {
  83. console.error(`Usage: node scripts/stripe/change_existing_subscription_prices.mjs [OPTS] [INPUT-FILE]
  84. Options:
  85. --region REGION Either 'uk' or 'us' (required)
  86. --timeframe TIMEFRAME Either 'renewal' or 'now' (default: renewal)
  87. --output PATH Output file path (default: /tmp/change_prices_output_<timestamp>.csv)
  88. Use '-' to write to stdout
  89. --commit Apply changes (without this, runs in dry-run mode)
  90. --throttle DURATION Minimum time between requests in ms (default: ${DEFAULT_THROTTLE})
  91. --force Overwrite any existing pending changes
  92. --help Show this help message
  93. See the source file header for detailed documentation on CSV format and pod usage.
  94. `)
  95. }
  96. /**
  97. * Main script entry point
  98. * @param {function(string): Promise<void>} trackProgress - Function to log progress messages
  99. */
  100. async function main(trackProgress) {
  101. const opts = parseArgs()
  102. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  103. const outputFile = opts.output ?? `/tmp/change_prices_output_${timestamp}.csv`
  104. const stripeClient = getRegionClient(opts.region)
  105. await trackProgress('Starting price change script for Stripe')
  106. await trackProgress(`Region: ${opts.region}`)
  107. await trackProgress(
  108. `Timeframe: ${opts.timeframe === 'now' ? 'now (immediate)' : 'renewal (at next cycle)'}`
  109. )
  110. await trackProgress(`Run mode: ${opts.commit ? 'COMMIT' : 'DRY RUN'}`)
  111. await trackProgress(`Throttle: ${opts.throttle}ms between requests`)
  112. await trackProgress(`Force mode: ${opts.force ? 'enabled' : 'disabled'}`)
  113. const inputStream = opts.inputFile
  114. ? fs.createReadStream(opts.inputFile)
  115. : process.stdin
  116. const csvReader = getCsvReader(inputStream)
  117. const csvWriter = getCsvWriter(outputFile)
  118. await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
  119. let processedCount = 0
  120. let successCount = 0
  121. let errorCount = 0
  122. let lastLoopTimestamp = 0
  123. for await (const change of csvReader) {
  124. const timeSinceLastLoop = Date.now() - lastLoopTimestamp
  125. if (timeSinceLastLoop < opts.throttle) {
  126. await setTimeout(opts.throttle - timeSinceLastLoop)
  127. }
  128. lastLoopTimestamp = Date.now()
  129. processedCount++
  130. try {
  131. const subscription = await processChange(
  132. change,
  133. stripeClient,
  134. opts.commit,
  135. opts.force,
  136. opts.timeframe
  137. )
  138. if (opts.commit && subscription) {
  139. try {
  140. const userId = subscription.customer.metadata?.userId
  141. await AnalyticsManager.recordEventForUser(
  142. userId,
  143. 'script_price_change',
  144. {
  145. subscriptionId: change.subscription_id,
  146. }
  147. )
  148. } catch (err) {
  149. await trackProgress(
  150. `Warning: failed to record analytics event after successful price change for ${change.subscription_id}: ${err.message}`
  151. )
  152. }
  153. }
  154. csvWriter.write({
  155. subscription_id: change.subscription_id,
  156. status: opts.commit ? 'changed' : 'validated',
  157. note: opts.commit ? undefined : 'dry run - no changes applied',
  158. })
  159. successCount++
  160. if (processedCount % 10 === 0) {
  161. await trackProgress(
  162. `Processed ${processedCount} subscriptions (${successCount} ${opts.commit ? 'changed' : 'validated'}, ${errorCount} errors)`
  163. )
  164. }
  165. } catch (err) {
  166. errorCount++
  167. if (err instanceof ReportError) {
  168. csvWriter.write({
  169. subscription_id: change.subscription_id,
  170. status: err.status,
  171. note: err.message,
  172. })
  173. } else {
  174. csvWriter.write({
  175. subscription_id: change.subscription_id,
  176. status: 'error',
  177. note: err.message,
  178. })
  179. await trackProgress(
  180. `Error processing ${change.subscription_id}: ${err.message}`
  181. )
  182. }
  183. }
  184. }
  185. await trackProgress('\n✨ FINAL SUMMARY ✨')
  186. await trackProgress(`📊 Total processed: ${processedCount}`)
  187. if (opts.commit) {
  188. await trackProgress(`✅ Successfully changed: ${successCount}`)
  189. } else {
  190. await trackProgress(`✅ Successfully validated: ${successCount}`)
  191. await trackProgress('ℹ️ DRY RUN: No changes were applied to Stripe')
  192. }
  193. await trackProgress(`❌ Errors: ${errorCount}`)
  194. await trackProgress('🎉 Script completed!')
  195. csvWriter.end()
  196. }
  197. /**
  198. * Get a CSV parser configured for subscription change input
  199. * @param {ReadStream | NodeJS.ReadableStream} inputStream - The input stream to parse
  200. * @returns {Parser} The configured CSV parser
  201. */
  202. function getCsvReader(inputStream) {
  203. const parser = csv.parse({
  204. columns: true,
  205. })
  206. inputStream.pipe(parser)
  207. return parser
  208. }
  209. /**
  210. * Get a CSV stringifier configured for output
  211. * @param {string} outputFile - The output file path to write to, or '-' for stdout
  212. * @returns {Stringifier} The configured CSV stringifier
  213. */
  214. function getCsvWriter(outputFile) {
  215. let outputStream
  216. if (outputFile === '-') {
  217. outputStream = process.stdout
  218. } else {
  219. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  220. outputStream = fs.createWriteStream(outputFile)
  221. }
  222. const writer = csv.stringify({
  223. columns: ['subscription_id', 'status', 'note'],
  224. header: true,
  225. })
  226. writer.on('error', err => {
  227. console.error(err)
  228. process.exit(1)
  229. })
  230. writer.pipe(outputStream)
  231. return writer
  232. }
  233. /**
  234. * Process a single subscription change
  235. * @param {CSVSubscriptionChange} change - The subscription change to process
  236. * @param {StripeClient} stripeClient - The Stripe client for the region
  237. * @param {boolean} commit - Whether to commit changes or run in dry-run mode
  238. * @param {boolean} force - Whether to overwrite existing pending changes
  239. * @param {Timeframe} timeframe - When to apply the change
  240. * @returns {Promise<Stripe.Subscription | undefined>} The subscription if commit mode, undefined otherwise
  241. */
  242. async function processChange(change, stripeClient, commit, force, timeframe) {
  243. const subscription = await fetchSubscription(
  244. change.subscription_id,
  245. stripeClient
  246. )
  247. const nextPrices = await fetchPrices(
  248. [change.new_lookup_key, change.new_add_on_lookup_key].filter(Boolean),
  249. stripeClient
  250. )
  251. validateChange(change, subscription, nextPrices, force)
  252. if (!commit) {
  253. // dry run mode - validation passed, no changes applied
  254. return
  255. }
  256. let updatedSubscription
  257. if (timeframe === 'now') {
  258. updatedSubscription = await updateSubscriptionImmediately(
  259. subscription,
  260. stripeClient,
  261. nextPrices
  262. )
  263. } else {
  264. updatedSubscription = await createSubscriptionSchedule(
  265. subscription,
  266. stripeClient,
  267. nextPrices
  268. )
  269. }
  270. return updatedSubscription
  271. }
  272. /**
  273. * Fetch a subscription from Stripe
  274. * @param {string} subscriptionId - The Stripe subscription id
  275. * @param {StripeClient} stripeClient - The Stripe client for the region
  276. * @returns {Promise<Stripe.Subscription>} The subscription with expanded items and schedule
  277. * @throws {ReportError} If subscription is not found
  278. */
  279. async function fetchSubscription(subscriptionId, stripeClient) {
  280. try {
  281. const subscription = await stripeClient.stripe.subscriptions.retrieve(
  282. subscriptionId,
  283. {
  284. expand: ['schedule', 'discounts'],
  285. }
  286. )
  287. return subscription
  288. } catch (err) {
  289. if (err.type === 'StripeInvalidRequestError' && err.statusCode === 404) {
  290. throw new ReportError('not-found', 'subscription not found')
  291. }
  292. throw err
  293. }
  294. }
  295. /**
  296. * Fetch price entities from Stripe with caching
  297. * @param {string[]} lookupKeys
  298. * @param {StripeClient} stripeClient - The Stripe client for the region
  299. * @returns {Promise<Stripe.Price[]>} the fetched prices
  300. * @throws {ReportError} If price is not found
  301. */
  302. async function fetchPrices(lookupKeys, stripeClient) {
  303. if (lookupKeys.length === 0) {
  304. throw new ReportError('not-found', 'no lookup keys provided')
  305. }
  306. const cachedPrices = []
  307. const keysToFetch = []
  308. for (const key of lookupKeys) {
  309. if (priceCache.has(key)) {
  310. cachedPrices.push(priceCache.get(key))
  311. } else {
  312. keysToFetch.push(key)
  313. }
  314. }
  315. if (keysToFetch.length === 0) {
  316. return cachedPrices
  317. }
  318. try {
  319. const results = await stripeClient.stripe.prices.list({
  320. lookup_keys: keysToFetch,
  321. limit: keysToFetch.length,
  322. })
  323. if (results.data.length === 0) {
  324. throw new ReportError(
  325. 'not-found',
  326. `${keysToFetch.join(', ')} prices not found in Stripe`
  327. )
  328. }
  329. for (const price of results.data) {
  330. priceCache.set(price.lookup_key, price)
  331. }
  332. return [...cachedPrices, ...results.data]
  333. } catch (err) {
  334. if (err.type === 'StripeInvalidRequestError' && err.statusCode === 404) {
  335. throw new ReportError(
  336. 'not-found',
  337. `${keysToFetch.join(', ')} prices not found in Stripe`
  338. )
  339. }
  340. throw err
  341. }
  342. }
  343. /**
  344. * Helper function to validate that the proposed price looks correct
  345. * @param {Stripe.Price} currentPrice - The current price
  346. * @param {Stripe.Price[]} nextPrices - Available next prices
  347. * @throws {ReportError} If validation fails
  348. */
  349. function validateProposedPrice(currentPrice, nextPrices) {
  350. const currentProductId = getProductIdFromPrice(currentPrice)
  351. if (!currentProductId) {
  352. throw new ReportError('mismatch', 'current price has no associated product')
  353. }
  354. // Find matching new price by product ID
  355. const matchingNewPrice = nextPrices.find(p => {
  356. const nextProductId = getProductIdFromPrice(p)
  357. return nextProductId === currentProductId
  358. })
  359. // Verify new price with matching product exists
  360. if (!matchingNewPrice) {
  361. throw new ReportError(
  362. 'mismatch',
  363. `no new prices found belonging to the same product: current ${currentProductId}`
  364. )
  365. }
  366. // Verify currency matches
  367. if (currentPrice.currency !== matchingNewPrice.currency) {
  368. throw new ReportError(
  369. 'mismatch',
  370. `currency mismatch found: current ${currentPrice.currency}, new ${matchingNewPrice.currency}`
  371. )
  372. }
  373. }
  374. /**
  375. * Validate that the subscription matches the expected state
  376. * @param {CSVSubscriptionChange} change - The subscription change to validate
  377. * @param {Stripe.Subscription} subscription - The Stripe subscription
  378. * @param {Stripe.Price[]} nextPrices - The next prices for the subscription
  379. * @param {boolean} force - Whether to ignore existing pending changes
  380. * @throws {ReportError} If validation fails
  381. */
  382. function validateChange(change, subscription, nextPrices, force) {
  383. // Check subscription is updatable
  384. const inactiveStatuses = [
  385. 'incomplete',
  386. 'incomplete_expired',
  387. 'canceled',
  388. 'trialing',
  389. ]
  390. if (inactiveStatuses.includes(subscription.status)) {
  391. throw new ReportError(
  392. 'inactive',
  393. `subscription status: ${subscription.status}`
  394. )
  395. }
  396. // Skip subscriptions that are scheduled to be canceled
  397. if (subscription.cancel_at_period_end) {
  398. throw new ReportError(
  399. 'inactive',
  400. 'subscription is scheduled to be canceled at period end'
  401. )
  402. }
  403. // Skip subscriptions that already have a schedule (unless force mode)
  404. if (subscription.schedule && !force) {
  405. if (typeof subscription.schedule === 'string') {
  406. throw new ReportError(
  407. 'pending-change',
  408. `subscription has a schedule (${subscription.schedule}) - re-run with expanded schedule data`
  409. )
  410. }
  411. if (subscription.schedule.status !== 'released') {
  412. throw new ReportError(
  413. 'pending-change',
  414. 'subscription already has an active schedule'
  415. )
  416. }
  417. }
  418. // Verify all requested next prices were found
  419. const requestedLookupKeys = [
  420. change.new_lookup_key,
  421. change.new_add_on_lookup_key,
  422. ].filter(Boolean)
  423. for (const requestedKey of requestedLookupKeys) {
  424. if (!nextPrices.find(p => p.lookup_key === requestedKey)) {
  425. throw new ReportError(
  426. 'not-found',
  427. `requested price with lookup key ${requestedKey} not found in Stripe`
  428. )
  429. }
  430. }
  431. // Verify current lookup keys exist in subscription
  432. const currentLookupKey = change.current_lookup_key
  433. const currentItem = subscription.items.data.find(
  434. item => item.price.lookup_key === currentLookupKey
  435. )
  436. if (!currentItem) {
  437. throw new ReportError(
  438. 'mismatch',
  439. `current_lookup_key ${currentLookupKey} not found in subscription items`
  440. )
  441. }
  442. if (change.current_add_on_lookup_key) {
  443. const currentAddOnItem = subscription.items.data.find(
  444. item => item.price.lookup_key === change.current_add_on_lookup_key
  445. )
  446. if (!currentAddOnItem) {
  447. throw new ReportError(
  448. 'mismatch',
  449. `current_add_on_lookup_key ${change.current_add_on_lookup_key} not found in subscription items`
  450. )
  451. }
  452. }
  453. // Verify the proposed new prices match the current prices' products
  454. // For plan price
  455. const currentPrice = currentItem.price
  456. validateProposedPrice(currentPrice, nextPrices)
  457. // For add-on price (if present)
  458. if (change.current_add_on_lookup_key) {
  459. const currentAddOnItem = subscription.items.data.find(
  460. item => item.price.lookup_key === change.current_add_on_lookup_key
  461. )
  462. const currentAddOnPrice = currentAddOnItem.price
  463. validateProposedPrice(currentAddOnPrice, nextPrices)
  464. }
  465. }
  466. /**
  467. * Helper function to find a matching new price for a subscription item
  468. * @param {Stripe.SubscriptionItem} item - The subscription item
  469. * @param {Stripe.Price[]} nextPrices - Available next prices
  470. * @returns {string} The matching price ID or the current price ID if no match
  471. */
  472. function findMatchingPriceId(item, nextPrices) {
  473. const itemProductId = getProductIdFromItem(item)
  474. if (!itemProductId) {
  475. // No product ID available, keep current price
  476. return item.price.id
  477. }
  478. // Find matching new price by product ID
  479. const matchingPrice = nextPrices.find(price => {
  480. const priceProductId = getProductIdFromPrice(price)
  481. return priceProductId === itemProductId
  482. })
  483. return matchingPrice?.id || item.price.id
  484. }
  485. /**
  486. * Update subscription prices immediately without proration
  487. * @param {Stripe.Subscription} subscription - The Stripe subscription
  488. * @param {StripeClient} stripeClient - The Stripe client for the region
  489. * @param {Stripe.Price[]} nextPrices - The next prices for the subscription
  490. * @returns {Promise<Stripe.Subscription>} The updated subscription
  491. * @throws {Error} If unable to build lookup key for plan
  492. * @throws {ReportError} If new price doesn't match expected price
  493. */
  494. async function updateSubscriptionImmediately(
  495. subscription,
  496. stripeClient,
  497. nextPrices
  498. ) {
  499. // Release any existing schedule. This only executes when --force is enabled,
  500. // as validation would have rejected subscriptions with active schedules otherwise.
  501. if (
  502. subscription.schedule &&
  503. typeof subscription.schedule !== 'string' &&
  504. subscription.schedule.status !== 'released'
  505. ) {
  506. await stripeClient.stripe.subscriptionSchedules.release(
  507. subscription.schedule.id
  508. )
  509. }
  510. // NOTE: The `id` field is required for all items when using this endpoint,
  511. // otherwise Stripe will append the subscription with the new item instead of
  512. // replacing the current item.
  513. const subscriptionItems = subscription.items.data.map(item => ({
  514. id: item.id,
  515. price: findMatchingPriceId(item, nextPrices),
  516. quantity: item.quantity || 1,
  517. }))
  518. const updatedSubscription = await stripeClient.stripe.subscriptions.update(
  519. subscription.id,
  520. {
  521. items: subscriptionItems,
  522. proration_behavior: 'none',
  523. expand: ['customer'],
  524. }
  525. )
  526. return updatedSubscription
  527. }
  528. /**
  529. * Create a subscription schedule to change prices at renewal
  530. * @param {Stripe.Subscription} subscription - The Stripe subscription
  531. * @param {StripeClient} stripeClient - The Stripe client for the region
  532. * @param {Stripe.Price[]} nextPrices - The next prices for the subscription
  533. * @returns {Promise<Stripe.Subscription>} The updated subscription
  534. * @throws {Error} If unable to build lookup key for plan
  535. * @throws {ReportError} If new price doesn't match expected price
  536. */
  537. async function createSubscriptionSchedule(
  538. subscription,
  539. stripeClient,
  540. nextPrices
  541. ) {
  542. // Release any existing schedule. This only executes when --force is enabled,
  543. // as validation would have rejected subscriptions with active schedules otherwise.
  544. if (
  545. subscription.schedule &&
  546. typeof subscription.schedule !== 'string' &&
  547. subscription.schedule.status !== 'released'
  548. ) {
  549. await stripeClient.stripe.subscriptionSchedules.release(
  550. subscription.schedule.id
  551. )
  552. }
  553. // NOTE: The `id` field cannot be used with this endpoint
  554. const nextPhaseItems = subscription.items.data.map(item => ({
  555. price: findMatchingPriceId(item, nextPrices),
  556. quantity: item.quantity || 1,
  557. }))
  558. // Create a subscription schedule
  559. const schedule = await stripeClient.stripe.subscriptionSchedules.create({
  560. from_subscription: subscription.id,
  561. expand: ['subscription', 'subscription.customer'],
  562. })
  563. const currentPhase = schedule.phases[0]
  564. // Update the schedule to include the new phase starting at the end of the current billing period
  565. // If the update fails, release the schedule to clean up the state
  566. try {
  567. const currentPhaseConfig = {
  568. start_date: currentPhase.start_date,
  569. end_date: currentPhase.end_date,
  570. items: currentPhase.items.map(item => ({
  571. price: getPriceIdFromItem(item),
  572. quantity: item.quantity,
  573. })),
  574. }
  575. const nextPhaseConfig = {
  576. start_date: currentPhase.end_date,
  577. items: nextPhaseItems,
  578. }
  579. // Stripe doesn't copy discount settings from subscription to schedule
  580. // so we need to manually preserve them
  581. if (subscription.discounts) {
  582. const discounts = subscription.discounts
  583. .map(discount => {
  584. if (discount.promotion_code) {
  585. return {
  586. promotion_code: discount.promotion_code,
  587. }
  588. } else if (discount.coupon) {
  589. return {
  590. coupon:
  591. typeof discount.coupon === 'string'
  592. ? discount.coupon
  593. : discount.coupon.id,
  594. }
  595. }
  596. return {}
  597. })
  598. .filter(d => d.coupon || d.promotion_code)
  599. currentPhaseConfig.discounts = discounts
  600. nextPhaseConfig.discounts = discounts
  601. }
  602. await stripeClient.stripe.subscriptionSchedules.update(schedule.id, {
  603. phases: [currentPhaseConfig, nextPhaseConfig],
  604. end_behavior: 'release',
  605. })
  606. } catch (err) {
  607. // If the update fails, release the schedule to prevent it from remaining in an invalid state
  608. try {
  609. await stripeClient.stripe.subscriptionSchedules.release(schedule.id)
  610. } catch (releaseErr) {
  611. // Do nothing, and throw the original error
  612. }
  613. throw err
  614. }
  615. return schedule.subscription
  616. }
  617. const paramsSchema = z.object({
  618. region: z.enum(['uk', 'us']),
  619. timeframe: z.enum(['renewal', 'now']).default('renewal'),
  620. output: z.string().optional(),
  621. commit: z.boolean().default(false),
  622. force: z.boolean().default(false),
  623. throttle: z
  624. .string()
  625. .optional()
  626. .transform(val => (val ? parseInt(val, 10) : DEFAULT_THROTTLE)),
  627. _: z.array(z.string()).max(1),
  628. help: z.boolean().optional(),
  629. })
  630. /**
  631. * Parse command line arguments
  632. * @returns {{inputFile: string | undefined, output: string | undefined, force: boolean, commit: boolean, timeframe: 'renewal' | 'now', throttle: number, region: 'uk' | 'us'}} Parsed options
  633. */
  634. function parseArgs() {
  635. const argv = minimist(process.argv.slice(2), {
  636. string: ['throttle', 'timeframe', 'output', 'region'],
  637. boolean: ['help', 'force', 'commit'],
  638. })
  639. if (argv.help) {
  640. usage()
  641. process.exit(0)
  642. }
  643. const parseResult = paramsSchema.safeParse(argv)
  644. if (!parseResult.success) {
  645. console.error(`Invalid parameters: ${parseResult.error.message}`)
  646. usage()
  647. process.exit(1)
  648. }
  649. const { region, timeframe, output, commit, force, throttle, _ } =
  650. parseResult.data
  651. return {
  652. inputFile: _[0],
  653. output,
  654. force,
  655. commit,
  656. timeframe,
  657. throttle,
  658. region,
  659. }
  660. }
  661. try {
  662. await scriptRunner(main)
  663. process.exit(0)
  664. } catch (error) {
  665. console.error(error)
  666. process.exit(1)
  667. }