migrate_recurly_customers_to_stripe.mjs 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315
  1. #!/usr/bin/env node
  2. /**
  3. * This script updates existing Stripe customer records with data from Recurly.
  4. *
  5. * It can be deleted once the Recurly to Stripe migration is complete.
  6. *
  7. * PREREQUISITE: Customers must already exist in the target Stripe account (created via PAN import
  8. * or other process). This script updates them with additional data from Recurly.
  9. *
  10. * RESUMABLE EXECUTION:
  11. * This script is designed to be re-runnable. If it fails partway through, you can fix
  12. * the issue and re-run with the same arguments. It will:
  13. * 1. Load already-processed records from the success output file
  14. * 2. Skip any records that were already successfully processed
  15. * 3. Re-attempt any records not in the success file (including previous failures)
  16. *
  17. * To force a fresh start, use --restart flag or delete the success output file.
  18. *
  19. * Input CSV format:
  20. * recurly_account_code,target_stripe_account,stripe_customer_id
  21. *
  22. * Where:
  23. * - recurly_account_code: The Recurly account code (also the Overleaf user ID)
  24. * - target_stripe_account: The target Stripe service name ('stripe-us' or 'stripe-uk')
  25. * - stripe_customer_id: The Stripe customer ID (required - customers must already exist)
  26. *
  27. * Output files:
  28. * --output (success file): Records that were successfully updated
  29. * Format: recurly_account_code,target_stripe_account,stripe_customer_id
  30. *
  31. * <output>_errors.csv: Records that failed (overwritten each run)
  32. * Format: recurly_account_code,target_stripe_account,stripe_customer_id,error
  33. *
  34. * <output>_stripe.json (dry-run only): Stripe customer update params
  35. * Format: Array of { recurly_account_code, target_stripe_account, stripe_customer_id, updateParams }
  36. *
  37. * <output>_stripe_existing_fields.json: Stripe customers that already had name/address/business_name set
  38. * (written in both dry-run and commit modes)
  39. * Format: Array of { recurly_account_code, stripe_account, stripe_customer_id, recurly: {...}, stripe: {...} }
  40. *
  41. * Resume behavior:
  42. * - Records in the success file are SKIPPED (already done)
  43. * - Records in the errors file are RE-PROCESSED (will be retried)
  44. * - After each run, the errors file contains ONLY the failures from that run
  45. * - Successfully retried records are moved from errors to success file
  46. *
  47. * Usage:
  48. * # Dry run (no changes made, outputs _stripe.json with what would be updated)
  49. * node scripts/recurly/migrate_recurly_customers_to_stripe.mjs --input customers.csv --output results.csv
  50. *
  51. * # Commit changes
  52. * node scripts/recurly/migrate_recurly_customers_to_stripe.mjs --input customers.csv --output results.csv --commit
  53. *
  54. * # Resume after failure (just run the same command again)
  55. * node scripts/recurly/migrate_recurly_customers_to_stripe.mjs --input customers.csv --output results.csv --commit
  56. *
  57. * Options:
  58. * --input, -i <file> Path to input CSV file
  59. * --output, -o <file> Path to success output CSV file
  60. * --limit, -l <n> Limit number of records processed (default: no limit)
  61. * --concurrency, -c <n> Number of customers to process concurrently (default: 10)
  62. * --recurly-rate-limit <n> Requests per second for Recurly (default: 10)
  63. * --recurly-api-retries <n> Number of retries on Recurly 429s (default: 5)
  64. * --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)
  65. * --stripe-rate-limit <n> Requests per second for Stripe (default: 50)
  66. * --stripe-api-retries <n> Number of retries on Stripe 429s (default: 5)
  67. * --stripe-retry-delay-ms <n> Delay between Stripe retries in ms (default: 1000)
  68. * --force-invalid-tax Allow VAT numbers that cannot be mapped to a tax ID type (default: false)
  69. * --commit Actually update customers in Stripe (default: dry-run mode)
  70. * --verbose, -v Enable debug logging
  71. * --restart Ignore existing output files and start fresh
  72. *
  73. *
  74. * Note, prior to running this script, environment variables must have been loaded from config/local.env
  75. *
  76. * ```
  77. * set -a
  78. * source ../../config/local.env
  79. * set +a
  80. * ```
  81. */
  82. import Settings from '@overleaf/settings'
  83. import Stripe from 'stripe'
  84. import recurly from 'recurly'
  85. import minimist from 'minimist'
  86. import PQueue from 'p-queue'
  87. import fs from 'node:fs'
  88. import * as csv from 'csv'
  89. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  90. import {
  91. areStripeAndRecurlyCardDetailsEqual,
  92. coalesceOrThrowPaymentMethod,
  93. getTaxIdType,
  94. normalizeRecurlyAddressToStripe,
  95. normalizeName,
  96. addressesEqual,
  97. resolveCustomerIdentity,
  98. sanitizeAccount,
  99. normalizeComparableString,
  100. hasAnyAddressValue,
  101. ccEmailsToArray,
  102. RECURLY_CUSTOM_FIELD_NAMES,
  103. extractRecurlyCustomFieldMetadata,
  104. } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
  105. import {
  106. createRateLimitedApiWrappers,
  107. DEFAULT_RECURLY_RATE_LIMIT,
  108. DEFAULT_STRIPE_RATE_LIMIT,
  109. DEFAULT_RECURLY_API_RETRIES,
  110. DEFAULT_RECURLY_RETRY_DELAY_MS,
  111. DEFAULT_STRIPE_API_RETRIES,
  112. DEFAULT_STRIPE_RETRY_DELAY_MS,
  113. } from '../stripe/RateLimiter.mjs'
  114. // =============================================================================
  115. // STRIPE CLIENT SETUP
  116. // =============================================================================
  117. const stripeClients = {}
  118. /**
  119. * Get a Stripe client by region ("us" or "uk").
  120. *
  121. * This intentionally mirrors the Stripe SDK construction used by subscriptions
  122. * (fetch http client + telemetry disabled), but without importing the full
  123. * subscriptions Stripe client module (which pulls in unrelated app code).
  124. */
  125. function getRegionClient(region) {
  126. const regionLower = String(region || '')
  127. .trim()
  128. .toLowerCase()
  129. if (regionLower !== 'us' && regionLower !== 'uk') {
  130. throw new Error(
  131. `Unknown Stripe region: ${region}. Expected stripe-us or stripe-uk.`
  132. )
  133. }
  134. if (stripeClients[regionLower]) return stripeClients[regionLower]
  135. const secretKey =
  136. regionLower === 'us'
  137. ? Settings.apis?.stripeUS?.secretKey ||
  138. process.env.STRIPE_OL_SECRET_KEY ||
  139. process.env.STRIPE_OL_US_SECRET_KEY
  140. : Settings.apis?.stripeUK?.secretKey ||
  141. process.env.STRIPE_OL_UK_SECRET_KEY
  142. if (!secretKey || !String(secretKey).trim()) {
  143. throw new Error(
  144. `No Stripe secret key configured for region ${regionLower}. ` +
  145. `Configure Settings.apis.stripeUS/stripeUK.secretKey or set ` +
  146. `${
  147. regionLower === 'us'
  148. ? 'STRIPE_OL_SECRET_KEY (or legacy STRIPE_OL_US_SECRET_KEY)'
  149. : 'STRIPE_OL_UK_SECRET_KEY'
  150. }.`
  151. )
  152. }
  153. const client = new Stripe(secretKey, {
  154. httpClient: Stripe.createFetchHttpClient(),
  155. telemetry: false,
  156. })
  157. // Add serviceName for rate limiter identification (stripe-us or stripe-uk)
  158. client.serviceName = `stripe-${regionLower}`
  159. stripeClients[regionLower] = client
  160. return client
  161. }
  162. // =============================================================================
  163. // RECURLY CLIENT SETUP
  164. // =============================================================================
  165. const recurlyApiKey =
  166. process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
  167. if (!recurlyApiKey) {
  168. throw new Error(
  169. 'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
  170. )
  171. }
  172. const recurlyClient = new recurly.Client(recurlyApiKey)
  173. // =============================================================================
  174. // LOGGING UTILITIES
  175. // =============================================================================
  176. /**
  177. * Get ISO timestamp for logging
  178. */
  179. function timestamp() {
  180. return new Date().toISOString()
  181. }
  182. /**
  183. * Log a warning message with timestamp
  184. */
  185. function logWarn(message, context = {}) {
  186. const contextStr =
  187. Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
  188. console.warn(`[${timestamp()}] WARN: ${message}${contextStr}`)
  189. }
  190. /**
  191. * Log an error message with timestamp and optional stack trace
  192. */
  193. function logError(message, error = null, context = {}) {
  194. const contextStr =
  195. Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
  196. console.error(`[${timestamp()}] ERROR: ${message}${contextStr}`)
  197. if (error?.stack) {
  198. console.error(`[${timestamp()}] STACK: ${error.stack}`)
  199. }
  200. }
  201. /**
  202. * Debug mode flag - controlled by --verbose/-v CLI arg.
  203. * (Intentionally not controlled via env var to avoid accidental noisy logs.)
  204. */
  205. let DEBUG_MODE = false
  206. /**
  207. * Log a message with timestamp.
  208. *
  209. * By default, logs at INFO level.
  210. * When { verboseOnly: true }, only logs when DEBUG_MODE is enabled.
  211. */
  212. function logDebug(message, context = {}, { verboseOnly = false } = {}) {
  213. if (verboseOnly && !DEBUG_MODE) return
  214. const contextStr =
  215. Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
  216. const level = verboseOnly ? 'DEBUG' : 'INFO'
  217. console.log(`[${timestamp()}] ${level}: ${message}${contextStr}`)
  218. }
  219. // =============================================================================
  220. // RESUME FUNCTIONALITY
  221. // =============================================================================
  222. /**
  223. * Load previously successfully processed records from the success output file.
  224. * Returns a Set of recurly_account_codes that have been successfully processed.
  225. *
  226. * Only records in the SUCCESS file are skipped. Records in the errors file
  227. * (or not in any file) will be processed/re-attempted.
  228. *
  229. * @param {string} successOutputPath - Path to the success output CSV file
  230. * @returns {Promise<Set<string>>}
  231. */
  232. async function loadSuccessfullyProcessed(successOutputPath) {
  233. const processed = new Set()
  234. if (!fs.existsSync(successOutputPath)) {
  235. logDebug('No existing success file found, starting fresh', {
  236. successOutputPath,
  237. })
  238. return processed
  239. }
  240. logDebug('Loading previously successful records from success file', {
  241. successOutputPath,
  242. })
  243. return new Promise((resolve, reject) => {
  244. fs.createReadStream(successOutputPath)
  245. .pipe(
  246. csv.parse({
  247. columns: true,
  248. trim: true,
  249. skip_empty_lines: true,
  250. relax_column_count: true,
  251. relax_column_count_less: true,
  252. })
  253. )
  254. .on('data', row => {
  255. if (row.recurly_account_code) {
  256. processed.add(row.recurly_account_code)
  257. }
  258. })
  259. .on('end', () => {
  260. logDebug('Loaded previously successful records', {
  261. count: processed.size,
  262. })
  263. resolve(processed)
  264. })
  265. .on('error', err => {
  266. logError('Failed to read success file', err, { successOutputPath })
  267. reject(err)
  268. })
  269. })
  270. }
  271. /**
  272. * Helper to write a CSV row with proper escaping
  273. */
  274. // TODO: consider using a CSV library
  275. function formatCsvRow(columns, row) {
  276. const values = columns.map(col => {
  277. const val = row[col] ?? ''
  278. // Escape CSV values that contain commas, quotes, or newlines
  279. if (
  280. typeof val === 'string' &&
  281. (val.includes(',') || val.includes('"') || val.includes('\n'))
  282. ) {
  283. return `"${val.replace(/"/g, '""')}"`
  284. }
  285. return val
  286. })
  287. return values.join(',') + '\n'
  288. }
  289. /**
  290. * Create output writers for success and error files.
  291. *
  292. * Success file: Append-only, contains all successfully updated records
  293. * Errors file: Overwritten each run, contains only failures from this run
  294. *
  295. * @param {string} successPath - Path to the success output CSV file
  296. * @param {string} errorsPath - Path to the errors output CSV file
  297. * @param {boolean} restart - If true, truncate existing files
  298. * @returns {{ writeSuccess: (row: object) => void, writeError: (row: object) => void, close: () => Promise<void> }}
  299. */
  300. function createOutputWriters(
  301. successPath,
  302. errorsPath,
  303. restart = false,
  304. { enableSuccessFile = true } = {}
  305. ) {
  306. // Success file columns
  307. const successColumns = [
  308. 'recurly_account_code',
  309. 'target_stripe_account',
  310. 'stripe_customer_id',
  311. ]
  312. // Errors file columns (includes error message)
  313. const errorsColumns = [
  314. 'recurly_account_code',
  315. 'target_stripe_account',
  316. 'stripe_customer_id',
  317. 'error',
  318. ]
  319. // Success file: append mode (unless restart)
  320. // NOTE: In dry-run mode, we intentionally do NOT create or write to the success file,
  321. // because commit mode uses it for resume/skip behavior.
  322. const successStream = enableSuccessFile
  323. ? (() => {
  324. const successExists = fs.existsSync(successPath)
  325. const successFlags = restart ? 'w' : 'a'
  326. const stream = fs.createWriteStream(successPath, {
  327. flags: successFlags,
  328. })
  329. if (restart || !successExists) {
  330. stream.write(successColumns.join(',') + '\n')
  331. }
  332. return stream
  333. })()
  334. : null
  335. // Errors file: always overwrite (contains only this run's errors)
  336. const errorsStream = fs.createWriteStream(errorsPath, { flags: 'w' })
  337. errorsStream.write(errorsColumns.join(',') + '\n')
  338. function writeSuccess(row) {
  339. if (!successStream) return
  340. successStream.write(formatCsvRow(successColumns, row))
  341. }
  342. function writeError(row) {
  343. errorsStream.write(formatCsvRow(errorsColumns, row))
  344. }
  345. async function close() {
  346. if (successStream) successStream.end()
  347. errorsStream.end()
  348. const closers = [
  349. new Promise((resolve, reject) => {
  350. errorsStream.on('finish', resolve)
  351. errorsStream.on('error', reject)
  352. }),
  353. ]
  354. if (successStream) {
  355. closers.unshift(
  356. new Promise((resolve, reject) => {
  357. successStream.on('finish', resolve)
  358. successStream.on('error', reject)
  359. })
  360. )
  361. }
  362. await Promise.all(closers)
  363. }
  364. return { writeSuccess, writeError, close }
  365. }
  366. /**
  367. * Get the errors file path from the success file path
  368. */
  369. function getErrorsPath(successPath) {
  370. return successPath.replace(/\.csv$/, '_errors.csv')
  371. }
  372. /**
  373. * Get the stripe.json file path from the success file path (for dry-run mode)
  374. */
  375. function getStripeJsonPath(successPath) {
  376. return successPath.replace(/\.csv$/, '_stripe.json')
  377. }
  378. /**
  379. * Get the stripe_existing_fields.json file path from the success file path.
  380. */
  381. function getStripeExistingFieldsJsonPath(successPath) {
  382. return successPath.replace(/\.csv$/, '_stripe_existing_fields.json')
  383. }
  384. /**
  385. * Stream a JSON array to disk without holding it all in memory.
  386. */
  387. function createJsonArrayWriter(jsonPath) {
  388. const stream = fs.createWriteStream(jsonPath, { flags: 'w' })
  389. stream.write('[\n')
  390. let wroteAny = false
  391. function write(value) {
  392. const serialized = JSON.stringify(value, null, 2)
  393. if (wroteAny) stream.write(',\n')
  394. stream.write(serialized)
  395. wroteAny = true
  396. }
  397. async function close() {
  398. stream.write('\n]\n')
  399. stream.end()
  400. await new Promise((resolve, reject) => {
  401. stream.on('finish', resolve)
  402. stream.on('error', reject)
  403. })
  404. }
  405. return { write, close }
  406. }
  407. // =============================================================================
  408. // RATE LIMITING
  409. // =============================================================================
  410. // rate limiters - initialized in main()
  411. let rateLimiters
  412. // =============================================================================
  413. // DATA TRANSFORMATION
  414. // =============================================================================
  415. /**
  416. * Fetch Recurly account data for a given account code.
  417. *
  418. * @param {string} accountCode - The Recurly account code (Overleaf user ID)
  419. * @returns {Promise<Account>}
  420. */
  421. async function fetchRecurlyData(accountCode, context) {
  422. return await rateLimiters.requestWithRetries(
  423. 'recurly',
  424. () => recurlyClient.getAccount(`code-${accountCode}`),
  425. context
  426. )
  427. }
  428. /**
  429. * Fetch live Recurly subscriptions for an account and ensure there is at most one.
  430. *
  431. * Returns the subscription object, or null if no live subscription exists.
  432. * Throws if more than one live subscription exists.
  433. *
  434. * @param {string} accountCode - Recurly account code
  435. * @param {object} context - Logging context
  436. * @returns {Promise<object|null>}
  437. */
  438. async function fetchRecurlyActiveSubscription(accountCode, context) {
  439. const subscriptions = await rateLimiters.requestWithRetries(
  440. 'recurly',
  441. async () => {
  442. const pager = recurlyClient.listAccountSubscriptions(
  443. `code-${accountCode}`,
  444. {
  445. params: { state: 'live' },
  446. }
  447. )
  448. // we don't strictly need to fetch all subscriptions since we only
  449. // care if there is one or more than one, but this is an unlikely
  450. // edge case and knowing the actual number may be helpful for debugging, so we fetch them all
  451. const results = []
  452. for await (const subscription of pager.each()) {
  453. results.push(subscription)
  454. }
  455. return results
  456. },
  457. context
  458. )
  459. if (subscriptions.length > 1) {
  460. const subscriptionIds = subscriptions
  461. .map(subscription => subscription?.id)
  462. .filter(Boolean)
  463. throw new Error(
  464. `Expected at most one live Recurly subscription for account ${accountCode}, found ${subscriptions.length}${
  465. subscriptionIds.length > 0 ? ` (${subscriptionIds.join(', ')})` : ''
  466. }`
  467. )
  468. }
  469. return subscriptions[0] ?? null
  470. }
  471. /**
  472. * Fetch existing customer from the target Stripe account by ID.
  473. *
  474. * @param {Stripe} stripeClient - The Stripe client for the target account
  475. * @param {string} stripeCustomerId - The Stripe customer ID
  476. * @returns {Promise<Stripe.Customer>}
  477. * @throws {Error} If customer is not found or is deleted
  478. */
  479. async function fetchTargetStripeCustomer(
  480. stripeClient,
  481. stripeCustomerId,
  482. context
  483. ) {
  484. const customer = await rateLimiters.requestWithRetries(
  485. stripeClient.serviceName,
  486. () =>
  487. stripeClient.customers.retrieve(stripeCustomerId, {
  488. expand: ['subscriptions'],
  489. }),
  490. { ...context, stripeApi: 'customers.retrieve' }
  491. )
  492. if (customer.deleted) {
  493. throw new Error(`Stripe customer ${stripeCustomerId} has been deleted`)
  494. }
  495. return customer
  496. }
  497. /**
  498. * Query for other matching customers from the target Stripe account by ID.
  499. *
  500. * @param {Stripe} stripeClient - The Stripe client for the target account
  501. * @param {string} userId - The user id to query
  502. * @param {string} stripeCustomerId - The Stripe customer ID to exclude from results (if any)
  503. * @param {object} context - Context for logging and rate limiter identification
  504. * @returns {Promise<Stripe.Customer | null>}
  505. */
  506. async function fetchOtherStripeCustomerByUserId(
  507. stripeClient,
  508. userId,
  509. stripeCustomerId,
  510. context
  511. ) {
  512. const results = await rateLimiters.requestWithRetries(
  513. stripeClient.serviceName,
  514. () =>
  515. stripeClient.customers.search({
  516. query: `metadata['userId']:"${userId}"`,
  517. limit: 100,
  518. expand: ['data.subscriptions'],
  519. }),
  520. { ...context, stripeApi: 'customers.search' }
  521. )
  522. const matchingCustomers = results.data?.filter(
  523. customer => customer.id !== stripeCustomerId
  524. )
  525. if (matchingCustomers.length > 1) {
  526. throw new Error(
  527. `Multiple Stripe customers found with userId metadata "${userId}": ${matchingCustomers.map(c => c.id).join(', ')}`
  528. )
  529. }
  530. return matchingCustomers[0] || null
  531. }
  532. /**
  533. * Mark a Stripe customer as a duplicate of another customer.
  534. *
  535. * @param {Stripe} stripeClient
  536. * @param {string} stripeCustomerId
  537. * @param {string} recurlyAccountCode
  538. * @param {object} context
  539. * @returns {Promise<void>}
  540. */
  541. async function markCustomerAsDuplicate(
  542. stripeClient,
  543. stripeCustomerId,
  544. recurlyAccountCode,
  545. context
  546. ) {
  547. const email =
  548. Settings.duplicateStripeCustomerAccountEmail?.replace(
  549. '@',
  550. `+${stripeCustomerId}@`
  551. ) || ''
  552. await rateLimiters.requestWithRetries(
  553. stripeClient.serviceName,
  554. () =>
  555. stripeClient.customers.update(stripeCustomerId, {
  556. email,
  557. metadata: {
  558. userId: '',
  559. duplicateUserId: recurlyAccountCode,
  560. },
  561. }),
  562. { ...context, stripeApi: 'customers.update' }
  563. )
  564. }
  565. /**
  566. * Fetch existing customer's payment method from the target Stripe account by ID.
  567. *
  568. * @param {Stripe} stripeClient - The Stripe client for the target account
  569. * @param {string} stripeCustomerId - The Stripe customer ID
  570. * @returns {Promise<Stripe.PaymentMethod[]>}
  571. */
  572. async function fetchTargetStripeCustomerPaymentMethods(
  573. stripeClient,
  574. stripeCustomerId,
  575. context
  576. ) {
  577. const paymentMethods = await rateLimiters.requestWithRetries(
  578. stripeClient.serviceName,
  579. () => stripeClient.customers.listPaymentMethods(stripeCustomerId),
  580. { ...context, stripeApi: 'customers.listPaymentMethods' }
  581. )
  582. return paymentMethods.data
  583. }
  584. /**
  585. * Creates a Stripe Setup Intent to import a PayPal billing agreement.
  586. *
  587. * @param {Stripe} stripeClient - The Stripe client for the target account
  588. * @param {string} stripeCustomerId - The Stripe customer ID
  589. * @param {string} billingAgreementId - The PayPal billing agreement ID
  590. * @param {object} context - Logging context
  591. * @returns {Promise<Stripe.PaymentMethod>}
  592. * @throws {Error} If the setup intent fails or does not produce a payment method
  593. */
  594. async function createPayPalPaymentMethod(
  595. stripeClient,
  596. stripeCustomerId,
  597. billingAgreementId,
  598. context
  599. ) {
  600. logDebug(
  601. 'Creating PayPal setup intent',
  602. {
  603. ...context,
  604. step: 'create_paypal_setup_intent',
  605. },
  606. { verboseOnly: true }
  607. )
  608. const setupIntent = await rateLimiters.requestWithRetries(
  609. stripeClient.serviceName,
  610. () =>
  611. stripeClient.setupIntents.create({
  612. customer: stripeCustomerId,
  613. payment_method_types: ['paypal'],
  614. payment_method_data: {
  615. type: 'paypal',
  616. },
  617. payment_method_options: {
  618. paypal: {
  619. billing_agreement_id: billingAgreementId,
  620. },
  621. },
  622. confirm: true,
  623. usage: 'off_session',
  624. mandate_data: {
  625. customer_acceptance: {
  626. type: 'offline',
  627. },
  628. },
  629. return_url: `${Settings.siteUrl}/user/subscription`, // required for PayPal setup intents, but not actually used since we're confirming immediately
  630. expand: ['payment_method'],
  631. }),
  632. { ...context, stripeApi: 'setupIntents.create' }
  633. )
  634. if (setupIntent.status !== 'succeeded') {
  635. throw new Error(
  636. `PayPal setup intent ${setupIntent.id} has unexpected status: ${setupIntent.status}`
  637. )
  638. }
  639. if (!setupIntent.payment_method) {
  640. throw new Error(
  641. `PayPal setup intent ${setupIntent.id} succeeded but has no payment_method`
  642. )
  643. }
  644. logDebug(
  645. 'Successfully created PayPal setup intent',
  646. {
  647. ...context,
  648. setupIntentId: setupIntent.id,
  649. paymentMethodId: setupIntent.payment_method.id,
  650. },
  651. { verboseOnly: true }
  652. )
  653. // The setup intent returns the full payment method object, but we only need the ID
  654. // to set it as the default on the customer.
  655. return setupIntent.payment_method
  656. }
  657. /**
  658. * Determines the payment method to set on the Stripe customer.
  659. *
  660. * This handles both migrating a PayPal billing agreement and matching an existing
  661. * credit card payment method.
  662. *
  663. * @param {Stripe} stripeClient - The Stripe client for the target account
  664. * @param {string} stripeCustomerId - The Stripe customer ID
  665. * @param {object} billingInfo - Recurly billing info object
  666. * @param {object} address - The customer's address (used for PayPal country check)
  667. * @param {boolean} commit - Whether this is a dry-run or a commit
  668. * @param {object} context - Logging context
  669. * @returns {Promise<Stripe.PaymentMethod>}
  670. * @throws {Error} If the payment method cannot be determined or created
  671. */
  672. async function getPaymentMethod(
  673. stripeClient,
  674. stripeCustomerId,
  675. billingInfo,
  676. address,
  677. commit,
  678. context
  679. ) {
  680. if (billingInfo?.paymentMethod?.object === 'paypal_billing_agreement') {
  681. const addressCountry = address?.country
  682. if (
  683. addressCountry === 'CA' ||
  684. addressCountry === 'US' ||
  685. stripeClient.serviceName === 'stripe-us'
  686. ) {
  687. throw new Error(
  688. `PayPal billing agreement migration is not supported for ${addressCountry} customers`
  689. )
  690. }
  691. if (commit) {
  692. return await createPayPalPaymentMethod(
  693. stripeClient,
  694. stripeCustomerId,
  695. billingInfo.paymentMethod.billingAgreementId,
  696. context
  697. )
  698. } else {
  699. logDebug('DRY RUN: Would create PayPal setup intent', context, {
  700. verboseOnly: true,
  701. })
  702. // Return a placeholder for dry-run output
  703. return { id: 'pm_placeholder_paypal_dry_run', type: 'paypal' }
  704. }
  705. }
  706. const paymentMethods = await fetchTargetStripeCustomerPaymentMethods(
  707. stripeClient,
  708. stripeCustomerId,
  709. context
  710. )
  711. return coalesceOrThrowPaymentMethod(
  712. paymentMethods,
  713. stripeCustomerId,
  714. billingInfo
  715. )
  716. }
  717. /**
  718. * Replace a customer's tax IDs (delete any existing, then create the desired one).
  719. *
  720. * This makes re-runs more predictable for customers where a tax ID was created
  721. * before a later step failed.
  722. */
  723. async function replaceCustomerTaxIds(
  724. stripeClient,
  725. stripeCustomerId,
  726. { taxIdType, vatNumber },
  727. context
  728. ) {
  729. // Stripe customers can have multiple tax IDs. For this migration, we want a single
  730. // authoritative tax ID derived from Recurly, so we remove any existing ones first.
  731. const existingTaxIds = []
  732. let startingAfter
  733. while (true) {
  734. const page = await rateLimiters.requestWithRetries(
  735. stripeClient.serviceName,
  736. () =>
  737. stripeClient.customers.listTaxIds(stripeCustomerId, {
  738. limit: 100,
  739. ...(startingAfter ? { starting_after: startingAfter } : {}),
  740. }),
  741. { ...context, stripeApi: 'customers.listTaxIds' }
  742. )
  743. existingTaxIds.push(...page.data)
  744. if (!page.has_more || page.data.length === 0) break
  745. startingAfter = page.data[page.data.length - 1].id
  746. }
  747. if (existingTaxIds.length > 0) {
  748. logDebug(
  749. 'Deleting existing Stripe tax IDs before creating new one',
  750. {
  751. ...context,
  752. existingTaxIdCount: existingTaxIds.length,
  753. },
  754. { verboseOnly: true }
  755. )
  756. for (const taxId of existingTaxIds) {
  757. await rateLimiters.requestWithRetries(
  758. stripeClient.serviceName,
  759. () => stripeClient.customers.deleteTaxId(stripeCustomerId, taxId.id),
  760. { ...context, stripeApi: 'customers.deleteTaxId' }
  761. )
  762. }
  763. }
  764. try {
  765. return await rateLimiters.requestWithRetries(
  766. stripeClient.serviceName,
  767. () =>
  768. stripeClient.customers.createTaxId(stripeCustomerId, {
  769. type: taxIdType,
  770. value: vatNumber,
  771. }),
  772. { ...context, stripeApi: 'customers.createTaxId' }
  773. )
  774. } catch (error) {
  775. const parts = [
  776. `Failed to create Stripe tax ID (type=${taxIdType}, value=${vatNumber})`,
  777. ]
  778. if (error.code) parts.push(`code=${error.code}`)
  779. if (error.message) parts.push(error.message)
  780. const wrappedError = new Error(parts.join(': '))
  781. wrappedError.code = error.code
  782. wrappedError.type = error.type
  783. wrappedError.statusCode = error.statusCode
  784. throw wrappedError
  785. }
  786. }
  787. function isStripeTaxIdInvalidError(error) {
  788. if (!error) return false
  789. return error.code === 'tax_id_invalid'
  790. }
  791. const STRIPE_METADATA_MAX_ALT_EMAILS = 5
  792. // =============================================================================
  793. // MAIN PROCESSING
  794. // =============================================================================
  795. /**
  796. * Resolve the Stripe customer for a given Recurly account.
  797. *
  798. * Handles three cases:
  799. * 1. Another customer with matching userId metadata exists when no stripeCustomerId is provided → reuse it
  800. * 2. No stripeCustomerId provided → create a new customer (or placeholder in dry-run)
  801. * 3. stripeCustomerId provided → fetch the existing customer
  802. *
  803. * @param {object} params
  804. * @param {Stripe} params.stripeClient - Stripe SDK client for the target account
  805. * @param {string|null} params.stripeCustomerId - Stripe customer ID from the input CSV (may be empty)
  806. * @param {string} params.recurlyAccountCode - Recurly account code / Overleaf user ID
  807. * @param {object} params.account - Recurly account object (used for email on create)
  808. * @param {boolean} params.commit - Whether to actually create/fetch in Stripe
  809. * @param {object} params.context - Logging context
  810. * @param {object} params.stripeContext - Stripe-specific logging context
  811. * @returns {Promise<Stripe.Customer|object>} - The resolved Stripe customer object (or placeholder in dry-run)
  812. * @throws {Error} if there are multiple matching customers found
  813. */
  814. async function resolveStripeCustomer({
  815. stripeClient,
  816. stripeCustomerId,
  817. recurlyAccountCode,
  818. account,
  819. commit,
  820. context,
  821. stripeContext,
  822. }) {
  823. const otherMatchingCustomer = await fetchOtherStripeCustomerByUserId(
  824. stripeClient,
  825. recurlyAccountCode,
  826. stripeCustomerId,
  827. stripeContext
  828. )
  829. if (otherMatchingCustomer) {
  830. if (stripeCustomerId) {
  831. const otherCustomerPaymentMethods =
  832. await fetchTargetStripeCustomerPaymentMethods(
  833. stripeClient,
  834. otherMatchingCustomer.id,
  835. stripeContext
  836. )
  837. const isRecurlyPaymentMethodPaypal =
  838. account?.billingInfo?.paymentMethod?.object ===
  839. 'paypal_billing_agreement'
  840. const isRecurlyPaymentMethodManual = !account?.billingInfo?.paymentMethod // billing info may be missing for manually billed customers
  841. const hasMatchingPaymentMethod = otherCustomerPaymentMethods.some(
  842. method =>
  843. areStripeAndRecurlyCardDetailsEqual(
  844. method,
  845. account?.billingInfo?.paymentMethod
  846. )
  847. )
  848. if (
  849. isRecurlyPaymentMethodPaypal ||
  850. isRecurlyPaymentMethodManual ||
  851. hasMatchingPaymentMethod
  852. ) {
  853. logDebug(
  854. 'Found another Stripe customer with matching userId metadata, reusing',
  855. {
  856. ...context,
  857. nextStripeCustomerId: otherMatchingCustomer.id,
  858. },
  859. { verboseOnly: true }
  860. )
  861. if (commit) {
  862. await markCustomerAsDuplicate(
  863. stripeClient,
  864. stripeCustomerId,
  865. recurlyAccountCode,
  866. stripeContext
  867. )
  868. logDebug(
  869. 'Marked CSV customer as a duplicate of the existing Stripe customer',
  870. {
  871. ...context,
  872. nextStripeCustomerId: otherMatchingCustomer.id,
  873. },
  874. { verboseOnly: true }
  875. )
  876. } else {
  877. logDebug(
  878. 'DRY RUN: Would mark CSV customer as a duplicate of the existing Stripe customer',
  879. {
  880. ...context,
  881. nextStripeCustomerId: otherMatchingCustomer.id,
  882. step: 'mark_duplicate',
  883. },
  884. { verboseOnly: true }
  885. )
  886. }
  887. return otherMatchingCustomer
  888. } else {
  889. throw new Error(
  890. `Found another Stripe customer with matching userId metadata but no matching payment method: ${otherMatchingCustomer.id}`
  891. )
  892. }
  893. }
  894. logDebug(
  895. 'Found Stripe customer with matching userId metadata, reusing',
  896. { ...context, otherStripeCustomerId: otherMatchingCustomer.id },
  897. { verboseOnly: true }
  898. )
  899. return otherMatchingCustomer
  900. }
  901. if (!stripeCustomerId) {
  902. if (commit) {
  903. const newCustomer = await rateLimiters.requestWithRetries(
  904. stripeClient.serviceName,
  905. () =>
  906. stripeClient.customers.create({
  907. email: account.email,
  908. metadata: { userId: recurlyAccountCode },
  909. }),
  910. { ...stripeContext, stripeApi: 'customers.create' }
  911. )
  912. logDebug(
  913. 'Created new Stripe customer',
  914. { ...context, newStripeCustomerId: newCustomer.id },
  915. { verboseOnly: true }
  916. )
  917. return newCustomer
  918. }
  919. logDebug(
  920. 'DRY RUN: Would create new Stripe customer',
  921. { ...context, step: 'create_stripe_customer' },
  922. { verboseOnly: true }
  923. )
  924. return {
  925. id: 'cus_dry_run_new_customer_placeholder',
  926. metadata: { userId: recurlyAccountCode },
  927. }
  928. }
  929. logDebug(
  930. 'Fetching existing Stripe customer',
  931. { ...context, step: 'fetch_stripe_customer' },
  932. { verboseOnly: true }
  933. )
  934. const customer = await fetchTargetStripeCustomer(
  935. stripeClient,
  936. stripeCustomerId,
  937. stripeContext
  938. )
  939. logDebug(
  940. 'Resolved existing Stripe customer',
  941. { ...context, stripeEmail: customer.email, stripeName: customer.name },
  942. { verboseOnly: true }
  943. )
  944. return customer
  945. }
  946. /**
  947. * Compute the billing_details params for a Stripe payment method from Recurly billing info.
  948. *
  949. * @param {object} billingInfo - Recurly billing info object
  950. * @returns {object|null} - billing_details params, or null if there is nothing to set
  951. */
  952. function computePaymentMethodBillingDetails(billingInfo) {
  953. const name = normalizeName(billingInfo?.firstName, billingInfo?.lastName)
  954. const address = normalizeRecurlyAddressToStripe(billingInfo?.address)
  955. const details = {}
  956. if (name) details.name = name
  957. if (address) details.address = address
  958. return Object.keys(details).length > 0 ? details : null
  959. }
  960. /**
  961. * Update billing_details on a Stripe payment method with data from Recurly billing info.
  962. *
  963. * Used for manual-collection customers when billing info and account info differ:
  964. * the account info is written to the Stripe customer record, and the billing info
  965. * is copied to the payment method's billing_details.
  966. *
  967. * @param {Stripe} stripeClient
  968. * @param {string} paymentMethodId
  969. * @param {object} billingInfo - Recurly billing info object
  970. * @param {object} context
  971. * @returns {Promise<void>}
  972. */
  973. async function updatePaymentMethodBillingDetails(
  974. stripeClient,
  975. paymentMethodId,
  976. billingInfo,
  977. context
  978. ) {
  979. const billingDetails = computePaymentMethodBillingDetails(billingInfo)
  980. if (!billingDetails) {
  981. logDebug(
  982. 'No billing info details to copy to payment method billing_details',
  983. context,
  984. { verboseOnly: true }
  985. )
  986. return
  987. }
  988. logDebug(
  989. 'Updating payment method billing_details with Recurly billing info',
  990. {
  991. ...context,
  992. paymentMethodId,
  993. step: 'update_payment_method_billing_details',
  994. },
  995. { verboseOnly: true }
  996. )
  997. await rateLimiters.requestWithRetries(
  998. stripeClient.serviceName,
  999. () =>
  1000. stripeClient.paymentMethods.update(paymentMethodId, {
  1001. billing_details: billingDetails,
  1002. }),
  1003. { ...context, stripeApi: 'paymentMethods.update' }
  1004. )
  1005. logDebug(
  1006. 'Successfully updated payment method billing_details',
  1007. { ...context, paymentMethodId },
  1008. { verboseOnly: true }
  1009. )
  1010. }
  1011. /**
  1012. * Process a single customer row from the input CSV.
  1013. *
  1014. * Customers are expected to already exist in the target Stripe account
  1015. * (created via PAN import). This function updates them with additional
  1016. * data from Recurly.
  1017. *
  1018. * @param {object} row - CSV row with recurly_account_code, target_stripe_account, stripe_customer_id
  1019. * @param {number} rowNumber - The row number in the input file (for logging)
  1020. * @param {boolean} commit - Whether to actually update the customer
  1021. * @returns {Promise<object>} - Result row for output CSV
  1022. */
  1023. async function processCustomer(
  1024. row,
  1025. rowNumber,
  1026. commit,
  1027. { writeStripeExistingFields, forceInvalidTax = false } = {}
  1028. ) {
  1029. const {
  1030. recurly_account_code: recurlyAccountCode,
  1031. target_stripe_account: targetStripeAccount,
  1032. } = row
  1033. let stripeCustomerId = row.stripe_customer_id
  1034. const context = {
  1035. rowNumber,
  1036. recurlyAccountCode,
  1037. targetStripeAccount,
  1038. stripeCustomerId,
  1039. }
  1040. const stripeContext = {
  1041. rowNumber,
  1042. stripeCustomerId,
  1043. stripeAccount: targetStripeAccount,
  1044. }
  1045. const result = {
  1046. recurly_account_code: recurlyAccountCode,
  1047. target_stripe_account: targetStripeAccount,
  1048. stripe_customer_id: stripeCustomerId || '',
  1049. outcome: '', // 'updated', 'dry_run', or 'error'
  1050. error: '',
  1051. customerParams: null, // Stripe customer params (for dry-run output)
  1052. taxInfoPending: null, // Recurly VAT number if tax ID type couldn't be determined
  1053. }
  1054. try {
  1055. // Validate required fields
  1056. if (!recurlyAccountCode) {
  1057. throw new Error('Missing required field: recurly_account_code')
  1058. }
  1059. if (!targetStripeAccount) {
  1060. throw new Error('Missing required field: target_stripe_account')
  1061. }
  1062. // Get Stripe client for target account
  1063. logDebug(
  1064. 'Getting Stripe client',
  1065. { ...context, step: 'get_stripe_client' },
  1066. { verboseOnly: true }
  1067. )
  1068. // get Stripe client for the target account (strip 'stripe-' prefix if present)
  1069. const region = String(targetStripeAccount || '')
  1070. .trim()
  1071. .toLowerCase()
  1072. .replace(/^stripe-/, '')
  1073. const stripeClient = getRegionClient(region)
  1074. // Fetch Recurly data
  1075. logDebug(
  1076. 'Fetching Recurly data',
  1077. { ...context, step: 'fetch_recurly' },
  1078. { verboseOnly: true }
  1079. )
  1080. const account = await fetchRecurlyData(recurlyAccountCode, context)
  1081. logDebug(
  1082. 'Fetched Recurly account',
  1083. {
  1084. ...context,
  1085. email: account.email,
  1086. hasBillingInfo: !!account.billingInfo,
  1087. paymentMethod:
  1088. account.billingInfo?.paymentMethod?.object ===
  1089. 'paypal_billing_agreement'
  1090. ? 'paypal'
  1091. : account.billingInfo?.cardType || 'none',
  1092. account: sanitizeAccount(account),
  1093. },
  1094. { verboseOnly: true }
  1095. )
  1096. const existingCustomer = await resolveStripeCustomer({
  1097. stripeClient,
  1098. stripeCustomerId,
  1099. recurlyAccountCode,
  1100. account,
  1101. commit,
  1102. context,
  1103. stripeContext,
  1104. })
  1105. stripeCustomerId = existingCustomer.id
  1106. result.stripe_customer_id = stripeCustomerId || ''
  1107. stripeContext.stripeCustomerId = stripeCustomerId
  1108. context.stripeCustomerId = stripeCustomerId
  1109. if (existingCustomer.subscriptions?.data.length > 0) {
  1110. throw new Error(
  1111. `Stripe customer ${stripeCustomerId} already has ${existingCustomer.subscriptions?.data?.length} active subscription(s).`
  1112. )
  1113. }
  1114. // Resolve customer identity (name, address, company, VAT number), handling
  1115. // conflicts between billing info and account fields via the subscription's
  1116. // collection_method.
  1117. const {
  1118. name,
  1119. address,
  1120. companyName,
  1121. vatNumber,
  1122. billingInfoForPaymentMethod,
  1123. } = await resolveCustomerIdentity(account, async () => {
  1124. logWarn(
  1125. 'Conflict between billing info and account fields; fetching subscription collection method to resolve',
  1126. { ...context }
  1127. )
  1128. const subscription = await fetchRecurlyActiveSubscription(
  1129. recurlyAccountCode,
  1130. context
  1131. )
  1132. const cm = subscription?.collectionMethod || null
  1133. logDebug(
  1134. 'Resolved collection method for conflict',
  1135. { ...context, collectionMethod: cm },
  1136. { verboseOnly: true }
  1137. )
  1138. return cm
  1139. })
  1140. if (name === null && companyName === null) {
  1141. // This should not happen since we're handling all the known cases in resolveCustomerIdentity but just in case
  1142. throw new Error(
  1143. 'Unable to resolve customer name: both billing info and account fields are missing'
  1144. )
  1145. }
  1146. let taxIdType = null
  1147. let createdTaxId = null
  1148. let taxInfoPendingValue = null
  1149. // Determine VAT number tax ID type (if possible)
  1150. if (vatNumber) {
  1151. const preValidateFormat = !commit
  1152. const taxIdTypeResult = getTaxIdType(
  1153. address?.country,
  1154. vatNumber,
  1155. address?.postal_code,
  1156. preValidateFormat
  1157. )
  1158. taxIdType = taxIdTypeResult.type
  1159. const taxIdTypeFailureReason = taxIdTypeResult.reason
  1160. if (!address?.country) {
  1161. if (!forceInvalidTax) {
  1162. throw new Error(
  1163. `Unprocessable VAT number ${vatNumber} (no country): ${taxIdTypeFailureReason}`
  1164. )
  1165. }
  1166. logWarn('VAT number present but no country in address', {
  1167. ...context,
  1168. vatNumber,
  1169. reason: taxIdTypeFailureReason,
  1170. })
  1171. taxInfoPendingValue = vatNumber
  1172. } else if (!taxIdType) {
  1173. if (!forceInvalidTax) {
  1174. throw new Error(
  1175. `Unprocessable VAT number ${vatNumber} (failed getTaxIdType): ${taxIdTypeFailureReason}`
  1176. )
  1177. }
  1178. logWarn('Unable to determine tax id type for VAT number', {
  1179. ...context,
  1180. vatNumber,
  1181. country: address?.country,
  1182. postalCode: address?.postal_code,
  1183. reason: taxIdTypeFailureReason,
  1184. })
  1185. taxInfoPendingValue = vatNumber
  1186. } else {
  1187. logDebug(
  1188. 'Will create tax ID',
  1189. {
  1190. ...context,
  1191. vatNumber,
  1192. country: address?.country,
  1193. taxIdType,
  1194. },
  1195. { verboseOnly: true }
  1196. )
  1197. }
  1198. }
  1199. const shouldCreateTaxId = !!(vatNumber && taxIdType && !taxInfoPendingValue)
  1200. if (commit) {
  1201. // Create tax ID first (validate it works before updating customer)
  1202. if (shouldCreateTaxId) {
  1203. logDebug(
  1204. 'Creating tax ID',
  1205. {
  1206. ...context,
  1207. step: 'create_tax_id',
  1208. taxIdType,
  1209. vatNumber,
  1210. },
  1211. { verboseOnly: true }
  1212. )
  1213. // Note: if re-running for a customer where the vatNumber was previously present in Recurly
  1214. // but removed since the last run, this code will not erase that vatNumber from Stripe.
  1215. // unlikely to ever occur but worth noting
  1216. try {
  1217. createdTaxId = await replaceCustomerTaxIds(
  1218. stripeClient,
  1219. stripeCustomerId,
  1220. { taxIdType, vatNumber },
  1221. context
  1222. )
  1223. logDebug(
  1224. 'Successfully created tax ID',
  1225. {
  1226. ...context,
  1227. taxId: createdTaxId.id,
  1228. taxIdType: createdTaxId.type,
  1229. taxIdValue: createdTaxId.value,
  1230. },
  1231. { verboseOnly: true }
  1232. )
  1233. } catch (error) {
  1234. if (forceInvalidTax && isStripeTaxIdInvalidError(error)) {
  1235. logWarn(
  1236. 'Stripe rejected tax ID as invalid; continuing because --force-invalid-tax is enabled',
  1237. {
  1238. ...context,
  1239. vatNumber,
  1240. country: address?.country,
  1241. taxIdType,
  1242. error: error.message,
  1243. }
  1244. )
  1245. taxInfoPendingValue = vatNumber
  1246. } else {
  1247. throw error
  1248. }
  1249. }
  1250. }
  1251. }
  1252. // Transform Recurly data to Stripe customer update params
  1253. logDebug(
  1254. 'Transforming Recurly data to Stripe params',
  1255. {
  1256. ...context,
  1257. step: 'transform',
  1258. },
  1259. { verboseOnly: true }
  1260. )
  1261. const paymentMethod = await getPaymentMethod(
  1262. stripeClient,
  1263. stripeCustomerId,
  1264. account.billingInfo,
  1265. address,
  1266. commit,
  1267. stripeContext
  1268. )
  1269. /** @type {Record<string, string>} */
  1270. const metadata = {}
  1271. if (account.createdAt) {
  1272. metadata.recurlyCreatedAt = account.createdAt.toISOString()
  1273. }
  1274. if (taxInfoPendingValue) {
  1275. metadata.taxInfoPending = taxInfoPendingValue
  1276. } else {
  1277. metadata.taxInfoPending = ''
  1278. }
  1279. if (
  1280. existingCustomer.metadata?.recurlyAccountCode &&
  1281. existingCustomer.metadata?.recurlyAccountCode !== recurlyAccountCode
  1282. ) {
  1283. throw new Error(
  1284. `Existing Stripe customer has unexpected recurlyAccountCode: (expected) ${recurlyAccountCode} (actual) ${existingCustomer.metadata?.recurlyAccountCode}`
  1285. )
  1286. }
  1287. if (
  1288. existingCustomer.metadata?.userId &&
  1289. existingCustomer.metadata?.userId !== recurlyAccountCode
  1290. ) {
  1291. throw new Error(
  1292. `Existing Stripe customer has unexpected userId: (expected) ${recurlyAccountCode} (actual) ${existingCustomer.metadata?.userId}`
  1293. )
  1294. }
  1295. metadata.recurlyAccountCode = ''
  1296. metadata.userId = recurlyAccountCode
  1297. const { metadata: customFieldMetadata, counts: customFieldCounts } =
  1298. extractRecurlyCustomFieldMetadata(account)
  1299. if (Object.keys(customFieldMetadata).length > 0) {
  1300. Object.assign(metadata, customFieldMetadata)
  1301. }
  1302. const ccEmailList = ccEmailsToArray(account.ccEmails)
  1303. if (ccEmailList.length > STRIPE_METADATA_MAX_ALT_EMAILS) {
  1304. // this limit is arbitrary just to catch any extreme outliers
  1305. throw new Error(
  1306. `Customer has ${ccEmailList.length} ccEmails; max supported is ${STRIPE_METADATA_MAX_ALT_EMAILS}`
  1307. )
  1308. }
  1309. ccEmailList.forEach(email => {
  1310. if (email.length > 500) {
  1311. // The limit for account.email is 512 characters.
  1312. // assuming similar for additional_emails.cc but 500 is plenty
  1313. // as the longest ccEmails in Recurly is 179
  1314. throw new Error(
  1315. `Recurly ${recurlyAccountCode}: ccEmail ${email} exceeds the maximum length of 500 characters`
  1316. )
  1317. }
  1318. })
  1319. // if there are any ccEmails in Recurly or Stripe,
  1320. // then overwrite additional_emails.cc below with the Recurly value preserving any other fields
  1321. // that might exist in additional_emails in Stripe
  1322. const updateCCEmails =
  1323. ccEmailList.length > 0 ||
  1324. existingCustomer.additional_emails?.cc?.length > 0
  1325. result.customFieldCounts = customFieldCounts
  1326. /** @type {Stripe.CustomerUpdateParams} */
  1327. const customerParams = {
  1328. email: account.email,
  1329. name,
  1330. metadata,
  1331. ...(address ? { address } : {}),
  1332. ...(companyName ? { business_name: companyName } : {}),
  1333. ...(paymentMethod
  1334. ? { invoice_settings: { default_payment_method: paymentMethod.id } }
  1335. : {}),
  1336. ...(updateCCEmails
  1337. ? {
  1338. additional_emails: {
  1339. ...existingCustomer.additional_emails,
  1340. cc: ccEmailList,
  1341. },
  1342. }
  1343. : {}),
  1344. // Recurly docs say the field is tax_exempt but in the actual response is taxExempt
  1345. tax_exempt: account.taxExempt ? 'exempt' : 'none',
  1346. }
  1347. // If Stripe already has any of the fields we're about to set, and the value is
  1348. // different from what we'd set, warn and capture both desired and existing.
  1349. const differingFields = []
  1350. if (
  1351. customerParams?.name != null &&
  1352. normalizeComparableString(existingCustomer?.name) !== '' &&
  1353. normalizeComparableString(existingCustomer?.name) !==
  1354. normalizeComparableString(customerParams.name)
  1355. ) {
  1356. differingFields.push('name')
  1357. }
  1358. if (
  1359. customerParams?.business_name != null &&
  1360. normalizeComparableString(existingCustomer?.business_name) !== '' &&
  1361. normalizeComparableString(existingCustomer?.business_name) !==
  1362. normalizeComparableString(customerParams.business_name)
  1363. ) {
  1364. differingFields.push('business_name')
  1365. }
  1366. if (
  1367. customerParams?.address &&
  1368. hasAnyAddressValue(existingCustomer?.address) &&
  1369. !addressesEqual(existingCustomer.address, customerParams.address)
  1370. ) {
  1371. differingFields.push('address')
  1372. }
  1373. if (differingFields.length > 0) {
  1374. logWarn('Stripe customer already has differing fields set', {
  1375. ...context,
  1376. fields: differingFields,
  1377. })
  1378. if (writeStripeExistingFields) {
  1379. writeStripeExistingFields({
  1380. recurly_account_code: recurlyAccountCode,
  1381. stripe_account: targetStripeAccount,
  1382. stripe_customer_id: stripeCustomerId,
  1383. recurly: {
  1384. ...(differingFields.includes('name')
  1385. ? { name: customerParams.name }
  1386. : {}),
  1387. ...(differingFields.includes('business_name')
  1388. ? { business_name: customerParams.business_name }
  1389. : {}),
  1390. ...(differingFields.includes('address')
  1391. ? { address: customerParams.address }
  1392. : {}),
  1393. },
  1394. stripe: {
  1395. ...(differingFields.includes('name')
  1396. ? { name: existingCustomer.name }
  1397. : {}),
  1398. ...(differingFields.includes('business_name')
  1399. ? { business_name: existingCustomer.business_name }
  1400. : {}),
  1401. ...(differingFields.includes('address')
  1402. ? { address: existingCustomer.address }
  1403. : {}),
  1404. },
  1405. })
  1406. }
  1407. }
  1408. logDebug(
  1409. 'Transformed customer params',
  1410. {
  1411. ...context,
  1412. params: customerParams,
  1413. },
  1414. { verboseOnly: true }
  1415. )
  1416. if (commit) {
  1417. // Update customer in Stripe
  1418. logDebug(
  1419. 'Updating Stripe customer',
  1420. {
  1421. ...context,
  1422. step: 'update_customer',
  1423. },
  1424. { verboseOnly: true }
  1425. )
  1426. await rateLimiters.requestWithRetries(
  1427. stripeClient.serviceName,
  1428. () => stripeClient.customers.update(stripeCustomerId, customerParams),
  1429. { ...stripeContext, stripeApi: 'customers.update' }
  1430. )
  1431. // For manual-collection customers where billing info and account info differ,
  1432. // copy the billing info to the payment method's billing_details.
  1433. //
  1434. // Note: If re-running this script for a given customer,
  1435. // then if by some chance the payment collection method has changed from automatic to manual since the last run,
  1436. // then we would potentially leave billing details in an inconsistent state
  1437. // I think this is vanishingly unlikely to be an issue in practice and a tricky problem to solve
  1438. // Highlighting here just in case.
  1439. if (billingInfoForPaymentMethod && paymentMethod) {
  1440. await updatePaymentMethodBillingDetails(
  1441. stripeClient,
  1442. paymentMethod.id,
  1443. billingInfoForPaymentMethod,
  1444. { ...context, step: 'update_payment_method_billing_details' }
  1445. )
  1446. }
  1447. result.outcome = 'updated'
  1448. logDebug(
  1449. 'Successfully updated Stripe customer',
  1450. {
  1451. ...context,
  1452. },
  1453. { verboseOnly: true }
  1454. )
  1455. } else {
  1456. result.outcome = 'dry_run'
  1457. result.customerParams = {
  1458. ...customerParams,
  1459. // Include tax ID info in dry-run output for review
  1460. _taxId: shouldCreateTaxId
  1461. ? {
  1462. type: taxIdType,
  1463. value: vatNumber,
  1464. country: address?.country,
  1465. createdTaxId,
  1466. }
  1467. : null,
  1468. _isPaypal: paymentMethod?.type === 'paypal',
  1469. _targetStripeCustomerId: stripeCustomerId,
  1470. // Include payment method billing_details update for dry-run review
  1471. _paymentMethodBillingDetailsUpdate:
  1472. billingInfoForPaymentMethod && paymentMethod
  1473. ? {
  1474. paymentMethodId: paymentMethod.id,
  1475. billingDetails: computePaymentMethodBillingDetails(
  1476. billingInfoForPaymentMethod
  1477. ),
  1478. }
  1479. : null,
  1480. }
  1481. logDebug(
  1482. 'DRY RUN: Would update Stripe customer',
  1483. {
  1484. ...context,
  1485. email: account.email,
  1486. taxId: vatNumber ? { type: taxIdType, value: vatNumber } : null,
  1487. },
  1488. { verboseOnly: true }
  1489. )
  1490. }
  1491. if (taxInfoPendingValue) {
  1492. result.taxInfoPending = taxInfoPendingValue
  1493. }
  1494. } catch (error) {
  1495. result.outcome = 'error'
  1496. // Include more error details
  1497. const errorDetails = []
  1498. errorDetails.push(error.message)
  1499. if (error.code) errorDetails.push(`code=${error.code}`)
  1500. if (error.type) errorDetails.push(`type=${error.type}`)
  1501. if (error.statusCode) errorDetails.push(`statusCode=${error.statusCode}`)
  1502. result.error = errorDetails.join('; ')
  1503. logError('Failed to process customer', error, context)
  1504. }
  1505. return result
  1506. }
  1507. function usage() {
  1508. console.error('Script to migrate Recurly customers to Stripe')
  1509. console.error('')
  1510. console.error('RESUMABLE: This script can be re-run after failures.')
  1511. console.error(
  1512. ' It will skip successfully processed records and retry failures.'
  1513. )
  1514. console.error('')
  1515. console.error('Usage:')
  1516. console.error(
  1517. ' node scripts/recurly/migrate_recurly_customers_to_stripe.mjs [options]'
  1518. )
  1519. console.error('')
  1520. console.error('Options:')
  1521. console.error(' --input, -i <file> Path to input CSV file (required)')
  1522. console.error(
  1523. ' --output, -o <file> Path to SUCCESS output CSV file (required)'
  1524. )
  1525. console.error(
  1526. ' --limit, -l <n> Limit number of records processed (default: no limit)'
  1527. )
  1528. console.error(
  1529. ' --concurrency, -c <n> Number of customers to process concurrently (default: 10)'
  1530. )
  1531. console.error(
  1532. ' --recurly-rate-limit <n> Requests per second for Recurly (default: 10)'
  1533. )
  1534. console.error(
  1535. ' --recurly-api-retries <n> Number of retries on Recurly 429s (default: 5)'
  1536. )
  1537. console.error(
  1538. ' --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)'
  1539. )
  1540. console.error(
  1541. ' --stripe-rate-limit <n> Requests per second for Stripe (default: 50)'
  1542. )
  1543. console.error(
  1544. ' --stripe-api-retries <n> Number of retries on Stripe 429s (default: 5)'
  1545. )
  1546. console.error(
  1547. ' --stripe-retry-delay-ms <n> Delay between Stripe retries in ms (default: 1000)'
  1548. )
  1549. console.error(
  1550. ' --force-invalid-tax Allow VAT numbers that cannot be mapped to a tax ID type (default: false)'
  1551. )
  1552. console.error(
  1553. ' --commit Actually update customers in Stripe (default: dry-run)'
  1554. )
  1555. console.error(' --verbose, -v Enable debug logging')
  1556. console.error(
  1557. ' --restart Ignore existing output files and start fresh'
  1558. )
  1559. console.error('')
  1560. console.error('Input CSV format:')
  1561. console.error(
  1562. ' recurly_account_code,target_stripe_account,stripe_customer_id'
  1563. )
  1564. console.error('')
  1565. console.error('Output files:')
  1566. console.error(' SUCCESS file (--output): Successfully updated customers')
  1567. console.error(
  1568. ' Format: recurly_account_code,target_stripe_account,stripe_customer_id'
  1569. )
  1570. console.error('')
  1571. console.error(
  1572. ' ERRORS file (<output>_errors.csv): Records that failed THIS run'
  1573. )
  1574. console.error(
  1575. ' Format: recurly_account_code,target_stripe_account,stripe_customer_id,error'
  1576. )
  1577. console.error('')
  1578. console.error(
  1579. ' STRIPE JSON (<output>_stripe.json): Dry-run only - customer params that would be used for update'
  1580. )
  1581. console.error('')
  1582. console.error(
  1583. ' STRIPE EXISTING FIELDS (<output>_stripe_existing_fields.json): Customers where Stripe already had name/address/business_name set'
  1584. )
  1585. console.error(
  1586. ' Written in both dry-run and commit modes (for auditing before overwriting fields)'
  1587. )
  1588. console.error('')
  1589. console.error('Resume behavior:')
  1590. console.error(' - Records in SUCCESS file are SKIPPED (already done)')
  1591. console.error(
  1592. ' - Records in ERRORS file are RE-PROCESSED (retried each run)'
  1593. )
  1594. console.error(
  1595. ' - After each run, ERRORS file contains ONLY failures from that run'
  1596. )
  1597. console.error(
  1598. ' - Use --restart to force processing all records from scratch'
  1599. )
  1600. }
  1601. function parseConcurrency(value, { defaultValue = 10 } = {}) {
  1602. if (value === undefined || value === null || value === '') {
  1603. return defaultValue
  1604. }
  1605. const parsed = Number(value)
  1606. if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
  1607. throw new Error(
  1608. `Invalid --concurrency value: ${value}. Expected a positive integer.`
  1609. )
  1610. }
  1611. return parsed
  1612. }
  1613. function parseRateLimit(value, { defaultValue, name }) {
  1614. if (value === undefined || value === null || value === '') {
  1615. return defaultValue
  1616. }
  1617. const parsed = Number(value)
  1618. if (!Number.isFinite(parsed) || parsed <= 0) {
  1619. throw new Error(
  1620. `Invalid --${name} value: ${value}. Expected a positive number.`
  1621. )
  1622. }
  1623. return parsed
  1624. }
  1625. function parseNonNegativeInt(value, { defaultValue, name }) {
  1626. if (value === undefined || value === null || value === '') {
  1627. return defaultValue
  1628. }
  1629. const parsed = Number(value)
  1630. if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
  1631. throw new Error(
  1632. `Invalid --${name} value: ${value}. Expected a non-negative integer.`
  1633. )
  1634. }
  1635. return parsed
  1636. }
  1637. function parseArgs() {
  1638. return minimist(process.argv.slice(2), {
  1639. alias: {
  1640. i: 'input',
  1641. o: 'output',
  1642. h: 'help',
  1643. v: 'verbose',
  1644. c: 'concurrency',
  1645. l: 'limit',
  1646. },
  1647. string: [
  1648. 'input',
  1649. 'output',
  1650. 'limit',
  1651. 'recurly-rate-limit',
  1652. 'recurly-api-retries',
  1653. 'recurly-retry-delay-ms',
  1654. 'stripe-rate-limit',
  1655. 'stripe-api-retries',
  1656. 'stripe-retry-delay-ms',
  1657. ],
  1658. boolean: ['commit', 'verbose', 'help', 'restart', 'force-invalid-tax'],
  1659. default: {
  1660. commit: false,
  1661. verbose: false,
  1662. restart: false,
  1663. 'force-invalid-tax': false,
  1664. concurrency: 10,
  1665. 'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
  1666. 'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
  1667. 'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
  1668. 'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
  1669. 'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
  1670. 'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
  1671. },
  1672. })
  1673. }
  1674. async function main(trackProgress) {
  1675. const startTime = new Date()
  1676. const args = parseArgs()
  1677. const {
  1678. input: inputPath,
  1679. output: successOutputPath,
  1680. commit,
  1681. verbose,
  1682. help,
  1683. restart,
  1684. 'force-invalid-tax': forceInvalidTax,
  1685. concurrency: concurrencyRaw,
  1686. limit: limitRaw,
  1687. 'recurly-rate-limit': recurlyRateLimitRaw,
  1688. 'recurly-api-retries': recurlyApiRetriesRaw,
  1689. 'recurly-retry-delay-ms': recurlyRetryDelayMsRaw,
  1690. 'stripe-rate-limit': stripeRateLimitRaw,
  1691. 'stripe-api-retries': stripeApiRetriesRaw,
  1692. 'stripe-retry-delay-ms': stripeRetryDelayMsRaw,
  1693. } = args
  1694. let concurrency
  1695. let recurlyRateLimit
  1696. let recurlyApiRetriesValue
  1697. let recurlyRetryDelayMsValue
  1698. let stripeRateLimitPerSecond
  1699. let stripeApiRetriesValue
  1700. let stripeRetryDelayMsValue
  1701. let limit
  1702. try {
  1703. concurrency = parseConcurrency(concurrencyRaw, { defaultValue: 10 })
  1704. limit = parseNonNegativeInt(limitRaw, {
  1705. defaultValue: null,
  1706. name: 'limit',
  1707. })
  1708. recurlyRateLimit = parseRateLimit(recurlyRateLimitRaw, {
  1709. defaultValue: DEFAULT_RECURLY_RATE_LIMIT,
  1710. name: 'recurly-rate-limit',
  1711. })
  1712. recurlyApiRetriesValue = parseNonNegativeInt(recurlyApiRetriesRaw, {
  1713. defaultValue: DEFAULT_RECURLY_API_RETRIES,
  1714. name: 'recurly-api-retries',
  1715. })
  1716. recurlyRetryDelayMsValue = parseNonNegativeInt(recurlyRetryDelayMsRaw, {
  1717. defaultValue: DEFAULT_RECURLY_RETRY_DELAY_MS,
  1718. name: 'recurly-retry-delay-ms',
  1719. })
  1720. stripeRateLimitPerSecond = parseRateLimit(stripeRateLimitRaw, {
  1721. defaultValue: DEFAULT_STRIPE_RATE_LIMIT,
  1722. name: 'stripe-rate-limit',
  1723. })
  1724. stripeApiRetriesValue = parseNonNegativeInt(stripeApiRetriesRaw, {
  1725. defaultValue: DEFAULT_STRIPE_API_RETRIES,
  1726. name: 'stripe-api-retries',
  1727. })
  1728. stripeRetryDelayMsValue = parseNonNegativeInt(stripeRetryDelayMsRaw, {
  1729. defaultValue: DEFAULT_STRIPE_RETRY_DELAY_MS,
  1730. name: 'stripe-retry-delay-ms',
  1731. })
  1732. } catch (error) {
  1733. logError(error.message)
  1734. usage()
  1735. process.exit(1)
  1736. }
  1737. // initialize rate limiters
  1738. rateLimiters = createRateLimitedApiWrappers({
  1739. recurlyRateLimit,
  1740. recurlyApiRetries: recurlyApiRetriesValue,
  1741. recurlyRetryDelayMs: recurlyRetryDelayMsValue,
  1742. stripeRateLimit: stripeRateLimitPerSecond,
  1743. stripeApiRetries: stripeApiRetriesValue,
  1744. stripeRetryDelayMs: stripeRetryDelayMsValue,
  1745. logDebug,
  1746. logWarn,
  1747. })
  1748. // Set DEBUG_MODE only from CLI arg (--verbose/-v)
  1749. DEBUG_MODE = !!verbose
  1750. if (help || !inputPath || !successOutputPath) {
  1751. usage()
  1752. process.exit(help ? 0 : 1)
  1753. }
  1754. const errorsOutputPath = getErrorsPath(successOutputPath)
  1755. const stripeJsonPath = getStripeJsonPath(successOutputPath)
  1756. const stripeExistingFieldsJsonPath =
  1757. getStripeExistingFieldsJsonPath(successOutputPath)
  1758. const mode = commit ? 'COMMIT MODE' : 'DRY RUN MODE'
  1759. logDebug(`Starting migration in ${mode}`, {
  1760. inputPath,
  1761. successOutputPath,
  1762. errorsOutputPath,
  1763. ...(commit ? {} : { stripeJsonPath }),
  1764. stripeExistingFieldsJsonPath,
  1765. concurrency,
  1766. recurlyRateLimit,
  1767. recurlyApiRetries: recurlyApiRetriesValue,
  1768. recurlyRetryDelayMs: recurlyRetryDelayMsValue,
  1769. stripeRateLimit: stripeRateLimitPerSecond,
  1770. stripeApiRetries: stripeApiRetriesValue,
  1771. stripeRetryDelayMs: stripeRetryDelayMsValue,
  1772. forceInvalidTax,
  1773. ...(limit != null ? { limit } : {}),
  1774. })
  1775. await trackProgress(`Starting migration in ${mode}`)
  1776. // Load previously successfully processed records (for resume functionality).
  1777. // IMPORTANT: commit mode uses the success file for resume/skip behavior.
  1778. // Dry-run mode does NOT read the success file.
  1779. let previouslyProcessed = new Set()
  1780. if (commit && !restart) {
  1781. try {
  1782. previouslyProcessed = await loadSuccessfullyProcessed(successOutputPath)
  1783. if (previouslyProcessed.size > 0) {
  1784. logDebug(
  1785. `Will skip ${previouslyProcessed.size} previously successful records`
  1786. )
  1787. await trackProgress(
  1788. `Resuming: will skip ${previouslyProcessed.size} previously successful records`
  1789. )
  1790. }
  1791. } catch (err) {
  1792. logWarn('Could not load previous success file, starting fresh', {
  1793. error: err.message,
  1794. })
  1795. }
  1796. } else if (restart) {
  1797. logDebug('Restart flag set, ignoring existing output files')
  1798. await trackProgress('Restart mode: processing all records from scratch')
  1799. }
  1800. // Create output writers.
  1801. // In dry-run mode, we intentionally do NOT write to the success file, because
  1802. // commit mode uses it for resume/skip behavior.
  1803. const {
  1804. writeSuccess,
  1805. writeError,
  1806. close: closeOutputs,
  1807. } = commit
  1808. ? createOutputWriters(successOutputPath, errorsOutputPath, restart, {
  1809. enableSuccessFile: true,
  1810. })
  1811. : createOutputWriters(successOutputPath, errorsOutputPath, true, {
  1812. enableSuccessFile: false,
  1813. })
  1814. // For dry-run mode, collect Stripe customer params to write to JSON
  1815. const stripeCustomerParams = []
  1816. // Records where Stripe already had name/address/business_name set
  1817. const stripeExistingFieldsWriter = createJsonArrayWriter(
  1818. stripeExistingFieldsJsonPath
  1819. )
  1820. try {
  1821. // Statistics
  1822. let totalInInput = 0
  1823. let processedThisRun = 0
  1824. let queuedThisRun = 0
  1825. let skippedPreviouslyProcessed = 0
  1826. let updatedCount = 0
  1827. let errorCount = 0
  1828. let dryRunCount = 0
  1829. let taxInfoPendingCount = 0
  1830. const customFieldStats = {
  1831. channel: 0,
  1832. Industry: 0,
  1833. ol_sales_person: 0,
  1834. MigratedfromFreeAgent: 0,
  1835. noCustomFields: 0,
  1836. }
  1837. // Track errors for final summary (just the account codes, not full results - memory efficient)
  1838. const errorAccountCodes = []
  1839. logDebug('Beginning to process input file', { inputPath })
  1840. // Process input CSV - true streaming (no collecting results in memory)
  1841. const inputStream = fs.createReadStream(inputPath)
  1842. const parser = csv.parse({
  1843. columns: true,
  1844. trim: true,
  1845. bom: true,
  1846. skip_empty_lines: true,
  1847. relax_column_count: true,
  1848. relax_column_count_less: true,
  1849. })
  1850. inputStream.pipe(parser)
  1851. const queue = new PQueue({ concurrency })
  1852. const maxQueueSize = concurrency
  1853. let lastCompletedRowNumber = 0
  1854. let limitReached = false
  1855. let rowNumber = 0
  1856. try {
  1857. for await (const row of parser) {
  1858. rowNumber++
  1859. totalInInput++
  1860. const thisRowNumber = rowNumber
  1861. const accountCode = row.recurly_account_code
  1862. // Check if already successfully processed in a previous run
  1863. if (previouslyProcessed.has(accountCode)) {
  1864. skippedPreviouslyProcessed++
  1865. logDebug(
  1866. 'Skipping previously successful record',
  1867. {
  1868. rowNumber: thisRowNumber,
  1869. accountCode,
  1870. },
  1871. { verboseOnly: true }
  1872. )
  1873. continue
  1874. }
  1875. if (limit != null && queuedThisRun >= limit) {
  1876. limitReached = true
  1877. logDebug('Record limit reached, stopping input processing', {
  1878. limit,
  1879. queuedThisRun,
  1880. rowNumber: thisRowNumber,
  1881. })
  1882. break
  1883. }
  1884. if (queue.size >= maxQueueSize) {
  1885. await queue.onSizeLessThan(maxQueueSize)
  1886. }
  1887. queuedThisRun++
  1888. queue.add(async () => {
  1889. let result
  1890. try {
  1891. result = await processCustomer(row, thisRowNumber, commit, {
  1892. writeStripeExistingFields: stripeExistingFieldsWriter.write,
  1893. forceInvalidTax,
  1894. })
  1895. } catch (error) {
  1896. result = {
  1897. ...row,
  1898. outcome: 'error',
  1899. error: error?.message || String(error),
  1900. }
  1901. logError('Unhandled error while processing customer', error, {
  1902. rowNumber: thisRowNumber,
  1903. accountCode,
  1904. })
  1905. }
  1906. processedThisRun++
  1907. lastCompletedRowNumber = thisRowNumber
  1908. if (result.customFieldCounts) {
  1909. for (const [field, count] of Object.entries(
  1910. result.customFieldCounts
  1911. )) {
  1912. if (customFieldStats[field] != null) {
  1913. customFieldStats[field] += count
  1914. }
  1915. }
  1916. }
  1917. if (result.taxInfoPending != null) {
  1918. taxInfoPendingCount++
  1919. }
  1920. // Write to appropriate output file based on outcome
  1921. if (result.outcome === 'error') {
  1922. writeError(result)
  1923. errorCount++
  1924. errorAccountCodes.push(accountCode)
  1925. } else {
  1926. writeSuccess(result)
  1927. // Update statistics and collect dry-run data
  1928. if (result.outcome === 'updated') {
  1929. updatedCount++
  1930. } else if (result.outcome === 'dry_run') {
  1931. dryRunCount++
  1932. // Collect customer params for stripe.json output
  1933. if (result.customerParams) {
  1934. stripeCustomerParams.push({
  1935. recurly_account_code: result.recurly_account_code,
  1936. target_stripe_account: result.target_stripe_account,
  1937. customerParams: result.customerParams,
  1938. })
  1939. }
  1940. }
  1941. }
  1942. // Progress update every 1000 customers (or 100 in debug mode)
  1943. const progressInterval = DEBUG_MODE ? 100 : 1000
  1944. if (processedThisRun % progressInterval === 0) {
  1945. const rateLimiterStats = rateLimiters.getRateLimiterStats()
  1946. const progress = {
  1947. rowNumber: lastCompletedRowNumber,
  1948. processedThisRun,
  1949. updated: updatedCount,
  1950. dryRun: dryRunCount,
  1951. taxInfoPending: taxInfoPendingCount,
  1952. errors: errorCount,
  1953. skippedPrevious: skippedPreviouslyProcessed,
  1954. recurlyRate: rateLimiterStats.recurly.currentRate,
  1955. stripeRate: rateLimiterStats.stripe.currentRate,
  1956. }
  1957. logDebug('Progress update', progress)
  1958. await trackProgress(
  1959. `Progress: row ${lastCompletedRowNumber}, ${processedThisRun} processed this run, ${errorCount} errors`
  1960. )
  1961. }
  1962. })
  1963. }
  1964. } finally {
  1965. await queue.onIdle()
  1966. }
  1967. if (limitReached) {
  1968. await trackProgress(
  1969. `Limit reached (${limit}). Stopped reading input; waiting for in-flight records to finish.`
  1970. )
  1971. }
  1972. // Write stripe.json file in dry-run mode
  1973. if (!commit && stripeCustomerParams.length > 0) {
  1974. await fs.promises.writeFile(
  1975. stripeJsonPath,
  1976. JSON.stringify(stripeCustomerParams, null, 2)
  1977. )
  1978. logDebug(
  1979. `Wrote ${stripeCustomerParams.length} customer params to ${stripeJsonPath}`
  1980. )
  1981. }
  1982. // Final summary
  1983. const endTime = new Date()
  1984. const durationMs = endTime.getTime() - startTime.getTime()
  1985. const durationTotalSeconds = Math.floor(durationMs / 1000)
  1986. const durationHours = Math.floor(durationTotalSeconds / 3600)
  1987. const durationMinutes = Math.floor((durationTotalSeconds % 3600) / 60)
  1988. const durationSeconds = durationTotalSeconds % 60
  1989. const durationHms =
  1990. String(durationHours).padStart(2, '0') +
  1991. ':' +
  1992. String(durationMinutes).padStart(2, '0') +
  1993. ':' +
  1994. String(durationSeconds).padStart(2, '0')
  1995. const totalSuccessful = commit
  1996. ? previouslyProcessed.size + updatedCount
  1997. : previouslyProcessed.size
  1998. const finalRateLimiterStats = rateLimiters.getRateLimiterStats()
  1999. await trackProgress('=== FINAL SUMMARY ===')
  2000. await trackProgress(`Start time: ${startTime.toISOString()}`)
  2001. await trackProgress(`End time: ${endTime.toISOString()}`)
  2002. await trackProgress(`Total runtime: ${durationHms}`)
  2003. await trackProgress('CLI parameters:')
  2004. await trackProgress(` - input: ${inputPath}`)
  2005. await trackProgress(` - output: ${successOutputPath}`)
  2006. await trackProgress(` - commit: ${commit}`)
  2007. await trackProgress(` - verbose: ${verbose}`)
  2008. await trackProgress(` - restart: ${restart}`)
  2009. await trackProgress(` - limit: ${limit != null ? limit : 'none'}`)
  2010. await trackProgress(` - concurrency: ${concurrency}`)
  2011. await trackProgress(` - recurly-rate-limit: ${recurlyRateLimit}`)
  2012. await trackProgress(` - recurly-api-retries: ${recurlyApiRetriesValue}`)
  2013. await trackProgress(
  2014. ` - recurly-retry-delay-ms: ${recurlyRetryDelayMsValue}`
  2015. )
  2016. await trackProgress(` - stripe-rate-limit: ${stripeRateLimitPerSecond}`)
  2017. await trackProgress(` - stripe-api-retries: ${stripeApiRetriesValue}`)
  2018. await trackProgress(` - stripe-retry-delay-ms: ${stripeRetryDelayMsValue}`)
  2019. await trackProgress(` - force-invalid-tax: ${forceInvalidTax}`)
  2020. await trackProgress(`Input file total rows: ${totalInInput}`)
  2021. await trackProgress(
  2022. `Previously successful (skipped): ${skippedPreviouslyProcessed}`
  2023. )
  2024. await trackProgress(`Processed this run: ${processedThisRun}`)
  2025. await trackProgress(
  2026. ` - ${commit ? 'Updated' : 'Would update'}: ${commit ? updatedCount : dryRunCount}`
  2027. )
  2028. await trackProgress(` - Tax info pending: ${taxInfoPendingCount}`)
  2029. await trackProgress(` - Errors: ${errorCount}`)
  2030. await trackProgress('')
  2031. await trackProgress('Custom fields summary (Recurly -> Stripe metadata):')
  2032. for (const fieldName of RECURLY_CUSTOM_FIELD_NAMES) {
  2033. await trackProgress(
  2034. ` - ${fieldName}: ${customFieldStats[fieldName] || 0}`
  2035. )
  2036. }
  2037. await trackProgress(
  2038. ` - No custom fields: ${customFieldStats.noCustomFields}`
  2039. )
  2040. await trackProgress('')
  2041. if (commit) {
  2042. await trackProgress(
  2043. `Success file: ${successOutputPath} (${totalSuccessful} records)`
  2044. )
  2045. } else {
  2046. await trackProgress(
  2047. `Success file: ${successOutputPath} (not modified in dry-run mode)`
  2048. )
  2049. }
  2050. await trackProgress(
  2051. `Errors file: ${errorsOutputPath} (${errorCount} records)`
  2052. )
  2053. await trackProgress(
  2054. `API calls - Recurly: ${finalRateLimiterStats.recurly.totalRequests}, Stripe: ${finalRateLimiterStats.stripe.totalRequests}`
  2055. )
  2056. if (!commit && dryRunCount > 0) {
  2057. await trackProgress('')
  2058. await trackProgress(
  2059. `Stripe params file: ${stripeJsonPath} (${stripeCustomerParams.length} records)`
  2060. )
  2061. await trackProgress(
  2062. 'To actually update customers, run the script with --commit flag'
  2063. )
  2064. logDebug('Dry-run params file written', {
  2065. stripeJsonPath,
  2066. records: stripeCustomerParams.length,
  2067. })
  2068. }
  2069. await trackProgress(
  2070. `Stripe existing fields file: ${stripeExistingFieldsJsonPath}`
  2071. )
  2072. // Log error account codes for easy reference
  2073. if (errorCount > 0) {
  2074. logWarn(`${errorCount} records failed and are in the errors file.`)
  2075. logWarn('Failed account codes:', {
  2076. first20: errorAccountCodes.slice(0, 20),
  2077. totalErrors: errorAccountCodes.length,
  2078. })
  2079. await trackProgress('')
  2080. await trackProgress(
  2081. `${errorCount} records failed. Re-run the script to retry them.`
  2082. )
  2083. await trackProgress(
  2084. `Failed accounts (first 20): ${errorAccountCodes.slice(0, 20).join(', ')}`
  2085. )
  2086. }
  2087. // Success/warning based on errors
  2088. if (errorCount === 0) {
  2089. logDebug('Migration completed successfully', { mode })
  2090. await trackProgress(`Migration completed successfully in ${mode}`)
  2091. // If no errors and errors file exists but is empty (just header), note that
  2092. if (fs.existsSync(errorsOutputPath)) {
  2093. await trackProgress(
  2094. `Errors file is empty (header only) - all records processed successfully!`
  2095. )
  2096. }
  2097. } else {
  2098. logWarn('Migration completed with errors', { mode, errorCount })
  2099. await trackProgress(
  2100. `Migration completed with ${errorCount} errors in ${mode}`
  2101. )
  2102. }
  2103. // Return exit code based on whether there were errors
  2104. return errorCount === 0 ? 0 : 1
  2105. } finally {
  2106. const results = await Promise.allSettled([
  2107. closeOutputs(),
  2108. stripeExistingFieldsWriter.close(),
  2109. ])
  2110. for (const result of results) {
  2111. if (result.status === 'rejected') {
  2112. logWarn('Failed to close output stream', {
  2113. error: result.reason?.message || String(result.reason),
  2114. })
  2115. }
  2116. }
  2117. }
  2118. }
  2119. // Execute the script using the runner
  2120. try {
  2121. const exitCode = await scriptRunner(main)
  2122. process.exit(exitCode ?? 0)
  2123. } catch (error) {
  2124. logError('Script failed with unhandled error', error)
  2125. process.exit(1)
  2126. }