scripts/backfill_stripe_customer_countries.ts
Rama main · réplica de lectura del repositorio original.
/*
* Backfill the admin "Top Billing Countries" metric.
*
* The dashboard reads public.stripe_info.customer_country. This script syncs
* that column from Stripe customer profile addresses for historical customers.
*
* Dry run for missing country rows:
* bun run stripe:backfill-customer-countries
*
* Apply missing countries:
* bun run stripe:backfill-customer-countries --apply
*
* Refresh existing country values too:
* bun run stripe:backfill-customer-countries --apply --refresh-existing
*/
import type Stripe from 'stripe'
import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts'
import { mkdir, writeFile } from 'node:fs/promises'
import process from 'node:process'
import { asyncPool, createStripeClient, createSupabaseServiceClient, DEFAULT_ENV_FILE, getArgValue, getRequiredEnv, isActionableStripeCustomerId, loadEnv, parsePositiveInteger } from './admin_stripe_backfill_utils.ts'
const DEFAULT_CONCURRENCY = 8
const DEFAULT_PAGE_SIZE = 1000
const FAILURE_OUTPUT = './tmp/stripe_customer_country_backfill_failures.json'
const ISO_COUNTRY_CODE_REGEX = /^[A-Z]{2}$/
type SupabaseClient = ReturnType<typeof createSupabaseServiceClient>
type StripeInfoCountryRow = Pick<
Database['public']['Tables']['stripe_info']['Row'],
'customer_id' | 'customer_country'
>
export interface StripeCustomerCountryBackfillCandidate {
current_country: string | null
customer_id: string
next_country: string | null
}
interface BackfillFailure {
customerId: string
error: string
}
export function normalizeStripeCountryCode(country: string | null | undefined) {
if (!country)
return null
const normalized = country.trim().toUpperCase()
if (!normalized || !ISO_COUNTRY_CODE_REGEX.test(normalized))
return null
return normalized
}
export function getCustomerProfileCountry(customer: Stripe.Customer | Stripe.DeletedCustomer) {
if (customer.deleted)
return null
return normalizeStripeCountryCode(customer.address?.country ?? null)
}
export function shouldUpdateCustomerCountry(currentCountry: string | null | undefined, nextCountry: string | null, refreshExisting: boolean) {
const normalizedCurrentCountry = normalizeStripeCountryCode(currentCountry)
if (refreshExisting)
return normalizedCurrentCountry !== nextCountry
return normalizedCurrentCountry === null && nextCountry !== null
}
async function fetchStripeInfoCountryRows(
supabase: SupabaseClient,
options: {
customerId?: string | null
missingOnly: boolean
},
) {
const rows: StripeInfoCountryRow[] = []
let lastSeenCustomerId: string | null = null
while (true) {
let query = supabase
.from('stripe_info')
.select('customer_id, customer_country')
.order('customer_id', { ascending: true })
.limit(DEFAULT_PAGE_SIZE)
if (options.customerId)
query = query.eq('customer_id', options.customerId)
else if (lastSeenCustomerId)
query = query.gt('customer_id', lastSeenCustomerId)
if (options.missingOnly)
query = query.is('customer_country', null)
const { data, error } = await query
if (error)
throw error
if (!data?.length)
break
rows.push(...data)
if (options.customerId || data.length < DEFAULT_PAGE_SIZE)
break
lastSeenCustomerId = data.at(-1)?.customer_id ?? null
}
return rows
}
async function updateCustomerCountry(supabase: SupabaseClient, customerId: string, country: string | null) {
const { error } = await supabase
.from('stripe_info')
.update({ customer_country: country })
.eq('customer_id', customerId)
if (error)
throw error
}
async function writeFailures(failures: BackfillFailure[]) {
if (failures.length === 0)
return
await mkdir('./tmp', { recursive: true })
await writeFile(FAILURE_OUTPUT, `${JSON.stringify(failures, null, 2)}\n`)
console.log(`Failure details written to ${FAILURE_OUTPUT}`)
}
async function main(args = process.argv.slice(2), runtimeEnv: Record<string, string | undefined> = process.env) {
const apply = args.includes('--apply')
const refreshExisting = args.includes('--refresh-existing')
const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE
const customerId = getArgValue(args, '--customer-id')
const limitArg = getArgValue(args, '--limit')
const limit = limitArg ? parsePositiveInteger(limitArg, '--limit', 0) : null
const concurrency = parsePositiveInteger(getArgValue(args, '--concurrency'), '--concurrency', DEFAULT_CONCURRENCY)
const fileEnv = await loadEnv(envFile)
const env = {
...fileEnv,
...runtimeEnv,
}
const supabase = createSupabaseServiceClient(env)
const stripe = createStripeClient(
getRequiredEnv(env, 'STRIPE_SECRET_KEY'),
env.STRIPE_API_BASE_URL?.trim(),
)
const rows = await fetchStripeInfoCountryRows(supabase, {
customerId,
missingOnly: !refreshExisting,
})
const actionableRows = rows.filter(row => isActionableStripeCustomerId(row.customer_id))
const limitedRows = limit ? actionableRows.slice(0, limit) : actionableRows
console.log(`Loaded ${rows.length} stripe_info rows (${actionableRows.length} actionable)`)
console.log(`Env file: ${envFile}`)
if (customerId)
console.log(`Scoped to customer: ${customerId}`)
if (refreshExisting)
console.log('Mode: refresh existing country values')
else
console.log('Mode: fill missing country values only')
const failures: BackfillFailure[] = []
const candidates: StripeCustomerCountryBackfillCandidate[] = []
let checked = 0
await asyncPool(concurrency, limitedRows, async (row) => {
try {
const customer = await stripe.customers.retrieve(row.customer_id)
const nextCountry = getCustomerProfileCountry(customer)
if (shouldUpdateCustomerCountry(row.customer_country, nextCountry, refreshExisting)) {
candidates.push({
customer_id: row.customer_id,
current_country: normalizeStripeCountryCode(row.customer_country),
next_country: nextCountry,
})
}
checked++
if (checked % 100 === 0 || checked === limitedRows.length)
console.log(`Checked ${checked}/${limitedRows.length}`)
}
catch (error) {
failures.push({
customerId: row.customer_id,
error: error instanceof Error ? error.message : String(error),
})
}
})
console.log(`Candidates needing update: ${candidates.length}`)
if (candidates.length > 0) {
console.log('Sample updates:')
for (const candidate of candidates.slice(0, 10)) {
const from = candidate.current_country ?? 'null'
const to = candidate.next_country ?? 'null'
console.log(`${candidate.customer_id}: ${from} -> ${to}`)
}
}
if (!apply) {
await writeFailures(failures)
if (failures.length > 0)
throw new Error(`Stripe customer country backfill dry run had ${failures.length} failures`)
console.log('Dry run only. Pass --apply to update stripe_info.')
return
}
let updated = 0
await asyncPool(concurrency, candidates, async (candidate) => {
try {
await updateCustomerCountry(supabase, candidate.customer_id, candidate.next_country)
updated++
if (updated % 100 === 0 || updated === candidates.length)
console.log(`Updated ${updated}/${candidates.length}`)
}
catch (error) {
failures.push({
customerId: candidate.customer_id,
error: error instanceof Error ? error.message : String(error),
})
}
})
await writeFailures(failures)
if (failures.length > 0)
throw new Error(`Stripe customer country backfill completed with ${failures.length} failures`)
console.log(`Done. Updated ${updated}/${candidates.length} customer country rows.`)
}
if (import.meta.main)
await main()