From 58b8e367396586c5010e605478ba00a6f0f0cffc Mon Sep 17 00:00:00 2001 From: roo hutton Date: Tue, 5 Aug 2025 11:31:17 +0100 Subject: [PATCH] Merge pull request #27215 from overleaf/rh-stripe-pause-status Update features and subscription state when Stripe pause starts and ends GitOrigin-RevId: 368f5d9b046cfe26e996be336189081b96926713 --- .../Subscription/PaymentProviderEntities.js | 2 + .../Subscription/SubscriptionHelper.js | 58 ++++++ .../Subscription/SubscriptionLocator.js | 5 + services/web/app/src/models/Subscription.js | 6 + .../src/Project/ProjectControllerTests.js | 1 + .../src/Subscription/FeaturesUpdaterTests.js | 1 + .../src/Subscription/RecurlyClientTests.js | 1 + .../Subscription/SubscriptionHelperTests.js | 193 ++++++++++++++++++ services/web/types/stripe/webhook-event.ts | 9 + 9 files changed, 276 insertions(+) diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index 5e64f634de..42206f6db1 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/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 } diff --git a/services/web/app/src/Features/Subscription/SubscriptionHelper.js b/services/web/app/src/Features/Subscription/SubscriptionHelper.js index 35c3b4c132..d65a7d3304 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHelper.js +++ b/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} - 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, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index a867cf698a..bb9013dcc5 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/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 }, diff --git a/services/web/app/src/models/Subscription.js b/services/web/app/src/models/Subscription.js index aa840a67de..b4fa238e59 100644 --- a/services/web/app/src/models/Subscription.js +++ b/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, }, diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 0acd900b90..cea42f1506 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/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, diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js b/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js index dccbff476c..86d6524ee6 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdaterTests.js +++ b/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': {}, }, }) }) diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 6194e35a5f..b2717fa05f 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -144,6 +144,7 @@ describe('RecurlyClient', function () { }, '../User/UserGetter': this.UserGetter, './Errors': this.Errors, + '../../models/Subscription': {}, }, })) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js index 4c618221ca..fae3e6e2e6 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js +++ b/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 + }) + }) + }) }) diff --git a/services/web/types/stripe/webhook-event.ts b/services/web/types/stripe/webhook-event.ts index 6f73ba8112..56e525a2c8 100644 --- a/services/web/types/stripe/webhook-event.ts +++ b/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