| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- #!/usr/bin/env node
- /**
- * Calculate Stripe taxes for addresses in a CSV
- *
- * This script reads a CSV file and calls the Stripe tax calculation API for each
- * address. By default, only US/CA addresses are processed.
- *
- * ⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!
- *
- * Usage:
- * node scripts/stripe/calculate_taxes.mjs [OPTIONS] INPUT-FILE
- *
- * Options:
- * --output PATH Output file path (default: /tmp/tax_output_<timestamp>.csv)
- * Use '-' to write to stdout
- * --concurrency, -c N Number of rows to process concurrently (default: 10)
- * --countries CODES Comma-separated country codes to process (default: no filter)
- * --rate-limit N Requests per second for Stripe (default: 50)
- * --api-retries N Number of retries on Stripe 429s (default: 5)
- * --retry-delay-ms N Delay between Stripe retries in ms (default: 1000)
- * --help Show a help message
- *
- * CSV Input Format:
- * The CSV must contain columns: user_id, billing_country, billing_postal_code, billing_address1, billing_address2, billing_city, billing_state
- * Optional columns: plan_code
- *
- * Output:
- * Writes a CSV with:
- * - user_id
- * - status (success, skipped_unsupported_country, skipped_missing_postal_code, invalid_address, api_error)
- * - stripe_tax_breakdown_amount
- * - stripe_tax_breakdown_taxability_reason
- * - stripe_tax_breakdown_taxable_amount
- *
- * Examples:
- * node scripts/stripe/calculate_taxes.mjs cohort.csv
- * node scripts/stripe/calculate_taxes.mjs --output results.csv cohort.csv
- * node scripts/stripe/calculate_taxes.mjs --countries US,CA,GB cohort.csv
- */
- import fs from 'node:fs'
- import path from 'node:path'
- import * as csv from 'csv'
- import minimist from 'minimist'
- import PQueue from 'p-queue'
- import { z } from '../../app/src/infrastructure/Validation.mjs'
- import { scriptRunner } from '../lib/ScriptRunner.mjs'
- import { getRegionClient } from '../../modules/subscriptions/app/src/StripeClient.mjs'
- import {
- createRateLimitedApiWrappers,
- DEFAULT_STRIPE_RATE_LIMIT,
- DEFAULT_STRIPE_API_RETRIES,
- DEFAULT_STRIPE_RETRY_DELAY_MS,
- } from './RateLimiter.mjs'
- /**
- * @import { ReadStream } from 'node:fs'
- * @import { Parser } from 'csv-parse'
- * @import { Stringifier } from 'csv-stringify'
- */
- const DEFAULT_CONCURRENCY = 10
- const preloadedProductMetadata = new Map()
- const AMOUNTS = {
- collaborator: 2100,
- 'collaborator-annual': 19900,
- collaborator_free_trial_7_days: 2100,
- professional: 4200,
- 'professional-annual': 39900,
- professional_free_trial_7_days: 4200,
- student: 1000,
- 'student-annual': 9800,
- student_free_trial_7_days: 1000,
- }
- /**
- * Print usage information to stderr
- */
- function usage() {
- console.error(`Usage: node scripts/stripe/calculate_taxes.mjs [OPTIONS] INPUT-FILE
- Calculate Stripe taxes for addresses.
- ⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!
- Options:
- --output PATH Output file path (default: /tmp/tax_output_<timestamp>.csv)
- Use '-' to write to stdout
- --concurrency N Number of rows to process concurrently (default: ${DEFAULT_CONCURRENCY})
- --countries CODES Comma-separated country codes to process (default: no filter)
- --rate-limit N Requests per second for Stripe (default: ${DEFAULT_STRIPE_RATE_LIMIT})
- --api-retries N Number of retries on Stripe 429s (default: ${DEFAULT_STRIPE_API_RETRIES})
- --retry-delay-ms N Delay between Stripe retries in ms (default: ${DEFAULT_STRIPE_RETRY_DELAY_MS})
- --help Show this help message
- Output Fields:
- - user_id
- - status (success, skipped_unsupported_country, skipped_missing_postal_code, invalid_address, api_error)
- - stripe_tax_breakdown_amount
- - stripe_tax_breakdown_taxability_reason
- - stripe_tax_breakdown_taxable_amount
- See the source file header for detailed documentation.
- `)
- }
- // rate limiters - initialized in main()
- let rateLimiters
- /**
- * Main script entry point
- * @param {function(string): Promise<void>} trackProgress - Function to log progress messages
- */
- async function main(trackProgress) {
- const opts = parseArgs()
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
- const outputFile = opts.output ?? `/tmp/tax_output_${timestamp}.csv`
- // initialize rate limiters
- rateLimiters = createRateLimitedApiWrappers({
- rateLimit: opts.rateLimit,
- apiRetries: opts.apiRetries,
- retryDelayMs: opts.retryDelayMs,
- })
- await trackProgress('Starting Stripe tax calculation')
- await trackProgress(
- '⚠️ This script calls an API endpoint that incurs a charge per call. Please be conscientious about using this!'
- )
- await trackProgress(
- `Run mode: concurrency=${opts.concurrency}, Stripe rate limit=${opts.rateLimit}/s`
- )
- if (opts.countries.size > 0) {
- await trackProgress(
- `Country filter applied: Only processing addresses from ${Array.from(opts.countries).join(', ')}`
- )
- } else {
- await trackProgress(
- 'No country filter applied: Processing addresses from all countries'
- )
- }
- await trackProgress('Populating product metadata...')
- await preloadProductMetadata('uk')
- await preloadProductMetadata('us')
- await trackProgress('Product metadata populated')
- const inputStream = fs.createReadStream(opts.inputFile)
- const csvReader = getCsvReader(inputStream)
- await trackProgress(`Output: ${outputFile === '-' ? 'stdout' : outputFile}`)
- let csvWriter = null
- let processedCount = 0
- let apiCalls = 0
- const queue = new PQueue({ concurrency: opts.concurrency })
- const maxQueueSize = opts.concurrency
- try {
- for await (const record of csvReader) {
- // Initialize writer on first record when we know the input columns
- if (!csvWriter) {
- csvWriter = getCsvWriter(outputFile)
- }
- // throttle input if queue is full
- if (queue.size >= maxQueueSize) {
- await queue.onSizeLessThan(maxQueueSize)
- }
- queue.add(async () => {
- processedCount++
- try {
- let taxResult
- const addressResult = buildCustomerAddress(record, opts.countries)
- if (addressResult.status !== 'ok') {
- taxResult = {
- status: addressResult.status,
- ...emptyTaxFields(),
- }
- } else {
- // Call Stripe API
- taxResult = await calculateTax(
- addressResult.address,
- record.plan_code
- )
- apiCalls++
- }
- csvWriter.write({
- user_id: record.user_id ?? '',
- ...taxResult,
- })
- if (processedCount % 10 === 0) {
- await trackProgress(
- `Processed ${processedCount} rows (${apiCalls} API calls)`
- )
- }
- } catch (err) {
- console.log(err)
- await trackProgress(
- `Error processing row ${processedCount}: ${err.message}`
- )
- csvWriter.write({
- user_id: record.user_id ?? '',
- status: 'api_error',
- ...emptyTaxFields(),
- })
- }
- })
- }
- } finally {
- // wait for all queued tasks to complete
- await queue.onIdle()
- }
- await trackProgress(
- `🎉 Script completed! Total: ${processedCount} rows, ${apiCalls} API calls`
- )
- csvWriter.end()
- }
- /**
- * Build a Stripe customer address from a CSV record
- * @param {Record<string, unknown>} record
- * @param {Set<string>} allowedCountries - Set of country codes to process
- * @returns {{status: 'ok', address: object} | {status: 'skipped_unsupported_country'} | {status: 'skipped_missing_postal_code'}}
- */
- function buildCustomerAddress(record, allowedCountries) {
- const country = record.billing_country.trim().toUpperCase()
- if (allowedCountries.size > 0 && !allowedCountries.has(country)) {
- return { status: 'skipped_unsupported_country' }
- }
- const address = {
- country,
- }
- if (['US', 'CA'].includes(country)) {
- const postalCode = normalizePostalCode(country, record.billing_postal_code)
- if (postalCode) {
- address.postal_code = postalCode
- } else {
- return { status: 'skipped_missing_postal_code' }
- }
- }
- const maybeSet = (key, value) => {
- if (value === undefined || value === null) return
- const str = value.toString().trim()
- if (!str) return
- address[key] = str
- }
- maybeSet('postal_code', record.billing_postal_code)
- maybeSet('line1', record.billing_address1)
- maybeSet('line2', record.billing_address2)
- maybeSet('city', record.billing_city)
- maybeSet('state', record.billing_state)
- return { status: 'ok', address }
- }
- /**
- * Normalize a postal code to a string suitable for Stripe.
- * - US: Pads 4-digit ZIPs to 5 digits.
- * - CA: Uppercases and formats as "A1A 1A1" when possible.
- * @param {'US' | 'CA'} country
- * @param {unknown} value
- * @returns {string}
- */
- function normalizePostalCode(country, value) {
- if (!value) return ''
- const trimmed = value.toString().trim()
- if (!trimmed) return ''
- if (country === 'US') {
- if (trimmed.length === 4) return `0${trimmed}`
- return trimmed
- }
- const compact = trimmed.replace(/[^0-9a-z]/gi, '').toUpperCase()
- if (!compact) return ''
- if (compact.length === 6) {
- return `${compact.slice(0, 3)} ${compact.slice(3)}`
- }
- return compact
- }
- /**
- * Preload product metadata for a given region to avoid redundant API calls during tax calculations
- * @param {'us' | 'uk'} region
- * @returns {Promise<void>}
- */
- async function preloadProductMetadata(region) {
- if (preloadedProductMetadata.has(region)) return
- const stripeClient = getRegionClient(region)
- const products = await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () =>
- stripeClient.stripe.products.list({
- active: true,
- limit: 100,
- }),
- { operation: 'products.list', region: stripeClient.serviceName }
- )
- const results = new Map()
- for (const product of products.data) {
- if (!product.metadata?.plan_code) continue
- results.set(product.metadata?.plan_code, product.id)
- }
- preloadedProductMetadata.set(region, results)
- }
- /**
- * Get a CSV parser configured for input
- * @param {ReadStream | NodeJS.ReadableStream} inputStream - The input stream to parse
- * @returns {Parser} The configured CSV parser
- */
- function getCsvReader(inputStream) {
- const parser = csv.parse({
- columns: true,
- })
- inputStream.pipe(parser)
- return parser
- }
- /**
- * Get a CSV stringifier configured for output
- * @param {string} outputFile - The output file path to write to, or '-' for stdout
- * @returns {Stringifier} The configured CSV stringifier
- */
- function getCsvWriter(outputFile) {
- let outputStream
- if (outputFile === '-') {
- outputStream = process.stdout
- } else {
- fs.mkdirSync(path.dirname(outputFile), { recursive: true })
- outputStream = fs.createWriteStream(outputFile)
- }
- const taxFields = [
- 'user_id',
- 'status',
- 'stripe_tax_breakdown_taxability_reason',
- 'stripe_tax_breakdown_amount',
- 'stripe_tax_breakdown_taxable_amount',
- ]
- const writer = csv.stringify({
- columns: taxFields,
- header: true,
- })
- writer.on('error', err => {
- console.error(err)
- process.exit(1)
- })
- writer.pipe(outputStream)
- return writer
- }
- /**
- * Calculate tax for an address using Stripe API
- * @param {{country: string, postal_code: string, line1?: string, line2?: string, city?: string, state?: string}} customerAddress
- * @param {string} planCode
- * @returns {Promise<Object>}
- */
- async function calculateTax(customerAddress, planCode) {
- const region = ['US', 'CA'].includes(customerAddress.country) ? 'us' : 'uk'
- const stripeClient = getRegionClient(region)
- const productId = preloadedProductMetadata.get(region)?.get(planCode)
- const amount = AMOUNTS[planCode] || AMOUNTS.collaborator
- let calculation
- try {
- calculation = await rateLimiters.requestWithRetries(
- stripeClient.serviceName,
- () =>
- stripeClient.stripe.tax.calculations.create({
- currency: 'USD',
- line_items: [
- {
- amount,
- product: productId,
- quantity: 1,
- reference: 'tax_calculation',
- },
- ],
- customer_details: {
- address: {
- ...customerAddress,
- },
- address_source: 'billing',
- },
- expand: ['line_items'],
- }),
- { operation: 'tax.calculations.create', region: stripeClient.serviceName }
- )
- } catch (err) {
- if (err.code === 'customer_tax_location_invalid') {
- return {
- status: 'invalid_address',
- ...emptyTaxFields(),
- }
- }
- throw err
- }
- // extract and aggregate taxes applied
- const result = (calculation.tax_breakdown || []).reduce(
- (acc, taxBreakdown) => {
- if (taxBreakdown?.amount > 0) {
- acc.stripe_tax_breakdown_amount += taxBreakdown?.amount ?? 0
- const taxabilityReason = taxBreakdown?.taxability_reason ?? ''
- if (taxabilityReason) {
- acc.stripe_tax_breakdown_taxability_reason +=
- acc.stripe_tax_breakdown_taxability_reason
- ? `;${taxabilityReason}`
- : taxabilityReason
- }
- acc.stripe_tax_breakdown_taxable_amount +=
- taxBreakdown?.taxable_amount ?? 0
- }
- return acc
- },
- emptyTaxFields()
- )
- return {
- status: 'success',
- ...result,
- }
- }
- /**
- * Return an object with all tax fields set to empty strings
- * @returns {Object} Object with empty tax fields
- */
- function emptyTaxFields() {
- return {
- stripe_tax_breakdown_amount: 0,
- stripe_tax_breakdown_taxability_reason: '',
- stripe_tax_breakdown_taxable_amount: 0,
- }
- }
- /**
- * Parse command line arguments
- * @returns {{inputFile: string, output: string | undefined, concurrency: number, countries: Set<string>, rateLimit: number, apiRetries: number, retryDelayMs: number}} Parsed options
- */
- function parseArgs() {
- const args = minimist(process.argv.slice(2), {
- string: [
- 'output',
- 'concurrency',
- 'countries',
- 'rate-limit',
- 'api-retries',
- 'retry-delay-ms',
- ],
- boolean: ['help'],
- alias: { c: 'concurrency' },
- default: {
- concurrency: DEFAULT_CONCURRENCY,
- 'rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
- 'api-retries': DEFAULT_STRIPE_API_RETRIES,
- 'retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
- },
- })
- if (args.help) {
- usage()
- process.exit(0)
- }
- const inputFile = args._[0]
- if (!inputFile) {
- console.error('Input file is required')
- usage()
- process.exit(1)
- }
- const paramsSchema = z.object({
- output: z.string().optional(),
- concurrency: z.number().int().positive(),
- countries: z.string().optional(),
- rateLimit: z.number().positive(),
- apiRetries: z.number().int().nonnegative(),
- retryDelayMs: z.number().int().nonnegative(),
- inputFile: z.string(),
- })
- let parsed
- try {
- parsed = paramsSchema.parse({
- output: args.output,
- concurrency: Number(args.concurrency),
- countries: args.countries,
- rateLimit: Number(args['rate-limit']),
- apiRetries: Number(args['api-retries']),
- retryDelayMs: Number(args['retry-delay-ms']),
- inputFile,
- })
- } catch (err) {
- console.error(`Invalid parameters: ${err.message}`)
- usage()
- process.exit(1)
- }
- // Parse countries into a Set, defaulting to no filter
- const countrySet = new Set(
- (parsed.countries || '')
- .split(',')
- .map(c => c.trim().toUpperCase())
- .filter(c => c.length > 0)
- )
- return {
- inputFile: parsed.inputFile,
- output: parsed.output,
- concurrency: parsed.concurrency,
- countries: countrySet,
- rateLimit: parsed.rateLimit,
- apiRetries: parsed.apiRetries,
- retryDelayMs: parsed.retryDelayMs,
- }
- }
- try {
- await scriptRunner(main)
- process.exit(0)
- } catch (error) {
- console.error(error)
- process.exit(1)
- }
|