Skip to content

Instantly share code, notes, and snippets.

@Merott
Last active July 7, 2023 19:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Merott/62201090ed3e6c29f513d930bd358d29 to your computer and use it in GitHub Desktop.
Save Merott/62201090ed3e6c29f513d930bd358d29 to your computer and use it in GitHub Desktop.
A basic Node.js script for copying customer subscriptions from one Stripe account to another after a data migration.
import readline from 'readline'
import Stripe from 'stripe'
if (!process.env.STRIPE_SK || !process.env.STRIPE_OLD_SK) {
throw new Error('Must set STRIPE_SK and STRIPE_OLD_SK environment variables!')
}
const apiVersion = '2022-11-15'
export const stripeNew = new Stripe(process.env.STRIPE_SK, { apiVersion })
export const stripeOld = new Stripe(process.env.STRIPE_OLD_SK, { apiVersion })
void (async () => {
let customers: Stripe.Response<Stripe.ApiList<Stripe.Customer>> | null =
await stripeNew.customers.list()
while (customers?.data.length) {
for (const customer of customers.data) {
const subscriptions = await stripeNew.subscriptions.list({
customer: customer.id,
})
if (subscriptions.data.length > 0) {
console.warn(
`✋ Customer ${
customer.email ?? customer.id
} already has a subscription. Skipped.`,
)
continue
}
try {
// Stripe maintains customer IDs when copying from accounts, so we can
// use that to fetch the customer data from the original Stripe account
const oldCustomer = await stripeOld.customers.retrieve(customer.id)
if (oldCustomer.deleted) {
console.warn(`✋ Customer ${customer.id} is deleted. Skipped.`)
continue
}
const oldSubscriptions = await stripeOld.subscriptions.list({
customer: oldCustomer.id,
})
const [subscriptionToCopy] = oldSubscriptions.data
if (!subscriptionToCopy || oldSubscriptions.data.length > 1) {
// not expecting more than 1 subscription, but just in case...
console.warn(
`✋ Customer ${oldCustomer.email ?? oldCustomer.id} has ${
oldSubscriptions.data.length
} subscriptions. Skipped.`,
)
continue
}
const subscriptionItems = subscriptionToCopy.items.data
if (subscriptionItems.length > 1) {
// not expecting more than 1 subscription item, but just in case...
console.warn(
`✋ Subscription ${subscriptionToCopy?.id} has ${subscriptionItems.length} items. Skipped.`,
)
continue
}
const [subscriptionItem] = subscriptionItems
if (!subscriptionItem) {
console.warn(
`✋ Subscription ${subscriptionToCopy.id} has 0 items. Skipped.`,
)
continue
}
const coupon = subscriptionToCopy.latest_invoice
? await getInvoiceCoupon(subscriptionToCopy.latest_invoice, 'old')
: undefined
const newSubscriptionCopy: Stripe.SubscriptionCreateParams = {
customer: customer.id,
metadata: { oldSubscriptionId: subscriptionToCopy.id },
cancel_at_period_end: subscriptionToCopy.cancel_at_period_end,
backdate_start_date: subscriptionToCopy.start_date,
trial_end: subscriptionToCopy.current_period_end,
items: [{ price: subscriptionItem.price.id }],
automatic_tax: subscriptionToCopy.automatic_tax,
coupon: coupon?.id,
}
const confirmed = await confirmContinue(
newSubscriptionCopy,
customer.email ?? customer.id,
)
if (confirmed) {
console.log('🤑 Creating subscription...')
const newSubscription = await stripeNew.subscriptions.create(
newSubscriptionCopy,
)
await stripeOld.subscriptions.update(subscriptionToCopy.id, {
// pause collecting payments for the old subscription
pause_collection: { behavior: 'void' },
// migratedSubscriptionId as metadata will come handy.
// I use it to check if an update notification is for a subscription
// that's already been migrateed, in which case I can ignore it.
// That's important here because pausing itself triggers an update!
metadata: { migratedSubscriptionId: newSubscription.id },
})
// At the time of building this, Stripe didn't seem to correctly copy
// the customer's locale, so let's just do that here too...
await stripeNew.customers.update(customer.id, {
preferred_locales: oldCustomer.preferred_locales ?? undefined,
})
console.log(
`✅ Created subscription for ${customer.email ?? customer.id}\n`,
)
} else {
console.log('↩️ Skipped.')
continue
}
} catch (error) {
const isNewCustomerOnly =
error instanceof Error && error.message.includes('No such customer')
if (isNewCustomerOnly) continue
else throw error
}
}
customers = customers.has_more
? await stripeNew.customers.list({
starting_after: customers.data.pop()?.id,
})
: null
}
})()
async function getInvoiceCoupon(
invoice: Stripe.Invoice | string,
env: 'old' | 'new',
) {
const stripe = env === 'old' ? stripeOld : stripeNew
const theInvoice =
typeof invoice === 'string'
? await stripe.invoices.retrieve(invoice)
: invoice
return theInvoice?.discount?.coupon
}
function timeString(timestamp: number | 'now') {
return timestamp === 'now'
? Date.now().toString()
: // eslint-disable-next-line skyscanner-dates/no-new-date-with-args
new Date(timestamp * 1000).toString()
}
async function confirmContinue(
subscription: Stripe.SubscriptionCreateParams,
customer: string,
) {
const { backdate_start_date, trial_end } = subscription
console.log(`Creating subscription for ${customer}:`, {
...subscription,
backdate_start_date: backdate_start_date && timeString(backdate_start_date),
trial_end: trial_end && timeString(trial_end),
})
const prompt = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise(resolve => {
prompt.question(
`\n🚦 Enter exactly "y" to sync subscription for ${customer}... `,
answer => {
prompt.close()
console.log()
resolve(answer === 'y' || answer === '"y"') // ¯\_(ツ)_/¯
},
)
})
}
@Merott
Copy link
Author

Merott commented Jul 7, 2023

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment