calculate_taxes.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. #!/usr/bin/env node
  2. /**
  3. * Calculate Stripe taxes for addresses in a CSV
  4. *
  5. * This script reads a CSV file and calls the Stripe tax calculation API for each
  6. * address. By default, only US/CA addresses are processed.
  7. *
  8. * ⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!
  9. *
  10. * Usage:
  11. * node scripts/stripe/calculate_taxes.mjs [OPTIONS] INPUT-FILE
  12. *
  13. * Options:
  14. * --output PATH Output file path (default: /tmp/tax_output_<timestamp>.csv)
  15. * Use '-' to write to stdout
  16. * --concurrency, -c N Number of rows to process concurrently (default: 10)
  17. * --countries CODES Comma-separated country codes to process (default: no filter)
  18. * --rate-limit N Requests per second for Stripe (default: 50)
  19. * --api-retries N Number of retries on Stripe 429s (default: 5)
  20. * --retry-delay-ms N Delay between Stripe retries in ms (default: 1000)
  21. * --help Show a help message
  22. *
  23. * CSV Input Format:
  24. * The CSV must contain columns: user_id, billing_country, billing_postal_code, billing_address1, billing_address2, billing_city, billing_state
  25. * Optional columns: plan_code
  26. *
  27. * Output:
  28. * Writes a CSV with:
  29. * - user_id
  30. * - status (success, skipped_unsupported_country, skipped_missing_postal_code, invalid_address, api_error)
  31. * - stripe_tax_breakdown_amount
  32. * - stripe_tax_breakdown_taxability_reason
  33. * - stripe_tax_breakdown_taxable_amount
  34. *
  35. * Examples:
  36. * node scripts/stripe/calculate_taxes.mjs cohort.csv
  37. * node scripts/stripe/calculate_taxes.mjs --output results.csv cohort.csv
  38. * node scripts/stripe/calculate_taxes.mjs --countries US,CA,GB cohort.csv
  39. */
  40. import fs from 'node:fs'
  41. import path from 'node:path'
  42. import * as csv from 'csv'
  43. import minimist from 'minimist'
  44. import PQueue from 'p-queue'
  45. import { z } from '../../app/src/infrastructure/Validation.mjs'
  46. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  47. import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
  48. import {
  49. createRateLimitedApiWrappers,
  50. DEFAULT_STRIPE_RATE_LIMIT,
  51. DEFAULT_STRIPE_API_RETRIES,
  52. DEFAULT_STRIPE_RETRY_DELAY_MS,
  53. } from './RateLimiter.mjs'
  54. /**
  55. * @import { ReadStream } from 'node:fs'
  56. * @import { Parser } from 'csv-parse'
  57. * @import { Stringifier } from 'csv-stringify'
  58. */
  59. const DEFAULT_CONCURRENCY = 10
  60. const preloadedProductMetadata = new Map()
  61. const AMOUNTS = {
  62. collaborator: 2100,
  63. 'collaborator-annual': 19900,
  64. collaborator_free_trial_7_days: 2100,
  65. professional: 4200,
  66. 'professional-annual': 39900,
  67. professional_free_trial_7_days: 4200,
  68. student: 1000,
  69. 'student-annual': 9800,
  70. student_free_trial_7_days: 1000,
  71. }
  72. /**
  73. * Print usage information to stderr
  74. */
  75. function usage() {
  76. console.error(`Usage: node scripts/stripe/calculate_taxes.mjs [OPTIONS] INPUT-FILE
  77. Calculate Stripe taxes for addresses.
  78. ⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!
  79. Options:
  80. --output PATH Output file path (default: /tmp/tax_output_<timestamp>.csv)
  81. Use '-' to write to stdout
  82. --concurrency N Number of rows to process concurrently (default: ${DEFAULT_CONCURRENCY})
  83. --countries CODES Comma-separated country codes to process (default: no filter)
  84. --rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
  85. --api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
  86. --retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
  87. --help Show this help message
  88. Output Fields:
  89. - user_id
  90. - status (success, skipped_unsupported_country, skipped_missing_postal_code, invalid_address, api_error)
  91. - stripe_tax_breakdown_amount
  92. - stripe_tax_breakdown_taxability_reason
  93. - stripe_tax_breakdown_taxable_amount
  94. See the source file header for detailed documentation.
  95. `)
  96. }
  97. // rate limiters - initialized in main()
  98. let rateLimiters
  99. /**
  100. * Main script entry point
  101. * @param {function(string): Promise<void>} trackProgress - Function to log progress messages
  102. */
  103. async function main(trackProgress) {
  104. const opts = parseArgs()
  105. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  106. const outputFile = opts.output ?? `/tmp/tax_output_${timestamp}.csv`
  107. // initialize rate limiters
  108. rateLimiters = createRateLimitedApiWrappers({
  109. rateLimit: opts.rateLimit,
  110. apiRetries: opts.apiRetries,
  111. retryDelayMs: opts.retryDelayMs,
  112. })
  113. await trackProgress('Starting Stripe tax calculation')
  114. await trackProgress(
  115. '⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!'
  116. )
  117. await trackProgress(
  118. `Run mode: concurrency=${opts.concurrency}, Stripe rate limit=${opts.rateLimit}/s`
  119. )
  120. if (opts.countries.size > 0) {
  121. await trackProgress(
  122. `Country filter applied: Only processing addresses from ${Array.from(opts.countries).join(', ')}`
  123. )
  124. } else {
  125. await trackProgress(
  126. 'No country filter applied: Processing addresses from all countries'
  127. )
  128. }
  129. await trackProgress('Populating product metadata...')
  130. await preloadProductMetadata('uk')
  131. await preloadProductMetadata('us')
  132. await trackProgress('Product metadata populated')
  133. const inputStream = fs.createReadStream(opts.inputFile)
  134. const csvReader = getCsvReader(inputStream)
  135. await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
  136. let csvWriter = null
  137. let processedCount = 0
  138. let apiCalls = 0
  139. const queue = new PQueue({ concurrency: opts.concurrency })
  140. const maxQueueSize = opts.concurrency
  141. try {
  142. for await (const record of csvReader) {
  143. // Initialize writer on first record when we know the input columns
  144. if (!csvWriter) {
  145. csvWriter = getCsvWriter(outputFile)
  146. }
  147. // throttle input if queue is full
  148. if (queue.size >= maxQueueSize) {
  149. await queue.onSizeLessThan(maxQueueSize)
  150. }
  151. queue.add(async () => {
  152. processedCount++
  153. try {
  154. let taxResult
  155. const addressResult = buildCustomerAddress(record, opts.countries)
  156. if (addressResult.status !== 'ok') {
  157. taxResult = {
  158. status: addressResult.status,
  159. ...emptyTaxFields(),
  160. }
  161. } else {
  162. // Call Stripe API
  163. taxResult = await calculateTax(
  164. addressResult.address,
  165. record.plan_code
  166. )
  167. apiCalls++
  168. }
  169. csvWriter.write({
  170. user_id: record.user_id ?? '',
  171. ...taxResult,
  172. })
  173. if (processedCount % 10 === 0) {
  174. await trackProgress(
  175. `Processed ${processedCount} rows (${apiCalls} API calls)`
  176. )
  177. }
  178. } catch (err) {
  179. console.log(err)
  180. await trackProgress(
  181. `Error processing row ${processedCount}: ${err.message}`
  182. )
  183. csvWriter.write({
  184. user_id: record.user_id ?? '',
  185. status: 'api_error',
  186. ...emptyTaxFields(),
  187. })
  188. }
  189. })
  190. }
  191. } finally {
  192. // wait for all queued tasks to complete
  193. await queue.onIdle()
  194. }
  195. await trackProgress(
  196. `🎉 Script completed! Total: ${processedCount} rows, ${apiCalls} API calls`
  197. )
  198. csvWriter.end()
  199. }
  200. /**
  201. * Build a Stripe customer address from a CSV record
  202. * @param {Record<string, unknown>} record
  203. * @param {Set<string>} allowedCountries - Set of country codes to process
  204. * @returns {{status: 'ok', address: object} | {status: 'skipped_unsupported_country'} | {status: 'skipped_missing_postal_code'}}
  205. */
  206. function buildCustomerAddress(record, allowedCountries) {
  207. const country = record.billing_country.trim().toUpperCase()
  208. if (allowedCountries.size > 0 && !allowedCountries.has(country)) {
  209. return { status: 'skipped_unsupported_country' }
  210. }
  211. const address = {
  212. country,
  213. }
  214. if (['US', 'CA'].includes(country)) {
  215. const postalCode = normalizePostalCode(country, record.billing_postal_code)
  216. if (postalCode) {
  217. address.postal_code = postalCode
  218. } else {
  219. return { status: 'skipped_missing_postal_code' }
  220. }
  221. }
  222. const maybeSet = (key, value) => {
  223. if (value === undefined || value === null) return
  224. const str = value.toString().trim()
  225. if (!str) return
  226. address[key] = str
  227. }
  228. maybeSet('postal_code', record.billing_postal_code)
  229. maybeSet('line1', record.billing_address1)
  230. maybeSet('line2', record.billing_address2)
  231. maybeSet('city', record.billing_city)
  232. maybeSet('state', record.billing_state)
  233. return { status: 'ok', address }
  234. }
  235. /**
  236. * Normalize a postal code to a string suitable for Stripe.
  237. * - US: Pads 4-digit ZIPs to 5 digits.
  238. * - CA: Uppercases and formats as "A1A 1A1" when possible.
  239. * @param {'US' | 'CA'} country
  240. * @param {unknown} value
  241. * @returns {string}
  242. */
  243. function normalizePostalCode(country, value) {
  244. if (!value) return ''
  245. const trimmed = value.toString().trim()
  246. if (!trimmed) return ''
  247. if (country === 'US') {
  248. if (trimmed.length === 4) return `0${trimmed}`
  249. return trimmed
  250. }
  251. const compact = trimmed.replace(/[^0-9a-z]/gi, '').toUpperCase()
  252. if (!compact) return ''
  253. if (compact.length === 6) {
  254. return `${compact.slice(0, 3)} ${compact.slice(3)}`
  255. }
  256. return compact
  257. }
  258. /**
  259. * Preload product metadata for a given region to avoid redundant API calls during tax calculations
  260. * @param {'us' | 'uk'} region
  261. * @returns {Promise<void>}
  262. */
  263. async function preloadProductMetadata(region) {
  264. if (preloadedProductMetadata.has(region)) return
  265. const stripeClient = getRegionClient(region)
  266. const products = await rateLimiters.requestWithRetries(
  267. stripeClient.serviceName,
  268. () =>
  269. stripeClient.stripe.products.list({
  270. active: true,
  271. limit: 100,
  272. }),
  273. { operation: 'products.list', region: stripeClient.serviceName }
  274. )
  275. const results = new Map()
  276. for (const product of products.data) {
  277. if (!product.metadata?.plan_code) continue
  278. results.set(product.metadata?.plan_code, product.id)
  279. }
  280. preloadedProductMetadata.set(region, results)
  281. }
  282. /**
  283. * Get a CSV parser configured for input
  284. * @param {ReadStream | NodeJS.ReadableStream} inputStream - The input stream to parse
  285. * @returns {Parser} The configured CSV parser
  286. */
  287. function getCsvReader(inputStream) {
  288. const parser = csv.parse({
  289. columns: true,
  290. })
  291. inputStream.pipe(parser)
  292. return parser
  293. }
  294. /**
  295. * Get a CSV stringifier configured for output
  296. * @param {string} outputFile - The output file path to write to, or '-' for stdout
  297. * @returns {Stringifier} The configured CSV stringifier
  298. */
  299. function getCsvWriter(outputFile) {
  300. let outputStream
  301. if (outputFile === '-') {
  302. outputStream = process.stdout
  303. } else {
  304. fs.mkdirSync(path.dirname(outputFile), { recursive: true })
  305. outputStream = fs.createWriteStream(outputFile)
  306. }
  307. const taxFields = [
  308. 'user_id',
  309. 'status',
  310. 'stripe_tax_breakdown_taxability_reason',
  311. 'stripe_tax_breakdown_amount',
  312. 'stripe_tax_breakdown_taxable_amount',
  313. ]
  314. const writer = csv.stringify({
  315. columns: taxFields,
  316. header: true,
  317. })
  318. writer.on('error', err => {
  319. console.error(err)
  320. process.exit(1)
  321. })
  322. writer.pipe(outputStream)
  323. return writer
  324. }
  325. /**
  326. * Calculate tax for an address using Stripe API
  327. * @param {{country: string, postal_code: string, line1?: string, line2?: string, city?: string, state?: string}} customerAddress
  328. * @param {string} planCode
  329. * @returns {Promise<Object>}
  330. */
  331. async function calculateTax(customerAddress, planCode) {
  332. const region = ['US', 'CA'].includes(customerAddress.country) ? 'us' : 'uk'
  333. const stripeClient = getRegionClient(region)
  334. const productId = preloadedProductMetadata.get(region)?.get(planCode)
  335. const amount = AMOUNTS[planCode] || AMOUNTS.collaborator
  336. let calculation
  337. try {
  338. calculation = await rateLimiters.requestWithRetries(
  339. stripeClient.serviceName,
  340. () =>
  341. stripeClient.stripe.tax.calculations.create({
  342. currency: 'USD',
  343. line_items: [
  344. {
  345. amount,
  346. product: productId,
  347. quantity: 1,
  348. reference: 'tax_calculation',
  349. },
  350. ],
  351. customer_details: {
  352. address: {
  353. ...customerAddress,
  354. },
  355. address_source: 'billing',
  356. },
  357. expand: ['line_items'],
  358. }),
  359. { operation: 'tax.calculations.create', region: stripeClient.serviceName }
  360. )
  361. } catch (err) {
  362. if (err.code === 'customer_tax_location_invalid') {
  363. return {
  364. status: 'invalid_address',
  365. ...emptyTaxFields(),
  366. }
  367. }
  368. throw err
  369. }
  370. // extract and aggregate taxes applied
  371. const result = (calculation.tax_breakdown || []).reduce(
  372. (acc, taxBreakdown) => {
  373. if (taxBreakdown?.amount > 0) {
  374. acc.stripe_tax_breakdown_amount += taxBreakdown?.amount ?? 0
  375. const taxabilityReason = taxBreakdown?.taxability_reason ?? ''
  376. if (taxabilityReason) {
  377. acc.stripe_tax_breakdown_taxability_reason +=
  378. acc.stripe_tax_breakdown_taxability_reason
  379. ? `;${taxabilityReason}`
  380. : taxabilityReason
  381. }
  382. acc.stripe_tax_breakdown_taxable_amount +=
  383. taxBreakdown?.taxable_amount ?? 0
  384. }
  385. return acc
  386. },
  387. emptyTaxFields()
  388. )
  389. return {
  390. status: 'success',
  391. ...result,
  392. }
  393. }
  394. /**
  395. * Return an object with all tax fields set to empty strings
  396. * @returns {Object} Object with empty tax fields
  397. */
  398. function emptyTaxFields() {
  399. return {
  400. stripe_tax_breakdown_amount: 0,
  401. stripe_tax_breakdown_taxability_reason: '',
  402. stripe_tax_breakdown_taxable_amount: 0,
  403. }
  404. }
  405. /**
  406. * Parse command line arguments
  407. * @returns {{inputFile: string, output: string | undefined, concurrency: number, countries: Set<string>, rateLimit: number, apiRetries: number, retryDelayMs: number}} Parsed options
  408. */
  409. function parseArgs() {
  410. const args = minimist(process.argv.slice(2), {
  411. string: [
  412. 'output',
  413. 'concurrency',
  414. 'countries',
  415. 'rate-limit',
  416. 'api-retries',
  417. 'retry-delay-ms',
  418. ],
  419. boolean: ['help'],
  420. alias: { c: 'concurrency' },
  421. default: {
  422. concurrency: DEFAULT_CONCURRENCY,
  423. 'rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
  424. 'api-retries': DEFAULT_STRIPE_API_RETRIES,
  425. 'retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
  426. },
  427. })
  428. if (args.help) {
  429. usage()
  430. process.exit(0)
  431. }
  432. const inputFile = args._[0]
  433. if (!inputFile) {
  434. console.error('Input file is required')
  435. usage()
  436. process.exit(1)
  437. }
  438. const paramsSchema = z.object({
  439. output: z.string().optional(),
  440. concurrency: z.number().int().positive(),
  441. countries: z.string().optional(),
  442. rateLimit: z.number().positive(),
  443. apiRetries: z.number().int().nonnegative(),
  444. retryDelayMs: z.number().int().nonnegative(),
  445. inputFile: z.string(),
  446. })
  447. let parsed
  448. try {
  449. parsed = paramsSchema.parse({
  450. output: args.output,
  451. concurrency: Number(args.concurrency),
  452. countries: args.countries,
  453. rateLimit: Number(args['rate-limit']),
  454. apiRetries: Number(args['api-retries']),
  455. retryDelayMs: Number(args['retry-delay-ms']),
  456. inputFile,
  457. })
  458. } catch (err) {
  459. console.error(`Invalid parameters: ${err.message}`)
  460. usage()
  461. process.exit(1)
  462. }
  463. // Parse countries into a Set, defaulting to no filter
  464. const countrySet = new Set(
  465. (parsed.countries || '')
  466. .split(',')
  467. .map(c => c.trim().toUpperCase())
  468. .filter(c => c.length > 0)
  469. )
  470. return {
  471. inputFile: parsed.inputFile,
  472. output: parsed.output,
  473. concurrency: parsed.concurrency,
  474. countries: countrySet,
  475. rateLimit: parsed.rateLimit,
  476. apiRetries: parsed.apiRetries,
  477. retryDelayMs: parsed.retryDelayMs,
  478. }
  479. }
  480. try {
  481. await scriptRunner(main)
  482. process.exit(0)
  483. } catch (error) {
  484. console.error(error)
  485. process.exit(1)
  486. }