mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #21274 from overleaf/em-recurly-client
Separate Recurly business logic GitOrigin-RevId: 9c3b5ce61bdc7a6a5d3f507a31dc8919c882e476
This commit is contained in:
parent
3d5bceffee
commit
1acb0d1bcd
7 changed files with 752 additions and 297 deletions
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
194
services/web/app/src/Features/Subscription/RecurlyEntities.js
Normal file
194
services/web/app/src/Features/Subscription/RecurlyEntities.js
Normal 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,
|
||||||
|
}
|
|
@ -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(
|
||||||
|
addOnCode,
|
||||||
const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode)
|
quantity
|
||||||
if (hasAddon) {
|
|
||||||
throw new DuplicateAddOnError('User already has add-on', {
|
|
||||||
userId: user._id,
|
|
||||||
addOnCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
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 = {
|
||||||
|
|
|
@ -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,69 +193,58 @@ 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)
|
this.subscription.id,
|
||||||
})
|
|
||||||
|
|
||||||
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(
|
).to.eventually.be.rejectedWith(Error)
|
||||||
'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,
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
).to.eventually.be.rejectedWith(Error)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
206
services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js
Normal file
206
services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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,105 +240,80 @@ describe('SubscriptionHandler', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function shouldUpdateSubscription() {
|
|
||||||
it('should update the 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 () {
|
|
||||||
this.user.id = this.activeRecurlySubscription.account.account_code
|
|
||||||
this.User.findById = (userId, projection) => ({
|
|
||||||
exec: () => {
|
|
||||||
userId.should.equal(this.user.id)
|
|
||||||
return Promise.resolve(this.user)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.plan_code = 'collaborator'
|
|
||||||
this.SubscriptionHelper.shouldPlanChangeAtTermEnd.returns(
|
|
||||||
shouldPlanChangeAtTermEnd
|
|
||||||
)
|
|
||||||
this.LimitationsManager.promises.userHasV2Subscription.resolves({
|
|
||||||
hasSubscription: true,
|
|
||||||
subscription: this.subscription,
|
|
||||||
})
|
|
||||||
await this.SubscriptionHandler.promises.updateSubscription(
|
|
||||||
this.user,
|
|
||||||
this.plan_code,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
shouldUpdateSubscription()
|
|
||||||
shouldSyncSubscription()
|
|
||||||
|
|
||||||
it('should update with timeframe ' + timeframe, function () {
|
|
||||||
const updateOptions =
|
|
||||||
this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1]
|
|
||||||
updateOptions.timeframe.should.equal(timeframe)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('updateSubscription', function () {
|
describe('updateSubscription', function () {
|
||||||
describe('with a user with a subscription', function () {
|
describe('with a user with a subscription', function () {
|
||||||
testUserWithASubscription(false, 'now')
|
beforeEach(async function () {
|
||||||
testUserWithASubscription(true, 'term_end')
|
this.user.id = this.activeRecurlySubscription.account.account_code
|
||||||
|
this.User.findById = (userId, projection) => ({
|
||||||
|
exec: () => {
|
||||||
|
userId.should.equal(this.user.id)
|
||||||
|
return Promise.resolve(this.user)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.plan_code = 'professional'
|
||||||
|
this.LimitationsManager.promises.userHasV2Subscription.resolves({
|
||||||
|
hasSubscription: true,
|
||||||
|
subscription: this.subscription,
|
||||||
|
})
|
||||||
|
await this.SubscriptionHandler.promises.updateSubscription(
|
||||||
|
this.user,
|
||||||
|
this.plan_code,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
describe('when plan(s) could not be located in settings', function () {
|
it('should update the subscription', function () {
|
||||||
beforeEach(async function () {
|
expect(
|
||||||
this.user.id = this.activeRecurlySubscription.account.account_code
|
this.RecurlyClient.promises.applySubscriptionChangeRequest
|
||||||
this.User.findById = (userId, projection) => ({
|
).to.have.been.calledWith(
|
||||||
exec: () => {
|
new RecurlySubscriptionChangeRequest({
|
||||||
userId.should.equal(this.user.id)
|
subscriptionId: this.subscription.recurlySubscription_id,
|
||||||
return Promise.resolve(this.user)
|
timeframe: 'now',
|
||||||
},
|
planCode: this.plan_code,
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
this.plan_code = 'collaborator'
|
it('should sync the new subscription to the user', function () {
|
||||||
this.PlansLocator.findLocalPlanInSettings.returns(null)
|
expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been
|
||||||
this.LimitationsManager.promises.userHasV2Subscription.resolves({
|
.called
|
||||||
hasSubscription: true,
|
|
||||||
subscription: this.subscription,
|
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 () {
|
||||||
|
beforeEach(async function () {
|
||||||
|
this.user.id = this.activeRecurlySubscription.account.account_code
|
||||||
|
this.User.findById = (userId, projection) => ({
|
||||||
|
exec: () => {
|
||||||
|
userId.should.equal(this.user.id)
|
||||||
|
return Promise.resolve(this.user)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should be rejected and should not update the subscription', function () {
|
this.LimitationsManager.promises.userHasV2Subscription.resolves({
|
||||||
expect(
|
hasSubscription: true,
|
||||||
this.SubscriptionHandler.promises.updateSubscription(
|
subscription: this.subscription,
|
||||||
this.user,
|
})
|
||||||
this.plan_code,
|
})
|
||||||
null
|
|
||||||
)
|
it('should be rejected and should not update the subscription', function () {
|
||||||
).to.be.rejected
|
expect(
|
||||||
this.RecurlyClient.promises.changeSubscriptionByUuid.called.should.equal(
|
this.SubscriptionHandler.promises.updateSubscription(
|
||||||
false
|
this.user,
|
||||||
|
'unknown-plan',
|
||||||
|
null
|
||||||
)
|
)
|
||||||
})
|
).to.be.rejected
|
||||||
|
this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal(
|
||||||
|
false
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue