Files
overleaf-cep/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs
T
Liangjun Song c9175327fb Merge pull request #29036 from overleaf/ls-enable-individual-to-group-upgrade-in-production
Enable individual to group plan upgrade

GitOrigin-RevId: 3413c012bee320e4a4a61f829aaeff55e6116702
2025-10-15 08:06:23 +00:00

1050 lines
35 KiB
JavaScript

import { vi, assert } from 'vitest'
import sinon from 'sinon'
import {
PaymentProviderAccount,
PaymentProviderSubscription,
PaymentProviderSubscriptionAddOn,
PaymentProviderSubscriptionChange,
} from '../../../../app/src/Features/Subscription/PaymentProviderEntities.js'
import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js'
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs'
describe('SubscriptionViewModelBuilder', function () {
beforeEach(async function (ctx) {
ctx.user = { _id: '5208dd34438842e2db333333' }
ctx.recurlySubscription_id = '123abc456def'
ctx.planCode = 'collaborator_monthly'
ctx.planFeatures = {
compileGroup: 'priority',
collaborators: -1,
compileTimeout: 240,
}
ctx.plan = {
planCode: ctx.planCode,
features: ctx.planFeatures,
}
ctx.annualPlanCode = 'collaborator_annual'
ctx.annualPlan = {
planCode: ctx.annualPlanCode,
features: ctx.planFeatures,
}
ctx.individualSubscription = {
planCode: ctx.planCode,
plan: ctx.plan,
recurlySubscription_id: ctx.recurlySubscription_id,
recurlyStatus: {
state: 'active',
},
}
ctx.paymentRecord = new PaymentProviderSubscription({
id: ctx.recurlySubscription_id,
userId: ctx.user._id,
currency: 'EUR',
planCode: 'plan-code',
planName: 'plan-name',
planPrice: 13,
addOns: [
new PaymentProviderSubscriptionAddOn({
code: 'addon-code',
name: 'addon name',
quantity: 1,
unitPrice: 2,
}),
],
subtotal: 15,
taxRate: 0.1,
taxAmount: 1.5,
total: 16.5,
periodStart: new Date('2025-01-20T12:00:00.000Z'),
periodEnd: new Date('2025-02-20T12:00:00.000Z'),
collectionMethod: 'automatic',
})
ctx.individualCustomSubscription = {
planCode: ctx.planCode,
plan: ctx.plan,
recurlySubscription_id: ctx.recurlySubscription_id,
}
ctx.groupPlanCode = 'group_collaborator_monthly'
ctx.groupPlanFeatures = {
compileGroup: 'priority',
collaborators: 10,
compileTimeout: 240,
}
ctx.groupPlan = {
planCode: ctx.groupPlanCode,
features: ctx.groupPlanFeatures,
membersLimit: 4,
membersLimitAddOn: 'additional-license',
groupPlan: true,
}
ctx.groupSubscription = {
planCode: ctx.groupPlanCode,
plan: ctx.plan,
recurlyStatus: {
state: 'active',
},
}
ctx.commonsPlanCode = 'commons_license'
ctx.commonsPlanFeatures = {
compileGroup: 'priority',
collaborators: '-1',
compileTimeout: 240,
}
ctx.commonsPlan = {
planCode: ctx.commonsPlanCode,
features: ctx.commonsPlanFeatures,
}
ctx.commonsSubscription = {
planCode: ctx.commonsPlanCode,
plan: ctx.commonsPlan,
name: 'Digital Science',
}
ctx.Settings = {
institutionPlanCode: ctx.commonsPlanCode,
}
ctx.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(),
getMemberSubscriptions: sinon.stub().resolves(),
},
getUsersSubscription: sinon.stub().yields(),
getMemberSubscriptions: sinon.stub().yields(null, []),
getManagedGroupSubscriptions: sinon.stub().yields(null, []),
findLocalPlanInSettings: sinon.stub(),
}
ctx.InstitutionsGetter = {
promises: {
getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
},
getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []),
getManagedInstitutions: sinon.stub().yields(null, []),
}
ctx.InstitutionsManager = {
promises: {
fetchV1Data: sinon.stub().resolves(),
},
}
ctx.PublishersGetter = {
promises: {
fetchV1Data: sinon.stub().resolves(),
},
getManagedPublishers: sinon.stub().yields(null, []),
}
ctx.RecurlyWrapper = {
promises: {
getSubscription: sinon.stub().resolves(),
},
}
ctx.SubscriptionUpdater = {
promises: {
updateSubscriptionFromRecurly: sinon.stub().resolves(),
},
}
ctx.PlansLocator = {
findLocalPlanInSettings: sinon.stub(),
}
ctx.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
},
}
vi.doMock('@overleaf/settings', () => ({
default: ctx.Settings,
}))
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator',
() => ({
default: ctx.SubscriptionLocator,
})
)
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsGetter',
() => ({
default: ctx.InstitutionsGetter,
})
)
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsManager',
() => ({
default: ctx.InstitutionsManager,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyWrapper',
() => ({
default: ctx.RecurlyWrapper,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionUpdater',
() => ({
default: ctx.SubscriptionUpdater,
})
)
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
default: ctx.PlansLocator,
}))
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
default: (ctx.Modules = {
promises: { hooks: { fire: sinon.stub().resolves([]) } },
hooks: {
fire: sinon.stub().yields(null, []),
},
}),
}))
vi.doMock(
'../../../../app/src/Features/Subscription/V1SubscriptionManager',
() => ({
default: {},
})
)
vi.doMock(
'../../../../app/src/Features/Publishers/PublishersGetter',
() => ({
default: ctx.PublishersGetter,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionHelper',
() => ({
default: SubscriptionHelper,
})
)
vi.doMock(
'../../../../app/src/Features/SplitTests/SplitTestHandler',
() => ({
default: ctx.SplitTestHandler,
})
)
ctx.SubscriptionViewModelBuilder = (await import(modulePath)).default
ctx.PlansLocator.findLocalPlanInSettings
.withArgs(ctx.planCode)
.returns(ctx.plan)
.withArgs(ctx.annualPlanCode)
.returns(ctx.annualPlan)
.withArgs(ctx.groupPlanCode)
.returns(ctx.groupPlan)
.withArgs(ctx.commonsPlanCode)
.returns(ctx.commonsPlan)
})
describe('getUsersSubscriptionDetails', function () {
it('should return a free plan when user has no subscription or affiliation', async function (ctx) {
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, { type: 'free' })
})
describe('with a individual subscription only', function () {
it('should return a individual subscription when user has non-Recurly one', async function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user)
.resolves(ctx.individualCustomSubscription)
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualCustomSubscription,
plan: ctx.plan,
remainingTrialDays: -1,
})
})
it('should return a individual subscription when user has an active one', async function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user)
.resolves(ctx.individualSubscription)
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualSubscription,
plan: ctx.plan,
remainingTrialDays: -1,
})
})
it('should return a individual subscription with remaining free trial days', async function (ctx) {
const threeDaysLater = new Date()
threeDaysLater.setDate(threeDaysLater.getDate() + 3)
ctx.individualSubscription.recurlyStatus.trialEndsAt = threeDaysLater
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user)
.resolves(ctx.individualSubscription)
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualSubscription,
plan: ctx.plan,
remainingTrialDays: 3,
})
})
it('should return a individual subscription with free trial on last day', async function (ctx) {
const threeHoursLater = new Date()
threeHoursLater.setTime(threeHoursLater.getTime() + 3 * 60 * 60 * 1000)
ctx.individualSubscription.recurlyStatus.trialEndsAt = threeHoursLater
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user)
.resolves(ctx.individualSubscription)
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualSubscription,
plan: ctx.plan,
remainingTrialDays: 1,
})
})
it('should update subscription if recurly payment state is missing', async function (ctx) {
ctx.individualSubscriptionWithoutPaymentState = {
planCode: ctx.planCode,
plan: ctx.plan,
recurlySubscription_id: ctx.recurlySubscription_id,
}
ctx.paymentRecord = {
state: 'active',
}
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user)
.onCall(0)
.resolves(ctx.individualSubscriptionWithoutPaymentState)
.withArgs(ctx.user)
.onCall(1)
.resolves(ctx.individualSubscription)
const payment = {
subscription: ctx.paymentRecord,
account: new PaymentProviderAccount({}),
coupons: [],
}
ctx.Modules.promises.hooks.fire
.withArgs(
'getPaymentFromRecordPromise',
ctx.individualSubscriptionWithoutPaymentState
)
.resolves([payment])
ctx.Modules.promises.hooks.fire
.withArgs(
'syncSubscription',
payment,
ctx.individualSubscriptionWithoutPaymentState
)
.resolves([])
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualSubscription,
plan: ctx.plan,
remainingTrialDays: -1,
})
assert.isTrue(
ctx.Modules.promises.hooks.fire.withArgs(
'getPaymentFromRecordPromise',
ctx.individualSubscriptionWithoutPaymentState
).calledOnce
)
})
it('should update subscription if stripe payment state is missing', async function (ctx) {
ctx.individualSubscriptionWithoutPaymentState = {
planCode: ctx.planCode,
plan: ctx.plan,
paymentProvider: {
subscriptionId: ctx.recurlySubscription_id,
},
}
ctx.paymentRecord = {
state: 'active',
}
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user)
.onCall(0)
.resolves(ctx.individualSubscriptionWithoutPaymentState)
.withArgs(ctx.user)
.onCall(1)
.resolves(ctx.individualSubscription)
const payment = {
subscription: ctx.paymentRecord,
account: new PaymentProviderAccount({}),
coupons: [],
}
ctx.Modules.promises.hooks.fire
.withArgs(
'getPaymentFromRecordPromise',
ctx.individualSubscriptionWithoutPaymentState
)
.resolves([payment])
ctx.Modules.promises.hooks.fire
.withArgs(
'syncSubscription',
payment,
ctx.individualSubscriptionWithoutPaymentState
)
.resolves([])
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualSubscription,
plan: ctx.plan,
remainingTrialDays: -1,
})
assert.isTrue(
ctx.Modules.promises.hooks.fire.withArgs(
'getPaymentFromRecordPromise',
ctx.individualSubscriptionWithoutPaymentState
).calledOnce
)
})
})
it('should return a group subscription when user has one', async function (ctx) {
ctx.SubscriptionLocator.promises.getMemberSubscriptions
.withArgs(ctx.user)
.resolves([ctx.groupSubscription])
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'group',
subscription: {},
plan: ctx.groupPlan,
remainingTrialDays: -1,
})
})
it('should return a group subscription with team name when user has one', async function (ctx) {
ctx.SubscriptionLocator.promises.getMemberSubscriptions
.withArgs(ctx.user)
.resolves([
Object.assign({}, ctx.groupSubscription, { teamName: 'test team' }),
])
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'group',
subscription: { teamName: 'test team' },
plan: ctx.groupPlan,
remainingTrialDays: -1,
})
})
it('should return a commons subscription when user has an institution affiliation', async function (ctx) {
ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
.withArgs(ctx.user._id)
.resolves([ctx.commonsSubscription])
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'commons',
subscription: ctx.commonsSubscription,
plan: ctx.commonsPlan,
})
})
describe('with multiple subscriptions', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user)
.resolves(ctx.individualSubscription)
ctx.SubscriptionLocator.promises.getMemberSubscriptions
.withArgs(ctx.user)
.resolves([ctx.groupSubscription])
ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
.withArgs(ctx.user._id)
.resolves([ctx.commonsSubscription])
})
it('should return individual when the individual subscription has the best feature set', async function (ctx) {
ctx.commonsPlan.features = {
compileGroup: 'standard',
collaborators: 1,
compileTimeout: 60,
}
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualSubscription,
plan: ctx.plan,
remainingTrialDays: -1,
})
})
it('should return group when the group subscription has the best feature set', async function (ctx) {
ctx.plan.features = {
compileGroup: 'standard',
collaborators: 1,
compileTimeout: 60,
}
ctx.commonsPlan.features = {
compileGroup: 'standard',
collaborators: 1,
compileTimeout: 60,
}
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'group',
subscription: {},
plan: ctx.groupPlan,
remainingTrialDays: -1,
})
})
it('should return commons when the commons affiliation has the best feature set', async function (ctx) {
ctx.plan.features = {
compileGroup: 'priority',
collaborators: 5,
compileTimeout: 240,
}
ctx.groupPlan.features = {
compileGroup: 'standard',
collaborators: 1,
compileTimeout: 60,
}
ctx.commonsPlan.features = {
compileGroup: 'priority',
collaborators: -1,
compileTimeout: 240,
}
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'commons',
subscription: ctx.commonsSubscription,
plan: ctx.commonsPlan,
})
})
it('should return individual with equal feature sets', async function (ctx) {
ctx.plan.features = {
compileGroup: 'priority',
collaborators: -1,
compileTimeout: 240,
}
ctx.groupPlan.features = {
compileGroup: 'priority',
collaborators: -1,
compileTimeout: 240,
}
ctx.commonsPlan.features = {
compileGroup: 'priority',
collaborators: -1,
compileTimeout: 240,
}
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'individual',
subscription: ctx.individualSubscription,
plan: ctx.plan,
remainingTrialDays: -1,
})
})
it('should return group over commons with equal feature sets', async function (ctx) {
ctx.plan.features = {
compileGroup: 'standard',
collaborators: 1,
compileTimeout: 60,
}
ctx.groupPlan.features = {
compileGroup: 'priority',
collaborators: -1,
compileTimeout: 240,
}
ctx.commonsPlan.features = {
compileGroup: 'priority',
collaborators: -1,
compileTimeout: 240,
}
const { bestSubscription: usersBestSubscription } =
await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
ctx.user
)
assert.deepEqual(usersBestSubscription, {
type: 'group',
subscription: {},
plan: ctx.groupPlan,
remainingTrialDays: -1,
})
})
})
})
describe('buildUsersSubscriptionViewModel', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.getUsersSubscription.yields(
null,
ctx.individualSubscription
)
ctx.Modules.hooks.fire
.withArgs('getPaymentFromRecord', ctx.individualSubscription)
.yields(null, [
{
subscription: ctx.paymentRecord,
account: new PaymentProviderAccount({}),
coupons: [],
},
])
})
describe('with a paid subscription', function () {
it('adds payment data to the personal subscription', async function (ctx) {
ctx.Modules.hooks.fire
.withArgs('getPaymentFromRecord', ctx.individualSubscription)
.yields(null, [
{
subscription: ctx.paymentRecord,
account: new PaymentProviderAccount({
email: 'example@example.com',
hasPastDueInvoice: false,
}),
coupons: [],
},
])
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.deepEqual(result.personalSubscription.payment, {
taxRate: 0.1,
billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink:
'/user/subscription/payment/account-management',
additionalLicenses: 0,
addOns: [
{
code: 'addon-code',
name: 'addon name',
quantity: 1,
unitPrice: 2,
preTaxTotal: 2,
},
],
totalLicenses: 0,
nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
nextPaymentDueDate: 'February 20th, 2025',
currency: 'EUR',
state: 'active',
trialEndsAtFormatted: null,
trialEndsAt: null,
activeCoupons: [],
accountEmail: 'example@example.com',
hasPastDueInvoice: false,
pausedAt: null,
remainingPauseCycles: null,
displayPrice: '€16.50',
planOnlyDisplayPrice: '€14.30',
addOnDisplayPricesWithoutAdditionalLicense: {
'addon-code': '€2.20',
},
isEligibleForGroupPlan: true,
isEligibleForPause: false,
isEligibleForDowngradeUpsell: true,
})
})
describe('isEligibleForGroupPlan', function () {
it('is false when in trial', async function (ctx) {
const msIn24Hours = 24 * 60 * 60 * 1000
const tomorrow = new Date(Date.now() + msIn24Hours)
ctx.paymentRecord.trialPeriodEnd = tomorrow
ctx.paymentRecord.service = 'recurly'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(
result.personalSubscription.payment.isEligibleForGroupPlan
)
})
it('is true when not in trial', async function (ctx) {
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isTrue(
result.personalSubscription.payment.isEligibleForGroupPlan
)
})
})
describe('isEligibleForPause', function () {
beforeEach(function (ctx) {
ctx.paymentRecord.service = 'recurly'
ctx.paymentRecord.addOns = []
ctx.paymentRecord.planCode = 'plan-code'
ctx.paymentRecord.trialPeriodEnd = null
ctx.individualSubscription.pendingPlan = undefined
ctx.individualSubscription.groupPlan = undefined
})
it('is false for Stripe subscriptions when feature flag is disabled', async function (ctx) {
ctx.paymentRecord.service = 'stripe-us'
ctx.SplitTestHandler.promises.getAssignmentForUser
.withArgs(ctx.user._id, 'stripe-pause')
.resolves({ variant: 'default' })
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is true for Stripe subscriptions when feature flag is enabled', async function (ctx) {
ctx.paymentRecord.service = 'stripe-us'
ctx.SplitTestHandler.promises.getAssignmentForUser
.withArgs(ctx.user._id, 'stripe-pause')
.resolves({ variant: 'enabled' })
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
})
it('is false for Stripe subscriptions with pending plan even when feature flag is enabled', async function (ctx) {
ctx.paymentRecord.service = 'stripe-us'
ctx.individualSubscription.pendingPlan = {} // anything
ctx.SplitTestHandler.promises.getAssignmentForUser
.withArgs(ctx.user._id, 'stripe-pause')
.resolves({ variant: 'enabled' })
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is false for Stripe subscriptions with annual plan even when feature flag is enabled', async function (ctx) {
ctx.paymentRecord.service = 'stripe-us'
ctx.paymentRecord.planCode = 'collaborator-annual'
ctx.SplitTestHandler.promises.getAssignmentForUser
.withArgs(ctx.user._id, 'stripe-pause')
.resolves({ variant: 'enabled' })
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is false for subscriptions with pending plan', async function (ctx) {
ctx.paymentRecord.service = 'recurly'
ctx.individualSubscription.pendingPlan = {} // anything
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is false for a group subscription', async function (ctx) {
ctx.paymentRecord.service = 'recurly'
ctx.individualSubscription.groupPlan = true
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is false when in trial', async function (ctx) {
ctx.paymentRecord.service = 'recurly'
const msIn24Hours = 24 * 60 * 60 * 1000
const tomorrow = new Date(Date.now() + msIn24Hours)
ctx.paymentRecord.trialPeriodEnd = tomorrow
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is false for annual subscriptions', async function (ctx) {
ctx.paymentRecord.service = 'recurly'
ctx.paymentRecord.planCode = 'collaborator-annual'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is false for subscriptions with add-ons', async function (ctx) {
ctx.paymentRecord.service = 'recurly'
ctx.paymentRecord.addOns = [{}] // anything
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
})
it('is true when conditions are met', async function (ctx) {
ctx.paymentRecord.service = 'recurly'
ctx.paymentRecord.addOns = []
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
})
})
describe('isEligibleForDowngradeUpsell', function () {
it('is true for eligible individual subscriptions', async function (ctx) {
ctx.paymentRecord.pausePeriodStart = null
ctx.paymentRecord.remainingPauseCycles = null
ctx.paymentRecord.trialPeriodEnd = null
ctx.paymentRecord.service = 'recurly'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isTrue(
result.personalSubscription.payment.isEligibleForDowngradeUpsell
)
})
it('is false for group plans', async function (ctx) {
ctx.individualSubscription.planCode = ctx.groupPlanCode
ctx.paymentRecord.pausePeriodStart = null
ctx.paymentRecord.remainingPauseCycles = null
ctx.paymentRecord.trialPeriodEnd = null
ctx.paymentRecord.service = 'recurly'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(
result.personalSubscription.payment.isEligibleForDowngradeUpsell
)
})
it('is false for annual individual plans', async function (ctx) {
ctx.individualSubscription.planCode = ctx.annualPlanCode
ctx.paymentRecord.pausePeriodStart = null
ctx.paymentRecord.remainingPauseCycles = null
ctx.paymentRecord.trialPeriodEnd = null
ctx.paymentRecord.service = 'recurly'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(
result.personalSubscription.payment.isEligibleForDowngradeUpsell
)
})
it('is false for paused plans', async function (ctx) {
ctx.paymentRecord.pausePeriodStart = new Date()
ctx.paymentRecord.remainingPauseCycles = 1
ctx.paymentRecord.trialPeriodEnd = null
ctx.paymentRecord.service = 'recurly'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(
result.personalSubscription.payment.isEligibleForDowngradeUpsell
)
})
it('is false for plans in free trial period', async function (ctx) {
ctx.paymentRecord.pausePeriodStart = null
ctx.paymentRecord.remainingPauseCycles = null
ctx.paymentRecord.trialPeriodEnd = new Date(
Date.now() + 24 * 60 * 60 * 1000 // tomorrow
)
ctx.paymentRecord.service = 'recurly'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(
result.personalSubscription.payment.isEligibleForDowngradeUpsell
)
})
it('is false for Stripe subscriptions', async function (ctx) {
ctx.paymentRecord.pausePeriodStart = null
ctx.paymentRecord.remainingPauseCycles = null
ctx.paymentRecord.trialPeriodEnd = null
ctx.paymentRecord.service = 'stripe-us'
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.isFalse(
result.personalSubscription.payment.isEligibleForDowngradeUpsell
)
})
})
it('includes pending changes', async function (ctx) {
ctx.paymentRecord.pendingChange = new PaymentProviderSubscriptionChange(
{
subscription: ctx.paymentRecord,
nextPlanCode: ctx.groupPlanCode,
nextPlanName: 'Group Collaborator (Annual) 4 licenses',
nextPlanPrice: 1400,
nextAddOns: [
new PaymentProviderSubscriptionAddOn({
code: 'additional-license',
name: 'additional license',
quantity: 8,
unitPrice: 24.4,
}),
new PaymentProviderSubscriptionAddOn({
code: 'addon-code',
name: 'addon name',
quantity: 1,
unitPrice: 2,
}),
],
}
)
ctx.Modules.hooks.fire
.withArgs('getPaymentFromRecord', ctx.individualSubscription)
.yields(null, [
{
subscription: ctx.paymentRecord,
account: {},
coupons: [],
},
])
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.equal(
result.personalSubscription.payment.displayPrice,
'€1,756.92'
)
assert.equal(
result.personalSubscription.payment.planOnlyDisplayPrice,
'€1,754.72'
)
assert.deepEqual(
result.personalSubscription.payment
.addOnDisplayPricesWithoutAdditionalLicense,
{ 'addon-code': '€2.20' }
)
assert.equal(
result.personalSubscription.payment.pendingAdditionalLicenses,
8
)
assert.equal(
result.personalSubscription.payment.pendingTotalLicenses,
12
)
})
it('does not add a billing details link for a Stripe subscription', async function (ctx) {
ctx.paymentRecord.service = 'stripe-us'
ctx.Modules.hooks.fire
.withArgs('getPaymentFromRecord', ctx.individualSubscription)
.yields(null, [
{
subscription: ctx.paymentRecord,
account: new PaymentProviderAccount({}),
coupons: [],
},
])
const result =
await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
ctx.user
)
assert.equal(
result.personalSubscription.payment.billingDetailsLink,
undefined
)
assert.equal(
result.personalSubscription.payment.accountManagementLink,
'/user/subscription/payment/account-management'
)
})
})
})
})