Przeglądaj źródła

Merge pull request #21869 from overleaf/em-repurchase-add-on

Repurchase the AI add-on when changing plans

GitOrigin-RevId: 1035e57af4c254fc73464f14010e4ba7e18cfe80
Eric Mc Sween 1 rok temu
rodzic
commit
087b612e16

+ 51 - 31
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -207,41 +207,53 @@ function subscriptionIsCanceledOrExpired(subscription) {
 /**
  * Build a RecurlySubscription from Recurly API data
  *
- * @param {recurly.Subscription} subscription
+ * @param {recurly.Subscription} apiSubscription
  * @return {RecurlySubscription}
  */
-function subscriptionFromApi(subscription) {
+function subscriptionFromApi(apiSubscription) {
   if (
-    subscription.uuid == null ||
-    subscription.plan == null ||
-    subscription.plan.code == null ||
-    subscription.plan.name == null ||
-    subscription.account == null ||
-    subscription.account.code == null ||
-    subscription.unitAmount == null ||
-    subscription.subtotal == null ||
-    subscription.total == null ||
-    subscription.currency == null ||
-    subscription.currentPeriodStartedAt == null ||
-    subscription.currentPeriodEndsAt == null
+    apiSubscription.uuid == null ||
+    apiSubscription.plan == null ||
+    apiSubscription.plan.code == null ||
+    apiSubscription.plan.name == null ||
+    apiSubscription.account == null ||
+    apiSubscription.account.code == null ||
+    apiSubscription.unitAmount == null ||
+    apiSubscription.subtotal == null ||
+    apiSubscription.total == null ||
+    apiSubscription.currency == null ||
+    apiSubscription.currentPeriodStartedAt == null ||
+    apiSubscription.currentPeriodEndsAt == null
   ) {
-    throw new OError('Invalid Recurly subscription', { subscription })
+    throw new OError('Invalid Recurly subscription', {
+      subscription: apiSubscription,
+    })
   }
-  return new RecurlySubscription({
-    id: subscription.uuid,
-    userId: subscription.account.code,
-    planCode: subscription.plan.code,
-    planName: subscription.plan.name,
-    planPrice: subscription.unitAmount,
-    addOns: (subscription.addOns ?? []).map(subscriptionAddOnFromApi),
-    subtotal: subscription.subtotal,
-    taxRate: subscription.taxInfo?.rate ?? 0,
-    taxAmount: subscription.tax ?? 0,
-    total: subscription.total,
-    currency: subscription.currency,
-    periodStart: subscription.currentPeriodStartedAt,
-    periodEnd: subscription.currentPeriodEndsAt,
+
+  const subscription = new RecurlySubscription({
+    id: apiSubscription.uuid,
+    userId: apiSubscription.account.code,
+    planCode: apiSubscription.plan.code,
+    planName: apiSubscription.plan.name,
+    planPrice: apiSubscription.unitAmount,
+    addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
+    subtotal: apiSubscription.subtotal,
+    taxRate: apiSubscription.taxInfo?.rate ?? 0,
+    taxAmount: apiSubscription.tax ?? 0,
+    total: apiSubscription.total,
+    currency: apiSubscription.currency,
+    periodStart: apiSubscription.currentPeriodStartedAt,
+    periodEnd: apiSubscription.currentPeriodEndsAt,
   })
+
+  if (apiSubscription.pendingChange != null) {
+    subscription.pendingChange = subscriptionChangeFromApi(
+      subscription,
+      apiSubscription.pendingChange
+    )
+  }
+
+  return subscription
 }
 
 /**
@@ -289,14 +301,22 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) {
   const nextAddOns = (subscriptionChange.addOns ?? []).map(
     subscriptionAddOnFromApi
   )
+
+  let immediateCharge =
+    subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0
+  for (const creditInvoice of subscriptionChange.invoiceCollection
+    ?.creditInvoices ?? []) {
+    // The credit invoice totals are already negative
+    immediateCharge += creditInvoice.total ?? 0
+  }
+
   return new RecurlySubscriptionChange({
     subscription,
     nextPlanCode: subscriptionChange.plan.code,
     nextPlanName: subscriptionChange.plan.name,
     nextPlanPrice: subscriptionChange.unitAmount,
     nextAddOns,
-    immediateCharge:
-      subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0,
+    immediateCharge,
   })
 }
 

+ 46 - 4
services/web/app/src/Features/Subscription/RecurlyEntities.js

@@ -24,6 +24,7 @@ class RecurlySubscription {
    * @param {number} props.total
    * @param {Date} props.periodStart
    * @param {Date} props.periodEnd
+   * @param {RecurlySubscriptionChange} [props.pendingChange]
    */
   constructor(props) {
     this.id = props.id
@@ -39,8 +40,15 @@ class RecurlySubscription {
     this.total = props.total
     this.periodStart = props.periodStart
     this.periodEnd = props.periodEnd
+    this.pendingChange = props.pendingChange ?? null
   }
 
+  /**
+   * Returns whether this subscription currently has the given add-on
+   *
+   * @param {string} code
+   * @return {boolean}
+   */
   hasAddOn(code) {
     return this.addOns.some(addOn => addOn.code === code)
   }
@@ -54,6 +62,25 @@ class RecurlySubscription {
     return isStandaloneAiAddOnPlanCode(this.planCode)
   }
 
+  /**
+   * Returns whether this subcription will have the given add-on next billing
+   * period.
+   *
+   * There are two cases: either the subscription already has the add-on and
+   * won't change next period, or the subscription will change next period and
+   * the change includes the add-on.
+   *
+   * @param {string} code
+   * @return {boolean}
+   */
+  hasAddOnNextPeriod(code) {
+    if (this.pendingChange != null) {
+      return this.pendingChange.nextAddOns.some(addOn => addOn.code === code)
+    } else {
+      return this.hasAddOn(code)
+    }
+  }
+
   /**
    * Change this subscription's plan
    *
@@ -70,16 +97,31 @@ class RecurlySubscription {
     if (newPlan == null) {
       throw new OError('Unable to find plan in settings', { planCode })
     }
-    const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
+    const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
       currentPlan,
       newPlan
     )
-    const timeframe = changeAtTermEnd ? 'term_end' : 'now'
-    return new RecurlySubscriptionChangeRequest({
+
+    const changeRequest = new RecurlySubscriptionChangeRequest({
       subscription: this,
-      timeframe,
+      timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
       planCode,
     })
+
+    // Carry the AI add-on to the new plan if applicable
+    if (
+      this.isStandaloneAiAddOn() ||
+      (!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) ||
+      (shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE))
+    ) {
+      const addOnUpdate = new RecurlySubscriptionAddOnUpdate({
+        code: AI_ADD_ON_CODE,
+        quantity: 1,
+      })
+      changeRequest.addOnUpdates = [addOnUpdate]
+    }
+
+    return changeRequest
   }
 
   /**

+ 3 - 2
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -3,6 +3,7 @@ const { expect } = require('chai')
 const recurly = require('recurly')
 const SandboxedModule = require('sandboxed-module')
 const {
+  RecurlySubscription,
   RecurlySubscriptionChangeRequest,
   RecurlySubscriptionAddOnUpdate,
 } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
@@ -35,7 +36,7 @@ describe('RecurlyClient', function () {
       preTaxTotal: 2,
     }
 
-    this.subscription = {
+    this.subscription = new RecurlySubscription({
       id: 'subscription-id',
       userId: 'user-id',
       currency: 'EUR',
@@ -49,7 +50,7 @@ describe('RecurlyClient', function () {
       total: 16.5,
       periodStart: new Date(),
       periodEnd: new Date(),
-    }
+    })
 
     this.recurlySubscription = {
       uuid: this.subscription.id,

+ 64 - 0
services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js

@@ -4,9 +4,11 @@ const SandboxedModule = require('sandboxed-module')
 const { expect } = require('chai')
 const Errors = require('../../../../app/src/Features/Subscription/Errors')
 const {
+  AI_ADD_ON_CODE,
   RecurlySubscriptionChangeRequest,
   RecurlySubscriptionChange,
   RecurlySubscription,
+  RecurlySubscriptionAddOnUpdate,
 } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
 
 const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
@@ -16,6 +18,7 @@ describe('RecurlyEntities', function () {
     beforeEach(function () {
       this.Settings = {
         plans: [
+          { planCode: 'assistant-annual', price_in_cents: 5900 },
           { planCode: 'cheap-plan', price_in_cents: 500 },
           { planCode: 'regular-plan', price_in_cents: 1000 },
           { planCode: 'premium-plan', price_in_cents: 2000 },
@@ -92,6 +95,67 @@ describe('RecurlyEntities', function () {
             })
           )
         })
+
+        it('preserves the AI add-on on upgrades', function () {
+          const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+          this.addOn.code = AI_ADD_ON_CODE
+          const changeRequest =
+            this.subscription.getRequestForPlanChange('premium-plan')
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscription: this.subscription,
+              timeframe: 'now',
+              planCode: 'premium-plan',
+              addOnUpdates: [
+                new RecurlySubscriptionAddOnUpdate({
+                  code: AI_ADD_ON_CODE,
+                  quantity: 1,
+                }),
+              ],
+            })
+          )
+        })
+
+        it('preserves the AI add-on on downgrades', function () {
+          const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+          this.addOn.code = AI_ADD_ON_CODE
+          const changeRequest =
+            this.subscription.getRequestForPlanChange('cheap-plan')
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscription: this.subscription,
+              timeframe: 'term_end',
+              planCode: 'cheap-plan',
+              addOnUpdates: [
+                new RecurlySubscriptionAddOnUpdate({
+                  code: AI_ADD_ON_CODE,
+                  quantity: 1,
+                }),
+              ],
+            })
+          )
+        })
+
+        it('preserves the AI add-on on upgrades from the standalone AI plan', function () {
+          const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+          this.subscription.planCode = 'assistant-annual'
+          this.subscription.addOns = []
+          const changeRequest =
+            this.subscription.getRequestForPlanChange('cheap-plan')
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscription: this.subscription,
+              timeframe: 'term_end',
+              planCode: 'cheap-plan',
+              addOnUpdates: [
+                new RecurlySubscriptionAddOnUpdate({
+                  code: AI_ADD_ON_CODE,
+                  quantity: 1,
+                }),
+              ],
+            })
+          )
+        })
       })
 
       describe('getRequestForAddOnPurchase()', function () {