-
Notifications
You must be signed in to change notification settings - Fork 212
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
feat(auth): cancel subscriptions to plan script
- Loading branch information
Showing
3 changed files
with
782 additions
and
0 deletions.
There are no files selected for viewing
102 changes: 102 additions & 0 deletions
102
packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan.ts
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
/* This Source Code Form is subject to the terms of the Mozilla Public | ||
* License, v. 2.0. If a copy of the MPL was not distributed with this | ||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
import program from 'commander'; | ||
|
||
import { setupProcessingTaskObjects } from '../lib/payments/processing-tasks-setup'; | ||
import { PlanCanceller } from './cancel-subscriptions-to-plan/cancel-subscriptions-to-plan'; | ||
|
||
const pckg = require('../package.json'); | ||
|
||
const parseBatchSize = (batchSize: string | number) => { | ||
return parseInt(batchSize.toString(), 10); | ||
}; | ||
|
||
const parseRateLimit = (rateLimit: string | number) => { | ||
return parseInt(rateLimit.toString(), 10); | ||
}; | ||
|
||
const parseExcludePlanIds = (planIds: string) => { | ||
return planIds.split(','); | ||
}; | ||
|
||
async function init() { | ||
program | ||
.version(pckg.version) | ||
.option( | ||
'-b, --batch-size [number]', | ||
'Number of subscriptions to query from firestore at a time. Defaults to 100.', | ||
100 | ||
) | ||
.option( | ||
'-o, --output-file [string]', | ||
'Output file to write report to. Will be output in CSV format. Defaults to cancel-subscriptions-to-plan.csv.', | ||
'cancel-subscriptions-to-plan.csv' | ||
) | ||
.option( | ||
'-r, --rate-limit [number]', | ||
'Rate limit for Stripe. Defaults to 70', | ||
70 | ||
) | ||
.option( | ||
'-p, --price [string]', | ||
'Stripe plan ID. All customers on this price ID will have their subscriptions cancelled' | ||
) | ||
.option( | ||
'-e, --exclude [string]', | ||
'Do not touch customers if they have a subscription to a price in this list', | ||
'' | ||
) | ||
.option( | ||
'--refund', | ||
"Refund the customer's entire last bill to their card, regardless of any remaining time" | ||
) | ||
.option( | ||
'--prorate', | ||
'Prorate the customers remaining time. Cannot be used with --refund' | ||
) | ||
.option( | ||
'--dry-run', | ||
'List the customers that would be deleted without actually deleting' | ||
) | ||
.parse(process.argv); | ||
|
||
const { stripeHelper, database } = await setupProcessingTaskObjects( | ||
'cancel-subscriptions-to-plan' | ||
); | ||
|
||
const batchSize = parseBatchSize(program.batchSize); | ||
const rateLimit = parseRateLimit(program.rateLimit); | ||
const excludePlanIds = parseExcludePlanIds(program.exclude); | ||
|
||
const dryRun = !!program.dryRun; | ||
if (!program.price) throw new Error('--price must be provided'); | ||
if (program.prorate && program.refund) | ||
throw new Error('--prorate and --refund cannot be used together'); | ||
|
||
const planCanceller = new PlanCanceller( | ||
program.price, | ||
program.refund, | ||
program.prorate, | ||
excludePlanIds, | ||
batchSize, | ||
program.outputFile, | ||
stripeHelper, | ||
database, | ||
dryRun, | ||
rateLimit | ||
); | ||
|
||
await planCanceller.run(); | ||
|
||
return 0; | ||
} | ||
|
||
if (require.main === module) { | ||
init() | ||
.catch((err) => { | ||
console.error(err); | ||
process.exit(1); | ||
}) | ||
.then((result) => process.exit(result)); | ||
} |
269 changes: 269 additions & 0 deletions
269
...ages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.ts
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,269 @@ | ||
/* This Source Code Form is subject to the terms of the Mozilla Public | ||
* License, v. 2.0. If a copy of the MPL was not distributed with this | ||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ | ||
|
||
import Stripe from 'stripe'; | ||
import { Firestore } from '@google-cloud/firestore'; | ||
import Container from 'typedi'; | ||
import fs from 'fs'; | ||
import PQueue from 'p-queue'; | ||
|
||
import { AppConfig, AuthFirestore } from '../../lib/types'; | ||
import { ConfigType } from '../../config'; | ||
import { StripeHelper } from '../../lib/payments/stripe'; | ||
|
||
/** | ||
* Firestore subscriptions contain additional expanded information | ||
* on top of the base Stripe.Subscription type | ||
*/ | ||
export interface FirestoreSubscription extends Stripe.Subscription { | ||
customer: string; | ||
plan: Stripe.Plan; | ||
price: Stripe.Price; | ||
} | ||
|
||
export class PlanCanceller { | ||
private config: ConfigType; | ||
private firestore: Firestore; | ||
private stripeQueue: PQueue; | ||
private stripe: Stripe; | ||
|
||
/** | ||
* A tool to cancel all subscriptions under a plan | ||
* @param priceId A Stripe plan or price ID for which all subscriptions will be cancelled | ||
* @param refund If true, all subscriptions will have their last charge reversed to their card, regardless of remaining time | ||
* @param prorate If true, all subscriptions cancellations will generate a proration invoice item that credits remaining time | ||
* @param excludePlanIds A list of Stripe plan or price ID which if customers have will not be subscribed to the destination plan | ||
* @param batchSize Number of subscriptions to fetch from Firestore at a time | ||
* @param outputFile A CSV file to output a report of affected subscriptions to | ||
* @param stripeHelper An instance of StripeHelper | ||
* @param rateLimit A limit for number of stripe requests within the period of 1 second | ||
* @param database A reference to the FXA database | ||
*/ | ||
constructor( | ||
private priceId: string, | ||
private refund: boolean, | ||
private prorate: boolean, | ||
private excludePlanIds: string[], | ||
private batchSize: number, | ||
private outputFile: string, | ||
private stripeHelper: StripeHelper, | ||
private database: any, | ||
public dryRun: boolean, | ||
rateLimit: number | ||
) { | ||
this.stripe = this.stripeHelper.stripe; | ||
|
||
const config = Container.get<ConfigType>(AppConfig); | ||
this.config = config; | ||
|
||
const firestore = Container.get<Firestore>(AuthFirestore); | ||
this.firestore = firestore; | ||
|
||
this.stripeQueue = new PQueue({ | ||
intervalCap: rateLimit, | ||
interval: 1000, // Stripe measures its rate limit per second | ||
}); | ||
} | ||
|
||
/** | ||
* Cancel all customer subscriptions in batches | ||
*/ | ||
async run(): Promise<void> { | ||
let startAfter: string | null = null; | ||
let hasMore = true; | ||
|
||
while (hasMore) { | ||
const subscriptions = await this.fetchSubsBatch(startAfter); | ||
|
||
startAfter = subscriptions.at(-1)?.id as string; | ||
if (!startAfter) hasMore = false; | ||
|
||
await Promise.all( | ||
subscriptions.map((sub) => this.processSubscription(sub)) | ||
); | ||
} | ||
} | ||
|
||
/** | ||
* Fetches subscriptions from Firestore paginated by batchSize | ||
* @param startAfter ID of the last element of the previous batch for pagination | ||
* @returns A list of subscriptions from firestore | ||
*/ | ||
async fetchSubsBatch( | ||
startAfter: string | null | ||
): Promise<FirestoreSubscription[]> { | ||
const collectionPrefix = `${this.config.authFirestore.prefix}stripe-`; | ||
const subscriptionCollection = `${collectionPrefix}subscriptions`; | ||
|
||
const subscriptionSnap = await this.firestore | ||
.collectionGroup(subscriptionCollection) | ||
.where('plan.id', '==', this.priceId) | ||
.orderBy('id') | ||
.startAfter(startAfter) | ||
.limit(this.batchSize) | ||
.get(); | ||
|
||
const subscriptions = subscriptionSnap.docs.map( | ||
(doc) => doc.data() as FirestoreSubscription | ||
); | ||
|
||
return subscriptions; | ||
} | ||
|
||
/** | ||
* Attempts to cancel a firestore subscription | ||
* @param firestoreSubscription The subscription to cancel | ||
*/ | ||
async processSubscription( | ||
firestoreSubscription: FirestoreSubscription | ||
): Promise<void> { | ||
const { id: subscriptionId, customer: customerId } = firestoreSubscription; | ||
|
||
try { | ||
const customer = await this.fetchCustomer(customerId); | ||
if (!customer?.subscriptions?.data) { | ||
console.error(`Customer not found: ${customerId}`); | ||
return; | ||
} | ||
|
||
const account = await this.database.account(customer.metadata.userid); | ||
if (!account) { | ||
console.error(`Account not found: ${customer.metadata.userid}`); | ||
return; | ||
} | ||
|
||
const isExcluded = this.isCustomerExcluded(customer.subscriptions.data); | ||
|
||
if (!this.dryRun && !isExcluded) { | ||
await this.cancelSubscription(firestoreSubscription); | ||
} | ||
|
||
const report = this.buildReport(customer, account, isExcluded); | ||
|
||
await this.writeReport(report); | ||
|
||
console.log(subscriptionId); | ||
} catch (e) { | ||
console.error(subscriptionId, e); | ||
} | ||
} | ||
|
||
/** | ||
* Retrieves a customer record directly from Stripe | ||
* @param customerId The Stripe customer ID of the customer to fetch | ||
* @returns The customer record for the customerId provided, or null if not found or deleted | ||
*/ | ||
async fetchCustomer(customerId: string): Promise<Stripe.Customer | null> { | ||
const customer = await this.enqueueRequest(() => | ||
this.stripe.customers.retrieve(customerId, { | ||
expand: ['subscriptions'], | ||
}) | ||
); | ||
|
||
if (customer.deleted) return null; | ||
|
||
return customer; | ||
} | ||
|
||
/** | ||
* Cancel subscription and refund customer for latest bill | ||
* @param subscription The subscription to cancel | ||
*/ | ||
async cancelSubscription(subscription: Stripe.Subscription): Promise<void> { | ||
await this.enqueueRequest(() => | ||
this.stripe.subscriptions.cancel(subscription.id, { | ||
prorate: this.prorate, | ||
}) | ||
); | ||
|
||
if (!this.refund) return; | ||
|
||
if (!subscription.latest_invoice) { | ||
console.log(`No latest invoice for ${subscription.id}`); | ||
return; | ||
} | ||
|
||
const latestInvoiceId = | ||
typeof subscription.latest_invoice === 'string' | ||
? subscription.latest_invoice | ||
: subscription.latest_invoice.id; | ||
|
||
const invoice = await this.enqueueRequest(() => | ||
this.stripe.invoices.retrieve(latestInvoiceId) | ||
); | ||
|
||
const chargeId = | ||
typeof invoice.charge === 'string' ? invoice.charge : invoice.charge?.id; | ||
if (!chargeId) { | ||
console.log(`No charge for ${invoice.id}`); | ||
return; | ||
} | ||
|
||
console.log(`Refunding ${chargeId}`); | ||
await this.enqueueRequest(() => | ||
this.stripe.refunds.create({ | ||
charge: chargeId, | ||
}) | ||
); | ||
} | ||
|
||
/** | ||
* Check if a customer's list of subscriptions contains an excluded price ID | ||
* @param subscriptions List of subscriptions to check for an excluded price ID in | ||
*/ | ||
isCustomerExcluded(subscriptions: Stripe.Subscription[]): boolean { | ||
for (const subscription of subscriptions) { | ||
for (const item of subscription.items.data) { | ||
if (this.excludePlanIds.includes(item.plan.id)) return true; | ||
} | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/** | ||
* Creates an ordered array of fields destined for CSV format | ||
* @returns An array representing the fields to be output to CSV | ||
*/ | ||
buildReport( | ||
customer: Stripe.Customer, | ||
account: any, | ||
isExcluded: boolean | ||
): string[] { | ||
// We build a temporary object first for readability & maintainability purposes | ||
const report = { | ||
uid: customer.metadata.userid, | ||
email: customer.email, | ||
isExcluded: isExcluded.toString(), | ||
|
||
locale: account.locale, | ||
}; | ||
|
||
return [ | ||
report.uid, | ||
`"${report.email}"`, | ||
report.isExcluded, | ||
`"${report.locale}"`, | ||
]; | ||
} | ||
|
||
/** | ||
* Appends the report to the output file | ||
* @param report an array representing the report CSV | ||
*/ | ||
async writeReport(report: (string | number | null)[]): Promise<void> { | ||
const reportCSV = report.join(',') + '\n'; | ||
|
||
await fs.promises.writeFile(this.outputFile, reportCSV, { | ||
flag: 'a+', | ||
encoding: 'utf-8', | ||
}); | ||
} | ||
|
||
async enqueueRequest<T>(callback: () => T): Promise<T> { | ||
return this.stripeQueue.add(callback, { | ||
throwOnTimeout: true, | ||
}); | ||
} | ||
} |
Oops, something went wrong.