Merge pull request #21274 from overleaf/em-recurly-client

Separate Recurly business logic

GitOrigin-RevId: 9c3b5ce61bdc7a6a5d3f507a31dc8919c882e476
This commit is contained in:
Eric Mc Sween 2024-10-30 08:51:57 -04:00 committed by Copybot
parent 3d5bceffee
commit 1acb0d1bcd
7 changed files with 752 additions and 297 deletions

View file

@ -7,14 +7,10 @@ const Queues = require('../../infrastructure/Queues')
const EMAIL_SETTINGS = Settings.email || {} const EMAIL_SETTINGS = Settings.email || {}
module.exports = { /**
sendEmail: callbackify(sendEmail), * @param {string} emailType
sendDeferredEmail, * @param {opts} any
promises: { */
sendEmail,
},
}
async function sendEmail(emailType, opts) { async function sendEmail(emailType, opts) {
const email = EmailBuilder.buildEmail(emailType, opts) const email = EmailBuilder.buildEmail(emailType, opts)
if (email.type === 'lifecycle' && !EMAIL_SETTINGS.lifecycle) { if (email.type === 'lifecycle' && !EMAIL_SETTINGS.lifecycle) {
@ -35,3 +31,11 @@ function sendDeferredEmail(emailType, opts, delay) {
logger.warn({ err, emailType, opts }, 'failed to queue deferred email') logger.warn({ err, emailType, opts }, 'failed to queue deferred email')
}) })
} }
module.exports = {
sendEmail: callbackify(sendEmail),
sendDeferredEmail,
promises: {
sendEmail,
},
}

View file

@ -1,8 +1,19 @@
// @ts-check
const recurly = require('recurly') const recurly = require('recurly')
const Settings = require('@overleaf/settings') const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger') const logger = require('@overleaf/logger')
const OError = require('@overleaf/o-error')
const { callbackify } = require('util') const { callbackify } = require('util')
const UserGetter = require('../User/UserGetter') const UserGetter = require('../User/UserGetter')
const {
RecurlySubscription,
RecurlySubscriptionAddOn,
} = require('./RecurlyEntities')
/**
* @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
*/
const recurlySettings = Settings.apis.recurly const recurlySettings = Settings.apis.recurly
const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
@ -40,25 +51,51 @@ async function createAccountForUserId(userId) {
return account return account
} }
/**
* Get a subscription from Recurly
*
* @param {string} subscriptionId
* @return {Promise<RecurlySubscription>}
*/
async function getSubscription(subscriptionId) { async function getSubscription(subscriptionId) {
return await client.getSubscription(subscriptionId) const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
return makeSubscription(subscription)
} }
async function getSubscriptionByUuid(subscriptionUuid) { /**
return await client.getSubscription('uuid-' + subscriptionUuid) * Request a susbcription change from Recurly
} *
* @param {RecurlySubscriptionChangeRequest} changeRequest
async function changeSubscription(subscriptionId, body) { */
const change = await client.createSubscriptionChange(subscriptionId, body) async function applySubscriptionChangeRequest(changeRequest) {
/** @type {recurly.SubscriptionChangeCreate} */
const body = {
timeframe: changeRequest.timeframe,
}
if (changeRequest.planCode != null) {
body.planCode = changeRequest.planCode
}
if (changeRequest.addOnUpdates != null) {
body.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
/** @type {recurly.SubscriptionAddOnUpdate} */
const update = { code: addOnUpdate.code }
if (addOnUpdate.quantity != null) {
update.quantity = addOnUpdate.quantity
}
if (addOnUpdate.unitPrice != null) {
update.unitAmount = addOnUpdate.unitPrice
}
return update
})
}
const change = await client.createSubscriptionChange(
`uuid-${changeRequest.subscriptionId}`,
body
)
logger.debug( logger.debug(
{ subscriptionId, changeId: change.id }, { subscriptionId: changeRequest.subscriptionId, changeId: change.id },
'created subscription change' 'created subscription change'
) )
return change
}
async function changeSubscriptionByUuid(subscriptionUuid, ...args) {
return await changeSubscription('uuid-' + subscriptionUuid, ...args)
} }
async function removeSubscriptionChange(subscriptionId) { async function removeSubscriptionChange(subscriptionId) {
@ -99,15 +136,73 @@ function subscriptionIsCanceledOrExpired(subscription) {
return state === 'canceled' || state === 'expired' return state === 'canceled' || state === 'expired'
} }
/**
* Build a RecurlySubscription from Recurly API data
*
* @param {recurly.Subscription} subscription
* @return {RecurlySubscription}
*/
function makeSubscription(subscription) {
if (
subscription.uuid == null ||
subscription.plan == null ||
subscription.plan.code == null ||
subscription.plan.name == null ||
subscription.account == null ||
subscription.account.code == null ||
subscription.unitAmount == null ||
subscription.subtotal == null ||
subscription.total == null ||
subscription.currency == null
) {
throw new OError('Invalid Recurly subscription', { subscription })
}
return new RecurlySubscription({
id: subscription.uuid,
userId: subscription.account.code,
planCode: subscription.plan.code,
planName: subscription.plan.name,
planPrice: subscription.unitAmount,
addOns: (subscription.addOns ?? []).map(makeSubscriptionAddOn),
subtotal: subscription.subtotal,
taxRate: subscription.taxInfo?.rate ?? 0,
taxAmount: subscription.tax ?? 0,
total: subscription.total,
currency: subscription.currency,
})
}
/**
* Build a RecurlySubscriptionAddOn from Recurly API data
*
* @param {recurly.SubscriptionAddOn} addOn
* @return {RecurlySubscriptionAddOn}
*/
function makeSubscriptionAddOn(addOn) {
if (
addOn.addOn == null ||
addOn.addOn.code == null ||
addOn.addOn.name == null ||
addOn.unitAmount == null
) {
throw new OError('Invalid Recurly add-on', { addOn })
}
return new RecurlySubscriptionAddOn({
code: addOn.addOn.code,
name: addOn.addOn.name,
quantity: addOn.quantity ?? 1,
unitPrice: addOn.unitAmount,
})
}
module.exports = { module.exports = {
errors: recurly.errors, errors: recurly.errors,
getAccountForUserId: callbackify(getAccountForUserId), getAccountForUserId: callbackify(getAccountForUserId),
createAccountForUserId: callbackify(createAccountForUserId), createAccountForUserId: callbackify(createAccountForUserId),
getSubscription: callbackify(getSubscription), getSubscription: callbackify(getSubscription),
getSubscriptionByUuid: callbackify(getSubscriptionByUuid), applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
changeSubscription: callbackify(changeSubscription),
changeSubscriptionByUuid: callbackify(changeSubscriptionByUuid),
removeSubscriptionChange: callbackify(removeSubscriptionChange), removeSubscriptionChange: callbackify(removeSubscriptionChange),
removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid), removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid), reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
@ -116,11 +211,9 @@ module.exports = {
promises: { promises: {
getSubscription, getSubscription,
getSubscriptionByUuid,
getAccountForUserId, getAccountForUserId,
createAccountForUserId, createAccountForUserId,
changeSubscription, applySubscriptionChangeRequest,
changeSubscriptionByUuid,
removeSubscriptionChange, removeSubscriptionChange,
removeSubscriptionChangeByUuid, removeSubscriptionChangeByUuid,
reactivateSubscriptionByUuid, reactivateSubscriptionByUuid,

View file

@ -0,0 +1,194 @@
// @ts-check
const OError = require('@overleaf/o-error')
const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper')
class RecurlySubscription {
/**
* @param {object} props
* @param {string} props.id
* @param {string} props.userId
* @param {string} props.planCode
* @param {string} props.planName
* @param {number} props.planPrice
* @param {RecurlySubscriptionAddOn[]} [props.addOns]
* @param {number} props.subtotal
* @param {number} [props.taxRate]
* @param {number} [props.taxAmount]
* @param {string} props.currency
* @param {number} props.total
*/
constructor(props) {
this.id = props.id
this.userId = props.userId
this.planCode = props.planCode
this.planName = props.planName
this.planPrice = props.planPrice
this.addOns = props.addOns ?? []
this.subtotal = props.subtotal
this.taxRate = props.taxRate ?? 0
this.taxAmount = props.taxAmount ?? 0
this.currency = props.currency
this.total = props.total
}
hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === code)
}
/**
* Change this subscription's plan
*
* @return {RecurlySubscriptionChangeRequest}
*/
getRequestForPlanChange(planCode) {
const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode)
if (currentPlan == null) {
throw new OError('Unable to find plan in settings', {
planCode: this.planCode,
})
}
const newPlan = PlansLocator.findLocalPlanInSettings(planCode)
if (newPlan == null) {
throw new OError('Unable to find plan in settings', { planCode })
}
const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan,
newPlan
)
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
return new RecurlySubscriptionChangeRequest({
subscriptionId: this.id,
timeframe,
planCode,
})
}
/**
* Purchase an add-on on this subscription
*
* @param {string} code
* @param {number} [quantity]
* @return {RecurlySubscriptionChangeRequest} - the change request to send to
* Recurly
*
* @throws {DuplicateAddOnError} if the add-on is already present on the subscription
*/
getRequestForAddOnPurchase(code, quantity = 1) {
if (this.hasAddOn(code)) {
throw new DuplicateAddOnError('Subscription already has add-on', {
subscriptionId: this.id,
addOnCode: code,
})
}
const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
return new RecurlySubscriptionChangeRequest({
subscriptionId: this.id,
timeframe: 'now',
addOnUpdates,
})
}
/**
* Remove an add-on from this subscription
*
* @param {string} code
* @return {RecurlySubscriptionChangeRequest}
*
* @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
*/
getRequestForAddOnRemoval(code) {
if (!this.hasAddOn(code)) {
throw new AddOnNotPresentError(
'Subscripiton does not have add-on to remove',
{
subscriptionId: this.id,
addOnCode: code,
}
)
}
const addOnUpdates = this.addOns
.filter(addOn => addOn.code !== code)
.map(addOn => addOn.toAddOnUpdate())
return new RecurlySubscriptionChangeRequest({
subscriptionId: this.id,
timeframe: 'term_end',
addOnUpdates,
})
}
}
class RecurlySubscriptionAddOn {
/**
* @param {object} props
* @param {string} props.code
* @param {string} props.name
* @param {number} props.quantity
* @param {number} props.unitPrice
*/
constructor(props) {
this.code = props.code
this.name = props.name
this.quantity = props.quantity
this.unitPrice = props.unitPrice
}
get preTaxTotal() {
return this.quantity * this.unitPrice
}
/**
* Return an add-on update that doesn't modify the add-on
*/
toAddOnUpdate() {
return new RecurlySubscriptionAddOnUpdate({
code: this.code,
quantity: this.quantity,
unitPrice: this.unitPrice,
})
}
}
class RecurlySubscriptionChangeRequest {
/**
* @param {object} props
* @param {string} props.subscriptionId
* @param {"now" | "term_end"} props.timeframe
* @param {string} [props.planCode]
* @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
*/
constructor(props) {
if (props.planCode == null && props.addOnUpdates == null) {
throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
}
this.subscriptionId = props.subscriptionId
this.timeframe = props.timeframe
this.planCode = props.planCode ?? null
this.addOnUpdates = props.addOnUpdates ?? null
}
}
class RecurlySubscriptionAddOnUpdate {
/**
* @param {object} props
* @param {string} props.code
* @param {number} [props.quantity]
* @param {number} [props.unitPrice]
*/
constructor(props) {
this.code = props.code
this.quantity = props.quantity ?? null
this.unitPrice = props.unitPrice ?? null
}
}
module.exports = {
RecurlySubscription,
RecurlySubscriptionAddOn,
RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate,
}

View file

@ -1,3 +1,5 @@
// @ts-check
const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyWrapper = require('./RecurlyWrapper')
const RecurlyClient = require('./RecurlyClient') const RecurlyClient = require('./RecurlyClient')
const { User } = require('../../models/User') const { User } = require('../../models/User')
@ -5,15 +7,13 @@ const logger = require('@overleaf/logger')
const SubscriptionUpdater = require('./SubscriptionUpdater') const SubscriptionUpdater = require('./SubscriptionUpdater')
const LimitationsManager = require('./LimitationsManager') const LimitationsManager = require('./LimitationsManager')
const EmailHandler = require('../Email/EmailHandler') const EmailHandler = require('../Email/EmailHandler')
const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper')
const { callbackify } = require('@overleaf/promise-utils') const { callbackify } = require('@overleaf/promise-utils')
const UserUpdater = require('../User/UserUpdater') const UserUpdater = require('../User/UserUpdater')
const { const { NoRecurlySubscriptionError } = require('./Errors')
DuplicateAddOnError,
AddOnNotPresentError, /**
NoRecurlySubscriptionError, * @import { RecurlySubscription } from './RecurlyEntities'
} = require('./Errors') */
async function validateNoSubscriptionInRecurly(userId) { async function validateNoSubscriptionInRecurly(userId) {
let subscriptions = let subscriptions =
@ -76,13 +76,18 @@ async function updateSubscription(user, planCode, couponCode) {
) )
} }
if (!hasSubscription) { if (
!hasSubscription ||
subscription == null ||
subscription.recurlySubscription_id == null
) {
return return
} }
const recurlySubscriptionId = subscription.recurlySubscription_id
if (couponCode) { if (couponCode) {
const usersSubscription = await RecurlyWrapper.promises.getSubscription( const usersSubscription = await RecurlyWrapper.promises.getSubscription(
subscription.recurlySubscription_id, recurlySubscriptionId,
{ includeAccount: true } { includeAccount: true }
) )
@ -91,47 +96,12 @@ async function updateSubscription(user, planCode, couponCode) {
couponCode couponCode
) )
} }
let changeAtTermEnd
const currentPlan = PlansLocator.findLocalPlanInSettings( const recurlySubscription = await RecurlyClient.promises.getSubscription(
subscription.planCode recurlySubscriptionId
) )
const newPlan = PlansLocator.findLocalPlanInSettings(planCode) const changeRequest = recurlySubscription.getRequestForPlanChange(planCode)
if (currentPlan && newPlan) { await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan,
newPlan
)
} else {
logger.error(
{ currentPlan: subscription.planCode, newPlan: planCode },
'unable to locate both plans in settings'
)
throw new Error('unable to locate both plans in settings')
}
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
const subscriptionChangeOptions = { planCode, timeframe }
await _updateAndSyncSubscription(
user,
subscription.recurlySubscription_id,
subscriptionChangeOptions
)
}
async function _updateAndSyncSubscription(
user,
recurlySubscriptionId,
subscriptionChangeOptions
) {
await RecurlyClient.promises.changeSubscriptionByUuid(
recurlySubscriptionId,
subscriptionChangeOptions
)
// v2 recurly API wants a UUID, but UUID isn't included in the subscription change response
// we got the UUID from the DB using userHasV2Subscription() - it is the only property
// we need to be able to build a 'recurlySubscription' object for syncSubscription()
await syncSubscription({ uuid: recurlySubscriptionId }, user._id) await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
} }
@ -139,7 +109,7 @@ async function cancelPendingSubscriptionChange(user) {
const { hasSubscription, subscription } = const { hasSubscription, subscription } =
await LimitationsManager.promises.userHasV2Subscription(user) await LimitationsManager.promises.userHasV2Subscription(user)
if (hasSubscription) { if (hasSubscription && subscription != null) {
await RecurlyClient.promises.removeSubscriptionChangeByUuid( await RecurlyClient.promises.removeSubscriptionChangeByUuid(
subscription.recurlySubscription_id subscription.recurlySubscription_id
) )
@ -150,7 +120,7 @@ async function cancelSubscription(user) {
try { try {
const { hasSubscription, subscription } = const { hasSubscription, subscription } =
await LimitationsManager.promises.userHasV2Subscription(user) await LimitationsManager.promises.userHasV2Subscription(user)
if (hasSubscription) { if (hasSubscription && subscription != null) {
await RecurlyClient.promises.cancelSubscriptionByUuid( await RecurlyClient.promises.cancelSubscriptionByUuid(
subscription.recurlySubscription_id subscription.recurlySubscription_id
) )
@ -178,7 +148,7 @@ async function reactivateSubscription(user) {
try { try {
const { hasSubscription, subscription } = const { hasSubscription, subscription } =
await LimitationsManager.promises.userHasV2Subscription(user) await LimitationsManager.promises.userHasV2Subscription(user)
if (hasSubscription) { if (hasSubscription && subscription != null) {
await RecurlyClient.promises.reactivateSubscriptionByUuid( await RecurlyClient.promises.reactivateSubscriptionByUuid(
subscription.recurlySubscription_id subscription.recurlySubscription_id
) )
@ -269,6 +239,9 @@ async function _updateSubscriptionFromRecurly(subscription) {
) )
} }
/**
* @return {Promise<RecurlySubscription>}
*/
async function _getSubscription(user) { async function _getSubscription(user) {
const { hasSubscription = false, subscription } = const { hasSubscription = false, subscription } =
await LimitationsManager.promises.userHasV2Subscription(user) await LimitationsManager.promises.userHasV2Subscription(user)
@ -281,50 +254,26 @@ async function _getSubscription(user) {
} }
const currentSub = await RecurlyClient.promises.getSubscription( const currentSub = await RecurlyClient.promises.getSubscription(
`uuid-${subscription.recurlySubscription_id}` subscription.recurlySubscription_id
) )
return currentSub return currentSub
} }
async function purchaseAddon(user, addOnCode, quantity) { async function purchaseAddon(user, addOnCode, quantity) {
const subscription = await _getSubscription(user) const subscription = await _getSubscription(user)
const currentAddons = subscription?.addOns || [] const changeRequest = subscription.getRequestForAddOnPurchase(
const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode)
if (hasAddon) {
throw new DuplicateAddOnError('User already has add-on', {
userId: user._id,
addOnCode, addOnCode,
}) quantity
}
const addOns = [...currentAddons, { code: addOnCode, quantity }]
const subscriptionChangeOptions = { addOns, timeframe: 'now' }
await _updateAndSyncSubscription(
user,
subscription.uuid,
subscriptionChangeOptions
) )
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
await syncSubscription({ uuid: subscription.id }, user._id)
} }
async function removeAddon(user, addOnCode) { async function removeAddon(user, addOnCode) {
const subscription = await _getSubscription(user) const subscription = await _getSubscription(user)
const currentAddons = subscription?.addOns || [] const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode)
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode) await syncSubscription({ uuid: subscription.id }, user._id)
if (!hasAddon) {
throw new AddOnNotPresentError('User does not have add-on to remove', {
userId: user._id,
addOnCode,
})
}
const addOns = currentAddons.filter(addOn => addOn.addOn.code !== addOnCode)
const subscriptionChangeOptions = { addOns, timeframe: 'term_end' }
await _updateAndSyncSubscription(
user,
subscription.uuid,
subscriptionChangeOptions
)
} }
module.exports = { module.exports = {

View file

@ -1,8 +1,13 @@
const sinon = require('sinon') const sinon = require('sinon')
const { expect } = require('chai') const { expect } = require('chai')
const recurly = require('recurly') const recurly = require('recurly')
const modulePath = '../../../../app/src/Features/Subscription/RecurlyClient'
const SandboxedModule = require('sandboxed-module') const SandboxedModule = require('sandboxed-module')
const {
RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate,
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
describe('RecurlyClient', function () { describe('RecurlyClient', function () {
beforeEach(function () { beforeEach(function () {
@ -13,20 +18,62 @@ describe('RecurlyClient', function () {
privateKey: 'private_nonsense', privateKey: 'private_nonsense',
}, },
}, },
plans: [],
features: [],
} }
this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' } this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
this.subscription = {
id: 'subscription-123',
uuid: 'subscription-uuid-123',
}
this.subscriptionChange = { id: 'subscription-change-123' } this.subscriptionChange = { id: 'subscription-change-123' }
this.recurlyAccount = new recurly.Account() this.recurlyAccount = new recurly.Account()
Object.assign(this.recurlyAccount, { code: this.user._id }) Object.assign(this.recurlyAccount, { code: this.user._id })
this.recurlySubscription = new recurly.Subscription() this.subscriptionAddOn = {
Object.assign(this.recurlySubscription, this.subscription) code: 'addon-code',
name: 'My Add-On',
quantity: 1,
unitPrice: 2,
}
this.subscription = {
id: 'subscription-id',
userId: 'user-id',
currency: 'EUR',
planCode: 'plan-code',
planName: 'plan-name',
planPrice: 13,
addOns: [this.subscriptionAddOn],
subtotal: 15,
taxRate: 0.1,
taxAmount: 1.5,
total: 16.5,
}
this.recurlySubscription = {
uuid: this.subscription.id,
account: {
code: this.subscription.userId,
},
plan: {
code: this.subscription.planCode,
name: this.subscription.planName,
},
addOns: [
{
addOn: {
code: this.subscriptionAddOn.code,
name: this.subscriptionAddOn.name,
},
quantity: this.subscriptionAddOn.quantity,
unitAmount: this.subscriptionAddOn.unitPrice,
},
],
unitAmount: this.subscription.planPrice,
subtotal: this.subscription.subtotal,
taxInfo: { rate: this.subscription.taxRate },
tax: this.subscription.taxAmount,
total: this.subscription.total,
currency: this.subscription.currency,
}
this.recurlySubscriptionChange = new recurly.SubscriptionChange() this.recurlySubscriptionChange = new recurly.SubscriptionChange()
Object.assign(this.recurlySubscriptionChange, this.subscriptionChange) Object.assign(this.recurlySubscriptionChange, this.subscriptionChange)
@ -52,7 +99,7 @@ describe('RecurlyClient', function () {
}, },
} }
return (this.RecurlyClient = SandboxedModule.require(modulePath, { return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, {
globals: { globals: {
console, console,
}, },
@ -130,12 +177,12 @@ describe('RecurlyClient', function () {
it('should return the subscription found by recurly', async function () { it('should return the subscription found by recurly', async function () {
this.client.getSubscription = sinon this.client.getSubscription = sinon
.stub() .stub()
.withArgs('uuid-subscription-id')
.resolves(this.recurlySubscription) .resolves(this.recurlySubscription)
await expect( const subscription = await this.RecurlyClient.promises.getSubscription(
this.RecurlyClient.promises.getSubscription(this.subscription.id) this.subscription.id
) )
.to.eventually.be.an.instanceOf(recurly.Subscription) expect(subscription).to.deep.equal(this.subscription)
.that.has.property('id', this.subscription.id)
}) })
it('should throw any API errors', async function () { it('should throw any API errors', async function () {
@ -146,71 +193,60 @@ describe('RecurlyClient', function () {
}) })
}) })
describe('changeSubscription', function () { describe('applySubscriptionChangeRequest', function () {
beforeEach(function () { beforeEach(function () {
this.client.createSubscriptionChange = sinon this.client.createSubscriptionChange = sinon
.stub() .stub()
.resolves(this.recurlySubscriptionChange) .resolves(this.recurlySubscriptionChange)
}) })
it('should attempt to create a subscription change', async function () { it('handles plan changes', async function () {
this.RecurlyClient.promises.changeSubscription(this.subscription.id, {}) await this.RecurlyClient.promises.applySubscriptionChangeRequest(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
timeframe: 'now',
planCode: 'new-plan',
})
)
expect(this.client.createSubscriptionChange).to.be.calledWith( expect(this.client.createSubscriptionChange).to.be.calledWith(
this.subscription.id 'uuid-subscription-id',
{ timeframe: 'now', planCode: 'new-plan' }
) )
}) })
it('should return the subscription change event', async function () { it('handles add-on changes', async function () {
await expect( await this.RecurlyClient.promises.applySubscriptionChangeRequest(
this.RecurlyClient.promises.changeSubscription( new RecurlySubscriptionChangeRequest({
this.subscriptionChange.id, subscriptionId: this.subscription.id,
{} timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
code: 'new-add-on',
quantity: 2,
unitPrice: 8.99,
}),
],
})
) )
expect(this.client.createSubscriptionChange).to.be.calledWith(
'uuid-subscription-id',
{
timeframe: 'now',
addOns: [{ code: 'new-add-on', quantity: 2, unitAmount: 8.99 }],
}
) )
.to.eventually.be.an.instanceOf(recurly.SubscriptionChange)
.that.has.property('id', this.subscriptionChange.id)
}) })
it('should throw any API errors', async function () { it('should throw any API errors', async function () {
this.client.createSubscriptionChange = sinon.stub().throws() this.client.createSubscriptionChange = sinon.stub().throws()
await expect( await expect(
this.RecurlyClient.promises.changeSubscription(this.subscription.id, {}) this.RecurlyClient.promises.applySubscriptionChangeRequest(
).to.eventually.be.rejectedWith(Error)
})
describe('changeSubscriptionByUuid', function () {
it('should attempt to create a subscription change', async function () {
this.RecurlyClient.promises.changeSubscriptionByUuid(
this.subscription.uuid,
{}
)
expect(this.client.createSubscriptionChange).to.be.calledWith(
'uuid-' + this.subscription.uuid
)
})
it('should return the subscription change event', async function () {
await expect(
this.RecurlyClient.promises.changeSubscriptionByUuid(
this.subscriptionChange.id,
{}
)
)
.to.eventually.be.an.instanceOf(recurly.SubscriptionChange)
.that.has.property('id', this.subscriptionChange.id)
})
it('should throw any API errors', async function () {
this.client.createSubscriptionChange = sinon.stub().throws()
await expect(
this.RecurlyClient.promises.changeSubscriptionByUuid(
this.subscription.id, this.subscription.id,
{} {}
) )
).to.eventually.be.rejectedWith(Error) ).to.eventually.be.rejectedWith(Error)
}) })
}) })
})
describe('removeSubscriptionChange', function () { describe('removeSubscriptionChange', function () {
beforeEach(function () { beforeEach(function () {
@ -265,11 +301,11 @@ describe('RecurlyClient', function () {
this.client.reactivateSubscription = sinon this.client.reactivateSubscription = sinon
.stub() .stub()
.resolves(this.recurlySubscription) .resolves(this.recurlySubscription)
await expect( const subscription =
this.RecurlyClient.promises.reactivateSubscriptionByUuid( await this.RecurlyClient.promises.reactivateSubscriptionByUuid(
this.subscription.uuid this.subscription.uuid
) )
).to.eventually.be.an.instanceOf(recurly.Subscription) expect(subscription).to.deep.equal(this.recurlySubscription)
expect(this.client.reactivateSubscription).to.be.calledWith( expect(this.client.reactivateSubscription).to.be.calledWith(
'uuid-' + this.subscription.uuid 'uuid-' + this.subscription.uuid
) )
@ -281,11 +317,11 @@ describe('RecurlyClient', function () {
this.client.cancelSubscription = sinon this.client.cancelSubscription = sinon
.stub() .stub()
.resolves(this.recurlySubscription) .resolves(this.recurlySubscription)
await expect( const subscription =
this.RecurlyClient.promises.cancelSubscriptionByUuid( await this.RecurlyClient.promises.cancelSubscriptionByUuid(
this.subscription.uuid this.subscription.uuid
) )
).to.eventually.be.an.instanceOf(recurly.Subscription) expect(subscription).to.deep.equal(this.recurlySubscription)
expect(this.client.cancelSubscription).to.be.calledWith( expect(this.client.cancelSubscription).to.be.calledWith(
'uuid-' + this.subscription.uuid 'uuid-' + this.subscription.uuid
) )

View file

@ -0,0 +1,206 @@
// @ts-check
const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Subscription/Errors')
const {
RecurlySubscriptionChangeRequest,
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
describe('RecurlyEntities', function () {
describe('RecurlySubscription', function () {
beforeEach(function () {
this.Settings = {
plans: [
{ planCode: 'cheap-plan', price_in_cents: 500 },
{ planCode: 'regular-plan', price_in_cents: 1000 },
{ planCode: 'premium-plan', price_in_cents: 2000 },
],
features: [],
}
this.RecurlyEntities = SandboxedModule.require(MODULE_PATH, {
requires: {
'@overleaf/settings': this.Settings,
'./Errors': Errors,
},
})
})
describe('with add-ons', function () {
beforeEach(function () {
const { RecurlySubscription, RecurlySubscriptionAddOn } =
this.RecurlyEntities
this.addOn = new RecurlySubscriptionAddOn({
code: 'add-on-code',
name: 'My Add-On',
quantity: 1,
unitPrice: 2,
})
this.subscription = new RecurlySubscription({
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 { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
const changeRequest =
this.subscription.getRequestForPlanChange('premium-plan')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
timeframe: 'now',
planCode: 'premium-plan',
})
)
})
it('returns a change request for downgrades', function () {
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
const changeRequest =
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
timeframe: 'term_end',
planCode: 'cheap-plan',
})
)
})
})
describe('getRequestForAddOnPurchase()', function () {
it('returns a change request', function () {
const {
RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate,
} = this.RecurlyEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('another-add-on')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
code: this.addOn.code,
quantity: this.addOn.quantity,
unitPrice: this.addOn.unitPrice,
}),
new RecurlySubscriptionAddOnUpdate({
code: 'another-add-on',
quantity: 1,
}),
],
})
)
})
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('getRequestForAddOnRemoval()', function () {
it('returns a change request', function () {
const changeRequest = this.subscription.getRequestForAddOnRemoval(
this.addOn.code
)
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
timeframe: 'term_end',
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('without add-ons', function () {
beforeEach(function () {
const { RecurlySubscription } = this.RecurlyEntities
this.subscription = new RecurlySubscription({
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 {
RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate,
} = this.RecurlyEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('some-add-on')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
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)
})
})
})
})
})
})

View file

@ -2,6 +2,10 @@ const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon') const sinon = require('sinon')
const chai = require('chai') const chai = require('chai')
const { expect } = chai const { expect } = chai
const {
RecurlySubscription,
RecurlySubscriptionChangeRequest,
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
const MODULE_PATH = const MODULE_PATH =
'../../../../app/src/Features/Subscription/SubscriptionHandler' '../../../../app/src/Features/Subscription/SubscriptionHandler'
@ -23,20 +27,16 @@ const mockRecurlySubscriptions = {
} }
const mockRecurlyClientSubscriptions = { const mockRecurlyClientSubscriptions = {
'subscription-123-active': { 'subscription-123-active': new RecurlySubscription({
id: 'subscription-123-recurly-id', id: 'subscription-123-active',
uuid: 'subscription-123-active', userId: 'user-id',
plan: { planCode: 'collaborator',
name: 'Gold', planName: 'Collaborator',
code: 'gold', planPrice: 10,
}, subtotal: 10,
currentPeriodEndsAt: new Date(), currency: 'USD',
state: 'active', total: 10,
unitAmount: 10, }),
account: {
code: 'user-123',
},
},
} }
const mockSubscriptionChanges = { const mockSubscriptionChanges = {
@ -53,11 +53,16 @@ describe('SubscriptionHandler', function () {
{ {
planCode: 'collaborator', planCode: 'collaborator',
name: 'Collaborator', name: 'Collaborator',
price_in_cents: 1000,
features: { features: {
collaborators: -1, collaborators: -1,
versioning: true, versioning: true,
}, },
}, },
{
planCode: 'professional',
price_in_cents: 1500,
},
], ],
defaultPlanCode: { defaultPlanCode: {
collaborators: 0, collaborators: 0,
@ -94,7 +99,7 @@ describe('SubscriptionHandler', function () {
.stub() .stub()
.resolves(this.activeRecurlyClientSubscription), .resolves(this.activeRecurlyClientSubscription),
cancelSubscriptionByUuid: sinon.stub().resolves(), cancelSubscriptionByUuid: sinon.stub().resolves(),
changeSubscriptionByUuid: sinon applySubscriptionChangeRequest: sinon
.stub() .stub()
.resolves(this.activeRecurlySubscriptionChange), .resolves(this.activeRecurlySubscriptionChange),
getSubscription: sinon getSubscription: sinon
@ -122,14 +127,6 @@ describe('SubscriptionHandler', function () {
sendDeferredEmail: sinon.stub(), sendDeferredEmail: sinon.stub(),
} }
this.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns({ planCode: 'plan' }),
}
this.SubscriptionHelper = {
shouldPlanChangeAtTermEnd: sinon.stub(),
}
this.UserUpdater = { this.UserUpdater = {
promises: { promises: {
updateUser: sinon.stub().resolves(), updateUser: sinon.stub().resolves(),
@ -148,8 +145,6 @@ describe('SubscriptionHandler', function () {
'./LimitationsManager': this.LimitationsManager, './LimitationsManager': this.LimitationsManager,
'../Email/EmailHandler': this.EmailHandler, '../Email/EmailHandler': this.EmailHandler,
'../Analytics/AnalyticsManager': this.AnalyticsManager, '../Analytics/AnalyticsManager': this.AnalyticsManager,
'./PlansLocator': this.PlansLocator,
'./SubscriptionHelper': this.SubscriptionHelper,
'../User/UserUpdater': this.UserUpdater, '../User/UserUpdater': this.UserUpdater,
}, },
}) })
@ -245,35 +240,8 @@ describe('SubscriptionHandler', function () {
}) })
}) })
function shouldUpdateSubscription() { describe('updateSubscription', function () {
it('should update the subscription', function () { describe('with a user with a subscription', function () {
expect(
this.RecurlyClient.promises.changeSubscriptionByUuid
).to.have.been.calledWith(this.subscription.recurlySubscription_id)
const updateOptions =
this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1]
updateOptions.planCode.should.equal(this.plan_code)
})
}
function shouldSyncSubscription() {
it('should sync the new subscription to the user', function () {
expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been
.called
this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
this.activeRecurlySubscription
)
this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
this.user._id
)
})
}
function testUserWithASubscription(shouldPlanChangeAtTermEnd, timeframe) {
describe(
'when change should happen with timeframe ' + timeframe,
function () {
beforeEach(async function () { beforeEach(async function () {
this.user.id = this.activeRecurlySubscription.account.account_code this.user.id = this.activeRecurlySubscription.account.account_code
this.User.findById = (userId, projection) => ({ this.User.findById = (userId, projection) => ({
@ -282,10 +250,7 @@ describe('SubscriptionHandler', function () {
return Promise.resolve(this.user) return Promise.resolve(this.user)
}, },
}) })
this.plan_code = 'collaborator' this.plan_code = 'professional'
this.SubscriptionHelper.shouldPlanChangeAtTermEnd.returns(
shouldPlanChangeAtTermEnd
)
this.LimitationsManager.promises.userHasV2Subscription.resolves({ this.LimitationsManager.promises.userHasV2Subscription.resolves({
hasSubscription: true, hasSubscription: true,
subscription: this.subscription, subscription: this.subscription,
@ -297,22 +262,30 @@ describe('SubscriptionHandler', function () {
) )
}) })
shouldUpdateSubscription() it('should update the subscription', function () {
shouldSyncSubscription() expect(
this.RecurlyClient.promises.applySubscriptionChangeRequest
it('should update with timeframe ' + timeframe, function () { ).to.have.been.calledWith(
const updateOptions = new RecurlySubscriptionChangeRequest({
this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1] subscriptionId: this.subscription.recurlySubscription_id,
updateOptions.timeframe.should.equal(timeframe) timeframe: 'now',
planCode: this.plan_code,
}) })
}
) )
} })
describe('updateSubscription', function () { it('should sync the new subscription to the user', function () {
describe('with a user with a subscription', function () { expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been
testUserWithASubscription(false, 'now') .called
testUserWithASubscription(true, 'term_end')
this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
this.activeRecurlySubscription
)
this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
this.user._id
)
})
})
describe('when plan(s) could not be located in settings', function () { describe('when plan(s) could not be located in settings', function () {
beforeEach(async function () { beforeEach(async function () {
@ -324,8 +297,6 @@ describe('SubscriptionHandler', function () {
}, },
}) })
this.plan_code = 'collaborator'
this.PlansLocator.findLocalPlanInSettings.returns(null)
this.LimitationsManager.promises.userHasV2Subscription.resolves({ this.LimitationsManager.promises.userHasV2Subscription.resolves({
hasSubscription: true, hasSubscription: true,
subscription: this.subscription, subscription: this.subscription,
@ -336,16 +307,15 @@ describe('SubscriptionHandler', function () {
expect( expect(
this.SubscriptionHandler.promises.updateSubscription( this.SubscriptionHandler.promises.updateSubscription(
this.user, this.user,
this.plan_code, 'unknown-plan',
null null
) )
).to.be.rejected ).to.be.rejected
this.RecurlyClient.promises.changeSubscriptionByUuid.called.should.equal( this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal(
false false
) )
}) })
}) })
})
describe('with a user without a subscription', function () { describe('with a user without a subscription', function () {
beforeEach(async function () { beforeEach(async function () {
@ -358,7 +328,7 @@ describe('SubscriptionHandler', function () {
}) })
it('should redirect to the subscription dashboard', function () { it('should redirect to the subscription dashboard', function () {
this.RecurlyClient.promises.changeSubscriptionByUuid.called.should.equal( this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal(
false false
) )
this.SubscriptionUpdater.promises.syncSubscription.called.should.equal( this.SubscriptionUpdater.promises.syncSubscription.called.should.equal(
@ -407,11 +377,14 @@ describe('SubscriptionHandler', function () {
it('should update the subscription', function () { it('should update the subscription', function () {
expect( expect(
this.RecurlyClient.promises.changeSubscriptionByUuid this.RecurlyClient.promises.applySubscriptionChangeRequest
).to.be.calledWith(this.subscription.recurlySubscription_id) ).to.be.calledWith(
const updateOptions = new RecurlySubscriptionChangeRequest({
this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1] subscriptionId: this.subscription.recurlySubscription_id,
updateOptions.planCode.should.equal(this.plan_code) timeframe: 'now',
planCode: this.plan_code,
})
)
}) })
}) })
}) })