Skip to content

Instantly share code, notes, and snippets.

@biancadanforth
Created September 20, 2021 14:53
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 biancadanforth/5e6e2d9cfa0f206e3d1c1a02f7778b5e to your computer and use it in GitHub Desktop.
Save biancadanforth/5e6e2d9cfa0f206e3d1c1a02f7778b5e to your computer and use it in GitHub Desktop.
FXA-3907 exploration: How many users are affected by off-session SCA?
/* 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 { AuthLogger } from 'fxa-auth-server/lib/types';
import { ACTIVE_SUBSCRIPTION_STATUSES } from 'fxa-shared/subscriptions/stripe';
import { StatsD } from 'hot-shots';
import stripe from 'stripe';
import Container from 'typedi';
import { CurrencyHelper } from '../lib/payments/currencies';
import { INVOICES_RESOURCE, StripeHelper, SUBSCRIPTIONS_RESOURCE } from '../lib/payments/stripe';
import { configureSentry } from '../lib/sentry';
const config = require('../config').getProperties();
class InvoiceRequiresSCAChecker {
private stripe: stripe;
constructor(private log: AuthLogger, private stripeHelper: StripeHelper) {
this.stripe = (this.stripeHelper as any).stripe as stripe;
}
private timer(ms: number) {
return new Promise(res => setTimeout(res, ms));
}
private async *invoicesForPaymentIntentRequiresAction(limit?: number) {
let count = 0;
for await (const event of this.stripe.events.list({
limit: 100,
type: 'payment_intent.requires_action',
// Unfortunately this API doesn't support expandable objects
})) {
const paymentIntent = event.data.object;
this.log.info('paymentIntent found', {
eventId: event.id,
paymentIntent,
});
await this.timer(200);
// @ts-ignore next line
const invoice = await this.stripeHelper.expandResource(paymentIntent.invoice, INVOICES_RESOURCE);
// We only care about recurring (i.e. off-session) payments.
if (
!invoice ||
(invoice as any).billing_reason !== 'subscription_cycle'
) {
continue;
}
this.log.info('recurring Invoice found', {
invoice,
});
yield invoice;
count++;
if (limit && count >= limit) {
break;
}
await this.timer(200);
}
}
async countRecurringInvoicesRequiringSCA() {
let invoiceCount = 0;
let activeSubscriptionCount = 0;
for await (const invoice of this.invoicesForPaymentIntentRequiresAction()) {
invoiceCount++;
// We've found a recurring invoice with a paymentIntent.status of "requires_action".
// In other words, this is a recurring invoice that requires SCA
// Get the subscription associated with the invoice and check its status,
// to see if the user resolved the SCA on their own.
await this.timer(200);
const subscription = await this.stripeHelper.expandResource(invoice.subscription, SUBSCRIPTIONS_RESOURCE);
if (ACTIVE_SUBSCRIPTION_STATUSES.includes(subscription.status)) {
this.log.info('active Subscription found', {
subscriptionId: subscription.id,
});
activeSubscriptionCount++;
}
}
// While it is typical for there to be more than one invoice per subscription (1 invoice per billing period),
// since we're only looking at the last 30 days, and our shortest billing period in prod is monthly,
// invoices:subscriptions should map 1:1 in prod, so we can use the invoice count as a proxy for the number
// of subscriptions that have required SCA on a recurring charge in the past month.
this.log.info('Total recurring invoices that require(d) SCA: ', { total: invoiceCount });
this.log.info('Total active subscriptions for those invoices: ', { total: activeSubscriptionCount });
}
}
export async function init() {
configureSentry(undefined, config);
const statsd = config.statsd.enabled
? new StatsD({
...config.statsd,
errorHandler: (err) => {
// eslint-disable-next-line no-use-before-define
log.error('statsd.error', err);
},
})
: ({
increment: () => {},
timing: () => {},
close: () => {},
} as unknown as StatsD);
Container.set(StatsD, statsd);
const log = require('../lib/log')({ ...config.log, statsd });
const currencyHelper = new CurrencyHelper(config);
Container.set(CurrencyHelper, currencyHelper);
const stripeHelper = new StripeHelper(log, config, statsd);
Container.set(StripeHelper, stripeHelper);
const scaChecker = new InvoiceRequiresSCAChecker(log, stripeHelper);
await scaChecker.countRecurringInvoicesRequiringSCA();
return 0;
}
if (require.main === module) {
init()
.catch((err) => {
console.error(err);
process.exit(1);
})
.then((result) => process.exit(result));
}
@biancadanforth
Copy link
Author

biancadanforth commented Sep 20, 2021

From the mozilla/fxa root dir (after fetching the latest main and yarn install):

  1. Save this file to fxa/packages/fxa-auth-server/scripts
  2. yarn start
  3. cd packages/fxa-auth-server
  4. stripe config --list to get the test_mode_api_key value and copy it to the clipboard
  5. export SUBHUB_STRIPE_APIKEY=${test_mode_api_key_value}
  6. NODE_ENV=dev ts-node ./scripts/temp-fxa-3907-script.ts

Example output:

bdanforth ~/Projects/fxa/packages/fxa-auth-server (10257-off-session-sca) $ NODE_ENV=dev ts-node ./scripts/temp-fxa-3907-script.ts
INFO fxa-auth-server.Total recurring invoices that require(d) SCA: : {"total":0}
INFO fxa-auth-server.Total active subscriptions for those invoices: : {"total":0}

To use with the live_mode_api_key:

  1. Same as above, but before executing the script, update the SUBHUB_STRIPE_APIKEY env var to use a restricted live Stripe API key (can create one from the Stripe dashboard) and add some sleeps in between Stripe API calls to avoid hitting Stripe's request rate limit.

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