فهرست منبع

Merge pull request #26574 from overleaf/ls-group-plan-seats-adding-in-stripe

Support group plan seats adding in Stripe

GitOrigin-RevId: 9c46c167388c5578a1513f908e409ab5d940c1df
Liangjun Song 1 سال پیش
والد
کامیت
c6b576b25a

+ 10 - 10
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -20,7 +20,6 @@ import {
   SubtotalLimitExceededError,
   HasPastDueInvoiceError,
 } from './Errors.js'
-import RecurlyClient from './RecurlyClient.js'
 
 /**
  * @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
@@ -138,13 +137,13 @@ async function _removeUserFromGroup(
 async function addSeatsToGroupSubscription(req, res) {
   try {
     const userId = SessionManager.getLoggedInUserId(req.session)
-    const { subscription, recurlySubscription, plan } =
+    const { subscription, paymentProviderSubscription, plan } =
       await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
         userId
       )
     await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
     await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
-      recurlySubscription
+      paymentProviderSubscription
     )
     await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
       subscription
@@ -162,15 +161,15 @@ async function addSeatsToGroupSubscription(req, res) {
 
     if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') {
       await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
-        recurlySubscription,
+        paymentProviderSubscription,
         userId
       )
     } else {
       await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
-        recurlySubscription
+        paymentProviderSubscription
       )
       // Check if the user has missing billing details
-      await RecurlyClient.promises.getPaymentMethod(userId)
+      await Modules.promises.hooks.fire('getPaymentMethod', userId)
     }
 
     res.render('subscriptions/add-seats', {
@@ -178,7 +177,8 @@ async function addSeatsToGroupSubscription(req, res) {
       groupName: subscription.teamName,
       totalLicenses: subscription.membersLimit,
       isProfessional: isProfessionalGroupPlan(subscription),
-      isCollectionMethodManual: recurlySubscription.isCollectionMethodManual,
+      isCollectionMethodManual:
+        paymentProviderSubscription.isCollectionMethodManual,
     })
   } catch (error) {
     if (error instanceof MissingBillingInfoError) {
@@ -300,15 +300,15 @@ async function submitForm(req, res) {
   const userEmail = await UserGetter.promises.getUserEmail(userId)
   const { adding, poNumber } = req.body
 
-  const { recurlySubscription } =
+  const { paymentProviderSubscription } =
     await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
       userId
     )
 
-  if (recurlySubscription.isCollectionMethodManual) {
+  if (paymentProviderSubscription.isCollectionMethodManual) {
     await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
       userId,
-      recurlySubscription,
+      paymentProviderSubscription,
       poNumber
     )
   }

+ 64 - 50
services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js

@@ -9,7 +9,6 @@ const { Subscription } = require('../../models/Subscription')
 const { User } = require('../../models/User')
 const RecurlyClient = require('./RecurlyClient')
 const PlansLocator = require('./PlansLocator')
-const SubscriptionHandler = require('./SubscriptionHandler')
 const TeamInvitesHandler = require('./TeamInvitesHandler')
 const GroupPlansData = require('./GroupPlansData')
 const Modules = require('../../infrastructure/Modules')
@@ -86,22 +85,24 @@ async function ensureSubscriptionIsActive(subscription) {
 }
 
 async function ensureSubscriptionCollectionMethodIsNotManual(
-  recurlySubscription
+  paymentProviderSubscription
 ) {
-  if (recurlySubscription.isCollectionMethodManual) {
+  if (paymentProviderSubscription.isCollectionMethodManual) {
     throw new ManuallyCollectedError(
       'This subscription is being collected manually',
       {
-        recurlySubscription_id: recurlySubscription.id,
+        subscription_id: paymentProviderSubscription.id,
       }
     )
   }
 }
 
-async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) {
-  if (recurlySubscription.pendingChange) {
+async function ensureSubscriptionHasNoPendingChanges(
+  paymentProviderSubscription
+) {
+  if (paymentProviderSubscription.pendingChange) {
     throw new PendingChangeError('This subscription has a pending change', {
-      recurlySubscription_id: recurlySubscription.id,
+      subscription_id: paymentProviderSubscription.id,
     })
   }
 }
@@ -136,47 +137,50 @@ async function getUsersGroupSubscriptionDetails(userId) {
 
   const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
 
-  const recurlySubscription = await RecurlyClient.promises.getSubscription(
-    subscription.recurlySubscription_id
+  const response = await Modules.promises.hooks.fire(
+    'getPaymentFromRecord',
+    subscription
   )
 
+  const { subscription: paymentProviderSubscription } = response[0]
+
   return {
     userId,
     subscription,
-    recurlySubscription,
+    paymentProviderSubscription,
     plan,
   }
 }
 
-async function checkBillingInfoExistence(recurlySubscription, userId) {
+async function checkBillingInfoExistence(paymentProviderSubscription, userId) {
   // Verify the billing info only if the collection method is not manual (e.g. automatic)
-  if (!recurlySubscription.isCollectionMethodManual) {
+  if (!paymentProviderSubscription.isCollectionMethodManual) {
     // Check if the user has missing billing details
-    await RecurlyClient.promises.getPaymentMethod(userId)
+    await Modules.promises.hooks.fire('getPaymentMethod', userId)
   }
 }
 
 async function _addSeatsSubscriptionChange(userId, adding) {
-  const { subscription, recurlySubscription, plan } =
+  const { subscription, paymentProviderSubscription, plan } =
     await getUsersGroupSubscriptionDetails(userId)
   await ensureFlexibleLicensingEnabled(plan)
   await ensureSubscriptionIsActive(subscription)
-  await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
-  await checkBillingInfoExistence(recurlySubscription, userId)
+  await ensureSubscriptionHasNoPendingChanges(paymentProviderSubscription)
+  await checkBillingInfoExistence(paymentProviderSubscription, userId)
   await ensureSubscriptionHasNoPastDueInvoice(subscription)
 
   const currentAddonQuantity =
-    recurlySubscription.addOns.find(
+    paymentProviderSubscription.addOns.find(
       addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE
     )?.quantity ?? 0
   // Keeps only the new total quantity of addon
   const nextAddonQuantity = currentAddonQuantity + adding
 
   let changeRequest
-  if (recurlySubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) {
+  if (paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) {
     // Not providing a custom price as once the subscription is locked
     // to an add-on at a given price, it will use it for subsequent payments
-    changeRequest = recurlySubscription.getRequestForAddOnUpdate(
+    changeRequest = paymentProviderSubscription.getRequestForAddOnUpdate(
       MEMBERS_LIMIT_ADD_ON_CODE,
       nextAddonQuantity
     )
@@ -185,7 +189,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
     const pattern =
       /^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/
     const [, planCode, size, usage] = plan.planCode.match(pattern)
-    const currency = recurlySubscription.currency
+    const currency = paymentProviderSubscription.currency
     const planPriceInCents =
       GroupPlansData[usage][planCode][currency][size].price_in_cents
     const legacyUnitPriceInCents =
@@ -194,7 +198,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
 
     if (
       _shouldUseLegacyPricing(
-        recurlySubscription.planPrice,
+        paymentProviderSubscription.planPrice,
         planPriceInCents / 100,
         usage,
         size
@@ -203,7 +207,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
       unitPrice = legacyUnitPriceInCents / 100
     }
 
-    changeRequest = recurlySubscription.getRequestForAddOnPurchase(
+    changeRequest = paymentProviderSubscription.getRequestForAddOnPurchase(
       MEMBERS_LIMIT_ADD_ON_CODE,
       nextAddonQuantity,
       unitPrice
@@ -213,7 +217,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
   return {
     changeRequest,
     currentAddonQuantity,
-    recurlySubscription,
+    paymentProviderSubscription,
   }
 }
 
@@ -237,37 +241,38 @@ function _shouldUseLegacyPricing(
 async function previewAddSeatsSubscriptionChange(userId, adding) {
   const { changeRequest, currentAddonQuantity } =
     await _addSeatsSubscriptionChange(userId, adding)
-  const subscriptionChange =
-    await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
-  const subscriptionChangePreview =
-    await SubscriptionController.makeChangePreview(
-      {
-        type: 'add-on-update',
-        addOn: {
-          code: MEMBERS_LIMIT_ADD_ON_CODE,
-          quantity: subscriptionChange.nextAddOns.find(
-            addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE
-          ).quantity,
-          prevQuantity: currentAddonQuantity,
-        },
+  const response = await Modules.promises.hooks.fire(
+    'previewSubscriptionChangeRequest',
+    changeRequest
+  )
+  const subscriptionChange = response[0]
+  const subscriptionChangePreview = SubscriptionController.makeChangePreview(
+    {
+      type: 'add-on-update',
+      addOn: {
+        code: MEMBERS_LIMIT_ADD_ON_CODE,
+        quantity: subscriptionChange.nextAddOns.find(
+          addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE
+        ).quantity,
+        prevQuantity: currentAddonQuantity,
       },
-      subscriptionChange
-    )
+    },
+    subscriptionChange
+  )
 
   return subscriptionChangePreview
 }
 
 async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
-  const { changeRequest, recurlySubscription } =
+  const { changeRequest, paymentProviderSubscription } =
     await _addSeatsSubscriptionChange(userId, adding)
 
-  if (recurlySubscription.isCollectionMethodManual) {
-    await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber)
+  if (paymentProviderSubscription.isCollectionMethodManual) {
+    await updateSubscriptionPaymentTerms(paymentProviderSubscription, poNumber)
   }
-
-  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
-  await SubscriptionHandler.promises.syncSubscription(
-    { uuid: recurlySubscription.id },
+  await Modules.promises.hooks.fire(
+    'applySubscriptionChangeRequestAndSync',
+    changeRequest,
     userId
   )
 
@@ -275,21 +280,30 @@ async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
 }
 
 async function updateSubscriptionPaymentTerms(
-  userId,
-  recurlySubscription,
+  paymentProviderSubscription,
   poNumber
 ) {
+  if (paymentProviderSubscription.service?.includes('stripe')) {
+    // TODO: Implement Stripe payment terms update
+    throw new OError(
+      'Updating payment terms is not supported for Stripe subscriptions',
+      {
+        subscriptionId: paymentProviderSubscription.id,
+      }
+    )
+  }
+
   const [termsAndConditions] = await Modules.promises.hooks.fire(
     'generateTermsAndConditions',
-    { currency: recurlySubscription.currency, poNumber }
+    { currency: paymentProviderSubscription.currency, poNumber }
   )
 
   const updateRequest = poNumber
-    ? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
+    ? paymentProviderSubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
         poNumber,
         termsAndConditions
       )
-    : recurlySubscription.getRequestForTermsAndConditionsUpdate(
+    : paymentProviderSubscription.getRequestForTermsAndConditionsUpdate(
         termsAndConditions
       )
 

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

@@ -74,7 +74,7 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
  */
 async function previewSubscriptionChange(userId, planCode) {
   const change = await Modules.promises.hooks.fire(
-    'previewSubscriptionChange',
+    'previewPlanChange',
     userId,
     planCode
   )

+ 1 - 1
services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs

@@ -55,7 +55,7 @@ describe('SubscriptionGroupController', function () {
         getUsersGroupSubscriptionDetails: sinon.stub().resolves({
           subscription: ctx.subscription,
           plan: ctx.plan,
-          recurlySubscription: ctx.recurlySubscription,
+          paymentProviderSubscription: ctx.recurlySubscription,
         }),
         previewAddSeatsSubscriptionChange: sinon
           .stub()

+ 60 - 36
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -193,21 +193,26 @@ describe('SubscriptionGroupHandler', function () {
     this.Modules = {
       promises: {
         hooks: {
-          fire: sinon.stub().callsFake(hookName => {
-            if (hookName === 'generateTermsAndConditions') {
-              return Promise.resolve(['T&Cs'])
-            }
-            if (hookName === 'getPaymentFromRecord') {
-              return Promise.resolve([
-                { account: { hasPastDueInvoice: false } },
-              ])
-            }
-            return Promise.resolve()
-          }),
+          fire: sinon.stub(),
         },
       },
     }
 
+    this.Modules.promises.hooks.fire
+      .withArgs('generateTermsAndConditions')
+      .resolves(['T&Cs'])
+      .withArgs('getPaymentFromRecord')
+      .resolves([
+        {
+          subscription: this.recurlySubscription,
+          account: { hasPastDueInvoice: false },
+        },
+      ])
+      .withArgs('previewSubscriptionChangeRequest')
+      .resolves([this.previewSubscriptionChange])
+      .withArgs('previewGroupPlanUpgrade')
+      .resolves([{ subscriptionChange: this.previewSubscriptionChange }])
+
     this.Handler = SandboxedModule.require(modulePath, {
       requires: {
         './SubscriptionUpdater': this.SubscriptionUpdater,
@@ -389,7 +394,7 @@ describe('SubscriptionGroupHandler', function () {
             this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
           canUseFlexibleLicensing: true,
         },
-        recurlySubscription: this.recurlySubscription,
+        paymentProviderSubscription: this.recurlySubscription,
       })
     })
   })
@@ -441,11 +446,16 @@ describe('SubscriptionGroupHandler', function () {
               this.adminUser_id,
               this.adding
             )
-          this.RecurlyClient.promises.getPaymentMethod
-            .calledWith(this.adminUser_id)
+          this.Modules.promises.hooks.fire
+            .calledWith('getPaymentFromRecord', {
+              groupPlan: true,
+              recurlyStatus: {
+                state: 'active',
+              },
+            })
             .should.equal(true)
-          this.RecurlyClient.promises.previewSubscriptionChange
-            .calledWith(this.changeRequest)
+          this.Modules.promises.hooks.fire
+            .calledWith('previewSubscriptionChangeRequest', this.changeRequest)
             .should.equal(true)
           this.SubscriptionController.makeChangePreview
             .calledWith(
@@ -473,9 +483,14 @@ describe('SubscriptionGroupHandler', function () {
               return true
             },
           }
-          this.RecurlyClient.promises.getSubscription = sinon
-            .stub()
-            .resolves(this.recurlySubscription)
+          this.Modules.promises.hooks.fire
+            .withArgs('getPaymentFromRecord')
+            .resolves([
+              {
+                subscription: this.recurlySubscription,
+                account: { hasPastDueInvoice: false },
+              },
+            ])
 
           const result =
             await this.Handler.promises.createAddSeatsSubscriptionChange(
@@ -491,13 +506,10 @@ describe('SubscriptionGroupHandler', function () {
                 .and(sinon.match.has('termsAndConditions'))
             )
             .should.equal(true)
-          this.RecurlyClient.promises.applySubscriptionChangeRequest
-            .calledWith(this.changeRequest)
-            .should.equal(true)
-          this.SubscriptionHandler.promises.syncSubscription
+          this.Modules.promises.hooks.fire
             .calledWith(
-              { uuid: this.recurlySubscription.id },
-              this.adminUser_id
+              'applySubscriptionChangeRequestAndSync',
+              this.changeRequest
             )
             .should.equal(true)
           expect(result).to.deep.equal({
@@ -511,7 +523,6 @@ describe('SubscriptionGroupHandler', function () {
       describe('accounts with PO number', function () {
         it('should update the subscription PO number and T&C', async function () {
           await this.Handler.promises.updateSubscriptionPaymentTerms(
-            this.adminUser_id,
             this.recurlySubscription,
             this.poNumberAndTermsAndConditionsUpdate.poNumber
           )
@@ -525,12 +536,23 @@ describe('SubscriptionGroupHandler', function () {
             .calledWith(this.poNumberAndTermsAndConditionsUpdate)
             .should.equal(true)
         })
+
+        it('should fail for stripe', async function () {
+          this.recurlySubscription.service = 'stripe'
+          await expect(
+            this.Handler.promises.updateSubscriptionPaymentTerms(
+              this.recurlySubscription,
+              this.poNumberAndTermsAndConditionsUpdate.poNumber
+            )
+          ).to.be.rejectedWith(
+            'Updating payment terms is not supported for Stripe subscriptions'
+          )
+        })
       })
 
       describe('accounts with no PO number', function () {
         it('should update the subscription T&C only', async function () {
           await this.Handler.promises.updateSubscriptionPaymentTerms(
-            this.adminUser_id,
             this.recurlySubscription
           )
           this.recurlySubscription.getRequestForTermsAndConditionsUpdate
@@ -570,11 +592,16 @@ describe('SubscriptionGroupHandler', function () {
         let preview
 
         afterEach(function () {
-          this.RecurlyClient.promises.getPaymentMethod
-            .calledWith(this.adminUser_id)
+          this.Modules.promises.hooks.fire
+            .calledWith('getPaymentFromRecord', {
+              groupPlan: true,
+              recurlyStatus: {
+                state: 'active',
+              },
+            })
             .should.equal(true)
-          this.RecurlyClient.promises.previewSubscriptionChange
-            .calledWith(this.changeRequest)
+          this.Modules.promises.hooks.fire
+            .calledWith('previewSubscriptionChangeRequest', this.changeRequest)
             .should.equal(true)
           this.SubscriptionController.makeChangePreview
             .calledWith(
@@ -781,9 +808,6 @@ describe('SubscriptionGroupHandler', function () {
 
   describe('getGroupPlanUpgradePreview', function () {
     it('should generate preview for subscription upgrade', async function () {
-      this.Modules.promises.hooks.fire.resolves([
-        { subscriptionChange: this.previewSubscriptionChange },
-      ])
       const result = await this.Handler.promises.getGroupPlanUpgradePreview(
         this.user_id
       )
@@ -797,8 +821,8 @@ describe('SubscriptionGroupHandler', function () {
         this.recurlySubscription,
         this.adminUser_id
       )
-      this.RecurlyClient.promises.getPaymentMethod
-        .calledWith(this.adminUser_id)
+      this.Modules.promises.hooks.fire
+        .calledWith('getPaymentMethod', this.adminUser_id)
         .should.equal(true)
     })