mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #27215 from overleaf/rh-stripe-pause-status
Update features and subscription state when Stripe pause starts and ends GitOrigin-RevId: 368f5d9b046cfe26e996be336189081b96926713
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ const SubscriptionSchema = new Schema(
|
||||
state: {
|
||||
type: String,
|
||||
},
|
||||
pausePeriodStart: {
|
||||
type: Date,
|
||||
},
|
||||
pausePeriodEnd: {
|
||||
type: Date,
|
||||
},
|
||||
trialStartedAt: {
|
||||
type: Date,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,6 +129,7 @@ describe('FeaturesUpdater', function () {
|
||||
'../Analytics/AnalyticsManager': this.AnalyticsManager,
|
||||
'../../infrastructure/Modules': this.Modules,
|
||||
'../../infrastructure/Queues': this.Queues,
|
||||
'../../models/Subscription': {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('RecurlyClient', function () {
|
||||
},
|
||||
'../User/UserGetter': this.UserGetter,
|
||||
'./Errors': this.Errors,
|
||||
'../../models/Subscription': {},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user