Files
overleaf-cep/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js
T
roo hutton 58b8e36739 Merge pull request #27215 from overleaf/rh-stripe-pause-status
Update features and subscription state when Stripe pause starts and ends

GitOrigin-RevId: 368f5d9b046cfe26e996be336189081b96926713
2025-08-06 08:04:57 +00:00

696 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const sinon = require('sinon')
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionHelper'
const plans = {
expensive: {
planCode: 'expensive',
price_in_cents: 1500,
},
cheaper: {
planCode: 'cheaper',
price_in_cents: 500,
},
alsoCheap: {
plancode: 'also-cheap',
price_in_cents: 500,
},
expensiveGroup: {
plancode: 'group_expensive',
price_in_cents: 49500,
groupPlan: true,
},
cheapGroup: {
plancode: 'group_cheap',
price_in_cents: 1000,
groupPlan: true,
},
bad: {},
}
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: {},
}
this.GroupPlansData = {
enterprise: {
collaborator: {
CHF: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 1000,
},
},
DKK: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 2000,
},
},
SEK: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 3000,
},
},
NOK: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 4000,
},
},
USD: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 5000,
},
},
},
professional: {
CHF: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 10000,
},
},
DKK: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 20000,
},
},
SEK: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 30000,
},
},
NOK: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 40000,
},
},
USD: {
[this.INITIAL_LICENSE_SIZE]: {
price_in_cents: 50000,
},
},
},
},
}
this.SubscriptionHelper = SandboxedModule.require(modulePath, {
globals: {
Date: this.clock.Date,
},
requires: {
'../../models/Subscription': {
Subscription: this.Subscription,
},
'@overleaf/settings': this.settings,
'./GroupPlansData': this.GroupPlansData,
},
})
})
describe('shouldPlanChangeAtTermEnd', function () {
it('should return false if isInTrial is true', function () {
const isInTrial = true
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.cheaper,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return true if the new plan is less expensive', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.cheaper,
isInTrial
)
expect(changeAtTermEnd).to.be.true
})
it('should return false if the new plan is more exepensive', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.cheaper,
plans.expensive,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return false if the new plan is the same price', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.cheaper,
plans.alsoCheap,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return false if the change is from an individual plan to a more expensive group plan', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.expensiveGroup,
isInTrial
)
expect(changeAtTermEnd).to.be.false
})
it('should return true if the change is from an individual plan to a cheaper group plan', function () {
const isInTrial = false
const changeAtTermEnd = this.SubscriptionHelper.shouldPlanChangeAtTermEnd(
plans.expensive,
plans.cheapGroup,
isInTrial
)
expect(changeAtTermEnd).to.be.true
})
})
describe('generateInitialLocalizedGroupPrice', function () {
describe('CHF currency', function () {
it('should return the correct localized price for every plan', function () {
const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'CHF',
'fr'
)
expect(localizedPrice).to.deep.equal({
price: {
collaborator: '10 CHF',
professional: '100 CHF',
},
pricePerUser: {
collaborator: '5 CHF',
professional: '50 CHF',
},
})
})
})
describe('DKK currency', function () {
it('should return the correct localized price for every plan', function () {
const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'DKK',
'da'
)
expect(localizedPrice).to.deep.equal({
price: {
collaborator: '20 kr.',
professional: '200 kr.',
},
pricePerUser: {
collaborator: '10 kr.',
professional: '100 kr.',
},
})
})
})
describe('SEK currency', function () {
it('should return the correct localized price for every plan', function () {
const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'SEK',
'sv'
)
expect(localizedPrice).to.deep.equal({
price: {
collaborator: '30 kr',
professional: '300 kr',
},
pricePerUser: {
collaborator: '15 kr',
professional: '150 kr',
},
})
})
})
describe('NOK currency', function () {
it('should return the correct localized price for every plan', function () {
const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'NOK',
// there seem to be possible inconsistencies with the CI
// maybe it depends on what languages are installed on the server?
'en'
)
expect(localizedPrice).to.deep.equal({
price: {
collaborator: 'kr 40',
professional: 'kr 400',
},
pricePerUser: {
collaborator: 'kr 20',
professional: 'kr 200',
},
})
})
})
describe('other supported currencies', function () {
it('should return the correct localized price for every plan', function () {
const localizedPrice =
this.SubscriptionHelper.generateInitialLocalizedGroupPrice(
'USD',
'en'
)
expect(localizedPrice).to.deep.equal({
price: {
collaborator: '$50',
professional: '$500',
},
pricePerUser: {
collaborator: '$25',
professional: '$250',
},
})
})
})
})
describe('isPaidSubscription', function () {
it('should return true for a subscription with a recurly subscription id', function () {
const result = this.SubscriptionHelper.isPaidSubscription({
recurlySubscription_id: 'some-id',
})
expect(result).to.be.true
})
it('should return true for a subscription with a stripe subscription id', function () {
const result = this.SubscriptionHelper.isPaidSubscription({
paymentProvider: { subscriptionId: 'some-id' },
})
expect(result).to.be.true
})
it('should return false for a free subscription', function () {
const result = this.SubscriptionHelper.isPaidSubscription({})
expect(result).to.be.false
})
it('should return false for a missing subscription', function () {
const result = this.SubscriptionHelper.isPaidSubscription()
expect(result).to.be.false
})
})
describe('isIndividualActivePaidSubscription', function () {
it('should return true for an active recurly subscription', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{
groupPlan: false,
recurlyStatus: { state: 'active' },
recurlySubscription_id: 'some-id',
}
)
expect(result).to.be.true
})
it('should return true for an active stripe subscription', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{
groupPlan: false,
paymentProvider: { subscriptionId: 'sub_123', state: 'active' },
}
)
expect(result).to.be.true
})
it('should return false for a canceled recurly subscription', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{
groupPlan: false,
recurlyStatus: { state: 'canceled' },
recurlySubscription_id: 'some-id',
}
)
expect(result).to.be.false
})
it('should return false for a canceled stripe subscription', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{
groupPlan: false,
paymentProvider: { state: 'canceled', subscriptionId: 'sub_123' },
}
)
expect(result).to.be.false
})
it('should return false for a group plan subscription', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{
groupPlan: true,
recurlyStatus: { state: 'active' },
recurlySubscription_id: 'some-id',
}
)
expect(result).to.be.false
})
it('should return false for a free subscription', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{}
)
expect(result).to.be.false
})
it('should return false for a subscription with an empty string for recurlySubscription_id', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{
groupPlan: false,
recurlySubscription_id: '',
recurlyStatus: { state: 'active' },
}
)
expect(result).to.be.false
})
it('should return false for a subscription with an empty string for paymentProvider.subscriptionId', function () {
const result = this.SubscriptionHelper.isIndividualActivePaidSubscription(
{
groupPlan: false,
paymentProvider: { state: 'active', subscriptionId: '' },
}
)
expect(result).to.be.false
})
it('should return false for a missing subscription', function () {
const result = this.SubscriptionHelper.isPaidSubscription()
expect(result).to.be.false
})
})
describe('getPaymentProviderSubscriptionId', function () {
it('should return the recurly subscription id if it exists', function () {
const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId({
recurlySubscription_id: 'some-id',
})
expect(result).to.equal('some-id')
})
it('should return the payment provider subscription id if it exists', function () {
const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId({
paymentProvider: { subscriptionId: 'sub_123' },
})
expect(result).to.equal('sub_123')
})
it('should return null if no subscription id exists', function () {
const result = this.SubscriptionHelper.getPaymentProviderSubscriptionId(
{}
)
expect(result).to.be.null
})
})
describe('getPaidSubscriptionState', function () {
it('should return the recurly state if it exists', function () {
const result = this.SubscriptionHelper.getPaidSubscriptionState({
recurlyStatus: { state: 'active' },
})
expect(result).to.equal('active')
})
it('should return the payment provider state if it exists', function () {
const result = this.SubscriptionHelper.getPaidSubscriptionState({
paymentProvider: { state: 'active' },
})
expect(result).to.equal('active')
})
it('should return null if no state exists', function () {
const result = this.SubscriptionHelper.getPaidSubscriptionState({})
expect(result).to.be.null
})
})
describe('getSubscriptionTrialStartedAt', function () {
it('should return the recurly trial start date if it exists', function () {
const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({
recurlySubscription_id: 'some-id',
recurlyStatus: { trialStartedAt: new Date('2023-01-01') },
})
expect(result).to.deep.equal(new Date('2023-01-01'))
})
it('should return the payment provider trial start date if it exists', function () {
const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({
recurlyStatus: {},
paymentProvider: { trialStartedAt: new Date('2023-01-01') },
})
expect(result).to.deep.equal(new Date('2023-01-01'))
})
it('should return undefined if no trial start date exists', function () {
const result = this.SubscriptionHelper.getSubscriptionTrialStartedAt({})
expect(result).to.be.undefined
})
})
describe('getSubscriptionTrialEndsAt', function () {
it('should return the recurly trial end date if it exists', function () {
const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({
recurlySubscription_id: 'some-id',
recurlyStatus: { trialEndsAt: new Date('2023-01-01') },
})
expect(result).to.deep.equal(new Date('2023-01-01'))
})
it('should return the payment provider trial end date if it exists', function () {
const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({
recurlyStatus: {},
paymentProvider: { trialEndsAt: new Date('2023-01-01') },
})
expect(result).to.deep.equal(new Date('2023-01-01'))
})
it('should return undefined if no trial end date exists', function () {
const result = this.SubscriptionHelper.getSubscriptionTrialEndsAt({})
expect(result).to.be.undefined
})
})
describe('isInTrial', function () {
it('should return false if trialEndsAt is null', function () {
const result = this.SubscriptionHelper.isInTrial(null)
expect(result).to.be.false
})
it('should return false if trialEndsAt is before now', function () {
const tenDaysAgo = new Date()
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
const result = this.SubscriptionHelper.isInTrial(tenDaysAgo)
expect(result).to.be.false
})
it('should return true if trialEndsAt is after now', function () {
const tenDaysFromNow = new Date()
tenDaysFromNow.setDate(tenDaysFromNow.getDate() + 10)
const result = this.SubscriptionHelper.isInTrial(tenDaysFromNow)
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
})
})
})
})