ソースを参照

Revert Recurly based subscription upgrades on failed payments (#25824)

* feat: add ability to set restore point for subscriptions

* feat: update recurly client with ability to get past due invoices and fail invoices

* utility to retrieve last valid subscription

* create revert requests and fail invoices, revert subscriptions to previous valid states on failed upgrade payments

* add restore point and call to revert plans on failed payments

* code style for PaymentProviderEntities

* moving subs restore point check to SubscriptionController, and removing unecessary error

* adding ability to stop sub restores without a deploy

* ensure that subs restore point is set before changing plan

* changing reverted flag on subscription to count, and only reverting automatic invoices

* updating tests with restorepoint functions

* rethrow error after voiding restore point, and ensure that recurly failed_payment always gets a 200 response

* only void restore point if the changeRequest fails

GitOrigin-RevId: cf3074c13db22d1cf680b59c4d57817c390db23e
Jimmy Domagala-Tang 1 年間 前
親
コミット
d49a8f83df

+ 3 - 0
services/web/app/src/Features/Errors/Errors.js

@@ -47,6 +47,8 @@ class DuplicateNameError extends OError {}
 
 class InvalidNameError extends BackwardCompatibleError {}
 
+class IndeterminateInvoiceError extends OError {}
+
 class UnsupportedFileTypeError extends BackwardCompatibleError {}
 
 class FileTooLargeError extends BackwardCompatibleError {}
@@ -333,6 +335,7 @@ module.exports = {
   UnconfirmedEmailError,
   EmailExistsError,
   InvalidError,
+  IndeterminateInvoiceError,
   NotInV2Error,
   OutputFileFetchFailedError,
   SAMLAssertionAudienceMismatch,

+ 38 - 0
services/web/app/src/Features/Subscription/PaymentProviderEntities.js

@@ -2,6 +2,7 @@
 
 /**
  * @import { PaymentProvider } from '../../../../types/subscription/dashboard/subscription'
+ * @import { AddOn } from '../../../../types/subscription/plan'
  */
 
 const OError = require('@overleaf/o-error')
@@ -254,6 +255,43 @@ class PaymentProviderSubscription {
     })
   }
 
+  /**
+   * Form a request to revert the plan to it's last saved backup state
+   *
+   * @param {string} previousPlanCode
+   * @param {Array<AddOn> | null} previousAddOns
+   * @return {PaymentProviderSubscriptionChangeRequest}
+   *
+   * @throws {OError} if the restore point plan doesnt exist
+   */
+  getRequestForPlanRevert(previousPlanCode, previousAddOns) {
+    const lastSuccessfulPlan =
+      PlansLocator.findLocalPlanInSettings(previousPlanCode)
+    if (lastSuccessfulPlan == null) {
+      throw new OError('Unable to find plan in settings', { previousPlanCode })
+    }
+    const changeRequest = new PaymentProviderSubscriptionChangeRequest({
+      subscription: this,
+      timeframe: 'now',
+      planCode: previousPlanCode,
+    })
+
+    // defaulting to empty array is important, as that will wipe away any add-ons that were added in the failed payment
+    //  but were not part of the last successful subscription
+    const addOns = []
+    for (const previousAddon of previousAddOns || []) {
+      const addOnUpdate = new PaymentProviderSubscriptionAddOnUpdate({
+        code: previousAddon.addOnCode,
+        quantity: previousAddon.quantity,
+        unitPrice: previousAddon.unitAmountInCents / 100,
+      })
+      addOns.push(addOnUpdate)
+    }
+    changeRequest.addOnUpdates = addOns
+
+    return changeRequest
+  }
+
   /**
    * Upgrade group plan with the plan code provided
    *

+ 36 - 0
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -685,6 +685,38 @@ function subscriptionUpdateRequestToApi(updateRequest) {
   return requestBody
 }
 
+/**
+ * Retrieves a list of failed invoices for a given Recurly subscription ID.
+ *
+ * @async
+ * @function
+ * @param {string} subscriptionId - The ID of the Recurly subscription to fetch failed invoices for.
+ * @returns {Promise<Array<recurly.Invoice>>} A promise that resolves to an array of failed invoice objects.
+ */
+async function getPastDueInvoices(subscriptionId) {
+  const failed = []
+  const invoices = client.listSubscriptionInvoices(`uuid-${subscriptionId}`, {
+    params: { state: 'past_due' },
+  })
+
+  for await (const invoice of invoices.each()) {
+    failed.push(invoice)
+  }
+  return failed
+}
+
+/**
+ * Marks an invoice as failed using the Recurly client.
+ *
+ * @async
+ * @function failInvoice
+ * @param {string} invoiceId - The ID of the invoice to be marked as failed.
+ * @returns {Promise<void>} Resolves when the invoice has been successfully marked as failed.
+ */
+async function failInvoice(invoiceId) {
+  await client.markInvoiceFailed(invoiceId)
+}
+
 module.exports = {
   errors: recurly.errors,
 
@@ -706,6 +738,8 @@ module.exports = {
   subscriptionIsCanceledOrExpired,
   pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
   resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
+  getPastDueInvoices: callbackify(getPastDueInvoices),
+  failInvoice: callbackify(failInvoice),
 
   promises: {
     getSubscription,
@@ -726,5 +760,7 @@ module.exports = {
     getPaymentMethod,
     getAddOn,
     getPlan,
+    getPastDueInvoices,
+    failInvoice,
   },
 }

+ 31 - 1
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -410,6 +410,8 @@ async function purchaseAddon(req, res, next) {
 
   logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
   try {
+    // set a restore point in the case of a failed payment for the upgrade (Recurly only)
+    await SubscriptionHandler.promises.setSubscriptionRestorePoint(user._id)
     await SubscriptionHandler.promises.purchaseAddon(
       user._id,
       addOnCode,
@@ -574,7 +576,35 @@ function recurlyCallback(req, res, next) {
     )
   )
 
-  if (
+  // this is a recurly only case which is required since Recurly does not have a reliable way to check credit info pre-upgrade purchase
+  if (event === 'failed_payment_notification') {
+    if (!Settings.planReverts?.enabled) {
+      return res.sendStatus(200)
+    }
+
+    SubscriptionHandler.getSubscriptionRestorePoint(
+      eventData.transaction.subscription_id,
+      function (err, lastSubscription) {
+        if (err) {
+          return next(err)
+        }
+        // if theres no restore point it could be a failed renewal, or no restore set. Either way it will be handled through dunning automatically
+        if (!lastSubscription || !lastSubscription?.planCode) {
+          res.sendStatus(200)
+        }
+        SubscriptionHandler.revertPlanChange(
+          eventData.transaction.subscription_id,
+          lastSubscription,
+          function (err) {
+            if (err) {
+              return next(err)
+            }
+            res.sendStatus(200)
+          }
+        )
+      }
+    )
+  } else if (
     [
       'new_subscription_notification',
       'updated_subscription_notification',

+ 81 - 1
services/web/app/src/Features/Subscription/SubscriptionHandler.js

@@ -11,7 +11,7 @@ const LimitationsManager = require('./LimitationsManager')
 const EmailHandler = require('../Email/EmailHandler')
 const { callbackify } = require('@overleaf/promise-utils')
 const UserUpdater = require('../User/UserUpdater')
-const { NotFoundError } = require('../Errors/Errors')
+const { NotFoundError, IndeterminateInvoiceError } = require('../Errors/Errors')
 const Modules = require('../../infrastructure/Modules')
 
 /**
@@ -387,6 +387,80 @@ async function resumeSubscription(user) {
   )
 }
 
+/**
+ * @param recurlySubscriptionId
+ */
+async function getSubscriptionRestorePoint(recurlySubscriptionId) {
+  const lastSubscription =
+    await SubscriptionLocator.promises.getLastSuccessfulSubscription(
+      recurlySubscriptionId
+    )
+  return lastSubscription
+}
+
+/**
+ * @param recurlySubscriptionId
+ * @param subscriptionRestorePoint
+ */
+async function revertPlanChange(
+  recurlySubscriptionId,
+  subscriptionRestorePoint
+) {
+  const subscription = await RecurlyClient.promises.getSubscription(
+    recurlySubscriptionId
+  )
+
+  const changeRequest = subscription.getRequestForPlanRevert(
+    subscriptionRestorePoint.planCode,
+    subscriptionRestorePoint.addOns
+  )
+
+  const pastDue = await RecurlyClient.promises.getPastDueInvoices(
+    recurlySubscriptionId
+  )
+
+  // only process revert requests within the past 24 hours, as we dont want to restore plans at the end of their dunning cycle
+  const yesterday = new Date()
+  yesterday.setDate(yesterday.getDate() - 1)
+  if (
+    pastDue.length !== 1 ||
+    !pastDue[0].id ||
+    !pastDue[0].dueAt ||
+    pastDue[0].dueAt < yesterday ||
+    pastDue[0].collectionMethod !== 'automatic'
+  ) {
+    throw new IndeterminateInvoiceError(
+      'cant determine invoice to fail for plan revert',
+      {
+        info: { recurlySubscriptionId },
+      }
+    )
+  }
+
+  await RecurlyClient.promises.failInvoice(pastDue[0].id)
+  await SubscriptionUpdater.promises.setSubscriptionWasReverted(
+    subscriptionRestorePoint._id
+  )
+  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
+  await syncSubscription({ uuid: recurlySubscriptionId }, {})
+}
+
+async function setSubscriptionRestorePoint(userId) {
+  const subscription =
+    await SubscriptionLocator.promises.getUsersSubscription(userId)
+  // if the subscription is not a recurly one, we can return early as we dont allow for failed payments on other payment providers
+  //  we need to deal with it for recurly, because we cant verify payment in advance
+  if (!subscription?.recurlySubscription_id || !subscription.planCode) {
+    return
+  }
+  await SubscriptionUpdater.promises.setRestorePoint(
+    subscription.id,
+    subscription.planCode,
+    subscription.addOns,
+    false
+  )
+}
+
 module.exports = {
   validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
   createSubscription: callbackify(createSubscription),
@@ -403,6 +477,9 @@ module.exports = {
   removeAddon: callbackify(removeAddon),
   pauseSubscription: callbackify(pauseSubscription),
   resumeSubscription: callbackify(resumeSubscription),
+  revertPlanChange: callbackify(revertPlanChange),
+  setSubscriptionRestorePoint: callbackify(setSubscriptionRestorePoint),
+  getSubscriptionRestorePoint: callbackify(getSubscriptionRestorePoint),
   promises: {
     validateNoSubscriptionInRecurly,
     createSubscription,
@@ -419,5 +496,8 @@ module.exports = {
     removeAddon,
     pauseSubscription,
     resumeSubscription,
+    revertPlanChange,
+    setSubscriptionRestorePoint,
+    getSubscriptionRestorePoint,
   },
 }

+ 27 - 1
services/web/app/src/Features/Subscription/SubscriptionLocator.js

@@ -1,3 +1,7 @@
+/**
+ * @import { AddOn } from '../../../../types/subscription/plan'
+ */
+
 const { callbackifyAll } = require('@overleaf/promise-utils')
 const { Subscription } = require('../../models/Subscription')
 const { DeletedSubscription } = require('../../models/DeletedSubscription')
@@ -124,7 +128,8 @@ const SubscriptionLocator = {
     // todo: as opposed to recurlyEntities which use addon.code, subscription model uses addon.addOnCode
     //  which we hope to align via https://github.com/overleaf/internal/issues/25494
     return Boolean(
-      isStandaloneAiAddOnPlanCode(subscription?.planCode) ||
+      (subscription?.planCode &&
+        isStandaloneAiAddOnPlanCode(subscription?.planCode)) ||
         subscription?.addOns?.some(addOn => addOn.addOnCode === AI_ADD_ON_CODE)
     )
   },
@@ -136,6 +141,27 @@ const SubscriptionLocator = {
       return userOrId
     }
   },
+
+  /**
+   * Retrieves the last successful subscription for a given user.
+   *
+   * @async
+   * @function
+   * @param {string} recurlyId - The ID of the recurly subscription tied to the mongo subscription to check for a previous successful state.
+   * @returns {Promise<{_id: ObjectId, planCode: string, addOns: [AddOn]}|null>} A promise that resolves to the last successful planCode and addon state,
+   *   or null if we havent stored a previous
+   */
+  async getLastSuccessfulSubscription(recurlyId) {
+    const subscription = await Subscription.findOne({
+      recurlySubscription_id: recurlyId,
+    }).exec()
+    return subscription && subscription.lastSuccesfulSubscription
+      ? {
+          ...subscription.lastSuccesfulSubscription,
+          _id: subscription._id,
+        }
+      : null
+  },
 }
 
 module.exports = {

+ 54 - 0
services/web/app/src/Features/Subscription/SubscriptionUpdater.js

@@ -19,6 +19,7 @@ const Modules = require('../../infrastructure/Modules')
  * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription
  * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider
  * @typedef {import('../../../../types/group-management/group-audit-log').GroupAuditLog} GroupAuditLog
+ * @import { AddOn } from '../../../../types/subscription/plan'
  */
 
 /**
@@ -486,6 +487,53 @@ async function _sendSubscriptionEventForAllMembers(subscriptionId, event) {
   }
 }
 
+/**
+ * Sets the plan code and addon state to revert the plan to in case of failed upgrades, or clears the last restore point if it was used/ voided
+ * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for
+ * @param {string} planCode the plan code to revert to
+ * @param {Array<AddOn>} addOns the addOns to revert to
+ * @param {Boolean} consumed whether the restore point was used to revert a subscription
+ */
+async function setRestorePoint(subscriptionId, planCode, addOns, consumed) {
+  const update = {
+    $set: {
+      'lastSuccesfulSubscription.planCode': planCode,
+      'lastSuccesfulSubscription.addOns': addOns,
+    },
+  }
+
+  if (consumed) {
+    update.$inc = { revertedDueToFailedPayment: 1 }
+  }
+
+  await Subscription.updateOne({ _id: subscriptionId }, update).exec()
+}
+
+/**
+ * Clears the restore point for a given subscription, and signals that the subscription was sucessfully reverted.
+ *
+ * @async
+ * @function setSubscriptionWasReverted
+ * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for
+ * @returns {Promise<void>} Resolves when the restore point has been cleared.
+ */
+async function setSubscriptionWasReverted(subscriptionId) {
+  // consume the backup and flag that the subscription was reverted due to failed payment
+  await setRestorePoint(subscriptionId, null, null, true)
+}
+
+/**
+ * Clears the restore point for a given subscription, and signals that the subscription was not reverted.
+ *
+ * @async
+ * @function voidRestorePoint
+ * @param {string} subscriptionId - The unique identifier of the subscription.
+ * @returns {Promise<void>} Resolves when the restore point has been cleared.
+ */
+async function voidRestorePoint(subscriptionId) {
+  await setRestorePoint(subscriptionId, null, null, false)
+}
+
 module.exports = {
   updateAdmin: callbackify(updateAdmin),
   syncSubscription: callbackify(syncSubscription),
@@ -500,6 +548,9 @@ module.exports = {
   restoreSubscription: callbackify(restoreSubscription),
   updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly),
   scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures),
+  setSubscriptionRestorePoint: callbackify(setRestorePoint),
+  setSubscriptionWasReverted: callbackify(setSubscriptionWasReverted),
+  voidRestorePoint: callbackify(voidRestorePoint),
   promises: {
     updateAdmin,
     syncSubscription,
@@ -514,5 +565,8 @@ module.exports = {
     restoreSubscription,
     updateSubscriptionFromRecurly,
     scheduleRefreshFeatures,
+    setRestorePoint,
+    setSubscriptionWasReverted,
+    voidRestorePoint,
   },
 }

+ 7 - 0
services/web/app/src/models/Subscription.js

@@ -25,6 +25,13 @@ const SubscriptionSchema = new Schema(
     invited_emails: [String],
     teamInvites: [TeamInviteSchema],
     recurlySubscription_id: String,
+    lastSuccesfulSubscription: {
+      planCode: {
+        type: String,
+      },
+      addOns: Schema.Types.Mixed,
+    },
+    timesRevertedDueToFailedPayment: { type: Number, default: 0 },
     teamName: { type: String },
     teamNotice: { type: String },
     planCode: { type: String },