list_recurly_accounts.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /**
  2. * List Recurly accounts and output as CSV for use with migrate_recurly_customers_to_stripe.mjs
  3. *
  4. * Useful for generating list of customers for testing purposes.
  5. *
  6. * This script can be deleted once the Recurly to Stripe migration is complete.
  7. *
  8. * Usage:
  9. * node scripts/recurly/list_recurly_accounts.mjs --limit 100 --output test_customers.csv
  10. *
  11. * Options:
  12. * --limit N Number of accounts to fetch (default: 100)
  13. * --output FILE Output CSV file (required)
  14. * --stripe-account Account ID to use for target_stripe_account column
  15. * --verbose Enable debug logging
  16. */
  17. import Settings from '@overleaf/settings'
  18. import recurly from 'recurly'
  19. import minimist from 'minimist'
  20. import fs from 'node:fs'
  21. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  22. import { normalizeRecurlyAddressToStripe } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
  23. const recurlyApiKey =
  24. process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
  25. if (!recurlyApiKey) {
  26. throw new Error(
  27. 'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
  28. )
  29. }
  30. const client = new recurly.Client(recurlyApiKey)
  31. function usage() {
  32. console.error(
  33. 'List Recurly accounts and output as CSV for use with migrate_recurly_customers_to_stripe.mjs'
  34. )
  35. console.error('')
  36. console.error('Usage:')
  37. console.error(
  38. ' node scripts/recurly/list_recurly_accounts.mjs --output <file> [options]'
  39. )
  40. console.error('')
  41. console.error('Options:')
  42. console.error(
  43. ' --limit, -l N Number of accounts to fetch (default: 100)'
  44. )
  45. console.error(' --output, -o FILE Output CSV file (required)')
  46. console.error(
  47. ' --stripe-account, -s ID Target Stripe account ID for all rows'
  48. )
  49. console.error(' --verbose, -v Enable debug logging')
  50. console.error(' --help, -h Show this help message')
  51. }
  52. function parseArgs() {
  53. return minimist(process.argv.slice(2), {
  54. alias: {
  55. o: 'output',
  56. l: 'limit',
  57. s: 'stripe-account',
  58. v: 'verbose',
  59. h: 'help',
  60. },
  61. default: { limit: 100 },
  62. string: ['output', 'stripe-account'],
  63. boolean: ['verbose'],
  64. })
  65. }
  66. async function main(trackProgress) {
  67. const args = parseArgs()
  68. const DEBUG = !!args.verbose
  69. function debug(message, context = {}) {
  70. if (!DEBUG) return
  71. const contextStr =
  72. Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
  73. console.log(`[DEBUG] ${message}${contextStr}`)
  74. }
  75. if (args.help || args.h) {
  76. usage()
  77. process.exit(0)
  78. }
  79. if (!args.output) {
  80. usage()
  81. console.error('')
  82. console.error('Error: --output is required')
  83. process.exit(1)
  84. }
  85. const limit = parseInt(args.limit, 10)
  86. const targetStripeAccount =
  87. args['stripe-account'] || 'REPLACE_WITH_STRIPE_ACCOUNT_ID'
  88. if (!Number.isFinite(limit) || limit <= 0) {
  89. throw new Error(`Invalid --limit: ${args.limit}`)
  90. }
  91. if (limit < 2) {
  92. throw new Error(
  93. 'Invalid --limit: must be >= 2 to satisfy VAT constraints (>=1 VAT account but <=50% VAT overall)'
  94. )
  95. }
  96. await trackProgress(`Fetching up to ${limit} accounts from Recurly...`)
  97. await trackProgress(`Target Stripe account: ${targetStripeAccount}`)
  98. if (DEBUG) {
  99. await trackProgress('Debug logging enabled')
  100. }
  101. const vatCandidates = []
  102. const nonVatCandidates = []
  103. let scanned = 0
  104. let acceptedWithAddress = 0
  105. let rejectedNoAddress = 0
  106. let rejectedVatOverCap = 0
  107. let billingInfoFetched = 0
  108. let billingInfoNotFound = 0
  109. let billingInfoOtherError = 0
  110. let usedBillingAddress = 0
  111. let usedBillingVatNumber = 0
  112. const vatCap = Math.floor(limit / 2)
  113. // List accounts with pagination - Recurly returns a Pager, need to call .each()
  114. // Options must be wrapped in { params: { ... } }
  115. const accountsPager = client.listAccounts({
  116. params: { limit: Math.min(limit, 200) },
  117. })
  118. for await (const account of accountsPager.each()) {
  119. scanned++
  120. const recurlyAccountCode = account.code
  121. const row = {
  122. recurly_account_code: recurlyAccountCode,
  123. target_stripe_account: targetStripeAccount,
  124. stripe_customer_id: '', // Empty - no existing Stripe customer
  125. email: account.email,
  126. state: account.state,
  127. }
  128. let address = account.address
  129. let vatNumber =
  130. typeof account.vatNumber === 'string' ? account.vatNumber : null
  131. // Fetch billing info only if needed for address/vat detection
  132. if (!normalizeRecurlyAddressToStripe(address) || !vatNumber) {
  133. try {
  134. billingInfoFetched++
  135. const billingInfo = await client.getBillingInfo(
  136. `code-${recurlyAccountCode}`
  137. )
  138. if (!address && billingInfo?.address) {
  139. address = billingInfo.address
  140. usedBillingAddress++
  141. }
  142. if (!vatNumber && billingInfo?.vatNumber) {
  143. vatNumber = billingInfo.vatNumber
  144. usedBillingVatNumber++
  145. }
  146. } catch (error) {
  147. if (!(error instanceof recurly.errors.NotFoundError)) {
  148. billingInfoOtherError++
  149. throw error
  150. }
  151. billingInfoNotFound++
  152. }
  153. }
  154. if (!normalizeRecurlyAddressToStripe(address)) {
  155. rejectedNoAddress++
  156. debug('Rejected account: no valid address', {
  157. scanned,
  158. recurlyAccountCode,
  159. rejectedNoAddress,
  160. })
  161. continue
  162. }
  163. acceptedWithAddress++
  164. const hasVat = !!(typeof vatNumber === 'string' && vatNumber.trim())
  165. if (hasVat) {
  166. // Enforce VAT upper bound while scanning.
  167. if (vatCandidates.length >= vatCap) {
  168. rejectedVatOverCap++
  169. debug('Rejected account: VAT over cap', {
  170. scanned,
  171. recurlyAccountCode,
  172. vatCandidates: vatCandidates.length,
  173. vatCap,
  174. rejectedVatOverCap,
  175. })
  176. continue
  177. }
  178. vatCandidates.push(row)
  179. } else {
  180. nonVatCandidates.push(row)
  181. }
  182. // Stop once we can satisfy constraints.
  183. const vatToTake = Math.min(vatCap, vatCandidates.length)
  184. const needsAtLeastOneVat = vatCandidates.length >= 1
  185. const nonVatNeeded = limit - Math.max(1, vatToTake)
  186. if (needsAtLeastOneVat && nonVatCandidates.length >= nonVatNeeded) {
  187. debug('Stopping early: constraints satisfied', {
  188. scanned,
  189. vatCandidates: vatCandidates.length,
  190. nonVatCandidates: nonVatCandidates.length,
  191. vatCap,
  192. nonVatNeeded,
  193. })
  194. break
  195. }
  196. if (scanned % 25 === 0) {
  197. await trackProgress(
  198. `Scanned ${scanned} accounts (acceptedWithAddress=${acceptedWithAddress}, vat=${vatCandidates.length}, nonVat=${nonVatCandidates.length})`
  199. )
  200. debug('Progress', {
  201. scanned,
  202. acceptedWithAddress,
  203. rejectedNoAddress,
  204. rejectedVatOverCap,
  205. vatCandidates: vatCandidates.length,
  206. nonVatCandidates: nonVatCandidates.length,
  207. billingInfoFetched,
  208. billingInfoNotFound,
  209. billingInfoOtherError,
  210. usedBillingAddress,
  211. usedBillingVatNumber,
  212. })
  213. }
  214. }
  215. if (vatCandidates.length < 1) {
  216. throw new Error(
  217. `Unable to find any accounts with VAT numbers (scanned=${scanned}, acceptedWithAddress=${acceptedWithAddress}, rejectedNoAddress=${rejectedNoAddress})`
  218. )
  219. }
  220. const vatToTake = Math.max(1, Math.min(vatCap, vatCandidates.length))
  221. const nonVatToTake = limit - vatToTake
  222. if (nonVatCandidates.length < nonVatToTake) {
  223. throw new Error(
  224. `Unable to satisfy VAT ratio constraint: need ${nonVatToTake} non-VAT + ${vatToTake} VAT, but have nonVat=${nonVatCandidates.length}, vat=${vatCandidates.length} (scanned=${scanned}, rejectedNoAddress=${rejectedNoAddress}, rejectedVatOverCap=${rejectedVatOverCap})`
  225. )
  226. }
  227. const accounts = [
  228. ...vatCandidates.slice(0, vatToTake),
  229. ...nonVatCandidates.slice(0, nonVatToTake),
  230. ]
  231. await trackProgress(
  232. `Selected ${accounts.length} accounts (vat=${vatToTake}, nonVat=${nonVatToTake}, scanned=${scanned}, rejectedNoAddress=${rejectedNoAddress})`
  233. )
  234. // Output CSV
  235. const csvHeader =
  236. 'recurly_account_code,target_stripe_account,stripe_customer_id'
  237. const csvRows = accounts.map(
  238. a =>
  239. `${a.recurly_account_code},${a.target_stripe_account},${a.stripe_customer_id}`
  240. )
  241. const csvContent = [csvHeader, ...csvRows].join('\n') + '\n'
  242. fs.writeFileSync(args.output, csvContent)
  243. await trackProgress(`Wrote ${accounts.length} accounts to ${args.output}`)
  244. // Output a summary
  245. const states = {}
  246. accounts.forEach(a => {
  247. states[a.state] = (states[a.state] || 0) + 1
  248. })
  249. await trackProgress('Account states:')
  250. for (const [state, stateCount] of Object.entries(states)) {
  251. await trackProgress(` ${state}: ${stateCount}`)
  252. }
  253. }
  254. try {
  255. await scriptRunner(main)
  256. process.exit(0)
  257. } catch (err) {
  258. console.error('Error:', err.message)
  259. process.exit(1)
  260. }