Last active
July 7, 2023 19:58
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"') // ¯\_(ツ)_/¯ | |
}, | |
) | |
}) | |
} |
Author
Merott
commented
Jul 7, 2023
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment