Răsfoiți Sursa

Merge pull request #27215 from overleaf/rh-stripe-pause-status

Update features and subscription state when Stripe pause starts and ends

GitOrigin-RevId: 368f5d9b046cfe26e996be336189081b96926713
roo hutton 1 an în urmă
părinte
comite
58b8e36739

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

@@ -40,6 +40,7 @@ class PaymentProviderSubscription {
    * @param {Date|null} [props.trialPeriodStart]
    * @param {Date|null} [props.trialPeriodEnd]
    * @param {Date|null} [props.pausePeriodStart]
+   * @param {Date|null} [props.pausePeriodEnd]
    * @param {number|null} [props.remainingPauseCycles]
    */
   constructor(props) {
@@ -66,6 +67,7 @@ class PaymentProviderSubscription {
     this.trialPeriodStart = props.trialPeriodStart ?? null
     this.trialPeriodEnd = props.trialPeriodEnd ?? null
     this.pausePeriodStart = props.pausePeriodStart ?? null
+    this.pausePeriodEnd = props.pausePeriodEnd ?? null
     this.remainingPauseCycles = props.remainingPauseCycles ?? null
   }
 

+ 58 - 0
services/web/app/src/Features/Subscription/SubscriptionHelper.js

@@ -1,6 +1,63 @@
 const { formatCurrency } = require('../../util/currency')
 const GroupPlansData = require('./GroupPlansData')
 const { isStandaloneAiAddOnPlanCode } = require('./AiHelper')
+const { Subscription } = require('../../models/Subscription')
+
+const MILLISECONDS = 1_000
+/**
+ * Recompute the subscription state for Stripe subscriptions based on pause periods.
+ * This function checks if a subscription should transition between 'active' and 'paused'
+ * states based on the current time and pause period metadata.
+ *
+ * @param {Object} subscription - The MongoDB subscription document
+ * @returns {Promise<Object>} - The updated subscription document with recomputed state
+ */
+async function recomputeSubscriptionState(subscription) {
+  if (
+    !subscription?.paymentProvider?.subscriptionId ||
+    !subscription.paymentProvider.pausePeriodStart ||
+    !subscription.paymentProvider.pausePeriodEnd ||
+    !subscription?.paymentProvider.service.includes('stripe')
+  ) {
+    return subscription
+  }
+  const now = Date.now() / MILLISECONDS
+  const pauseStartTime =
+    new Date(subscription.paymentProvider.pausePeriodStart).getTime() /
+    MILLISECONDS
+  const currentState = subscription.paymentProvider.state
+
+  const pauseEndTime =
+    new Date(subscription.paymentProvider.pausePeriodEnd).getTime() /
+    MILLISECONDS
+
+  const shouldBePaused =
+    pauseEndTime && now >= pauseStartTime && now < pauseEndTime
+
+  let newState
+
+  if (shouldBePaused && currentState !== 'paused') {
+    newState = 'paused'
+  } else if (
+    !shouldBePaused &&
+    currentState === 'paused' &&
+    pauseEndTime &&
+    now >= pauseEndTime
+  ) {
+    newState = 'active'
+  }
+
+  if (newState) {
+    await Subscription.updateOne(
+      { _id: subscription._id },
+      { 'paymentProvider.state': newState }
+    ).exec()
+
+    subscription.paymentProvider.state = newState
+  }
+
+  return subscription
+}
 
 /**
  * If the user changes to a less expensive plan, we shouldn't apply the change immediately.
@@ -149,4 +206,5 @@ module.exports = {
   getSubscriptionTrialStartedAt,
   getSubscriptionTrialEndsAt,
   isInTrial,
+  recomputeSubscriptionState,
 }

+ 5 - 0
services/web/app/src/Features/Subscription/SubscriptionLocator.js

@@ -15,6 +15,11 @@ const SubscriptionLocator = {
     const userId = SubscriptionLocator._getUserId(userOrId)
     const subscription = await Subscription.findOne({ admin_id: userId }).exec()
     logger.debug({ userId }, 'got users subscription')
+
+    if (subscription) {
+      return await SubscriptionHelper.recomputeSubscriptionState(subscription)
+    }
+
     return subscription
   },
 

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

@@ -76,6 +76,12 @@ const SubscriptionSchema = new Schema(
       state: {
         type: String,
       },
+      pausePeriodStart: {
+        type: Date,
+      },
+      pausePeriodEnd: {
+        type: Date,
+      },
       trialStartedAt: {
         type: Date,
       },

+ 1 - 0
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -238,6 +238,7 @@ describe('ProjectController', function () {
         '../Subscription/LimitationsManager': this.LimitationsManager,
         '../Tags/TagsHandler': this.TagsHandler,
         '../../models/User': { User: this.UserModel },
+        '../../models/Subscription': {},
         '../Authorization/AuthorizationManager': this.AuthorizationManager,
         '../InactiveData/InactiveProjectManager': this.InactiveProjectManager,
         './ProjectUpdateHandler': this.ProjectUpdateHandler,

+ 1 - 0
services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js

@@ -129,6 +129,7 @@ describe('FeaturesUpdater', function () {
         '../Analytics/AnalyticsManager': this.AnalyticsManager,
         '../../infrastructure/Modules': this.Modules,
         '../../infrastructure/Queues': this.Queues,
+        '../../models/Subscription': {},
       },
     })
   })

+ 1 - 0
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -144,6 +144,7 @@ describe('RecurlyClient', function () {
         },
         '../User/UserGetter': this.UserGetter,
         './Errors': this.Errors,
+        '../../models/Subscription': {},
       },
     }))
   })

+ 193 - 0
services/web/test/unit/src/Subscription/SubscriptionHelperTests.js

@@ -1,5 +1,6 @@
 const SandboxedModule = require('sandboxed-module')
 const { expect } = require('chai')
+const sinon = require('sinon')
 const modulePath =
   '../../../../app/src/Features/Subscription/SubscriptionHelper'
 
@@ -31,6 +32,23 @@ const plans = {
 
 describe('SubscriptionHelper', function () {
   beforeEach(function () {
+    this.clock = sinon.useFakeTimers(new Date('2023-06-15T10:00:00Z'))
+    this.Subscription = {
+      findOne: sinon.stub().returns({
+        exec: sinon.stub().resolves(),
+      }),
+      updateOne: sinon.stub().returns({
+        exec: sinon.stub().resolves(),
+      }),
+      find: sinon.stub().returns({
+        populate: sinon.stub().returns({
+          populate: sinon.stub().returns({
+            exec: sinon.stub().resolves([]),
+          }),
+        }),
+        exec: sinon.stub().resolves(),
+      }),
+    }
     this.INITIAL_LICENSE_SIZE = 2
     this.settings = {
       groupPlanModalOptions: {},
@@ -94,7 +112,13 @@ describe('SubscriptionHelper', function () {
       },
     }
     this.SubscriptionHelper = SandboxedModule.require(modulePath, {
+      globals: {
+        Date: this.clock.Date,
+      },
       requires: {
+        '../../models/Subscription': {
+          Subscription: this.Subscription,
+        },
         '@overleaf/settings': this.settings,
         './GroupPlansData': this.GroupPlansData,
       },
@@ -499,4 +523,173 @@ describe('SubscriptionHelper', function () {
       expect(result).to.be.true
     })
   })
+
+  describe('recomputeSubscriptionState', function () {
+    beforeEach(function () {
+      this.clock.now = new Date('2023-06-15T10:00:00Z')
+
+      this.baseSubscription = {
+        _id: 'subscription_id',
+        paymentProvider: {
+          service: 'stripe-test',
+          subscriptionId: 'stripe_sub_123',
+          state: 'active',
+          pausePeriodStart: '2023-06-15T09:00:00Z',
+          pausePeriodEnd: '2023-06-15T11:00:00Z',
+        },
+      }
+    })
+
+    describe('when subscription has no paymentProvider subscriptionId', function () {
+      it('should return subscription unchanged', async function () {
+        const subscription = { _id: 'subscription_id' }
+        const result =
+          await this.SubscriptionHelper.recomputeSubscriptionState(subscription)
+        expect(result).to.equal(subscription)
+      })
+    })
+
+    describe('when subscription has no pausePeriodStart', function () {
+      it('should return subscription unchanged', async function () {
+        const subscription = {
+          _id: 'subscription_id',
+          paymentProvider: {
+            subscriptionId: 'stripe_sub_123',
+            state: 'active',
+          },
+        }
+        const result =
+          await this.SubscriptionHelper.recomputeSubscriptionState(subscription)
+        expect(result).to.equal(subscription)
+      })
+    })
+
+    describe('when subscription should be paused', function () {
+      describe('and current state is active', function () {
+        it('should change state to paused', async function () {
+          const subscription = { ...this.baseSubscription }
+          const result =
+            await this.SubscriptionHelper.recomputeSubscriptionState(
+              subscription
+            )
+
+          expect(result.paymentProvider.state).to.equal('paused')
+          expect(this.Subscription.updateOne).to.have.been.calledWith(
+            { _id: 'subscription_id' },
+            { 'paymentProvider.state': 'paused' }
+          )
+        })
+      })
+
+      describe('and current state is already paused', function () {
+        it('should not change state', async function () {
+          const subscription = {
+            ...this.baseSubscription,
+            paymentProvider: {
+              ...this.baseSubscription.paymentProvider,
+              state: 'paused',
+            },
+          }
+          const result =
+            await this.SubscriptionHelper.recomputeSubscriptionState(
+              subscription
+            )
+
+          expect(result.paymentProvider.state).to.equal('paused')
+          expect(this.Subscription.updateOne.called).to.be.false
+        })
+      })
+    })
+
+    describe('when subscription should not be paused', function () {
+      describe('before pause period starts', function () {
+        beforeEach(function () {
+          this.clock.now = new Date('2023-06-15T08:00:00Z')
+        })
+
+        it('should keep active state unchanged', async function () {
+          const subscription = { ...this.baseSubscription }
+          const result =
+            await this.SubscriptionHelper.recomputeSubscriptionState(
+              subscription
+            )
+
+          expect(result.paymentProvider.state).to.equal('active')
+          expect(this.Subscription.updateOne.called).to.be.false
+        })
+      })
+
+      describe('after pause period ends', function () {
+        beforeEach(function () {
+          this.clock.now = new Date('2023-06-15T12:00:00Z')
+        })
+
+        describe('and current state is paused', function () {
+          it('should change state to active', async function () {
+            const subscription = {
+              ...this.baseSubscription,
+              paymentProvider: {
+                ...this.baseSubscription.paymentProvider,
+                state: 'paused',
+              },
+            }
+            const result =
+              await this.SubscriptionHelper.recomputeSubscriptionState(
+                subscription
+              )
+
+            expect(result.paymentProvider.state).to.equal('active')
+            expect(this.Subscription.updateOne).to.have.been.calledWith(
+              { _id: 'subscription_id' },
+              { 'paymentProvider.state': 'active' }
+            )
+          })
+        })
+
+        describe('and current state is already active', function () {
+          it('should keep state unchanged', async function () {
+            const subscription = { ...this.baseSubscription }
+            const result =
+              await this.SubscriptionHelper.recomputeSubscriptionState(
+                subscription
+              )
+
+            expect(result.paymentProvider.state).to.equal('active')
+            expect(this.Subscription.updateOne.called).to.be.false
+          })
+        })
+      })
+    })
+
+    describe('when subscription has no pausePeriodEnd (indefinite pause)', function () {
+      beforeEach(function () {
+        this.baseSubscription.paymentProvider.pausePeriodEnd = undefined
+      })
+
+      it('should not transition to paused state when pausePeriodEnd is missing', async function () {
+        const subscription = { ...this.baseSubscription }
+        const result =
+          await this.SubscriptionHelper.recomputeSubscriptionState(subscription)
+
+        expect(result.paymentProvider.state).to.equal('active')
+        expect(this.Subscription.updateOne.called).to.be.false
+      })
+
+      it('should keep paused state when already paused and no end date', async function () {
+        const subscription = {
+          ...this.baseSubscription,
+          paymentProvider: {
+            ...this.baseSubscription.paymentProvider,
+            state: 'paused',
+            pausePeriodEnd: undefined,
+          },
+        }
+        const result =
+          await this.SubscriptionHelper.recomputeSubscriptionState(subscription)
+
+        expect(result.paymentProvider.state).to.equal('paused')
+        expect(this.Subscription.updateOne.called).to.be.false
+      })
+    })
+  })
 })

+ 9 - 0
services/web/types/stripe/webhook-event.ts

@@ -74,6 +74,14 @@ export type PaymentIntentPaymentFailedWebhookEvent = {
   request: Stripe.Event.Request
 }
 
+export type InvoiceVoidedWebhookEvent = {
+  type: 'invoice.voided'
+  data: {
+    object: Stripe.Invoice
+  }
+  request: Stripe.Event.Request
+}
+
 export type CustomerSubscriptionWebhookEvent =
   | CustomerSubscriptionUpdatedWebhookEvent
   | CustomerSubscriptionCreatedWebhookEvent
@@ -82,4 +90,5 @@ export type CustomerSubscriptionWebhookEvent =
 export type WebhookEvent =
   | CustomerSubscriptionWebhookEvent
   | InvoicePaidWebhookEvent
+  | InvoiceVoidedWebhookEvent
   | PaymentIntentPaymentFailedWebhookEvent