Files
overleaf-cep/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js
Jimmy Domagala-Tang 89f416af05 feat: add tests for recurly plan revert feature (#25966)
GitOrigin-RevId: 9bb0c198d237a9c86b63da8b4892c7867f79c71f
2025-09-18 08:06:28 +00:00

666 lines
23 KiB
JavaScript

// @ts-check
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Subscription/Errors')
const {
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionUpdateRequest,
PaymentProviderSubscriptionChange,
PaymentProviderSubscription,
PaymentProviderSubscriptionAddOnUpdate,
} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
const {
AI_ADD_ON_CODE,
} = require('../../../../app/src/Features/Subscription/AiHelper')
const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper')
const MODULE_PATH =
'../../../../app/src/Features/Subscription/PaymentProviderEntities'
describe('PaymentProviderEntities', function () {
describe('PaymentProviderSubscription', function () {
beforeEach(function () {
this.Settings = {
plans: [
{ planCode: 'assistant-annual', price_in_cents: 5900 },
{ planCode: 'cheap-plan', price_in_cents: 500 },
{ planCode: 'regular-plan', price_in_cents: 1000 },
{ planCode: 'premium-plan', price_in_cents: 2000 },
{
planCode: 'group_collaborator_10_enterprise',
price_in_cents: 10000,
},
],
features: [],
}
this.PaymentProviderEntities = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.Settings,
'./Errors': Errors,
'./SubscriptionHelper': SubscriptionHelper,
},
})
})
describe('with add-ons', function () {
beforeEach(function () {
const {
PaymentProviderSubscription,
PaymentProviderSubscriptionAddOn,
} = this.PaymentProviderEntities
this.addOn = new PaymentProviderSubscriptionAddOn({
code: 'add-on-code',
name: 'My Add-On',
quantity: 1,
unitPrice: 2,
})
this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
planName: 'My Plan',
planPrice: 10,
addOns: [this.addOn],
subtotal: 10.99,
taxRate: 0.2,
taxAmount: 2.4,
total: 14.4,
currency: 'USD',
})
})
describe('hasAddOn()', function () {
it('returns true if the subscription has the given add-on', function () {
expect(this.subscription.hasAddOn(this.addOn.code)).to.be.true
})
it("returns false if the subscription doesn't have the given add-on", function () {
expect(this.subscription.hasAddOn('another-add-on')).to.be.false
})
})
describe('getRequestForPlanChange()', function () {
it('returns a change request for upgrades', function () {
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
const changeRequest = this.subscription.getRequestForPlanChange(
'premium-plan',
1,
this.subscription.shouldPlanChangeAtTermEnd('premium-plan')
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'premium-plan',
})
)
})
it('returns a change request for downgrades', function () {
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
const changeRequest = this.subscription.getRequestForPlanChange(
'cheap-plan',
1,
this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
})
)
})
it('returns a change request for downgrades while on trial', function () {
const fiveDaysFromNow = new Date()
fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
this.subscription.trialPeriodEnd = fiveDaysFromNow
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
const changeRequest = this.subscription.getRequestForPlanChange(
'cheap-plan',
1,
this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'cheap-plan',
})
)
})
it('preserves the AI add-on on upgrades', function () {
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
this.addOn.code = AI_ADD_ON_CODE
const changeRequest = this.subscription.getRequestForPlanChange(
'premium-plan',
1,
this.subscription.shouldPlanChangeAtTermEnd('premium-plan')
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'premium-plan',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
],
})
)
})
it('preserves the AI add-on on downgrades', function () {
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
this.addOn.code = AI_ADD_ON_CODE
const changeRequest = this.subscription.getRequestForPlanChange(
'cheap-plan',
1,
this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
],
})
)
})
it('preserves the AI add-on on upgrades from the standalone AI plan', function () {
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
this.subscription.planCode = 'assistant-annual'
this.subscription.addOns = []
const changeRequest = this.subscription.getRequestForPlanChange(
'cheap-plan',
1,
this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'cheap-plan',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
],
})
)
})
it('upgrade from individual to group plan for Stripe subscription', function () {
this.subscription.service = 'stripe-uk'
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
const changeRequest = this.subscription.getRequestForPlanChange(
'group_collaborator',
10,
this.subscription.shouldPlanChangeAtTermEnd(
'group_collaborator_10_enterprise'
)
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'group_collaborator',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'additional-license',
quantity: 10,
}),
],
})
)
})
it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function () {
this.subscription.service = 'stripe-uk'
const { PaymentProviderSubscriptionChangeRequest } =
this.PaymentProviderEntities
this.addOn.code = AI_ADD_ON_CODE
const changeRequest = this.subscription.getRequestForPlanChange(
'group_collaborator',
10,
this.subscription.shouldPlanChangeAtTermEnd(
'group_collaborator_10_enterprise'
)
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'group_collaborator',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'additional-license',
quantity: 10,
}),
new PaymentProviderSubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
],
})
)
})
})
describe('getRequestForAddOnPurchase()', function () {
it('returns a change request', function () {
const {
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionAddOnUpdate,
} = this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('another-add-on')
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: this.addOn.quantity,
unitPrice: this.addOn.unitPrice,
}),
new PaymentProviderSubscriptionAddOnUpdate({
code: 'another-add-on',
quantity: 1,
}),
],
})
)
})
it('returns a change request with quantity and unit price specified', function () {
const {
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionAddOnUpdate,
} = this.PaymentProviderEntities
const quantity = 5
const unitPrice = 10
const changeRequest = this.subscription.getRequestForAddOnPurchase(
'another-add-on',
quantity,
unitPrice
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: this.addOn.quantity,
unitPrice: this.addOn.unitPrice,
}),
new PaymentProviderSubscriptionAddOnUpdate({
code: 'another-add-on',
quantity,
unitPrice,
}),
],
})
)
})
it('throws a DuplicateAddOnError if the subscription already has the add-on', function () {
expect(() =>
this.subscription.getRequestForAddOnPurchase(this.addOn.code)
).to.throw(Errors.DuplicateAddOnError)
})
})
describe('getRequestForAddOnUpdate()', function () {
it('returns a change request', function () {
const {
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionAddOnUpdate,
} = this.PaymentProviderEntities
const newQuantity = 2
const changeRequest = this.subscription.getRequestForAddOnUpdate(
'add-on-code',
newQuantity
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: newQuantity,
unitPrice: this.addOn.unitPrice,
}),
],
})
)
})
it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function () {
expect(() =>
this.subscription.getRequestForAddOnUpdate('another-add-on', 2)
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForAddOnRemoval()', function () {
it('returns a change request', function () {
const changeRequest = this.subscription.getRequestForAddOnRemoval(
this.addOn.code
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
addOnUpdates: [],
})
)
})
it('returns a change request when in trial', function () {
const fiveDaysFromNow = new Date()
fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
this.subscription.trialPeriodEnd = fiveDaysFromNow
const changeRequest = this.subscription.getRequestForAddOnRemoval(
this.addOn.code
)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [],
})
)
})
it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function () {
expect(() =>
this.subscription.getRequestForAddOnRemoval('another-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForAddOnReactivation()', function () {
it('throws an AddOnNotPresentError', function () {
expect(() =>
this.subscription.getRequestForAddOnReactivation(this.addOn.code)
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForGroupPlanUpgrade()', function () {
it('returns a correct change request', function () {
const changeRequest =
this.subscription.getRequestForGroupPlanUpgrade('test_plan_code')
const addOns = [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'add-on-code',
quantity: 1,
}),
]
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: addOns,
planCode: 'test_plan_code',
})
)
})
})
describe('getRequestForPoNumberAndTermsAndConditionsUpdate()', function () {
it('returns a correct update request', function () {
const updateRequest =
this.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
'O12345',
'T&C copy'
)
expect(updateRequest).to.deep.equal(
new PaymentProviderSubscriptionUpdateRequest({
subscription: this.subscription,
poNumber: 'O12345',
termsAndConditions: 'T&C copy',
})
)
})
})
describe('getRequestForTermsAndConditionsUpdate()', function () {
it('returns a correct update request', function () {
const updateRequest =
this.subscription.getRequestForTermsAndConditionsUpdate('T&C copy')
expect(updateRequest).to.deep.equal(
new PaymentProviderSubscriptionUpdateRequest({
subscription: this.subscription,
termsAndConditions: 'T&C copy',
})
)
})
})
describe('with an add-on pending cancellation', function () {
beforeEach(function () {
this.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: this.subscription,
nextPlanCode: this.subscription.planCode,
nextPlanName: this.subscription.planName,
nextPlanPrice: this.subscription.planPrice,
nextAddOns: [],
})
})
describe('getRequestForAddOnReactivation()', function () {
it('returns a change request', function () {
const changeRequest =
this.subscription.getRequestForAddOnReactivation(this.addOn.code)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
addOnUpdates: [this.addOn.toAddOnUpdate()],
})
)
})
it('throws an AddOnNotPresentError if given the wrong add-on', function () {
expect(() =>
this.subscription.getRequestForAddOnReactivation('some-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForPlanRevert()', function () {
beforeEach(function () {
const { PaymentProviderSubscription } = this.PaymentProviderEntities
this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
planName: 'My Plan',
planPrice: 10,
addOns: [
{
addOnCode: 'addon-1',
quantity: 2,
unitAmountInCents: 500,
},
{
addOnCode: 'addon-2',
quantity: 1,
unitAmountInCents: 600,
},
],
subtotal: 10.99,
taxRate: 0.2,
taxAmount: 2.4,
total: 14.4,
currency: 'USD',
})
})
it('throws if the plan to revert to doesnt exist', function () {
const invalidPlanCode = 'non-existent-plan'
expect(() =>
this.subscription.getRequestForPlanRevert(invalidPlanCode, null)
).to.throw('Unable to find plan in settings')
})
it('creates a change request with the restore point', function () {
const previousPlanCode = 'cheap-plan'
const previousAddOns = [
{ addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 },
]
const changeRequest = this.subscription.getRequestForPlanRevert(
previousPlanCode,
previousAddOns
)
expect(changeRequest).to.be.an.instanceOf(
this.PaymentProviderEntities
.PaymentProviderSubscriptionChangeRequest
)
expect(changeRequest.planCode).to.equal(previousPlanCode)
expect(changeRequest.addOnUpdates).to.deep.equal([
{
code: 'addon-1',
quantity: 1,
unitPrice: 5,
},
])
})
it('defaults to addons to an empty array to clear the addon state', function () {
const previousPlanCode = 'cheap-plan'
const changeRequest = this.subscription.getRequestForPlanRevert(
previousPlanCode,
null
)
expect(changeRequest.addOnUpdates).to.deep.equal([])
})
})
})
})
describe('without add-ons', function () {
beforeEach(function () {
const { PaymentProviderSubscription } = this.PaymentProviderEntities
this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
planName: 'My Plan',
planPrice: 10,
subtotal: 10.99,
taxRate: 0.2,
taxAmount: 2.4,
total: 14.4,
currency: 'USD',
})
})
describe('hasAddOn()', function () {
it('returns false for any add-on', function () {
expect(this.subscription.hasAddOn('some-add-on')).to.be.false
})
})
describe('getRequestForAddOnPurchase()', function () {
it('returns a change request', function () {
const {
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionAddOnUpdate,
} = this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('some-add-on')
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'some-add-on',
quantity: 1,
}),
],
})
)
})
})
describe('getRequestForAddOnRemoval()', function () {
it('throws an AddOnNotPresentError', function () {
expect(() =>
this.subscription.getRequestForAddOnRemoval('some-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForAddOnReactivation()', function () {
it('throws an AddOnNotPresentError', function () {
expect(() =>
this.subscription.getRequestForAddOnReactivation('some-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
})
})
})
describe('PaymentProviderSubscriptionChange', function () {
describe('constructor', function () {
it('rounds the amounts when calculating the taxes', function () {
const subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'premium-plan',
planName: 'Premium plan',
planPrice: 10,
subtotal: 10,
taxRate: 0.15,
taxAmount: 1.5,
currency: 'USD',
total: 11.5,
periodStart: new Date(),
periodEnd: new Date(),
collectionMethod: 'automatic',
netTerms: 0,
poNumber: '012345',
termsAndConditions: 'T&C copy',
})
const change = new PaymentProviderSubscriptionChange({
subscription,
nextPlanCode: 'promotional-plan',
nextPlanName: 'Promotial plan',
nextPlanPrice: 8.99,
nextAddOns: [],
})
expect(change.tax).to.equal(1.35)
expect(change.total).to.equal(10.34)
})
})
})
})