Skip to content

Commit

Permalink
feat: scheduled hourly function to cancel unpaid renewals
Browse files Browse the repository at this point in the history
  • Loading branch information
th0rgall committed Jan 4, 2024
1 parent 507a470 commit 955a1d2
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 0 deletions.
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@google-cloud/firestore": "^6.4.1",
"@sendgrid/client": "^7.7.0",
"@sendgrid/mail": "^7.1.1",
"es6-promise-pool": "^2.5.0",
"firebase-admin": "^11.9.0",
"firebase-functions": "^4.6.0",
"lodash.groupby": "^4.6.0",
Expand Down
6 changes: 6 additions & 0 deletions api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const { cleanupUserOnDelete } = require('./user/cleanupUserOnDelete');
const { onUserWrite } = require('./user/onUserWrite');
const { onUserPrivateWrite } = require('./user/onUserPrivateWrite');

const cancelUnpaidRenewals = require('./subscriptions/cancelUnpaidRenewals');

// Regions
// This is in Belgium! All new functions should be deployed here.
const euWest1 = functions.region('europe-west1');
Expand Down Expand Up @@ -104,3 +106,7 @@ exports.notifyOnChat = usCentral1.firestore

// Scheduled tasks
exports.scheduledFirestoreBackup = functions.pubsub.schedule('every 6 hours').onRun(doBackup);
exports.cancelUnpaidRenewals = functions.pubsub.schedule('every hour').onRun(cancelUnpaidRenewals);

// Only for testing the above cancellation function!
// exports.cancelUnpaidRenewalsTest = euWest1.https.onRequest(cancelUnpaidRenewals);
100 changes: 100 additions & 0 deletions api/src/subscriptions/cancelUnpaidRenewals.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @ts-check
// The es6-promise-pool to limit the concurrency of promises.
// Docs: https://www.npmjs.com/package/es6-promise-pool
// Suggested on: https://firebase.google.com/docs/functions/schedule-functions?gen=2nd
const { logger } = require('firebase-functions');
const PromisePool = require('es6-promise-pool');
const { db } = require('../firebase');
const { stripeSubscriptionKeys } = require('./constants');
const stripe = require('./stripe');

// Maximum concurrent cancellation operations.
const MAX_CONCURRENT = 3;

const { statusKey, latestInvoiceStatusKey, startDateKey } = stripeSubscriptionKeys;

module.exports = async () => {
// One year ago (365 days)
// NOTE: this may cause some inconsistencies depending on how Stripe sees a year
const nowSecs = new Date().getTime() / 1000;
const oneYearAgoSecs = nowSecs - 365 * 24 * 60 * 60;
const oneMonthAgo = nowSecs - 31 * 24 * 60 * 60;
const oneWeekAgoSecs = nowSecs - 7 * 24 * 60 * 60;

// Get all users with a subscription that expired >= 7 days ago
//
// There are compound query limitations, so we can't use all combinations of conditions.
// Further filtering is done below on the downloaded data.
// https://firebase.google.com/docs/firestore/query-data/queries#limitations
const query = db
.collection('users-private')
// The subscription status is "past_due"
// based on the default settings we're using, it goes from 'active' to 'past_due' 24 hours
// after the creation of a (renewal) invoice
.where(statusKey, '==', 'past_due')
// UNUSED: The last invoice isn't paid
// .where(latestInvoiceStatusKey, '!=', 'paid')
// INSTEAD: The last invoice is open (avoid compound query limitations on '!=')
.where(latestInvoiceStatusKey, '==', 'open')
// The start date is over a year ago (to only get those invoices that are renewals)
.where(startDateKey, '<=', oneYearAgoSecs);

const { docs } = await query.get();

// Further filtering
const filteredDocs = docs.filter((doc) => {
const sub = doc.data().stripeSubscription;
// Renewal invoice link exists (it should be created only upon renewal, so must exist)
return (
!!sub.renewalInvoiceLink &&
// last period started more than 7 days ago
sub.currentPeriodStart <= oneWeekAgoSecs &&
// ... but also at most one month ago
sub.currentPeriodStart >= oneMonthAgo
);
});

// https://www.npmjs.com/package/es6-promise-pool#iterator
const generatePromises = function* () {
for (let i = 0; i < filteredDocs.length; i += 1) {
const doc = filteredDocs[i];
const data = doc.data();
// Create a promise that first cancels the subscription, then voids its last invoice
//
// A voided invoice can not be paid anymore.
// By default, the invoice is left open (and marked uncollectible after 30 days, depending on Billing settings)
// An uncollectible invoice can still be paid, but because cancellation switches off the invoice auto-advance,
// I'm not sure if the original subscription is marked active again (not tested)
// In any case, for predictability, marking as "void" after 7 days is best to force the user to start a new subscription.
yield stripe.subscriptions
.cancel(data.stripeSubscription.id)
.then(async (cancelledSubscription) => {
if (typeof cancelledSubscription.latest_invoice === 'string') {
const voidedInvoice = await stripe.invoices.voidInvoice(
cancelledSubscription.latest_invoice
);
// Sync this status to the users-private doc
await doc.ref.update({ [latestInvoiceStatusKey]: voidedInvoice.status });
logger.log(
`Successfully canceled the subscription ${data.stripeSubscription.id} of ${data.stripeCustomerId} and voided its latest invoice`
);
}
return true;
})
.catch((e) => {
logger.error(e);
logger.error(
`Something went wrong when cancelling the subscription ${data.stripeSubscription.id}, or voiding its latest invoice.`
);
});
// Note: because the subscription was cancelled, the subscription.deleted event will result in an email
}
};

const promiseIterator = generatePromises();
const pool = new PromisePool(promiseIterator, MAX_CONCURRENT);
await pool
.start()
.then(() => logger.log(`Completed ${filteredDocs.length} cancellations`))
.catch(() => logger.error(`Couldn't finish ${filteredDocs.length} cancellations`));
};
3 changes: 3 additions & 0 deletions api/src/subscriptions/constants.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ type StripeUpdateKey = StripeSubscriptionKeys[number];
type StripeUpdateKeysWithKeySuffix = `${StripeUpdateKey}Key`;
type StripeObjectUpdateKeys = { [key in StripeUpdateKeysWithKeySuffix]: string };

// These keys include the subscription parent key and child key (ex. stripeSubscription.currentPeriodEnd)
// to directly subscript the users-private collection.
//
// Types for autocomplete & type checking in VSCode.
// Good to later convert to real ts.
export const stripeSubscriptionKeys: StripeObjectUpdateKeys;
8 changes: 8 additions & 0 deletions api/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2773,6 +2773,13 @@ __metadata:
languageName: node
linkType: hard

"es6-promise-pool@npm:^2.5.0":
version: 2.5.0
resolution: "es6-promise-pool@npm:2.5.0"
checksum: e472ec5959b022b28e678446674c78dd2d198dd50c537ef59916d32d2423fe4518c43f132d81f2e98249b8b8450c95f77b8d9aecc1fb15e8dcd224c5b98f0cce
languageName: node
linkType: hard

"escalade@npm:^3.1.1":
version: 3.1.1
resolution: "escalade@npm:3.1.1"
Expand Down Expand Up @@ -3393,6 +3400,7 @@ __metadata:
"@google-cloud/firestore": ^6.4.1
"@sendgrid/client": ^7.7.0
"@sendgrid/mail": ^7.1.1
es6-promise-pool: ^2.5.0
eslint: ^8.29.0
eslint-config-airbnb: ^19.0.4
eslint-config-prettier: ^8.5.0
Expand Down

0 comments on commit 955a1d2

Please sign in to comment.